НMerge branch 'dev' into create-shout-2
This commit is contained in:
commit
62399ce138
3
public/icons/editor-unlink.svg
Normal file
3
public/icons/editor-unlink.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5542 12.1407L16.417 15.0035L15.4205 16L11.657 12.2365H9.26153V10.6123H10.0497L8.25384 8.84457H5.0531V7.18383H6.58994L4.80805 5.40195H4.19466C3.50174 5.39979 2.83667 5.6739 2.34637 6.16338C1.85628 6.65308 1.58137 7.31794 1.58278 8.01086C1.58415 8.70357 1.8616 9.36744 2.35366 9.85502C2.83688 10.3504 3.50289 10.6243 4.19466 10.6123H7.60079V12.2365H4.19466C3.07508 12.2532 1.99923 11.801 1.2278 10.9895C0.441236 10.1933 0 9.11922 0 8C0 6.88077 0.4412 5.80674 1.2278 5.01052C1.78565 4.42814 2.50526 4.02601 3.29384 3.85636L0.417108 0.996475L1.41358 0L5.17706 3.7776H5.19669L6.82089 5.4018H6.79831L8.58019 7.18368L10.2551 8.84442L12.0369 10.6263L13.5683 12.1378L13.5542 12.1407ZM15.6203 10.9895C16.4069 10.1933 16.8481 9.11927 16.8481 8.00005C16.8481 6.88083 16.4069 5.80679 15.6203 5.01057C14.8463 4.20416 13.7709 3.75724 12.6535 3.7777H9.26153V5.4019H12.6677H12.6675C13.3604 5.39974 14.0255 5.67385 14.5157 6.16333C15.0058 6.65303 15.2807 7.31789 15.2793 8.01081C15.278 8.70352 15.0005 9.36739 14.5085 9.85497C14.3296 10.0348 14.1269 10.1892 13.9061 10.3138L15.0771 11.4819V11.4821C15.2715 11.3333 15.4533 11.1685 15.6204 10.9895L15.6203 10.9895Z" fill="currentColor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -2,24 +2,22 @@
|
|||
"...subscribing": "...subscribing",
|
||||
"About myself": "About myself",
|
||||
"About the project": "About the project",
|
||||
"actions": "actions",
|
||||
"Add comment": "Comment",
|
||||
"Address on Discourse": "Address on Discourse",
|
||||
"All": "All",
|
||||
"All authors": "All authors",
|
||||
"All posts": "All posts",
|
||||
"all topics": "all topics",
|
||||
"All topics": "All topics",
|
||||
"Almost done! Check your email.": "Almost done! Just checking your email.",
|
||||
"Artworks": "Artworks",
|
||||
"Audio": "Audio",
|
||||
"author": "author",
|
||||
"Author": "Author",
|
||||
"authors": "authors",
|
||||
"Author subscriptions": "Подписки на авторов",
|
||||
"Authors": "Authors",
|
||||
"Back to main page": "Back to main page",
|
||||
"Become an author": "Become an author",
|
||||
"Bookmarked": "Saved",
|
||||
"Bookmarks": "Bookmarks",
|
||||
"By alphabet": "By alphabet",
|
||||
"By authors": "By authors",
|
||||
"By name": "By name",
|
||||
|
@ -28,23 +26,23 @@
|
|||
"By relevance": "By relevance",
|
||||
"By shouts": "By publications",
|
||||
"By signing up you agree with our": "By signing up you agree with our",
|
||||
"By time": "By time",
|
||||
"By title": "By title",
|
||||
"By updates": "By updates",
|
||||
"By views": "By views",
|
||||
"cancel": "Cancel",
|
||||
"Characters": "Знаков",
|
||||
"Chat Title": "Chat Title",
|
||||
"Choose who you want to write to": "Choose who you want to write to",
|
||||
"Collaborate": "Help Edit",
|
||||
"collections": "collections",
|
||||
"Comments": "Comments",
|
||||
"Communities": "Communities",
|
||||
"community": "community",
|
||||
"Cooperate": "Cooperate",
|
||||
"Copy": "Copy",
|
||||
"Copy link": "Copy link",
|
||||
"Create account": "Create an account",
|
||||
"Corrections history": "Corrections history",
|
||||
"Create Chat": "Create Chat",
|
||||
"Create Group": "Create a group",
|
||||
"Create account": "Create an account",
|
||||
"Create post": "Create post",
|
||||
"Date of Birth": "Date of Birth",
|
||||
"Delete": "Delete",
|
||||
|
@ -52,30 +50,28 @@
|
|||
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects",
|
||||
"Discours is created with our common effort": "Discours exists because of our common effort",
|
||||
"Discussing": "Discussing",
|
||||
"discussion": "discourse",
|
||||
"Discussion rules": "Discussion rules",
|
||||
"Dogma": "Dogma",
|
||||
"Drafts": "Drafts",
|
||||
"Edit": "Edit",
|
||||
"Editing": "Editing",
|
||||
"Email": "Mail",
|
||||
"email not confirmed": "email not confirmed",
|
||||
"enter": "enter",
|
||||
"Enter": "Enter",
|
||||
"Enter URL address": "Enter URL address",
|
||||
"Enter text": "Enter text",
|
||||
"Enter the code or click the link from email to confirm": "Enter the code from the email or follow the link in the email to confirm registration",
|
||||
"Enter the Discours": "Enter the Discours",
|
||||
"Enter the code or click the link from email to confirm": "Enter the code from the email or follow the link in the email to confirm registration",
|
||||
"Enter your new password": "Enter your new password",
|
||||
"Error": "Error",
|
||||
"Everything is ok, please give us your email address": "It's okay, just enter your email address to receive a password reset link.",
|
||||
"FAQ": "Tips and suggestions",
|
||||
"Favorite": "Favorites",
|
||||
"Favorite topics": "Favorite topics",
|
||||
"feed": "feed",
|
||||
"Feed settings": "Feed settings",
|
||||
"Feedback": "Feedback",
|
||||
"Fill email": "Fill email",
|
||||
"Follow": "Follow",
|
||||
"Follow the topic": "Follow the topic",
|
||||
"follower": "follower",
|
||||
"Followers": "Followers",
|
||||
"Forgot password?": "Forgot your password?",
|
||||
"Forward": "Forward",
|
||||
|
@ -84,12 +80,16 @@
|
|||
"Go to main page": "Go to main page",
|
||||
"Group Chat": "Group Chat",
|
||||
"Groups": "Groups",
|
||||
"Headers": "Headers",
|
||||
"Help": "Помощь",
|
||||
"Help to edit": "Help to edit",
|
||||
"Here you can customize your profile the way you want.": "Here you can customize your profile the way you want.",
|
||||
"Hooray! Welcome!": "Hooray! Welcome!",
|
||||
"Horizontal collaborative journalistic platform": "Horizontal collaborative journalism platform",
|
||||
"Hotkeys": "Горячие клавиши",
|
||||
"How can I help/skills": "How can I help/skills",
|
||||
"How it works": "How it works",
|
||||
"How to write a good article": "Как написать хорошую статью",
|
||||
"How to write an article": "How to write an article",
|
||||
"I have an account": "I have an account!",
|
||||
"I have no account yet": "I don't have an account yet",
|
||||
|
@ -97,7 +97,8 @@
|
|||
"Independant magazine with an open horizontal cooperation about culture, science and society": "Independant magazine with an open horizontal cooperation about culture, science and society",
|
||||
"Introduce": "Introduction",
|
||||
"Invalid email": "Check if your email is correct",
|
||||
"invalid password": "invalid password",
|
||||
"Invalid url format": "Invalid url format",
|
||||
"Invite co-authors": "Invite co-authors",
|
||||
"Invite to collab": "Invite to Collab",
|
||||
"It does not look like url": "It doesn't look like a link",
|
||||
"Join": "Join",
|
||||
|
@ -106,10 +107,13 @@
|
|||
"Join the global community of authors!": "Join the global community of authors from all over the world!",
|
||||
"Just start typing...": "Just start typing...",
|
||||
"Knowledge base": "Knowledge base",
|
||||
"Last rev.": "Посл. изм.",
|
||||
"Link sent, check your email": "Link sent, check your email",
|
||||
"Lists": "Lists",
|
||||
"Literature": "Literature",
|
||||
"Load more": "Show more",
|
||||
"Loading": "Loading",
|
||||
"Logout": "Logout",
|
||||
"Manifest": "Manifest",
|
||||
"More": "More",
|
||||
"Most commented": "Commented",
|
||||
|
@ -117,6 +121,7 @@
|
|||
"My feed": "My feed",
|
||||
"My subscriptions": "Subscriptions",
|
||||
"Name": "Name",
|
||||
"New only": "New only",
|
||||
"New password": "New password",
|
||||
"New stories every day and even more!": "New stories and more are waiting for you every day!",
|
||||
"No such account, please try to register": "No such account found, please try to register",
|
||||
|
@ -124,13 +129,14 @@
|
|||
"Nothing is here": "There is nothing here",
|
||||
"Or continue with social network": "Or continue with social network",
|
||||
"Our regular contributor": "Our regular contributor",
|
||||
"Paragraphs": "Абзацев",
|
||||
"Participating": "Participating",
|
||||
"Partners": "Partners",
|
||||
"Password": "Password",
|
||||
"Password again": "Password again",
|
||||
"Passwords are not equal": "Passwords are not equal",
|
||||
"Paste Embed code": "Paste Embed code",
|
||||
"Personal": "Personal",
|
||||
"personal data usage and email notifications": "to process personal data and receive email notifications",
|
||||
"Pin": "Pin",
|
||||
"Please check your email address": "Please check your email address",
|
||||
"Please confirm your email to finish": "Confirm your email and the action will complete",
|
||||
|
@ -141,19 +147,19 @@
|
|||
"Please, confirm email": "Please confirm email",
|
||||
"Popular": "Popular",
|
||||
"Popular authors": "Popular authors",
|
||||
"post": "post",
|
||||
"Principles": "Community principles",
|
||||
"Profile": "Profile",
|
||||
"Profile settings": "Profile settings",
|
||||
"Publications": "Publications",
|
||||
"Quit": "Quit",
|
||||
"Quotes": "Quotes",
|
||||
"Reason uknown": "Reason unknown",
|
||||
"Recent": "Fresh",
|
||||
"register": "register",
|
||||
"Reply": "Reply",
|
||||
"Report": "Complain",
|
||||
"Resend code": "Send confirmation",
|
||||
"Restore password": "Restore password",
|
||||
"Save draft": "Save draft",
|
||||
"Save settings": "Save settings",
|
||||
"Search": "Search",
|
||||
"Search author": "Search author",
|
||||
|
@ -165,10 +171,8 @@
|
|||
"Send link again": "Send link again",
|
||||
"Settings": "Settings",
|
||||
"Share": "Share",
|
||||
"shout": "post",
|
||||
"Short opening": "Short opening",
|
||||
"Show": "Show",
|
||||
"sign up or sign in": "sign up or sign in",
|
||||
"slug is used by another user": "Slug is already taken by another user",
|
||||
"Social networks": "Social networks",
|
||||
"Something went wrong, check email and password": "Something went wrong. Check your email and password",
|
||||
"Something went wrong, please try again": "Something went wrong, please try again",
|
||||
|
@ -184,10 +188,11 @@
|
|||
"Successfully authorized": "Authorization successful",
|
||||
"Suggest an idea": "Suggest an idea",
|
||||
"Support us": "Help the magazine",
|
||||
"terms of use": "terms of use",
|
||||
"Terms of use": "Site rules",
|
||||
"Thank you": "Thank you",
|
||||
"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 post has not been rated yet": "This post has not been rated yet",
|
||||
"To leave a comment please": "To leave a comment please",
|
||||
"To write a comment, you must": "To write a comment, you must",
|
||||
"Top authors": "Authors rating",
|
||||
|
@ -199,17 +204,15 @@
|
|||
"Top topics": "Interesting topics",
|
||||
"Top viewed": "Most viewed",
|
||||
"Topic is supported by": "Topic is supported by",
|
||||
"topics": "topics",
|
||||
"Topic subscriptions": "Подписки на темы",
|
||||
"Topics": "Topics",
|
||||
"Topics which supported by author": "Topics which supported by author",
|
||||
"Try to find another way": "Try to find another way",
|
||||
"Unfollow": "Unfollow",
|
||||
"Unfollow the topic": "Unfollow the topic",
|
||||
"user already exist": "user already exists",
|
||||
"Username": "Username",
|
||||
"Userpic": "Userpic",
|
||||
"Video": "Video",
|
||||
"view": "view",
|
||||
"Views": "Views",
|
||||
"We are convinced that one voice is good, but many is better": "We are convinced that one voice is good, but many is better",
|
||||
"We can't find you, check email or": "We can't find you, check email or",
|
||||
|
@ -217,10 +220,12 @@
|
|||
"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.",
|
||||
"Where": "From",
|
||||
"Words": "Слов",
|
||||
"Work with us": "Cooperate with Discourse",
|
||||
"Write": "Write",
|
||||
"Write a comment...": "Write a comment...",
|
||||
"Write about the topic": "Write about the topic",
|
||||
"Write an article": "Write an article",
|
||||
"Write comment": "Write comment",
|
||||
"Write message": "Write a message",
|
||||
"Write to us": "Write to us",
|
||||
|
@ -229,18 +234,35 @@
|
|||
"You've confirmed email": "You've confirmed email",
|
||||
"You've reached a non-existed page": "You've reached a non-existed page",
|
||||
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Your name will appear on your profile page and as your signature in publications, comments and responses",
|
||||
"zine": "zine",
|
||||
"By time": "By time",
|
||||
"New only": "New only",
|
||||
"Short opening": "Short opening",
|
||||
"Write an article": "Write an article",
|
||||
"Enter URL address": "Enter URL address",
|
||||
"Invalid url format": "Invalid url format",
|
||||
"Headers": "Headers",
|
||||
"Quotes": "Quotes",
|
||||
"Lists": "Lists",
|
||||
"Bookmarks": "Bookmarks",
|
||||
"Logout": "Logout",
|
||||
"This comment has not yet been rated": "This comment has not yet been rated",
|
||||
"This post has not been rated yet": "This post has not been rated yet"
|
||||
"accomplices": "accomplices",
|
||||
"actions": "actions",
|
||||
"all topics": "all topics",
|
||||
"author": "author",
|
||||
"authors": "authors",
|
||||
"bookmarks": "bookmarks",
|
||||
"cancel": "Cancel",
|
||||
"collections": "collections",
|
||||
"community": "community",
|
||||
"discussion": "discourse",
|
||||
"discussions": "discussions",
|
||||
"drafts": "drafts",
|
||||
"email not confirmed": "email not confirmed",
|
||||
"enter": "enter",
|
||||
"feed": "feed",
|
||||
"follower": "follower",
|
||||
"general feed": "general tape",
|
||||
"invalid password": "invalid password",
|
||||
"my feed": "my ribbon",
|
||||
"notifications": "notifications",
|
||||
"personal data usage and email notifications": "to process personal data and receive email notifications",
|
||||
"post": "post",
|
||||
"register": "register",
|
||||
"shout": "post",
|
||||
"sign up or sign in": "sign up or sign in",
|
||||
"slug is used by another user": "Slug is already taken by another user",
|
||||
"terms of use": "terms of use",
|
||||
"topics": "topics",
|
||||
"user already exist": "user already exists",
|
||||
"view": "view",
|
||||
"zine": "zine"
|
||||
}
|
||||
|
|
|
@ -14,10 +14,12 @@
|
|||
"Artworks": "Артворки",
|
||||
"Audio": "Аудио",
|
||||
"Author": "Автор",
|
||||
"Author subscriptions": "Подписки на авторов",
|
||||
"Authors": "Авторы",
|
||||
"Back to main page": "Вернуться на главную",
|
||||
"Become an author": "Стать автором",
|
||||
"Bookmarked": "Сохранено",
|
||||
"Bookmarks": "Закладки",
|
||||
"By alphabet": "По алфавиту",
|
||||
"By authors": "По авторам",
|
||||
"By name": "По имени",
|
||||
|
@ -26,9 +28,11 @@
|
|||
"By relevance": "По релевантности",
|
||||
"By shouts": "По публикациям",
|
||||
"By signing up you agree with our": "Регистрируясь, вы соглашаетесь с",
|
||||
"By time": "По порядку",
|
||||
"By title": "По названию",
|
||||
"By updates": "По обновлениям",
|
||||
"By views": "По просмотрам",
|
||||
"Characters": "Знаков",
|
||||
"Chat Title": "Тема дискурса",
|
||||
"Choose who you want to write to": "Выберите кому хотите написать",
|
||||
"Collaborate": "Помочь редактировать",
|
||||
|
@ -56,6 +60,7 @@
|
|||
"Editing": "Редактирование",
|
||||
"Email": "Почта",
|
||||
"Enter": "Войти",
|
||||
"Enter URL address": "Введите адрес ссылки",
|
||||
"Enter text": "Введите текст",
|
||||
"Enter the Discours": "Войти в Дискурс",
|
||||
"Enter the code or click the link from email to confirm": "Введите код из письма или пройдите по ссылке в письме для подтверждения регистрации",
|
||||
|
@ -80,12 +85,16 @@
|
|||
"Group Chat": "Общий чат",
|
||||
"Groups": "Группы",
|
||||
"Header": "Заголовок",
|
||||
"Headers": "Заголовки",
|
||||
"Help": "Помощь",
|
||||
"Help to edit": "Помочь редактировать",
|
||||
"Here you can customize your profile the way you want.": "Здесь можно настроить свой профиль так, как вы хотите.",
|
||||
"Hooray! Welcome!": "Ура! Добро пожаловать!",
|
||||
"Horizontal collaborative journalistic platform": "Горизонтальная платформа для коллаборативной журналистики",
|
||||
"Hotkeys": "Горячие клавиши",
|
||||
"How can I help/skills": "Чем могу помочь/навыки",
|
||||
"How it works": "Как это работает",
|
||||
"How to write a good article": "Как написать хорошую статью",
|
||||
"How to write an article": "Как написать статью",
|
||||
"I have an account": "У меня есть аккаунт!",
|
||||
"I have no account yet": "У меня еще нет аккаунта",
|
||||
|
@ -94,6 +103,7 @@
|
|||
"Introduce": "Представление",
|
||||
"Invalid email": "Проверьте правильность ввода почты",
|
||||
"Invite co-authors": "Пригласить соавторов",
|
||||
"Invalid url format": "Неверный формат ссылки",
|
||||
"Invite experts": "Пригласить экспертов",
|
||||
"Invite to collab": "Пригласить к участию",
|
||||
"It does not look like url": "Это не похоже на ссылку",
|
||||
|
@ -104,10 +114,13 @@
|
|||
"Just start typing...": "Просто начните печатать...",
|
||||
"Karma": "Карма",
|
||||
"Knowledge base": "База знаний",
|
||||
"Last rev.": "Посл. изм.",
|
||||
"Link sent, check your email": "Ссылка отправлена, проверьте почту",
|
||||
"Lists": "Списки",
|
||||
"Literature": "Литература",
|
||||
"Load more": "Показать ещё",
|
||||
"Loading": "Загрузка",
|
||||
"Logout": "Выход",
|
||||
"Manifest": "Манифест",
|
||||
"More": "Ещё",
|
||||
"Most commented": "Комментируемое",
|
||||
|
@ -115,6 +128,7 @@
|
|||
"My feed": "Моя лента",
|
||||
"My subscriptions": "Подписки",
|
||||
"Name": "Имя",
|
||||
"New only": "Только новые",
|
||||
"New password": "Новый пароль",
|
||||
"New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
|
||||
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
|
||||
|
@ -122,11 +136,13 @@
|
|||
"Nothing is here": "Здесь ничего нет",
|
||||
"Or continue with social network": "Или продолжите через соцсеть",
|
||||
"Our regular contributor": "Наш постоянный автор",
|
||||
"Paragraphs": "Абзацев",
|
||||
"Participating": "Участвовать",
|
||||
"Partners": "Партнёры",
|
||||
"Password": "Пароль",
|
||||
"Password again": "Пароль ещё раз",
|
||||
"Passwords are not equal": "Пароли не совпадают",
|
||||
"Paste Embed code": "Вставьте embed код",
|
||||
"Personal": "Личные",
|
||||
"Pin": "Закрепить",
|
||||
"Please check your email address": "Пожалуйста, проверьте введенный адрес почты",
|
||||
|
@ -147,6 +163,7 @@
|
|||
"Publication settings": "Настройки публикации",
|
||||
"Publish": "Опубликовать",
|
||||
"Quit": "Выйти",
|
||||
"Quotes": "Цитаты",
|
||||
"Reason uknown": "Причина неизвестна",
|
||||
"Recent": "Свежее",
|
||||
"Reply": "Ответить",
|
||||
|
@ -154,8 +171,8 @@
|
|||
"Resend code": "Выслать подтверждение",
|
||||
"Restore password": "Восстановить пароль",
|
||||
"Save": "Сохранить",
|
||||
"Save settings": "Сохранить настройки",
|
||||
"Save draft": "Сохранить черновик",
|
||||
"Save settings": "Сохранить настройки",
|
||||
"Search": "Поиск",
|
||||
"Search author": "Поиск автора",
|
||||
"Search topic": "Поиск темы",
|
||||
|
@ -166,6 +183,7 @@
|
|||
"Send link again": "Прислать ссылку ещё раз",
|
||||
"Settings": "Настройки",
|
||||
"Share": "Поделиться",
|
||||
"Short opening": "Небольшое вступление, чтобы заинтересовать читателя",
|
||||
"Show": "Показать",
|
||||
"Social networks": "Социальные сети",
|
||||
"Something went wrong, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль",
|
||||
|
@ -185,7 +203,9 @@
|
|||
"Support us": "Помочь журналу",
|
||||
"Terms of use": "Правила сайта",
|
||||
"Thank you": "Благодарности",
|
||||
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
|
||||
"This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы",
|
||||
"This post has not been rated yet": "Эту публикацию еще пока никто не оценил",
|
||||
"To leave a comment please": "Чтобы оставить комментарий, необходимо",
|
||||
"To write a comment, you must": "Чтобы написать комментарий, необходимо",
|
||||
"Top authors": "Рейтинг авторов",
|
||||
|
@ -197,6 +217,7 @@
|
|||
"Top topics": "Интересные темы",
|
||||
"Top viewed": "Самое читаемое",
|
||||
"Topic is supported by": "Тему поддерживают",
|
||||
"Topic subscriptions": "Подписки на темы",
|
||||
"Topics": "Темы",
|
||||
"Topics which supported by author": "Автор поддерживает темы",
|
||||
"Try to find another way": "Попробуйте найти по-другому",
|
||||
|
@ -213,10 +234,12 @@
|
|||
"We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.",
|
||||
"Welcome!": "Добро пожаловать!",
|
||||
"Where": "Откуда",
|
||||
"Words": "Слов",
|
||||
"Work with us": "Сотрудничать с Дискурсом",
|
||||
"Write": "Написать",
|
||||
"Write a comment...": "Написать комментарий...",
|
||||
"Write about the topic": "Написать в тему",
|
||||
"Write an article": "Написать статью",
|
||||
"Write comment": "Написать комментарий",
|
||||
"Write message": "Написать сообщение",
|
||||
"Write to us": "Напишите нам",
|
||||
|
@ -226,10 +249,12 @@
|
|||
"You've reached a non-existed page": "Вы попали на несуществующую страницу",
|
||||
"You've successfully logged out": "Вы успешно вышли из аккаунта",
|
||||
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Ваше имя появится на странице вашего профиля и как ваша подпись в публикациях, комментариях и откликах",
|
||||
"accomplices": "соучастники",
|
||||
"actions": "действия",
|
||||
"all topics": "все темы",
|
||||
"author": "автор",
|
||||
"authors": "авторы",
|
||||
"bookmarks": "закладки",
|
||||
"cancel": "Отмена",
|
||||
"collections": "коллекции",
|
||||
"community": "сообщество",
|
||||
|
@ -237,11 +262,16 @@
|
|||
"create_group": "Создать группу",
|
||||
"discourse_theme": "Тема дискурса",
|
||||
"discussion": "дискурс",
|
||||
"discussions": "дискуссии",
|
||||
"drafts": "черновики",
|
||||
"email not confirmed": "email не подтвержден",
|
||||
"enter": "войдите",
|
||||
"feed": "лента",
|
||||
"follower": "подписчик",
|
||||
"general feed": "общая лента",
|
||||
"invalid password": "некорректный пароль",
|
||||
"my feed": "моя лента",
|
||||
"notifications": "уведомления",
|
||||
"or": "или",
|
||||
"personal data usage and email notifications": "на обработку персональных данных и на получение почтовых уведомлений",
|
||||
"post": "пост",
|
||||
|
@ -255,18 +285,5 @@
|
|||
"topics": "темы",
|
||||
"user already exist": "пользователь уже существует",
|
||||
"view": "просмотр",
|
||||
"zine": "журнал",
|
||||
"By time": "По порядку",
|
||||
"New only": "Только новые",
|
||||
"Short opening": "Небольшое вступление, чтобы заинтересовать читателя",
|
||||
"Write an article": "Написать статью",
|
||||
"Enter URL address": "Введите адрес ссылки",
|
||||
"Invalid url format": "Неверный формат ссылки",
|
||||
"Headers": "Заголовки",
|
||||
"Quotes": "Цитаты",
|
||||
"Lists": "Списки",
|
||||
"Bookmarks": "Закладки",
|
||||
"Logout": "Выход",
|
||||
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
|
||||
"This post has not been rated yet": "Эту публикацию еще пока никто не оценил"
|
||||
"zine": "журнал"
|
||||
}
|
||||
|
|
|
@ -37,6 +37,7 @@ import { ProfileSecurityPage } from '../pages/profile/profileSecurity.page'
|
|||
import { ProfileSubscriptionsPage } from '../pages/profile/profileSubscriptions.page'
|
||||
import { SnackbarProvider } from '../context/snackbar'
|
||||
import { LocalizeProvider } from '../context/localize'
|
||||
import { EditorProvider } from '../context/editor'
|
||||
|
||||
// TODO: lazy load
|
||||
// const SomePage = lazy(() => import('./Pages/SomePage'))
|
||||
|
@ -93,11 +94,13 @@ export const App = (props: PageProps) => {
|
|||
|
||||
return (
|
||||
<LocalizeProvider>
|
||||
<SnackbarProvider>
|
||||
<SessionProvider>
|
||||
<Dynamic component={pageComponent()} {...props} />
|
||||
</SessionProvider>
|
||||
</SnackbarProvider>
|
||||
<EditorProvider>
|
||||
<SnackbarProvider>
|
||||
<SessionProvider>
|
||||
<Dynamic component={pageComponent()} {...props} />
|
||||
</SessionProvider>
|
||||
</SnackbarProvider>
|
||||
</EditorProvider>
|
||||
</LocalizeProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -186,9 +186,27 @@ img {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shoutStatsItemInner {
|
||||
cursor: pointer;
|
||||
margin: -0.3em -0.3em 0;
|
||||
padding: 0.3em;
|
||||
|
||||
.icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.iconEdit {
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #000;
|
||||
cursor: pointer;
|
||||
img {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@ interface MediaItem {
|
|||
|
||||
const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
|
||||
const { t } = useLocalize()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch fallback={<a href={props.media.url}>{t('Cannot show this media type')}</a>}>
|
||||
|
@ -84,12 +85,11 @@ export const FullArticle = (props: ArticleProps) => {
|
|||
const body = createMemo(() => props.article.body)
|
||||
const media = createMemo(() => {
|
||||
const mi = JSON.parse(props.article.media || '[]')
|
||||
console.debug(mi)
|
||||
console.debug('!!! media items', mi)
|
||||
return mi
|
||||
})
|
||||
|
||||
const commentsRef: { current: HTMLDivElement } = { current: null }
|
||||
|
||||
const scrollToComments = () => {
|
||||
window.scrollTo({
|
||||
top: commentsRef.current.offsetTop - 96,
|
||||
|
@ -97,12 +97,20 @@ export const FullArticle = (props: ArticleProps) => {
|
|||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
const { searchParams } = useRouter()
|
||||
|
||||
createEffect(() => {
|
||||
if (props.scrollToComments) {
|
||||
scrollToComments()
|
||||
}
|
||||
})
|
||||
const { changeSearchParam } = useRouter()
|
||||
createEffect(() => {
|
||||
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
|
||||
scrollToComments()
|
||||
changeSearchParam('scrollTo', null)
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
actions: { loadReactionsBy }
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { createEffect } from 'solid-js'
|
||||
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
import { Blockquote } from '@tiptap/extension-blockquote'
|
||||
|
@ -19,7 +20,6 @@ import { HardBreak } from '@tiptap/extension-hard-break'
|
|||
import { Heading } from '@tiptap/extension-heading'
|
||||
import { Highlight } from '@tiptap/extension-highlight'
|
||||
import { Link } from '@tiptap/extension-link'
|
||||
import { Youtube } from '@tiptap/extension-youtube'
|
||||
import { Document } from '@tiptap/extension-document'
|
||||
import { Text } from '@tiptap/extension-text'
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
|
@ -37,6 +37,8 @@ import { IndexeddbPersistence } from 'y-indexeddb'
|
|||
import { useSession } from '../../context/session'
|
||||
import uniqolor from 'uniqolor'
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider'
|
||||
import { Embed } from './extensions/embed'
|
||||
import { useEditorContext } from '../../context/editor'
|
||||
|
||||
type EditorProps = {
|
||||
shoutSlug: string
|
||||
|
@ -128,7 +130,7 @@ export const Editor = (props: EditorProps) => {
|
|||
HardBreak,
|
||||
Highlight,
|
||||
Image,
|
||||
Youtube,
|
||||
Embed,
|
||||
TrailingNode,
|
||||
BubbleMenu.configure({
|
||||
element: bubbleMenuRef.current
|
||||
|
@ -142,6 +144,22 @@ export const Editor = (props: EditorProps) => {
|
|||
]
|
||||
}))
|
||||
|
||||
const html = useEditorHTML(() => editor())
|
||||
|
||||
const {
|
||||
actions: { countWords }
|
||||
} = useEditorContext()
|
||||
|
||||
createEffect(() => {
|
||||
props.onChange(html())
|
||||
if (html()) {
|
||||
countWords({
|
||||
characters: editor().storage.characterCount.characters(),
|
||||
words: editor().storage.characterCount.words()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={(el) => (editorElRef.current = el)} />
|
||||
|
|
|
@ -5,7 +5,8 @@ import { Icon } from '../../_shared/Icon'
|
|||
import { clsx } from 'clsx'
|
||||
import { createEditorTransaction } from 'solid-tiptap'
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { LinkForm } from './LinkForm'
|
||||
import { InlineForm } from '../InlineForm'
|
||||
import validateUrl from '../../../utils/validateUrl'
|
||||
|
||||
type BubbleMenuProps = {
|
||||
editor: Editor
|
||||
|
@ -55,12 +56,37 @@ export const EditorBubbleMenu = (props: BubbleMenuProps) => {
|
|||
setListBubbleOpen((prev) => !prev)
|
||||
}
|
||||
|
||||
const handleLinkFormSubmit = (value: string) => {
|
||||
props.editor.chain().focus().setLink({ href: value }).run()
|
||||
}
|
||||
|
||||
const currentUrl = createEditorTransaction(
|
||||
() => props.editor,
|
||||
(editor) => {
|
||||
return (editor && editor.getAttributes('link').href) || ''
|
||||
}
|
||||
)
|
||||
|
||||
const handleClearLinkForm = () => {
|
||||
if (currentUrl()) {
|
||||
props.editor.chain().focus().unsetLink().run()
|
||||
}
|
||||
setLinkEditorOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={props.ref} class={styles.bubbleMenu}>
|
||||
<Switch>
|
||||
<Match when={linkEditorOpen()}>
|
||||
<LinkForm editor={props.editor} onClose={() => setLinkEditorOpen(false)} />
|
||||
<InlineForm
|
||||
variant="inBubble"
|
||||
initialValue={currentUrl() ?? ''}
|
||||
onClear={handleClearLinkForm}
|
||||
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
|
||||
onSubmit={handleLinkFormSubmit}
|
||||
onClose={() => setLinkEditorOpen(false)}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!linkEditorOpen()}>
|
||||
<>
|
||||
|
|
|
@ -6,5 +6,10 @@
|
|||
button {
|
||||
opacity: 0.3;
|
||||
vertical-align: text-bottom;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,51 @@
|
|||
import { createSignal, Show } from 'solid-js'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import styles from './EditorFloatingMenu.module.scss'
|
||||
import { Icon } from '../_shared/Icon'
|
||||
import { createSignal } from 'solid-js'
|
||||
import { InlineForm } from './InlineForm'
|
||||
import styles from './EditorFloatingMenu.module.scss'
|
||||
import HTMLParser from 'html-to-json-parser'
|
||||
|
||||
type FloatingMenuProps = {
|
||||
editor: Editor
|
||||
ref: (el: HTMLDivElement) => void
|
||||
}
|
||||
|
||||
const embedData = async (data) => {
|
||||
const result = await HTMLParser(data, false)
|
||||
if (result && 'type' in result && result.type === 'iframe') {
|
||||
return result.attributes
|
||||
}
|
||||
}
|
||||
|
||||
export const EditorFloatingMenu = (props: FloatingMenuProps) => {
|
||||
const [inlineEditorOpen, setInlineEditorOpen] = createSignal<boolean>(false)
|
||||
|
||||
const handleEmbedFormSubmit = async (value: string) => {
|
||||
// TODO: add support instagram embed (blockquote)
|
||||
const emb = await embedData(value)
|
||||
props.editor.chain().focus().setIframe(emb).run()
|
||||
}
|
||||
|
||||
const validateEmbed = async (value) => {
|
||||
const iframeData = await HTMLParser(value, false)
|
||||
if (iframeData && iframeData.type !== 'iframe') {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={props.ref} class={styles.editorFloatingMenu}>
|
||||
<button>
|
||||
<button type="button" onClick={() => setInlineEditorOpen(true)}>
|
||||
<Icon name="editor-plus" />
|
||||
</button>
|
||||
<Show when={inlineEditorOpen()}>
|
||||
<InlineForm
|
||||
variant="inFloating"
|
||||
onClose={() => setInlineEditorOpen(false)}
|
||||
validate={validateEmbed}
|
||||
onSubmit={handleEmbedFormSubmit}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
67
src/components/Editor/InlineForm/InlineForm.module.scss
Normal file
67
src/components/Editor/InlineForm/InlineForm.module.scss
Normal file
|
@ -0,0 +1,67 @@
|
|||
.InlineForm {
|
||||
position: relative;
|
||||
|
||||
&.inBubble {
|
||||
//...
|
||||
}
|
||||
|
||||
&.inFloating {
|
||||
position: absolute;
|
||||
left: calc(100% + 1rem);
|
||||
top: -0.8rem;
|
||||
min-width: 64vw;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 10px rgba(#000, 0.25);
|
||||
|
||||
button {
|
||||
opacity: 1;
|
||||
&:disabled,
|
||||
&:disabled:hover {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
padding: 6px 11px;
|
||||
|
||||
input {
|
||||
margin: 0 12px 0 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
border: none;
|
||||
min-width: 200px;
|
||||
display: block;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(#000, 0.3);
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linkError {
|
||||
padding: 6px 11px;
|
||||
color: red;
|
||||
font-size: 0.7em;
|
||||
position: absolute;
|
||||
bottom: -3rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 0;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 10px rgba(#000, 0.25);
|
||||
opacity: 0;
|
||||
transition: height 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
|
||||
&.visible {
|
||||
height: 32px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
90
src/components/Editor/InlineForm/InlineForm.tsx
Normal file
90
src/components/Editor/InlineForm/InlineForm.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
import styles from './InlineForm.module.scss'
|
||||
import { Icon } from '../../_shared/Icon'
|
||||
import { createEditorTransaction } from 'solid-tiptap'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
onClear?: () => void
|
||||
onSubmit: (value: string) => void
|
||||
variant: 'inBubble' | 'inFloating'
|
||||
validate?: (value: string) => string | Promise<string>
|
||||
initialValue?: string
|
||||
}
|
||||
|
||||
export const InlineForm = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const [formValue, setFormValue] = createSignal(props.initialValue || '')
|
||||
const [formValueError, setFormValueError] = createSignal('')
|
||||
|
||||
const handleFormInput = (value) => {
|
||||
setFormValue(value)
|
||||
}
|
||||
|
||||
const handleSaveButtonClick = async () => {
|
||||
const errorMessage = await props.validate(formValue())
|
||||
if (errorMessage) {
|
||||
setFormValueError(errorMessage)
|
||||
return
|
||||
}
|
||||
props.onSubmit(formValue())
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
const handleKeyPress = async (event) => {
|
||||
setFormValueError('')
|
||||
const key = event.key
|
||||
|
||||
if (key === 'Enter') {
|
||||
await handleSaveButtonClick()
|
||||
}
|
||||
|
||||
if (key === 'Esc') {
|
||||
props.onClear
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={clsx(styles.InlineForm, {
|
||||
[styles.inBubble]: props.variant === 'inBubble',
|
||||
[styles.inFloating]: props.variant === 'inFloating'
|
||||
})}
|
||||
>
|
||||
<div class={styles.form}>
|
||||
<Show when={props.variant === 'inBubble'}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Enter URL address')}
|
||||
autofocus
|
||||
value={props.initialValue}
|
||||
onKeyPress={(e) => handleKeyPress(e)}
|
||||
onInput={(e) => handleFormInput(e.currentTarget.value)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.variant === 'inFloating'}>
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
placeholder={t('Paste Embed code')}
|
||||
onKeyPress={(e) => handleKeyPress(e)}
|
||||
onInput={(e) => handleFormInput(e.currentTarget.value)}
|
||||
/>
|
||||
</Show>
|
||||
<button type="button" onClick={handleSaveButtonClick} disabled={formValueError() !== ''}>
|
||||
<Icon name="status-done" />
|
||||
</button>
|
||||
<button type="button" onClick={props.onClear}>
|
||||
{props.initialValue ? <Icon name="editor-unlink" /> : <Icon name="status-cancel" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class={clsx(styles.linkError, { [styles.visible]: Boolean(formValueError()) })}>
|
||||
{formValueError()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
1
src/components/Editor/InlineForm/index.ts
Normal file
1
src/components/Editor/InlineForm/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { InlineForm } from './InlineForm'
|
93
src/components/Editor/Panel/Panel.module.scss
Normal file
93
src/components/Editor/Panel/Panel.module.scss
Normal file
|
@ -0,0 +1,93 @@
|
|||
.Panel {
|
||||
background: #1f1f1f;
|
||||
color: rgb(255 255 255 / 0.35);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 1.7rem;
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
line-height: 1.4;
|
||||
padding: $grid-gutter-width $grid-gutter-width / 2;
|
||||
position: fixed;
|
||||
transition: transform 0.3s;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
.close {
|
||||
filter: invert(1);
|
||||
margin: -1.6rem 0 0 -1.6rem;
|
||||
}
|
||||
|
||||
.actionsHolder {
|
||||
padding: 0 $grid-gutter-width / 2;
|
||||
|
||||
&.scrolled {
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
border-bottom: 2px solid rgb(255 255 255 / 0.1);
|
||||
padding: 1.8rem 0;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.6em 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
font-weight: normal;
|
||||
margin-left: -1.6rem;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonWithIcon {
|
||||
margin-left: -1.6rem;
|
||||
|
||||
.icon {
|
||||
filter: invert(0.5);
|
||||
margin-right: 0.3em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
margin-top: 3em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(255 255 255 / 0.35);
|
||||
font-weight: normal !important;
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
&.hidden {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
107
src/components/Editor/Panel/Panel.tsx
Normal file
107
src/components/Editor/Panel/Panel.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
import { Show } from 'solid-js'
|
||||
import { clsx } from 'clsx'
|
||||
import { Button } from '../../_shared/Button'
|
||||
import { Icon } from '../../_shared/Icon'
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import styles from './Panel.module.scss'
|
||||
import { useEditorContext } from '../../../context/editor'
|
||||
|
||||
type Props = {
|
||||
// isVisible: boolean
|
||||
}
|
||||
|
||||
export const Panel = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const {
|
||||
isEditorPanelVisible,
|
||||
wordCounter,
|
||||
actions: { toggleEditorPanel }
|
||||
} = useEditorContext()
|
||||
|
||||
return (
|
||||
<aside class={clsx('col-md-6', styles.Panel, { [styles.hidden]: !isEditorPanelVisible() })}>
|
||||
<div class={styles.actionsHolder}>
|
||||
<Button
|
||||
value={<Icon name="close" />}
|
||||
variant={'inline'}
|
||||
class={styles.close}
|
||||
onClick={() => toggleEditorPanel()}
|
||||
/>
|
||||
</div>
|
||||
<div class={clsx(styles.actionsHolder, styles.scrolled)}>
|
||||
<section>
|
||||
<Button value={t('Publish')} variant={'inline'} class={styles.button} />
|
||||
<Button value={t('Save draft')} variant={'inline'} class={styles.button} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Button
|
||||
value={
|
||||
<>
|
||||
<Icon name="eye" class={styles.icon} />
|
||||
{t('Preview')}
|
||||
</>
|
||||
}
|
||||
variant={'inline'}
|
||||
class={clsx(styles.button, styles.buttonWithIcon)}
|
||||
/>
|
||||
<Button
|
||||
value={
|
||||
<>
|
||||
<Icon name="pencil-outline" class={styles.icon} />
|
||||
{t('Editing')}
|
||||
</>
|
||||
}
|
||||
variant={'inline'}
|
||||
class={clsx(styles.button, styles.buttonWithIcon)}
|
||||
/>
|
||||
<Button
|
||||
value={
|
||||
<>
|
||||
<Icon name="feed-discussion" class={styles.icon} />
|
||||
{t('FAQ')}
|
||||
</>
|
||||
}
|
||||
variant={'inline'}
|
||||
class={clsx(styles.button, styles.buttonWithIcon)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<Button value={t('Invite co-authors')} variant={'inline'} class={styles.button} />
|
||||
<Button value={t('Publication settings')} variant={'inline'} class={styles.button} />
|
||||
<Button value={t('Corrections history')} variant={'inline'} class={styles.button} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p>
|
||||
<a href="/how-to-write-a-good-article">{t('How to write a good article')}</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#">{t('Hotkeys')}</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#">{t('Help')}</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class={styles.stats}>
|
||||
<div>
|
||||
{t('Characters')}: <em>{wordCounter().characters}</em>
|
||||
</div>
|
||||
<div>
|
||||
{t('Words')}: <em>{wordCounter().words}</em>
|
||||
</div>
|
||||
<Show when={wordCounter().paragraphs}>
|
||||
<div>
|
||||
{t('Paragraphs')}: <em>{wordCounter().paragraphs}</em>
|
||||
</div>
|
||||
</Show>
|
||||
<div>
|
||||
{t('Last rev.')}: <em>22.03.22 в 18:20</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
1
src/components/Editor/Panel/index.ts
Normal file
1
src/components/Editor/Panel/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Panel } from './Panel'
|
|
@ -47,3 +47,17 @@
|
|||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.embed-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: #f1f1f1;
|
||||
margin: 4rem 0;
|
||||
|
||||
iframe {
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
|
73
src/components/Editor/extensions/embed.ts
Normal file
73
src/components/Editor/extensions/embed.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { mergeAttributes, Node } from '@tiptap/core'
|
||||
import { NodeRange } from 'prosemirror-model'
|
||||
import { insert } from 'solid-js/web'
|
||||
import { TextSelection } from 'prosemirror-state'
|
||||
|
||||
export interface IframeOptions {
|
||||
allowFullscreen: boolean
|
||||
HTMLAttributes: {
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
iframe: {
|
||||
setIframe: (options: { src: string }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const Embed = Node.create<IframeOptions>({
|
||||
name: 'embed',
|
||||
group: 'block',
|
||||
selectable: true,
|
||||
atom: true,
|
||||
draggable: true,
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
width: { default: null },
|
||||
height: { default: null }
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'iframe'
|
||||
}
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['iframe', mergeAttributes(HTMLAttributes)]
|
||||
},
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const div = document.createElement('div')
|
||||
div.className = 'embed-wrapper'
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.width = node.attrs.width
|
||||
iframe.height = node.attrs.height
|
||||
iframe.allowfullscreen = node.attrs.allowfullscreen
|
||||
iframe.src = node.attrs.src
|
||||
div.append(iframe)
|
||||
return {
|
||||
dom: div
|
||||
}
|
||||
}
|
||||
},
|
||||
addCommands() {
|
||||
return {
|
||||
setIframe:
|
||||
(options) =>
|
||||
({ tr, dispatch }) => {
|
||||
const { selection } = tr
|
||||
const node = this.type.create(options)
|
||||
if (dispatch) {
|
||||
tr.replaceRangeWith(selection.from, selection.to, node)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
|
@ -12,6 +12,8 @@ import stylesHeader from '../Nav/Header.module.scss'
|
|||
import { getDescription } from '../../utils/meta'
|
||||
import { FeedArticlePopup } from './FeedArticlePopup'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
import { openPage } from '@nanostores/router'
|
||||
import { router, useRouter } from '../../stores/router'
|
||||
|
||||
interface ArticleCardProps {
|
||||
settings?: {
|
||||
|
@ -35,6 +37,7 @@ interface ArticleCardProps {
|
|||
isBeside?: boolean
|
||||
}
|
||||
article: Shout
|
||||
scrollTo: 'comments'
|
||||
}
|
||||
|
||||
const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string } => {
|
||||
|
@ -75,6 +78,13 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
|
||||
const { cover, layout, slug, authors, stat, body } = props.article
|
||||
|
||||
const { changeSearchParam } = useRouter()
|
||||
const scrollToComments = (event) => {
|
||||
event.preventDefault()
|
||||
openPage(router, 'article', { slug: slug })
|
||||
changeSearchParam('scrollTo', 'comments')
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
class={clsx(styles.shoutCard, `${props.settings?.additionalClass || ''}`)}
|
||||
|
@ -172,7 +182,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
</div>
|
||||
|
||||
<div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardComments)}>
|
||||
<a href={`/${slug + '#comments'}`}>
|
||||
<a href="#" onClick={(event) => scrollToComments(event)}>
|
||||
<Icon name="comment" class={clsx(styles.icon, styles.feedControlIcon)} />
|
||||
{stat?.commented || t('Add comment')}
|
||||
</a>
|
||||
|
|
|
@ -1,83 +0,0 @@
|
|||
.sidebar {
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow: auto;
|
||||
position: sticky;
|
||||
top: 120px;
|
||||
}
|
||||
|
||||
.sidebarItemName {
|
||||
margin-right: 0.5em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.counter {
|
||||
@include font-size(1.2rem);
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
background: #f6f6f6;
|
||||
border-radius: 0.8rem;
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
justify-content: center;
|
||||
min-width: 2em;
|
||||
margin-left: 0.5em;
|
||||
padding: 0.25em 0.5em 0.15em;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.unread {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
background: #2638d9;
|
||||
border-radius: 100%;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 0.5em;
|
||||
left: 100%;
|
||||
margin-left: 0.3em;
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
width: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.settings {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
a {
|
||||
img {
|
||||
transition: filter 0.3s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.counter {
|
||||
background: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
height: 2rem;
|
||||
margin-right: 0.5em;
|
||||
vertical-align: middle;
|
||||
width: 2.2rem;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
|
@ -1,138 +0,0 @@
|
|||
import { For } from 'solid-js'
|
||||
import type { Author } from '../../graphql/types.gen'
|
||||
import { useAuthorsStore } from '../../stores/zine/authors'
|
||||
|
||||
import { Icon } from '../_shared/Icon'
|
||||
import { useTopicsStore } from '../../stores/zine/topics'
|
||||
import { useArticlesStore } from '../../stores/zine/articles'
|
||||
import { useSeenStore } from '../../stores/zine/seen'
|
||||
import { useSession } from '../../context/session'
|
||||
import styles from './Sidebar.module.scss'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
|
||||
type FeedSidebarProps = {
|
||||
authors: Author[]
|
||||
}
|
||||
|
||||
export const FeedSidebar = (props: FeedSidebarProps) => {
|
||||
const { t } = useLocalize()
|
||||
const { seen } = useSeenStore()
|
||||
const { session } = useSession()
|
||||
const { authorEntities } = useAuthorsStore({ authors: props.authors })
|
||||
const { articlesByTopic } = useArticlesStore()
|
||||
const { topicEntities } = useTopicsStore()
|
||||
|
||||
const checkTopicIsSeen = (topicSlug: string) => {
|
||||
return articlesByTopic()[topicSlug]?.every((article) => Boolean(seen()[article.slug]))
|
||||
}
|
||||
|
||||
const checkAuthorIsSeen = (authorSlug: string) => {
|
||||
return Boolean(seen()[authorSlug])
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={styles.sidebar}>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="#">
|
||||
<span class={styles.sidebarItemName}>
|
||||
<Icon name="feed-all" class={styles.icon} />
|
||||
<strong>общая лента</strong>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">
|
||||
<span class={styles.sidebarItemName}>
|
||||
<Icon name="feed-my" class={styles.icon} />
|
||||
моя лента
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">
|
||||
<span class={styles.sidebarItemName}>
|
||||
<Icon name="feed-collaborate" class={styles.icon} />
|
||||
соучастие
|
||||
</span>
|
||||
<span class={styles.counter}>7</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">
|
||||
<span class={styles.sidebarItemName}>
|
||||
<Icon name="feed-discussion" class={styles.icon} />
|
||||
дискуссии
|
||||
</span>
|
||||
<span class={styles.counter}>18</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">
|
||||
<span class={styles.sidebarItemName}>
|
||||
<Icon name="feed-drafts" class={styles.icon} />
|
||||
черновики
|
||||
</span>
|
||||
<span class={styles.counter}>283</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">
|
||||
<span class={styles.sidebarItemName}>
|
||||
<Icon name="bookmark" class={styles.icon} />
|
||||
закладки
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#">
|
||||
<span class={styles.sidebarItemName}>
|
||||
<Icon name="feed-notifications" class={styles.icon} />
|
||||
уведомления
|
||||
</span>
|
||||
<span class={styles.counter}>283</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/feed?by=subscribed">
|
||||
<strong>{t('My subscriptions')}</strong>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<For each={session()?.news?.authors}>
|
||||
{(authorSlug: string) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/author/${authorSlug}`}
|
||||
classList={{ [styles.unread]: checkAuthorIsSeen(authorSlug) }}
|
||||
>
|
||||
<small>@{authorSlug}</small>
|
||||
{authorEntities()[authorSlug]?.name}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<For each={session()?.news?.topics}>
|
||||
{(topicSlug: string) => (
|
||||
<li>
|
||||
<a href={`/author/${topicSlug}`} classList={{ [styles.unread]: checkTopicIsSeen(topicSlug) }}>
|
||||
{topicEntities()[topicSlug]?.title}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
|
||||
<div class={styles.settings}>
|
||||
<a href="/feed/settings">
|
||||
<Icon name="settings" class={styles.icon} />
|
||||
{t('Feed settings')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
89
src/components/Feed/Sidebar/Sidebar.module.scss
Normal file
89
src/components/Feed/Sidebar/Sidebar.module.scss
Normal file
|
@ -0,0 +1,89 @@
|
|||
.sidebar {
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow: auto;
|
||||
position: sticky;
|
||||
top: 120px;
|
||||
|
||||
ul > li {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebarItemName {
|
||||
margin-right: 0.5em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.counter {
|
||||
@include font-size(1.2rem);
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
background: #f6f6f6;
|
||||
border-radius: 0.8rem;
|
||||
display: inline-flex;
|
||||
font-weight: bold;
|
||||
justify-content: center;
|
||||
min-width: 2em;
|
||||
margin-left: 0.5em;
|
||||
padding: 0.25em 0.5em 0.15em;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.unread {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
background: #2638d9;
|
||||
border-radius: 100%;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 0.5em;
|
||||
left: 100%;
|
||||
margin-left: 0.3em;
|
||||
position: absolute;
|
||||
top: 0.5em;
|
||||
width: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.settings {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
a {
|
||||
img {
|
||||
transition: filter 0.3s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.counter {
|
||||
background: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
height: 2rem;
|
||||
margin-right: 0.5em;
|
||||
vertical-align: middle;
|
||||
width: 2.2rem;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
130
src/components/Feed/Sidebar/Sidebar.tsx
Normal file
130
src/components/Feed/Sidebar/Sidebar.tsx
Normal file
|
@ -0,0 +1,130 @@
|
|||
import { createSignal, For } from 'solid-js'
|
||||
import type { Author } from '../../../graphql/types.gen'
|
||||
import { useAuthorsStore } from '../../../stores/zine/authors'
|
||||
import { Icon } from '../../_shared/Icon'
|
||||
import { useTopicsStore } from '../../../stores/zine/topics'
|
||||
import { useArticlesStore } from '../../../stores/zine/articles'
|
||||
import { useSeenStore } from '../../../stores/zine/seen'
|
||||
import { useSession } from '../../../context/session'
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import styles from './Sidebar.module.scss'
|
||||
|
||||
type FeedSidebarProps = {
|
||||
authors: Author[]
|
||||
}
|
||||
|
||||
type ListItem = {
|
||||
title: string
|
||||
icon?: string
|
||||
counter?: number
|
||||
href?: string
|
||||
isBold?: boolean
|
||||
}
|
||||
|
||||
export const Sidebar = (props: FeedSidebarProps) => {
|
||||
const { t } = useLocalize()
|
||||
const { seen } = useSeenStore()
|
||||
const { session } = useSession()
|
||||
const { authorEntities } = useAuthorsStore({ authors: props.authors })
|
||||
const { articlesByTopic } = useArticlesStore()
|
||||
const { topicEntities } = useTopicsStore()
|
||||
|
||||
createSignal(() => {
|
||||
console.log('!!! topicEntities:', topicEntities())
|
||||
})
|
||||
|
||||
const checkTopicIsSeen = (topicSlug: string) => {
|
||||
return articlesByTopic()[topicSlug]?.every((article) => Boolean(seen()[article.slug]))
|
||||
}
|
||||
|
||||
const checkAuthorIsSeen = (authorSlug: string) => {
|
||||
return Boolean(seen()[authorSlug])
|
||||
}
|
||||
|
||||
const menuItems: ListItem[] = [
|
||||
{
|
||||
icon: 'feed-all',
|
||||
title: t('general feed')
|
||||
},
|
||||
{
|
||||
icon: 'feed-my',
|
||||
title: t('my feed')
|
||||
},
|
||||
{
|
||||
icon: 'feed-collaborate',
|
||||
title: t('accomplices')
|
||||
},
|
||||
{
|
||||
icon: 'feed-discussion',
|
||||
title: t('discussions'),
|
||||
counter: 4
|
||||
},
|
||||
{
|
||||
icon: 'feed-drafts',
|
||||
title: t('drafts'),
|
||||
counter: 14
|
||||
},
|
||||
{
|
||||
icon: 'bookmark',
|
||||
title: t('bookmarks'),
|
||||
counter: 6
|
||||
},
|
||||
{
|
||||
icon: 'feed-notifications',
|
||||
title: t('notifications')
|
||||
},
|
||||
{
|
||||
href: '/feed?by=subscribed',
|
||||
title: t('My subscriptions'),
|
||||
isBold: true
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div class={styles.sidebar}>
|
||||
<ul>
|
||||
<For each={menuItems}>
|
||||
{(item: ListItem, index) => (
|
||||
<li key={index}>
|
||||
<a href="#">
|
||||
<span class={styles.sidebarItemName}>
|
||||
{item.icon && <Icon name={item.icon} class={styles.icon} />}
|
||||
<strong>{item.title}</strong>
|
||||
{item.counter && <span class={styles.counter}>18</span>}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
<For each={session()?.news?.authors}>
|
||||
{(authorSlug: string) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/author/${authorSlug}`}
|
||||
classList={{ [styles.unread]: checkAuthorIsSeen(authorSlug) }}
|
||||
>
|
||||
<small>@{authorSlug}</small>
|
||||
{authorEntities()[authorSlug]?.name}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
<For each={session()?.news?.topics}>
|
||||
{(topicSlug: string) => (
|
||||
<li>
|
||||
<a href={`/author/${topicSlug}`} classList={{ [styles.unread]: checkTopicIsSeen(topicSlug) }}>
|
||||
{topicEntities()[topicSlug]?.title ?? topicSlug}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<div class={styles.settings}>
|
||||
<a href="/feed/settings">
|
||||
<Icon name="settings" class={styles.icon} />
|
||||
{t('Feed settings')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
1
src/components/Feed/Sidebar/index.ts
Normal file
1
src/components/Feed/Sidebar/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Sidebar } from './Sidebar'
|
|
@ -80,7 +80,8 @@ export const Header = (props: Props) => {
|
|||
})
|
||||
})
|
||||
|
||||
const scrollToComments = (value) => {
|
||||
const scrollToComments = (event, value) => {
|
||||
event.preventDefault()
|
||||
props.scrollToComments(value)
|
||||
}
|
||||
|
||||
|
@ -110,7 +111,6 @@ export const Header = (props: Props) => {
|
|||
<Show when={props.title}>
|
||||
<div class={styles.articleHeader}>{props.title}</div>
|
||||
</Show>
|
||||
|
||||
<ul
|
||||
class={clsx(styles.mainNavigation, 'col text-xl inline-flex')}
|
||||
classList={{ fixed: fixed() }}
|
||||
|
@ -144,7 +144,7 @@ export const Header = (props: Props) => {
|
|||
containerCssClass={styles.control}
|
||||
trigger={<Icon name="share-outline" class={styles.icon} />}
|
||||
/>
|
||||
<div onClick={() => scrollToComments(true)} class={styles.control}>
|
||||
<div onClick={(event) => scrollToComments(event, true)} class={styles.control}>
|
||||
<Icon name="comments-outline" class={styles.icon} />
|
||||
</div>
|
||||
<a href={getPagePath(router, 'create')} class={styles.control}>
|
||||
|
|
|
@ -14,6 +14,7 @@ import { useSession } from '../../context/session'
|
|||
import { useLocalize } from '../../context/localize'
|
||||
import { getPagePath } from '@nanostores/router'
|
||||
import { Button } from '../_shared/Button'
|
||||
import { useEditorContext } from '../../context/editor'
|
||||
|
||||
type HeaderAuthProps = {
|
||||
setIsProfilePopupVisible: (value: boolean) => void
|
||||
|
@ -27,6 +28,10 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
|||
|
||||
const { session, isSessionLoaded, isAuthenticated } = useSession()
|
||||
|
||||
const {
|
||||
actions: { toggleEditorPanel }
|
||||
} = useEditorContext()
|
||||
|
||||
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
|
||||
|
||||
const handleBellIconClick = (event: Event) => {
|
||||
|
@ -36,7 +41,6 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
|||
showModal('auth')
|
||||
return
|
||||
}
|
||||
|
||||
toggleWarnings()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { For, Show } from 'solid-js'
|
||||
import type { Topic } from '../../graphql/types.gen'
|
||||
import { Icon } from '../_shared/Icon'
|
||||
import './Topics.scss'
|
||||
|
||||
import { useLocalize } from '../../context/localize'
|
||||
import './Topics.scss'
|
||||
|
||||
export const NavTopics = (props: { topics: Topic[] }) => {
|
||||
const { t, lang } = useLocalize()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Show, createMemo, createSignal, Switch, onMount, For, Match, createEffect } from 'solid-js'
|
||||
import type { Author, Shout } from '../../graphql/types.gen'
|
||||
import type { Author, Shout, Topic } from '../../graphql/types.gen'
|
||||
import { Row1 } from '../Feed/Row1'
|
||||
import { Row2 } from '../Feed/Row2'
|
||||
import { AuthorFull } from '../Author/Full'
|
||||
|
@ -19,6 +19,7 @@ import { apiClient } from '../../utils/apiClient'
|
|||
import { Comment } from '../Article/Comment'
|
||||
import { useLocalize } from '../../context/localize'
|
||||
import { AuthorRatingControl } from '../Author/AuthorRatingControl'
|
||||
import { TopicCard } from '../Topic/Card'
|
||||
|
||||
type AuthorProps = {
|
||||
shouts: Shout[]
|
||||
|
@ -27,7 +28,17 @@ type AuthorProps = {
|
|||
}
|
||||
|
||||
export type AuthorPageSearchParams = {
|
||||
by: '' | 'viewed' | 'rating' | 'commented' | 'recent' | 'followed' | 'about' | 'popular'
|
||||
by:
|
||||
| ''
|
||||
| 'viewed'
|
||||
| 'rating'
|
||||
| 'commented'
|
||||
| 'recent'
|
||||
| 'subscribed-authors'
|
||||
| 'subscribed-topics'
|
||||
| 'followers'
|
||||
| 'about'
|
||||
| 'popular'
|
||||
}
|
||||
|
||||
export const PRERENDERED_ARTICLES_COUNT = 12
|
||||
|
@ -35,35 +46,50 @@ const LOAD_MORE_PAGE_SIZE = 9
|
|||
|
||||
export const AuthorView = (props: AuthorProps) => {
|
||||
const { t } = useLocalize()
|
||||
const { sortedArticles } = useArticlesStore({
|
||||
shouts: props.shouts
|
||||
})
|
||||
const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
|
||||
const { searchParams, changeSearchParam } = useRouter<AuthorPageSearchParams>()
|
||||
const { authorEntities } = useAuthorsStore({ authors: [props.author] })
|
||||
const author = authorEntities()[props.authorSlug]
|
||||
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
||||
|
||||
const author = createMemo(() => authorEntities()[props.authorSlug])
|
||||
const [followers, setFollowers] = createSignal<Author[]>([])
|
||||
const [followingUsers, setFollowingUsers] = createSignal<Author[]>([])
|
||||
const [subscribedTopics, setSubscribedTopics] = createSignal<Topic[]>([])
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const authorSubscribers = await apiClient.getAuthorFollowers({ slug: props.author.slug })
|
||||
setFollowers(authorSubscribers)
|
||||
const userSubscribers = await apiClient.getAuthorFollowers({ slug: props.authorSlug })
|
||||
setFollowers(userSubscribers)
|
||||
} catch (error) {
|
||||
console.log('[getAuthorSubscribers]', error)
|
||||
console.log('[getAuthorFollowers]', error)
|
||||
}
|
||||
})
|
||||
|
||||
const { searchParams, changeSearchParam } = useRouter<AuthorPageSearchParams>()
|
||||
try {
|
||||
const authorSubscriptionsUsers = await apiClient.getAuthorFollowingUsers({ slug: props.authorSlug })
|
||||
setFollowingUsers(authorSubscriptionsUsers)
|
||||
} catch (error) {
|
||||
console.log('[getAuthorFollowingUsers]', error)
|
||||
}
|
||||
|
||||
try {
|
||||
const authorSubscriptionsTopics = await apiClient.getAuthorFollowingTopics({ slug: props.authorSlug })
|
||||
setSubscribedTopics(authorSubscriptionsTopics)
|
||||
} catch (error) {
|
||||
console.log('[getAuthorFollowing]', error)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!searchParams().by) {
|
||||
changeSearchParam('by', 'rating')
|
||||
}
|
||||
|
||||
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
|
||||
await loadMore()
|
||||
}
|
||||
})
|
||||
|
||||
const loadMore = async () => {
|
||||
saveScrollPosition()
|
||||
const { hasMore } = await loadShouts({
|
||||
filters: { author: author().slug },
|
||||
filters: { author: props.authorSlug },
|
||||
limit: LOAD_MORE_PAGE_SIZE,
|
||||
offset: sortedArticles().length
|
||||
})
|
||||
|
@ -71,12 +97,6 @@ export const AuthorView = (props: AuthorProps) => {
|
|||
restoreScrollPosition()
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
|
||||
loadMore()
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: use title
|
||||
// const title = createMemo(() => {
|
||||
// const m = searchParams().by
|
||||
|
@ -91,6 +111,7 @@ export const AuthorView = (props: AuthorProps) => {
|
|||
)
|
||||
|
||||
const [commented, setCommented] = createSignal([])
|
||||
|
||||
createEffect(async () => {
|
||||
if (searchParams().by === 'commented') {
|
||||
try {
|
||||
|
@ -107,7 +128,7 @@ export const AuthorView = (props: AuthorProps) => {
|
|||
return (
|
||||
<div class="author-page">
|
||||
<div class="wide-container">
|
||||
<AuthorFull author={author()} />
|
||||
<AuthorFull author={author} />
|
||||
<div class="row group__controls">
|
||||
<div class="col-md-16">
|
||||
<ul class="view-switcher">
|
||||
|
@ -116,23 +137,26 @@ export const AuthorView = (props: AuthorProps) => {
|
|||
{t('Publications')}
|
||||
</button>
|
||||
</li>
|
||||
<li classList={{ selected: searchParams().by === 'followed' }}>
|
||||
<button type="button" onClick={() => changeSearchParam('by', 'followed')}>
|
||||
<li classList={{ selected: searchParams().by === 'followers' }}>
|
||||
<button type="button" onClick={() => changeSearchParam('by', 'followers')}>
|
||||
{t('Followers')}
|
||||
</button>
|
||||
</li>
|
||||
<li classList={{ selected: searchParams().by === 'subscribed-authors' }}>
|
||||
<button type="button" onClick={() => changeSearchParam('by', 'subscribed-authors')}>
|
||||
{t('Author subscriptions')}
|
||||
</button>
|
||||
</li>
|
||||
<li classList={{ selected: searchParams().by === 'subscribed-topics' }}>
|
||||
<button type="button" onClick={() => changeSearchParam('by', 'subscribed-topics')}>
|
||||
{t('Topic subscriptions')}
|
||||
</button>
|
||||
</li>
|
||||
<li classList={{ selected: searchParams().by === 'commented' }}>
|
||||
<button type="button" onClick={() => changeSearchParam('by', 'commented')}>
|
||||
{t('Comments')}
|
||||
</button>
|
||||
</li>
|
||||
{/*
|
||||
<li classList={{ selected: searchParams().by === 'popular' }}>
|
||||
<button type="button" onClick={() => changeSearchParam('by', 'popular')}>
|
||||
Популярное
|
||||
</button>
|
||||
</li>
|
||||
*/}
|
||||
<li classList={{ selected: searchParams().by === 'about' }}>
|
||||
<button type="button" onClick={() => changeSearchParam('by', 'about')}>
|
||||
{t('About myself')}
|
||||
|
@ -197,9 +221,7 @@ export const AuthorView = (props: AuthorProps) => {
|
|||
>
|
||||
<Match when={searchParams().by === 'about'}>
|
||||
<div class="wide-container">
|
||||
<Show when={author().bio}>
|
||||
<p>{author().bio}</p>
|
||||
</Show>
|
||||
<p>{author.bio}</p>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={searchParams().by === 'commented'}>
|
||||
|
@ -209,7 +231,20 @@ export const AuthorView = (props: AuthorProps) => {
|
|||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={searchParams().by === 'followed'}>
|
||||
<Match when={searchParams().by === 'subscribed-topics'}>
|
||||
<div class="wide-container">
|
||||
<div class="row">
|
||||
<For each={subscribedTopics()}>
|
||||
{(topic) => (
|
||||
<div class="col-md-12 col-lg-8">
|
||||
<TopicCard compact iconButton isTopicInRow topic={topic} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={searchParams().by === 'followers'}>
|
||||
<div class="wide-container">
|
||||
<div class="row">
|
||||
<For each={followers()}>
|
||||
|
@ -222,6 +257,19 @@ export const AuthorView = (props: AuthorProps) => {
|
|||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={searchParams().by === 'subscribed-authors'}>
|
||||
<div class="wide-container">
|
||||
<div class="row">
|
||||
<For each={followingUsers()}>
|
||||
{(follower: Author) => (
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<AuthorCard author={follower} hideWriteButton={true} hasLink={true} />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={searchParams().by === 'rating'}>
|
||||
<Row1 article={sortedArticles()[0]} />
|
||||
<Row2 articles={sortedArticles().slice(1, 3)} isEqual={true} />
|
||||
|
|
57
src/components/Views/Create.module.scss
Normal file
57
src/components/Views/Create.module.scss
Normal file
|
@ -0,0 +1,57 @@
|
|||
:global(.main-content) {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.articlePreview {
|
||||
border: 2px solid #e8e8e8;
|
||||
min-height: 10em;
|
||||
padding: 1rem 1.2rem;
|
||||
}
|
||||
|
||||
.formHolder {
|
||||
padding: 0 4rem;
|
||||
}
|
||||
|
||||
.saveBlock {
|
||||
background: #f1f1f1;
|
||||
line-height: 1.4;
|
||||
margin-top: 6.4rem;
|
||||
padding: 1.6rem 3.2rem;
|
||||
text-align: center;
|
||||
|
||||
@include media-breakpoint-up(md) {
|
||||
padding: 3.2rem 8rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0 divide($container-padding-x, 2);
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
.titleInput,
|
||||
.subtitleInput {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0;
|
||||
font-size: 36px;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.3;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.createSettings,
|
||||
.create {
|
||||
display: none;
|
||||
|
||||
&.visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ import '../../styles/Feed.scss'
|
|||
import { Icon } from '../_shared/Icon'
|
||||
import { ArticleCard } from '../Feed/Card'
|
||||
import { AuthorCard } from '../Author/Card'
|
||||
import { FeedSidebar } from '../Feed/Sidebar'
|
||||
import { Sidebar } from '../Feed/Sidebar'
|
||||
import { loadShouts, useArticlesStore } from '../../stores/zine/articles'
|
||||
import { useAuthorsStore } from '../../stores/zine/authors'
|
||||
import { useTopicsStore } from '../../stores/zine/topics'
|
||||
|
@ -73,18 +73,18 @@ export const FeedView = () => {
|
|||
|
||||
onMount(async () => {
|
||||
// load recent shouts not only published ( visibility = community )
|
||||
loadMore()
|
||||
await loadMore()
|
||||
// load 5 recent comments overall
|
||||
const comments = await loadReactionsBy({ by: { comment: true }, limit: 5 })
|
||||
setTopComments(comments)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div class="wide-container feed">
|
||||
<div class="row">
|
||||
<div class={clsx('col-md-5 col-xl-4', styles.feedNavigation)}>
|
||||
<FeedSidebar authors={sortedAuthors()} />
|
||||
<Sidebar authors={sortedAuthors()} />
|
||||
</div>
|
||||
|
||||
<div class="col-md-12 offset-xl-1">
|
||||
|
@ -198,6 +198,6 @@ export const FeedView = () => {
|
|||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import styles from './Button.module.scss'
|
|||
type Props = {
|
||||
value: string | JSX.Element
|
||||
size?: 'S' | 'M' | 'L'
|
||||
variant?: 'primary' | 'secondary' | 'inline'
|
||||
variant?: 'primary' | 'secondary' | 'inline' | 'outline'
|
||||
type?: 'submit' | 'button'
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
|
|
41
src/context/editor.tsx
Normal file
41
src/context/editor.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import type { JSX } from 'solid-js'
|
||||
import { Accessor, createContext, createSignal, useContext } from 'solid-js'
|
||||
|
||||
type WordCounter = {
|
||||
characters: number
|
||||
words: number
|
||||
paragraphs?: number
|
||||
}
|
||||
|
||||
type EditorContextType = {
|
||||
isEditorPanelVisible: Accessor<boolean>
|
||||
wordCounter: Accessor<WordCounter>
|
||||
actions: {
|
||||
toggleEditorPanel: () => void
|
||||
countWords: (value: WordCounter) => void
|
||||
}
|
||||
}
|
||||
|
||||
const EditorContext = createContext<EditorContextType>()
|
||||
|
||||
export function useEditorContext() {
|
||||
return useContext(EditorContext)
|
||||
}
|
||||
|
||||
export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||
const [isEditorPanelVisible, setEditorPanelVisible] = createSignal<boolean>(false)
|
||||
const [wordCounter, setWordCounter] = createSignal<WordCounter>({
|
||||
characters: 0,
|
||||
words: 0
|
||||
})
|
||||
const toggleEditorPanel = () => setEditorPanelVisible(!isEditorPanelVisible())
|
||||
const countWords = (value) => setWordCounter(value)
|
||||
const actions = {
|
||||
toggleEditorPanel,
|
||||
countWords
|
||||
}
|
||||
|
||||
const value: EditorContextType = { actions, isEditorPanelVisible, wordCounter }
|
||||
|
||||
return <EditorContext.Provider value={value}>{props.children}</EditorContext.Provider>
|
||||
}
|
|
@ -5,7 +5,7 @@ export default gql`
|
|||
createShout(inp: $shout) {
|
||||
error
|
||||
shout {
|
||||
id
|
||||
_id: slug
|
||||
slug
|
||||
title
|
||||
subtitle
|
||||
|
|
19
src/graphql/query/author-following-topics.ts
Normal file
19
src/graphql/query/author-following-topics.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
query UserFollowingTopicsQuery($slug: String!) {
|
||||
userFollowedTopics(slug: $slug) {
|
||||
id
|
||||
slug
|
||||
title
|
||||
body
|
||||
pic
|
||||
# community
|
||||
stat {
|
||||
shouts
|
||||
followers
|
||||
authors
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
12
src/graphql/query/author-following-users.ts
Normal file
12
src/graphql/query/author-following-users.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
query UserFollowingUsersQuery($slug: String!) {
|
||||
userFollowedAuthors(slug: $slug) {
|
||||
id
|
||||
slug
|
||||
name
|
||||
userpic
|
||||
}
|
||||
}
|
||||
`
|
|
@ -1,23 +0,0 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
query UserFollowingQuery($slug: String!) {
|
||||
userFollowing(slug: $slug) {
|
||||
_id: slug
|
||||
id
|
||||
slug
|
||||
name
|
||||
bio
|
||||
userpic
|
||||
communities
|
||||
links
|
||||
createdAt
|
||||
lastSeen
|
||||
ratings {
|
||||
_id: rater
|
||||
rater
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
|
@ -150,9 +150,13 @@ export const ProfileSettingsPage = () => {
|
|||
|
||||
<h4>{t('Introduce')}</h4>
|
||||
<div class="pretty-form__item">
|
||||
<textarea name="presentation" id="presentation" placeholder={t('Introduce')}>
|
||||
{form.bio}
|
||||
</textarea>
|
||||
<textarea
|
||||
name="bio"
|
||||
id="bio"
|
||||
placeholder={t('Introduce')}
|
||||
value={form.bio}
|
||||
onChange={(event) => updateFormField('bio', event.currentTarget.value)}
|
||||
/>
|
||||
<label for="presentation">{t('Introduce')}</label>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ import type { Accessor } from 'solid-js'
|
|||
import { createRouter, createSearchParams } from '@nanostores/router'
|
||||
import { isServer } from 'solid-js/web'
|
||||
import { useStore } from '@nanostores/solid'
|
||||
import { getPageLoadManagerPromise } from '../utils/pageLoadManager'
|
||||
|
||||
export const ROUTES = {
|
||||
home: '/',
|
||||
|
@ -105,33 +104,9 @@ const handleClientRouteLinkClick = async (event) => {
|
|||
top: 0,
|
||||
left: 0
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await getPageLoadManagerPromise()
|
||||
|
||||
const images = document.querySelectorAll('img')
|
||||
|
||||
let imagesLoaded = 0
|
||||
|
||||
const imageLoadEventHandler = () => {
|
||||
imagesLoaded++
|
||||
if (imagesLoaded === images.length) {
|
||||
scrollToHash(url.hash)
|
||||
images.forEach((image) => image.removeEventListener('load', imageLoadEventHandler))
|
||||
images.forEach((image) => image.removeEventListener('error', imageLoadEventHandler))
|
||||
}
|
||||
}
|
||||
|
||||
images.forEach((image) => {
|
||||
if (image.complete) {
|
||||
imagesLoaded++
|
||||
}
|
||||
|
||||
image.addEventListener('load', imageLoadEventHandler)
|
||||
image.addEventListener('error', imageLoadEventHandler)
|
||||
})
|
||||
scrollToHash(url.hash)
|
||||
}
|
||||
|
||||
export const initRouter = (pathname: string, search: Record<string, string>) => {
|
||||
|
|
|
@ -2,7 +2,6 @@ import type { FollowingEntity } from '../../graphql/types.gen'
|
|||
import { apiClient } from '../../utils/apiClient'
|
||||
|
||||
export const follow = async ({ what, slug }: { what: FollowingEntity; slug: string }) => {
|
||||
console.log('!!! follow:')
|
||||
await apiClient.follow({ what, slug })
|
||||
}
|
||||
export const unfollow = async ({ what, slug }: { what: FollowingEntity; slug: string }) => {
|
||||
|
|
|
@ -39,7 +39,8 @@ import myChats from '../graphql/query/chats-load'
|
|||
import chatMessagesLoadBy from '../graphql/query/chat-messages-load-by'
|
||||
import authorBySlug from '../graphql/query/author-by-slug'
|
||||
import userSubscribers from '../graphql/query/author-followers'
|
||||
import userFollowing from '../graphql/query/author-following'
|
||||
import userFollowedAuthors from '../graphql/query/author-following-users'
|
||||
import userFollowedTopics from '../graphql/query/author-following-topics'
|
||||
import topicBySlug from '../graphql/query/topic-by-slug'
|
||||
import createChat from '../graphql/mutation/create-chat'
|
||||
import reactionsLoadBy from '../graphql/query/reactions-load-by'
|
||||
|
@ -224,9 +225,13 @@ export const apiClient = {
|
|||
const response = await publicGraphQLClient.query(userSubscribers, { slug }).toPromise()
|
||||
return response.data.userFollowers
|
||||
},
|
||||
getAuthorFollowing: async ({ slug }: { slug: string }): Promise<Author[]> => {
|
||||
const response = await publicGraphQLClient.query(userFollowing, { slug }).toPromise()
|
||||
return response.data.userFollowing
|
||||
getAuthorFollowingUsers: async ({ slug }: { slug: string }): Promise<Author[]> => {
|
||||
const response = await publicGraphQLClient.query(userFollowedAuthors, { slug }).toPromise()
|
||||
return response.data.userFollowedAuthors
|
||||
},
|
||||
getAuthorFollowingTopics: async ({ slug }: { slug: string }): Promise<Topic[]> => {
|
||||
const response = await publicGraphQLClient.query(userFollowedTopics, { slug }).toPromise()
|
||||
return response.data.userFollowedTopics
|
||||
},
|
||||
updateProfile: async (input: ProfileInput) => {
|
||||
const response = await privateGraphQLClient.mutation(updateProfile, { profile: input }).toPromise()
|
||||
|
|
Loading…
Reference in New Issue
Block a user