Merge branch 'dev' into feature/rating

This commit is contained in:
Untone 2024-03-07 15:38:35 +03:00
commit cc951c305b
37 changed files with 470 additions and 287 deletions

1
.gitignore vendored
View File

@ -22,4 +22,5 @@ bun.lockb
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/plawright-report/ /plawright-report/
target
.venv .venv

View File

@ -18,6 +18,7 @@
"Add signature": "Add signature", "Add signature": "Add signature",
"Add subtitle": "Add subtitle", "Add subtitle": "Add subtitle",
"Add url": "Add url", "Add url": "Add url",
"try": "попробуйте",
"Add": "Add", "Add": "Add",
"Address on Discours": "Address on Discours", "Address on Discours": "Address on Discours",
"Album name": "Название aльбома", "Album name": "Название aльбома",
@ -144,7 +145,6 @@
"Enter your new password": "Enter your new password", "Enter your new password": "Enter your new password",
"Enter": "Enter", "Enter": "Enter",
"Error": "Error", "Error": "Error",
"Please give us your email address": "Please provide us your email address to get the password reset link",
"Experience": "Experience", "Experience": "Experience",
"FAQ": "Tips and suggestions", "FAQ": "Tips and suggestions",
"Favorite topics": "Favorite topics", "Favorite topics": "Favorite topics",
@ -254,7 +254,6 @@
"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",
@ -323,7 +322,7 @@
"Self-publishing exists thanks to the help of wonderful people from all over the world. Thank you!": "Samizdat exists thanks to the help of wonderful people from all over the world. Thank you!", "Self-publishing exists thanks to the help of wonderful people from all over the world. Thank you!": "Samizdat exists thanks to the help of wonderful people from all over the world. Thank you!",
"Send link again": "Send link again", "Send link again": "Send link again",
"Send": "Send", "Send": "Send",
"Set the new password": "Set the new password", "Forgot password?": "Forgot password?",
"Settings": "Settings", "Settings": "Settings",
"Share publication": "Share publication", "Share publication": "Share publication",
"Share": "Share", "Share": "Share",
@ -380,6 +379,7 @@
"There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?", "There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?",
"There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?", "There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?",
"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 content is not published yet": "This content is not published yet",
"This email is": "This email is", "This email is": "This email is",
"This email is not verified": "This email is not verified", "This email is not verified": "This email is not verified",
"This email is verified": "This email is verified", "This email is verified": "This email is verified",
@ -525,5 +525,8 @@
"video": "video", "video": "video",
"view": "view", "view": "view",
"viewsWithCount": "{count} {count, plural, one {view} other {views}}", "viewsWithCount": "{count} {count, plural, one {view} other {views}}",
"yesterday": "yesterday" "yesterday": "yesterday",
"Failed to delete comment": "Failed to delete comment",
"It's OK. Just enter your email to receive a link to change your password": "It's OK. Just enter your email to receive a link to change your password",
"Restore password": "Restore password"
} }

View File

@ -149,8 +149,8 @@
"Enter the code or click the link from email to confirm": "Введите код из письма или пройдите по ссылке в письме для подтверждения регистрации", "Enter the code or click the link from email to confirm": "Введите код из письма или пройдите по ссылке в письме для подтверждения регистрации",
"Enter your new password": "Введите новый пароль", "Enter your new password": "Введите новый пароль",
"Enter": "Войти", "Enter": "Войти",
"This content is not published yet": "Содержимое ещё не опубликовано",
"Error": "Ошибка", "Error": "Ошибка",
"Please give us your email address": "Пожалуйста, укажите свою почту, чтобы получить ссылку для сброса пароля",
"Experience": "Личный опыт", "Experience": "Личный опыт",
"FAQ": "Советы и предложения", "FAQ": "Советы и предложения",
"Favorite topics": "Избранные темы", "Favorite topics": "Избранные темы",
@ -266,7 +266,6 @@
"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": "Наш постоянный автор",
@ -287,7 +286,7 @@
"Pin": "Закрепить", "Pin": "Закрепить",
"Platform Guide": "Гид по дискурсу", "Platform Guide": "Гид по дискурсу",
"Please check your email address": "Пожалуйста, проверьте введенный адрес почты", "Please check your email address": "Пожалуйста, проверьте введенный адрес почты",
"Please check your inbox! We have sent a password reset link.": "Пожалуйста, проверьте ваш адрес почты, мы отправили ссылку для сброса пароля", "Please check your inbox! We have sent a password reset link.": "Пожалуйста, проверьте свою почту, мы отправили вам письмо со ссылкой для сброса пароля",
"Please confirm your email to finish": "Подтвердите почту и действие совершится", "Please confirm your email to finish": "Подтвердите почту и действие совершится",
"Please enter a name to sign your comments and publication": "Пожалуйста, введите имя, которое будет отображаться на сайте", "Please enter a name to sign your comments and publication": "Пожалуйста, введите имя, которое будет отображаться на сайте",
"Please enter email": "Пожалуйста, введите почту", "Please enter email": "Пожалуйста, введите почту",
@ -328,7 +327,7 @@
"Reports": "Репортажи", "Reports": "Репортажи",
"Required": "Поле обязательно для заполнения", "Required": "Поле обязательно для заполнения",
"Resend code": "Выслать подтверждение", "Resend code": "Выслать подтверждение",
"Set the new password": "Задать новый пароль", "Forgot password?": "Забыли пароль?",
"Rules of the journal Discours": "Правила журнала Дискурс", "Rules of the journal Discours": "Правила журнала Дискурс",
"Save draft": "Сохранить черновик", "Save draft": "Сохранить черновик",
"Save settings": "Сохранить настройки", "Save settings": "Сохранить настройки",
@ -404,6 +403,7 @@
"This email is": "Этот email", "This email is": "Этот email",
"This email is not verified": "Этот email не подтвержден", "This email is not verified": "Этот email не подтвержден",
"This email is verified": "Этот email подтвержден", "This email is verified": "Этот email подтвержден",
"try": "попробуйте",
"This email is registered": "Этот email уже зарегистрирован", "This email is registered": "Этот 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 month": "За месяц",
@ -531,6 +531,7 @@
"repeat": "повторить", "repeat": "повторить",
"resend confirmation link": "отправить ссылку ещё раз", "resend confirmation link": "отправить ссылку ещё раз",
"shout": "пост", "shout": "пост",
"shout not found": "публикация не найдена",
"shoutsWithCount": "{count} {count, plural, one {пост} few {поста} other {постов}}", "shoutsWithCount": "{count} {count, plural, one {пост} few {поста} other {постов}}",
"sign in": "войти", "sign in": "войти",
"sign up or sign in": "зарегистрироваться или войти", "sign up or sign in": "зарегистрироваться или войти",
@ -551,5 +552,8 @@
"video": "видео", "video": "видео",
"view": "просмотр", "view": "просмотр",
"viewsWithCount": "{count} {count, plural, one {просмотр} few {просмотрa} other {просмотров}}", "viewsWithCount": "{count} {count, plural, one {просмотр} few {просмотрa} other {просмотров}}",
"yesterday": "вчера" "yesterday": "вчера",
"Failed to delete comment": "Не удалось удалить комментарий",
"It's OK. Just enter your email to receive a link to change your password": "Ничего страшного. Просто укажите свою почту, чтобы получить ссылку для смены пароля",
"Restore password": "Восстановить пароль"
} }

View File

@ -64,14 +64,19 @@ export const Comment = (props: Props) => {
}) })
if (isConfirmed) { if (isConfirmed) {
await deleteReaction(props.comment.id) const { error } = await deleteReaction(props.comment.id)
// TODO: Учесть то что deleteReaction может вернуть error const notificationType = error ? 'error' : 'success'
if (props.onDelete) { const notificationMessage = error
? t('Failed to delete comment')
: t('Comment successfully deleted')
await showSnackbar({ type: notificationType, body: notificationMessage })
if (!error && props.onDelete) {
props.onDelete(props.comment.id) props.onDelete(props.comment.id)
} }
await showSnackbar({ body: t('Comment successfully deleted') })
} }
} catch (error) { } catch (error) {
await showSnackbar({ body: 'error' })
console.error('[deleteReaction]', error) console.error('[deleteReaction]', error)
} }
} }

View File

@ -2,8 +2,6 @@
@include font-size(1.2rem); @include font-size(1.2rem);
color: var(--secondary-color); color: var(--secondary-color);
// align-self: center;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: flex-start; justify-content: flex-start;

View File

@ -141,7 +141,7 @@ export const FullArticle = (props: Props) => {
const media = createMemo<MediaItem[]>(() => { const media = createMemo<MediaItem[]>(() => {
try { try {
return JSON.parse(props.article.media) return JSON.parse(props.article?.media || '[]')
} catch { } catch {
return [] return []
} }

View File

@ -67,19 +67,19 @@ const getTitleAndSubtitle = (
subtitle: string subtitle: string
} => { } => {
let title = article.title let title = article.title
let subtitle = article.subtitle let subtitle: string = article.subtitle || ''
if (!subtitle) { if (!subtitle) {
let tt = article.title?.split('. ') || [] let titleParts = article.title?.split('. ') || []
if (tt?.length === 1) { if (titleParts?.length === 1) {
tt = article.title?.split(/{!|\?|:|;}\s/) || [] titleParts = article.title?.split(/{!|\?|:|;}\s/) || []
} }
if (tt && tt.length > 1) { if (titleParts && titleParts.length > 1) {
const sep = article.title?.replace(tt[0], '').split(' ', 1)[0] const sep = article.title?.replace(titleParts[0], '').split(' ', 1)[0]
title = tt[0] + (sep === '.' || sep === ':' ? '' : sep) title = titleParts[0] + (sep === '.' || sep === ':' ? '' : sep)
subtitle = capitalize(article.title?.replace(tt[0] + sep, ''), true) subtitle = capitalize(article.title?.replace(titleParts[0] + sep, ''), true) || ''
} }
} }
@ -117,7 +117,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
const { title, subtitle } = getTitleAndSubtitle(props.article) const { title, subtitle } = getTitleAndSubtitle(props.article)
const formattedDate = createMemo<string>(() => const formattedDate = createMemo<string>(() =>
props.article.published_at ? formatDate(new Date(props.article.published_at * 1000)) : '', props.article?.published_at ? formatDate(new Date(props.article.published_at * 1000)) : '',
) )
const canEdit = createMemo( const canEdit = createMemo(
@ -135,6 +135,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
scrollTo: 'comments', scrollTo: 'comments',
}) })
} }
return ( return (
<section <section
class={clsx(styles.shoutCard, props.settings?.additionalClass, { class={clsx(styles.shoutCard, props.settings?.additionalClass, {
@ -153,7 +154,9 @@ export const ArticleCard = (props: ArticleCardProps) => {
[aspectRatio()]: props.withAspectRatio, [aspectRatio()]: props.withAspectRatio,
})} })}
> >
{/* Cover Image */}
<Show when={!(props.settings?.noimage || props.settings?.isFeedMode)}> <Show when={!(props.settings?.noimage || props.settings?.isFeedMode)}>
{/* Cover Image Container */}
<div class={styles.shoutCardCoverContainer}> <div class={styles.shoutCardCoverContainer}>
<div <div
class={clsx(styles.shoutCardCover, { class={clsx(styles.shoutCardCover, {
@ -178,7 +181,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
</div> </div>
</div> </div>
</Show> </Show>
{/* Shout Card Content */}
<div class={styles.shoutCardContent}> <div class={styles.shoutCardContent}>
{/* Shout Card Icon */}
<Show <Show
when={ when={
props.article.layout && props.article.layout &&
@ -195,6 +201,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
</div> </div>
</Show> </Show>
{/* Main Topic */}
<Show when={!props.settings?.isGroup && mainTopicSlug}> <Show when={!props.settings?.isGroup && mainTopicSlug}>
<CardTopic <CardTopic
title={mainTopicTitle} title={mainTopicTitle}
@ -205,6 +212,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
/> />
</Show> </Show>
{/* Title and Subtitle */}
<div <div
class={clsx(styles.shoutCardTitlesContainer, { class={clsx(styles.shoutCardTitlesContainer, {
[styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode, [styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode,
@ -224,22 +232,23 @@ export const ArticleCard = (props: ArticleCardProps) => {
</Show> </Show>
</a> </a>
</div> </div>
{/* Details */}
<Show when={!(props.settings?.noauthor && props.settings?.nodate)}> <Show when={!(props.settings?.noauthor && props.settings?.nodate)}>
{/* Author and Date */}
<div <div
class={clsx(styles.shoutDetails, { [styles.shoutDetailsFeedMode]: props.settings?.isFeedMode })} class={clsx(styles.shoutDetails, { [styles.shoutDetailsFeedMode]: props.settings?.isFeedMode })}
> >
<Show when={!props.settings?.noauthor}> <Show when={!props.settings?.noauthor}>
<div class={styles.shoutAuthor}> <div class={styles.shoutAuthor}>
<For each={props.article.authors}> <For each={props.article.authors}>
{(a: Author) => { {(a: Author) => (
return ( <AuthorLink
<AuthorLink size={'XS'}
size={'XS'} author={a}
author={a} isFloorImportant={props.settings.isFloorImportant || props.settings?.isWithCover}
isFloorImportant={props.settings.isFloorImportant || props.settings?.isWithCover} />
/> )}
)
}}
</For> </For>
</div> </div>
</Show> </Show>
@ -248,6 +257,8 @@ export const ArticleCard = (props: ArticleCardProps) => {
</Show> </Show>
</div> </div>
</Show> </Show>
{/* Description */}
<Show when={props.article.description}> <Show when={props.article.description}>
<section class={styles.shoutCardDescription} innerHTML={props.article.description} /> <section class={styles.shoutCardDescription} innerHTML={props.article.description} />
</Show> </Show>

View File

@ -51,7 +51,15 @@ const DialogAvatar = (props: Props) => {
<Show when={Boolean(props.url)} fallback={<div class={styles.letter}>{nameFirstLetter()}</div>}> <Show when={Boolean(props.url)} fallback={<div class={styles.letter}>{nameFirstLetter()}</div>}>
<div <div
class={styles.imageHolder} class={styles.imageHolder}
style={{ 'background-image': `url(${getImageUrl(props.url, { width: 40, height: 40 })})` }} style={{
'background-image': `url(
${
props.url.includes('discours.io')
? getImageUrl(props.url, { width: 40, height: 40 })
: props.url
}
)`,
}}
/> />
</Show> </Show>
</div> </div>

View File

@ -1,5 +1,5 @@
.view { .view {
background: #fff; background: var(--background-color);
min-height: 550px; min-height: 550px;
position: relative; position: relative;
justify-content: center; justify-content: center;
@ -154,17 +154,6 @@
margin-bottom: 1em; margin-bottom: 1em;
} }
.authInfo {
font-weight: 400;
font-size: smaller;
margin-top: -2em;
position: absolute;
.warn {
color: #a00;
}
}
.authForm { .authForm {
display: flex; display: flex;
flex: 1; flex: 1;
@ -221,3 +210,7 @@
line-height: 24px; line-height: 24px;
margin-bottom: 52px; margin-bottom: 52px;
} }
.submitError {
margin: -1rem 0 -2rem;
}

View File

@ -33,7 +33,7 @@ export const ChangePasswordForm = () => {
event.preventDefault() event.preventDefault()
setIsSubmitting(true) setIsSubmitting(true)
if (newPassword()) { if (newPassword()) {
await changePassword(newPassword(), searchParams()?.token) changePassword(newPassword(), searchParams()?.token)
setTimeout(() => { setTimeout(() => {
setIsSubmitting(false) setIsSubmitting(false)
setIsSuccess(true) setIsSuccess(true)
@ -60,11 +60,6 @@ export const ChangePasswordForm = () => {
> >
<div> <div>
<h4>{t('Enter a new password')}</h4> <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>
<Show when={validationErrors()}> <Show when={validationErrors()}>
<div>{validationErrors().password}</div> <div>{validationErrors().password}</div>
</Show> </Show>

View File

@ -1,13 +0,0 @@
.title {
font-size: 26px;
line-height: 32px;
font-weight: 700;
color: #141414;
margin-bottom: 16px;
}
.text {
font-size: 15px;
line-height: 24px;
margin-bottom: 52px;
}

View File

@ -17,19 +17,20 @@ export const EmailConfirm = () => {
const [emailConfirmed, setEmailConfirmed] = createSignal(false) const [emailConfirmed, setEmailConfirmed] = createSignal(false)
createEffect(() => { createEffect(() => {
const e = session()?.user?.email const email = session()?.user?.email
const v = session()?.user?.email_verified const isVerified = session()?.user?.email_verified
if (e) {
setEmail(e.toLowerCase()) if (email) {
if (v) setEmailConfirmed(v) setEmail(email.toLowerCase())
if (isVerified) setEmailConfirmed(isVerified)
if (authError()) { if (authError()) {
changeSearchParams({}, true) changeSearchParams({}, true)
} }
} }
})
createEffect(() => { if (authError()) {
if (authError()) console.debug('[AuthModal.EmailConfirm] auth error:', authError()) console.debug('[AuthModal.EmailConfirm] auth error:', authError())
}
}) })
return ( return (

View File

@ -1,7 +1,7 @@
import type { AuthModalSearchParams } from './types' import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js' import { JSX, Show, createEffect, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
@ -27,12 +27,11 @@ type ValidationErrors = Partial<Record<keyof FormFields, string>>
export const LoginForm = () => { export const LoginForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>() const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const { t } = useLocalize() const { t } = useLocalize()
const [submitError, setSubmitError] = createSignal('') const [submitError, setSubmitError] = createSignal<string | JSX.Element>()
const [isSubmitting, setIsSubmitting] = createSignal(false) const [isSubmitting, setIsSubmitting] = createSignal(false)
const [password, setPassword] = createSignal('') const [password, setPassword] = createSignal('')
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({}) const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
// TODO: better solution for interactive error messages
const [isEmailNotConfirmed, setIsEmailNotConfirmed] = createSignal(false)
const [isLinkSent, setIsLinkSent] = createSignal(false) const [isLinkSent, setIsLinkSent] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null } const authFormRef: { current: HTMLFormElement } = { current: null }
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
@ -52,43 +51,43 @@ export const LoginForm = () => {
event.preventDefault() event.preventDefault()
setIsLinkSent(true) setIsLinkSent(true)
setIsEmailNotConfirmed(false) setSubmitError()
setSubmitError('') changeSearchParams({ mode: 'send-confirm-email' })
changeSearchParams({ mode: 'send-reset-link' })
// NOTE: temporary solution, needs logic in authorizer
/* FIXME:
const { authorizer } = useSession()
const result = await authorizer().verifyEmail({ token })
if (!result) setSubmitError('cant sign send link')
*/
} }
const preSendValidate = async (value: string, type: 'email' | 'password'): Promise<boolean> => {
if (type === 'email') {
if (value === '' || !validateEmail(value)) {
setValidationErrors((prev) => ({
...prev,
email: t('Invalid email'),
}))
return false
}
} else if (type === 'password') {
if (value === '') {
setValidationErrors((prev) => ({
...prev,
password: t('Please enter password'),
}))
return false
}
}
return true
}
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
await preSendValidate(email(), 'email')
await preSendValidate(password(), 'password')
setIsLinkSent(false) setIsLinkSent(false)
setIsEmailNotConfirmed(false) setSubmitError()
setSubmitError('')
const newValidationErrors: ValidationErrors = {}
const validateAndSetError = (field, message) => {
if (!field()) {
newValidationErrors[field.name] = t(message)
}
}
validateAndSetError(email, 'Please enter email')
validateAndSetError(() => validateEmail(email()), 'Invalid email')
validateAndSetError(password, 'Please enter password')
if (Object.keys(newValidationErrors).length > 0) {
setValidationErrors(newValidationErrors)
if (Object.keys(validationErrors()).length > 0) {
authFormRef.current authFormRef.current
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`) .querySelector<HTMLInputElement>(`input[name="${Object.keys(validationErrors())[0]}"]`)
?.focus() ?.focus()
return return
} }
@ -96,14 +95,27 @@ export const LoginForm = () => {
try { try {
const { errors } = await signIn({ email: email(), password: password() }) const { errors } = await signIn({ email: email(), password: password() })
console.error('[signIn errors]', errors)
if (errors?.length > 0) { if (errors?.length > 0) {
if (errors.some((error) => error.message.includes('bad user credentials'))) { if (errors.some((error) => error.message.includes('bad user credentials'))) {
setValidationErrors((prev) => ({ setValidationErrors((prev) => ({
...prev, ...prev,
password: t('Something went wrong, check email and password'), password: t('Something went wrong, check email and password'),
})) }))
} else if (errors.some((error) => error.message.includes('user not found'))) {
setSubmitError('Пользователь не найден')
} else if (errors.some((error) => error.message.includes('email not verified'))) {
setSubmitError(
<div class={styles.info}>
{t('This email is not verified')}
{'. '}
<span class={'link'} onClick={handleSendLinkAgainClick}>
{t('Send link again')}
</span>
</div>,
)
} else { } else {
setSubmitError(t('Error')) setSubmitError(t('Error', errors[0].message))
} }
return return
} }
@ -121,19 +133,6 @@ export const LoginForm = () => {
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}> <form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}>
<div> <div>
<AuthModalHeader modalType="login" /> <AuthModalHeader modalType="login" />
<Show when={submitError()}>
<div class={styles.authInfo}>
<div class={styles.warn}>{submitError()}</div>
<Show when={isEmailNotConfirmed()}>
<span class={'link'} onClick={handleSendLinkAgainClick}>
{t('Send link again')}
</span>
</Show>
</div>
</Show>
<Show when={isLinkSent()}>
<div class={styles.authInfo}>{t('Link sent, check your email')}</div>
</Show>
<div <div
class={clsx('pretty-form__item', { class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().email, 'pretty-form__item--error': validationErrors().email,
@ -154,11 +153,14 @@ export const LoginForm = () => {
</Show> </Show>
</div> </div>
<PasswordField variant={'login'} onInput={(value) => handlePasswordInput(value)} /> <PasswordField
<Show when={validationErrors().password}> variant={'login'}
<div class={styles.validationError} style={{ position: 'static', 'font-size': '1.4rem' }}> setError={validationErrors().password}
{validationErrors().password} onInput={(value) => handlePasswordInput(value)}
</div> />
<Show when={submitError()}>
<div class={clsx('form-message--error', styles.submitError)}>{submitError()}</div>
</Show> </Show>
<div> <div>
@ -175,7 +177,7 @@ export const LoginForm = () => {
}) })
} }
> >
{t('Set the new password')} {t('Forgot password?')}
</span> </span>
</div> </div>
</div> </div>

View File

@ -31,11 +31,11 @@
} }
/* Red/500 */ /* Red/500 */
color: #d00820; color: orange;
a { a {
color: #d00820; color: orange;
border-color: #d00820; border-color: orange;
&:hover { &:hover {
color: var(--default-color-invert); color: var(--default-color-invert);

View File

@ -11,21 +11,23 @@ type Props = {
disabled?: boolean disabled?: boolean
placeholder?: string placeholder?: string
errorMessage?: (error: string) => void errorMessage?: (error: string) => void
setError?: string
onInput: (value: string) => void onInput: (value: string) => void
onBlur?: (value: string) => void
variant?: 'login' | 'registration' variant?: 'login' | 'registration'
disableAutocomplete?: boolean disableAutocomplete?: boolean
} }
const minLength = 8
const hasNumber = /\d/
const hasSpecial = /[!#$%&*@^]/
export const PasswordField = (props: Props) => { export const PasswordField = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [showPassword, setShowPassword] = createSignal(false) const [showPassword, setShowPassword] = createSignal(false)
const [error, setError] = createSignal<string>() const [error, setError] = createSignal<string>()
const validatePassword = (passwordToCheck) => { const validatePassword = (passwordToCheck) => {
const minLength = 8
const hasNumber = /\d/
const hasSpecial = /[!#$%&*@^]/
if (passwordToCheck.length < minLength) { if (passwordToCheck.length < minLength) {
return t('Password should be at least 8 characters') return t('Password should be at least 8 characters')
} }
@ -35,11 +37,17 @@ export const PasswordField = (props: Props) => {
if (!hasSpecial.test(passwordToCheck)) { if (!hasSpecial.test(passwordToCheck)) {
return t('Password should contain at least one special character: !@#$%^&*') return t('Password should contain at least one special character: !@#$%^&*')
} }
return null return null
} }
const handleInputChange = (value) => { const handleInputBlur = (value: string) => {
if (props.variant === 'login') {
return props.onBlur(value)
}
if (value.length < 1) {
return
}
props.onInput(value) props.onInput(value)
const errorValue = validatePassword(value) const errorValue = validatePassword(value)
if (errorValue) { if (errorValue) {
@ -58,14 +66,13 @@ export const PasswordField = (props: Props) => {
{ defer: true }, { defer: true },
), ),
) )
createEffect(() => {
setError(props.setError)
})
return ( return (
<div class={clsx(styles.PassportField, props.class)}> <div class={clsx(styles.PassportField, props.class)}>
<div <div class="pretty-form__item">
class={clsx('pretty-form__item', {
'pretty-form__item--error': error() && props.variant !== 'login',
})}
>
<input <input
id="password" id="password"
name="password" name="password"
@ -73,7 +80,7 @@ export const PasswordField = (props: Props) => {
autocomplete={props.disableAutocomplete ? 'one-time-code' : 'current-password'} autocomplete={props.disableAutocomplete ? 'one-time-code' : 'current-password'}
type={showPassword() ? 'text' : 'password'} type={showPassword() ? 'text' : 'password'}
placeholder={props.placeholder || t('Password')} placeholder={props.placeholder || t('Password')}
onInput={(event) => handleInputChange(event.currentTarget.value)} onBlur={(event) => handleInputBlur(event.currentTarget.value)}
/> />
<label for="password">{t('Password')}</label> <label for="password">{t('Password')}</label>
<button <button
@ -83,8 +90,14 @@ export const PasswordField = (props: Props) => {
> >
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} /> <Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
</button> </button>
<Show when={error() && props.variant !== 'login'}> <Show when={error()}>
<div class={clsx(styles.registerPassword, styles.validationError)}>{error()}</div> <div
class={clsx(styles.registerPassword, styles.validationError, {
'form-message--error': props.setError,
})}
>
{error()}
</div>
</Show> </Show>
</div> </div>
</div> </div>

View File

@ -28,10 +28,6 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>> type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
const handleEmailInput = (newEmail: string) => {
setEmail(newEmail.toLowerCase())
}
export const RegisterForm = () => { export const RegisterForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>() const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const { t } = useLocalize() const { t } = useLocalize()
@ -137,7 +133,8 @@ export const RegisterForm = () => {
setValidationErrors((prev) => ({ setValidationErrors((prev) => ({
email: ( email: (
<> <>
{t('This email is verified')}. {t('You can')}{' '} {t('This email is registered')}. {t('try')}
{', '}
<span class="link" onClick={() => changeSearchParams({ mode: 'login' })}> <span class="link" onClick={() => changeSearchParams({ mode: 'login' })}>
{t('enter')} {t('enter')}
</span> </span>
@ -150,9 +147,10 @@ export const RegisterForm = () => {
...prev, ...prev,
email: ( email: (
<> <>
{t('This email is registered')}. {t('You can')}{' '} {t('This email is registered')}
{'. '}
<span class="link" onClick={() => changeSearchParams({ mode: 'send-reset-link' })}> <span class="link" onClick={() => changeSearchParams({ mode: 'send-reset-link' })}>
{t('Set the new password').toLocaleLowerCase()} {t('Set the new password')}
</span> </span>
</> </>
), ),
@ -172,17 +170,18 @@ export const RegisterForm = () => {
} }
} }
const handleEmailInput = (newEmail: string) => {
setEmailStatus('')
setValidationErrors({})
setEmail(newEmail.toLowerCase())
}
return ( return (
<> <>
<Show when={!isSuccess()}> <Show when={!isSuccess()}>
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}> <form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}>
<div> <div>
<AuthModalHeader modalType="register" /> <AuthModalHeader modalType="register" />
<Show when={submitError()}>
<div class={styles.authInfo}>
<div class={styles.warn}>{submitError()}</div>
</div>
</Show>
<div <div
class={clsx('pretty-form__item', { class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().fullName, 'pretty-form__item--error': validationErrors().fullName,
@ -194,7 +193,7 @@ export const RegisterForm = () => {
disabled={Boolean(emailStatus())} disabled={Boolean(emailStatus())}
placeholder={t('Full name')} placeholder={t('Full name')}
autocomplete="one-time-code" autocomplete="one-time-code"
onInput={(event) => handleNameInput(event.currentTarget.value)} onChange={(event) => handleNameInput(event.currentTarget.value)}
/> />
<label for="name">{t('Full name')}</label> <label for="name">{t('Full name')}</label>
<Show when={validationErrors().fullName && !emailStatus()}> <Show when={validationErrors().fullName && !emailStatus()}>
@ -217,16 +216,18 @@ export const RegisterForm = () => {
onBlur={handleEmailBlur} onBlur={handleEmailBlur}
/> />
<label for="email">{t('Email')}</label> <label for="email">{t('Email')}</label>
<div class={clsx(styles.validationError, { info: Boolean(emailStatus()) })}> <Show when={validationErrors().email || emailStatus()}>
{validationErrors().email} <div class={clsx(styles.validationError, { info: Boolean(emailStatus()) })}>
</div> {validationErrors().email}
</div>
</Show>
</div> </div>
<PasswordField <PasswordField
disableAutocomplete={true} disableAutocomplete={true}
disabled={Boolean(emailStatus())} disabled={Boolean(emailStatus())}
errorMessage={(err) => setPasswordError(err)} errorMessage={(err) => !emailStatus() && setPasswordError(err)}
onInput={(value) => setPassword(value)} onInput={(value) => setPassword(emailStatus() ? '' : value)}
/> />
<div> <div>
@ -259,12 +260,14 @@ export const RegisterForm = () => {
</form> </form>
</Show> </Show>
<Show when={isSuccess()}> <Show when={isSuccess()}>
<div class={styles.title}>{t('Almost done! Check your email.')}</div> <div style={{ 'justify-content': 'center' }}>
<div class={styles.text}>{t("We've sent you a message with a link to enter our website.")}</div> <div class={styles.title}>{t('Almost done! Check your email.')}</div>
<div> <div class={styles.text}>{t("We've sent you a message with a link to enter our website.")}</div>
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}> <div>
{t('Back to main page')} <button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
</button> {t('Back to main page')}
</button>
</div>
</div> </div>
</Show> </Show>
</> </>

View File

@ -0,0 +1,24 @@
import { clsx } from 'clsx'
import { useLocalize } from '../../../context/localize'
import { hideModal } from '../../../stores/ui'
import styles from './AuthModal.module.scss'
export const SendEmailConfirm = () => {
const { t } = useLocalize()
return (
<div
style={{
'align-items': 'center',
'justify-content': 'center',
}}
>
<div class={styles.text}>{t('Link sent, check your email')}</div>
<div>
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
{t('Go to main page')}
</button>
</div>
</div>
)
}

View File

@ -1,7 +1,7 @@
import type { AuthModalSearchParams } from './types' import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { JSX, Show, createSignal } from 'solid-js' import { JSX, Show, createSignal, onMount } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
@ -72,6 +72,12 @@ export const SendResetLinkForm = () => {
} }
} }
onMount(() => {
if (email()) {
console.info('[SendResetLinkForm] email detected')
}
})
return ( return (
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
@ -79,8 +85,12 @@ export const SendResetLinkForm = () => {
ref={(el) => (authFormRef.current = el)} ref={(el) => (authFormRef.current = el)}
> >
<div> <div>
<h4>{t('Set the new password')}</h4> <h4>{t('Forgot password?')}</h4>
<div class={styles.authSubtitle}>{t(message()) || t('Please give us your email address')}</div> <Show when={!message()}>
<div class={styles.authSubtitle}>
{t("It's OK. Just enter your email to receive a link to change your password")}
</div>
</Show>
<div <div
class={clsx('pretty-form__item', { class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().email, 'pretty-form__item--error': validationErrors().email,
@ -94,7 +104,7 @@ export const SendResetLinkForm = () => {
type="email" type="email"
value={email()} value={email()}
placeholder={t('Email')} placeholder={t('Email')}
onInput={(event) => handleEmailInput(event.currentTarget.value)} onChange={(event) => handleEmailInput(event.currentTarget.value)}
/> />
<label for="email">{t('Email')}</label> <label for="email">{t('Email')}</label>
<Show when={isUserNotFound()}> <Show when={isUserNotFound()}>
@ -104,7 +114,7 @@ export const SendResetLinkForm = () => {
class={'link'} class={'link'}
onClick={() => onClick={() =>
changeSearchParams({ changeSearchParams({
mode: 'login', mode: 'register',
}) })
} }
> >
@ -116,28 +126,31 @@ export const SendResetLinkForm = () => {
<div class={styles.validationError}>{validationErrors().email}</div> <div class={styles.validationError}>{validationErrors().email}</div>
</Show> </Show>
</div> </div>
<Show when={!message()} fallback={<div class={styles.authSubtitle}>{t(message())}</div>}>
<div style={{ 'margin-top': '5rem' }}> <>
<button <div style={{ 'margin-top': '5rem' }}>
class={clsx('button', styles.submitButton)} <button
disabled={isSubmitting() || Boolean(message())} class={clsx('button', styles.submitButton)}
type="submit" disabled={isSubmitting() || Boolean(message())}
> type="submit"
{isSubmitting() ? '...' : t('Send')} >
</button> {isSubmitting() ? '...' : t('Restore password')}
</div> </button>
<div class={styles.authControl}> </div>
<span <div class={styles.authControl}>
class={styles.authLink} <span
onClick={() => class={styles.authLink}
changeSearchParams({ onClick={() =>
mode: 'login', changeSearchParams({
}) mode: 'login',
} })
> }
{t('I know the password')} >
</span> {t('I know the password')}
</div> </span>
</div>
</>
</Show>
</div> </div>
</form> </form>
) )

View File

@ -1,8 +1,8 @@
import { For } from 'solid-js' import { For } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../../context/localize'
import { useSession } from '../../../context/session' import { useSession } from '../../../../context/session'
import { Icon } from '../../_shared/Icon' import { Icon } from '../../../_shared/Icon'
import styles from './SocialProviders.module.scss' import styles from './SocialProviders.module.scss'
@ -18,7 +18,7 @@ export const SocialProviders = () => {
<div class={styles.social}> <div class={styles.social}>
<For each={PROVIDERS}> <For each={PROVIDERS}>
{(provider) => ( {(provider) => (
<button class={styles[provider]} onClick={(_e) => oauth(provider)}> <button type="button" class={styles[provider]} onClick={(_e) => oauth(provider)}>
<Icon name={provider} /> <Icon name={provider} />
</button> </button>
)} )}

View File

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

View File

@ -16,12 +16,14 @@ import { RegisterForm } from './RegisterForm'
import { SendResetLinkForm } from './SendResetLinkForm' import { SendResetLinkForm } from './SendResetLinkForm'
import styles from './AuthModal.module.scss' import styles from './AuthModal.module.scss'
import { SendEmailConfirm } from './SendEmailConfirm'
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = { const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
login: LoginForm, login: LoginForm,
register: RegisterForm, register: RegisterForm,
'send-reset-link': SendResetLinkForm, 'send-reset-link': SendResetLinkForm,
'confirm-email': EmailConfirm, 'confirm-email': EmailConfirm,
'send-confirm-email': SendEmailConfirm,
'change-password': ChangePasswordForm, 'change-password': ChangePasswordForm,
} }

View File

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

View File

@ -115,15 +115,17 @@ export const TopicBadge = (props: Props) => {
</div> </div>
</div> </div>
<div class={styles.stats}> <div class={styles.stats}>
<span class={styles.statsItem}>{t('shoutsWithCount', {count: props.topic?.stat?.shouts})}</span> <span class={styles.statsItem}>{t('shoutsWithCount', { count: props.topic?.stat?.shouts })}</span>
<span class={styles.statsItem}>{t('authorsWithCount', {count: props.topic?.stat?.authors})}</span> <span class={styles.statsItem}>{t('authorsWithCount', { count: props.topic?.stat?.authors })}</span>
<span class={styles.statsItem}> <span class={styles.statsItem}>
{t('FollowersWithCount', {count: props.topic?.stat?.followers})} {t('FollowersWithCount', { count: props.topic?.stat?.followers })}
</span> </span>
<Show when={props.topic?.stat?.comments}> <Show when={props.topic?.stat?.comments}>
<span class={styles.statsItem}>{t('CommentsWithCount', {count: props.topic?.stat?.comments ?? 0})}</span> <span class={styles.statsItem}>
{t('CommentsWithCount', { count: props.topic?.stat?.comments ?? 0 })}
</span>
</Show> </Show>
</div> </div>
</div> </div>
) )
} }

View File

@ -64,10 +64,11 @@ export const EditView = (props: Props) => {
getDraftFromLocalStorage, getDraftFromLocalStorage,
} = useEditorContext() } = useEditorContext()
const shoutTopics = props.shout.topics || [] const shoutTopics = props.shout.topics || []
const draft = getDraftFromLocalStorage(props.shout.id)
// TODO: проверить сохранение черновика в local storage (не работает)
const draft = getDraftFromLocalStorage(props.shout.id)
if (draft) { if (draft) {
setForm(draft) setForm(Object.keys(draft).length !== 0 ? draft : { shoutId: props.shout.id })
} else { } else {
setForm({ setForm({
slug: props.shout.slug, slug: props.shout.slug,
@ -179,6 +180,7 @@ export const EditView = (props: Props) => {
let autoSaveTimeOutId: number | string | NodeJS.Timeout let autoSaveTimeOutId: number | string | NodeJS.Timeout
//TODO: add throttle
const autoSaveRecursive = () => { const autoSaveRecursive = () => {
autoSaveTimeOutId = setTimeout(async () => { autoSaveTimeOutId = setTimeout(async () => {
const hasChanges = !deepEqual(form, prevForm) const hasChanges = !deepEqual(form, prevForm)
@ -307,10 +309,10 @@ export const EditView = (props: Props) => {
subtitleInput.current = el subtitleInput.current = el
}} }}
allowEnterKey={false} allowEnterKey={false}
value={(value) => setForm('subtitle', value)} value={(value) => setForm('subtitle', value || '')}
class={styles.subtitleInput} class={styles.subtitleInput}
placeholder={t('Subheader')} placeholder={t('Subheader')}
initialValue={form.subtitle} initialValue={form.subtitle || ''}
maxLength={MAX_HEADER_LIMIT} maxLength={MAX_HEADER_LIMIT}
/> />
</Show> </Show>

View File

@ -70,10 +70,10 @@ export const PublishSettings = (props: Props) => {
return { return {
coverImageUrl: props.form?.coverImageUrl, coverImageUrl: props.form?.coverImageUrl,
mainTopic: props.form?.mainTopic || EMPTY_TOPIC, mainTopic: props.form?.mainTopic || EMPTY_TOPIC,
slug: props.form?.slug, slug: props.form?.slug || '',
title: props.form?.title, title: props.form?.title || '',
subtitle: props.form?.subtitle, subtitle: props.form?.subtitle || '',
description: composeDescription(), description: composeDescription() || '',
selectedTopics: [], selectedTopics: [],
} }
}) })
@ -100,7 +100,7 @@ export const PublishSettings = (props: Props) => {
const handleTopicSelectChange = (newSelectedTopics) => { const handleTopicSelectChange = (newSelectedTopics) => {
if ( if (
props.form.selectedTopics.length === 0 || props.form.selectedTopics.length === 0 ||
newSelectedTopics.every((topic) => topic.id !== props.form.mainTopic.id) newSelectedTopics.every((topic) => topic.id !== props.form.mainTopic?.id)
) { ) {
setSettingsForm((prev) => { setSettingsForm((prev) => {
return { return {
@ -176,7 +176,7 @@ export const PublishSettings = (props: Props) => {
<div class={styles.mainTopic}>{settingsForm.mainTopic.title}</div> <div class={styles.mainTopic}>{settingsForm.mainTopic.title}</div>
</Show> </Show>
<div class={styles.shoutCardTitle}>{settingsForm.title}</div> <div class={styles.shoutCardTitle}>{settingsForm.title}</div>
<div class={styles.shoutCardSubtitle}>{settingsForm.subtitle}</div> <div class={styles.shoutCardSubtitle}>{settingsForm.subtitle || ''}</div>
<div class={styles.shoutAuthor}>{author()?.name}</div> <div class={styles.shoutAuthor}>{author()?.name}</div>
</div> </div>
</div> </div>
@ -203,7 +203,7 @@ export const PublishSettings = (props: Props) => {
variant="bordered" variant="bordered"
fieldName={t('Subheader')} fieldName={t('Subheader')}
placeholder={t('Come up with a subtitle for your story')} placeholder={t('Come up with a subtitle for your story')}
initialValue={settingsForm.subtitle} initialValue={settingsForm.subtitle || ''}
value={(value) => setSettingsForm('subtitle', value)} value={(value) => setSettingsForm('subtitle', value)}
allowEnterKey={false} allowEnterKey={false}
maxLength={100} maxLength={100}

View File

@ -50,7 +50,7 @@ export const ConnectProvider = (props: { children: JSX.Element }) => {
Authorization: token, Authorization: token,
}, },
onmessage(event) { onmessage(event) {
const m: SSEMessage = JSON.parse(event.data) const m: SSEMessage = JSON.parse(event.data || '{}')
console.log('[context.connect] Received message:', m) console.log('[context.connect] Received message:', m)
// Iterate over all registered handlers and call them // Iterate over all registered handlers and call them

View File

@ -39,7 +39,7 @@ type EditorContextType = {
wordCounter: Accessor<WordCounter> wordCounter: Accessor<WordCounter>
form: ShoutForm form: ShoutForm
formErrors: Record<keyof ShoutForm, string> formErrors: Record<keyof ShoutForm, string>
editorRef: { current: () => Editor } editorRef: { current: () => Editor | null }
saveShout: (form: ShoutForm) => Promise<void> saveShout: (form: ShoutForm) => Promise<void>
saveDraft: (form: ShoutForm) => Promise<void> saveDraft: (form: ShoutForm) => Promise<void>
saveDraftToLocalStorage: (form: ShoutForm) => void saveDraftToLocalStorage: (form: ShoutForm) => void
@ -72,7 +72,7 @@ const saveDraftToLocalStorage = (formToSave: ShoutForm) => {
localStorage.setItem(`shout-${formToSave.shoutId}`, JSON.stringify(formToSave)) localStorage.setItem(`shout-${formToSave.shoutId}`, JSON.stringify(formToSave))
} }
const getDraftFromLocalStorage = (shoutId: number) => { const getDraftFromLocalStorage = (shoutId: number) => {
return JSON.parse(localStorage.getItem(`shout-${shoutId}`)) return JSON.parse(localStorage.getItem(`shout-${shoutId}`) || '{}')
} }
const removeDraftFromLocalStorage = (shoutId: number) => { const removeDraftFromLocalStorage = (shoutId: number) => {
@ -80,13 +80,19 @@ const removeDraftFromLocalStorage = (shoutId: number) => {
} }
export const EditorProvider = (props: { children: JSX.Element }) => { export const EditorProvider = (props: { children: JSX.Element }) => {
const { t } = useLocalize() const localize = useLocalize()
const { page } = useRouter() const { page } = useRouter()
const { showSnackbar } = useSnackbar() const snackbar = useSnackbar()
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false) const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
const editorRef: { current: () => Editor } = { current: null } const editorRef: { current: () => Editor | null } = { current: () => null }
const [form, setForm] = createStore<ShoutForm>(null) const [form, setForm] = createStore<ShoutForm>({
const [formErrors, setFormErrors] = createStore<Record<keyof ShoutForm, string>>(null) body: '',
slug: '',
shoutId: 0,
title: '',
selectedTopics: [],
})
const [formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
const [wordCounter, setWordCounter] = createSignal<WordCounter>({ const [wordCounter, setWordCounter] = createSignal<WordCounter>({
characters: 0, characters: 0,
words: 0, words: 0,
@ -95,13 +101,16 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
const countWords = (value) => setWordCounter(value) const countWords = (value) => setWordCounter(value)
const validate = () => { const validate = () => {
if (!form.title) { if (!form.title) {
setFormErrors('title', t('Please, set the article title')) setFormErrors('title', localize?.t('Please, set the article title') || '')
return false return false
} }
const parsedMedia = JSON.parse(form.media) const parsedMedia = JSON.parse(form.media || '[]')
if (form.layout === 'video' && !parsedMedia[0]) { if (form.layout === 'video' && !parsedMedia[0]) {
showSnackbar({ type: 'error', body: t('Looks like you forgot to upload the video') }) snackbar?.showSnackbar({
type: 'error',
body: localize?.t('Looks like you forgot to upload the video'),
})
return false return false
} }
@ -110,7 +119,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
const validateSettings = () => { const validateSettings = () => {
if (form.selectedTopics.length === 0) { if (form.selectedTopics.length === 0) {
setFormErrors('selectedTopics', t('Required')) setFormErrors('selectedTopics', localize?.t('Required') || '')
return false return false
} }
@ -118,6 +127,10 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
} }
const updateShout = async (formToUpdate: ShoutForm, { publish }: { publish: boolean }) => { const updateShout = async (formToUpdate: ShoutForm, { publish }: { publish: boolean }) => {
if (!formToUpdate.shoutId) {
console.error(formToUpdate)
return { error: 'not enought data' }
}
return await apiClient.updateArticle({ return await apiClient.updateArticle({
shout_id: formToUpdate.shoutId, shout_id: formToUpdate.shoutId,
shout_input: { shout_input: {
@ -143,48 +156,61 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
toggleEditorPanel() toggleEditorPanel()
} }
if (page().route === 'edit' && !validate()) { if (page()?.route === 'edit' && !validate()) {
return return
} }
if (page().route === 'editSettings' && !validateSettings()) { if (page()?.route === 'editSettings' && !validateSettings()) {
return return
} }
try { try {
const shout = await updateShout(formToSave, { publish: false }) const { shout, error } = await updateShout(formToSave, { publish: false })
if (error) {
snackbar?.showSnackbar({ type: 'error', body: localize?.t(error) || '' })
return
}
removeDraftFromLocalStorage(formToSave.shoutId) removeDraftFromLocalStorage(formToSave.shoutId)
if (shout.published_at) { if (shout?.published_at) {
openPage(router, 'article', { slug: shout.slug }) openPage(router, 'article', { slug: shout.slug })
} else { } else {
openPage(router, 'drafts') openPage(router, 'drafts')
} }
} catch (error) { } catch (error) {
console.error('[saveShout]', error) console.error('[saveShout]', error)
showSnackbar({ type: 'error', body: t('Error') }) snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
} }
} }
const saveDraft = async (draftForm: ShoutForm) => { const saveDraft = async (draftForm: ShoutForm) => {
await updateShout(draftForm, { publish: false }) const { error } = await updateShout(draftForm, { publish: false })
if (error) {
snackbar?.showSnackbar({ type: 'error', body: localize?.t(error) || '' })
return
}
} }
const publishShout = async (formToPublish: ShoutForm) => { const publishShout = async (formToPublish: ShoutForm) => {
if (isEditorPanelVisible()) { const editorPanelVisible = isEditorPanelVisible()
const pageRoute = page()?.route
if (editorPanelVisible) {
toggleEditorPanel() toggleEditorPanel()
} }
if (page().route === 'edit') { if (pageRoute === 'edit') {
if (!validate()) { if (!validate()) {
return return
} }
await updateShout(formToPublish, { publish: false })
const slug = slugify(form.title) const slug = slugify(form.title)
setForm('slug', slug) setForm('slug', slug)
openPage(router, 'editSettings', { shoutId: form.shoutId.toString() }) openPage(router, 'editSettings', { shoutId: form.shoutId.toString() })
const { error } = await updateShout(formToPublish, { publish: false })
if (error) {
snackbar?.showSnackbar({ type: 'error', body: localize?.t(error) || '' })
}
return return
} }
@ -193,20 +219,33 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
} }
try { try {
await updateShout(formToPublish, { publish: true }) const { error } = await updateShout(formToPublish, { publish: true })
if (error) {
snackbar?.showSnackbar({ type: 'error', body: localize?.t(error) || '' })
return
}
openPage(router, 'feed') openPage(router, 'feed')
} catch (error) { } catch (error) {
console.error('[publishShout]', error) console.error('[publishShout]', error)
showSnackbar({ type: 'error', body: t('Error') }) snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
} }
} }
const publishShoutById = async (shout_id: number) => { const publishShoutById = async (shout_id: number) => {
if (!shout_id) {
console.error(`shout_id is ${shout_id}`)
return
}
try { try {
const newShout = await apiClient.updateArticle({ const { shout: newShout, error } = await apiClient.updateArticle({
shout_id, shout_id,
publish: true, publish: true,
}) })
if (error) {
console.error(error)
snackbar?.showSnackbar({ type: 'error', body: error })
return
}
if (newShout) { if (newShout) {
addArticles([newShout]) addArticles([newShout])
openPage(router, 'feed') openPage(router, 'feed')
@ -215,7 +254,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
} }
} catch (error) { } catch (error) {
console.error('[publishShoutById]', error) console.error('[publishShoutById]', error)
showSnackbar({ type: 'error', body: t('Error') }) snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') })
} }
} }
@ -226,7 +265,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
}) })
return true return true
} catch { } catch {
showSnackbar({ type: 'error', body: t('Error') }) snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
return false return false
} }
} }

View File

@ -5,7 +5,8 @@ import { createStore, reconcile } from 'solid-js/store'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { Reaction, ReactionBy, ReactionInput, ReactionKind } from '../graphql/schema/core.gen' import { Reaction, ReactionBy, ReactionInput, ReactionKind } from '../graphql/schema/core.gen'
import { useSession } from './session' import { useLocalize } from './localize'
import { useSnackbar } from './snackbar'
type ReactionsContextType = { type ReactionsContextType = {
reactionEntities: Accessor<Record<number, Reaction>> reactionEntities: Accessor<Record<number, Reaction>>
@ -20,7 +21,7 @@ type ReactionsContextType = {
}) => Promise<Reaction[]> }) => Promise<Reaction[]>
createReaction: (reaction: ReactionInput) => Promise<void> createReaction: (reaction: ReactionInput) => Promise<void>
updateReaction: (reaction: ReactionInput) => Promise<Reaction> updateReaction: (reaction: ReactionInput) => Promise<Reaction>
deleteReaction: (id: number) => Promise<void> deleteReaction: (id: number) => Promise<{ error: string }>
} }
const ReactionsContext = createContext<ReactionsContextType>() const ReactionsContext = createContext<ReactionsContextType>()
@ -30,8 +31,9 @@ export function useReactions() {
} }
export const ReactionsProvider = (props: { children: JSX.Element }) => { export const ReactionsProvider = (props: { children: JSX.Element }) => {
const [reactionEntities, setReactionEntities] = createSignal<Record<number, Reaction> | undefined>() const [reactionEntities, setReactionEntities] = createStore<Record<number, Reaction>>({})
const { author } = useSession() const { t } = useLocalize()
const { showSnackbar } = useSnackbar()
const loadReactionsBy = async ({ const loadReactionsBy = async ({
by, by,
@ -55,18 +57,8 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
} }
const createReaction = async (input: ReactionInput): Promise<void> => { const createReaction = async (input: ReactionInput): Promise<void> => {
const fakeId = Date.now() + Math.floor(Math.random() * 1000) const { error, reaction } = await apiClient.createReaction(input)
setReactionEntities((rrr: Record<number, Reaction>) => ({ if (error) await showSnackbar({ type: 'error', body: t(error) })
...rrr,
[fakeId]: {
...input,
id: fakeId,
created_by: author(),
created_at: Math.floor(Date.now() / 1000),
} as unknown as Reaction,
}))
const reaction = await apiClient.createReaction(input)
setReactionEntities({ [fakeId]: undefined })
if (!reaction) return if (!reaction) return
const changes = { const changes = {
[reaction.id]: reaction, [reaction.id]: reaction,
@ -92,19 +84,22 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
setReactionEntities(changes) setReactionEntities(changes)
} }
const deleteReaction = async (reaction: number): Promise<void> => { const deleteReaction = async (reaction_id: number): Promise<{ error: string; reaction?: string }> => {
setReactionEntities({ [reaction]: undefined }) if (reaction_id) {
await apiClient.destroyReaction(reaction) const result = await apiClient.destroyReaction(reaction_id)
if (!result.error) {
setReactionEntities({
[reaction_id]: undefined,
})
}
return result
}
} }
const updateReaction = async (input: ReactionInput): Promise<Reaction> => { const updateReaction = async (input: ReactionInput): Promise<Reaction> => {
const reaction = await apiClient.updateReaction(input) const { error, reaction } = await apiClient.updateReaction(input)
if (reaction) { if (error) await showSnackbar({ type: 'error', body: t(error) })
setReactionEntities((rrr) => { if (reaction) setReactionEntities(reaction.id, reaction)
rrr[reaction.id] = reaction
return rrr
})
}
return reaction return reaction
} }

View File

@ -28,6 +28,7 @@ import reactionDestroy from '../mutation/core/reaction-destroy'
import reactionUpdate from '../mutation/core/reaction-update' import reactionUpdate from '../mutation/core/reaction-update'
import unfollowMutation from '../mutation/core/unfollow' import unfollowMutation from '../mutation/core/unfollow'
import shoutLoad from '../query/core/article-load' import shoutLoad from '../query/core/article-load'
import getMyShout from '../query/core/article-my'
import shoutsLoadBy from '../query/core/articles-load-by' import shoutsLoadBy from '../query/core/articles-load-by'
import draftsLoad from '../query/core/articles-load-drafts' import draftsLoad from '../query/core/articles-load-drafts'
import myFeed from '../query/core/articles-load-feed' import myFeed from '../query/core/articles-load-feed'
@ -41,7 +42,6 @@ import authorFollows from '../query/core/author-follows'
import authorId from '../query/core/author-id' import authorId from '../query/core/author-id'
import authorsAll from '../query/core/authors-all' import authorsAll from '../query/core/authors-all'
import authorsLoadBy from '../query/core/authors-load-by' import authorsLoadBy from '../query/core/authors-load-by'
import mySubscriptions from '../query/core/my-followed'
import reactionsLoadBy from '../query/core/reactions-load-by' import reactionsLoadBy from '../query/core/reactions-load-by'
import topicBySlug from '../query/core/topic-by-slug' import topicBySlug from '../query/core/topic-by-slug'
import topicsAll from '../query/core/topics-all' import topicsAll from '../query/core/topics-all'
@ -135,7 +135,6 @@ export const apiClient = {
user?: string user?: string
}): Promise<AuthorFollows> => { }): Promise<AuthorFollows> => {
const response = await publicGraphQLClient.query(authorFollows, params).toPromise() const response = await publicGraphQLClient.query(authorFollows, params).toPromise()
console.log('!!! response:', response)
return response.data.get_author_follows return response.data.get_author_follows
}, },
@ -162,12 +161,12 @@ export const apiClient = {
shout_id: number shout_id: number
shout_input?: ShoutInput shout_input?: ShoutInput
publish: boolean publish: boolean
}): Promise<Shout> => { }): Promise<CommonResult> => {
const response = await apiClient.private const response = await apiClient.private
.mutation(updateArticle, { shout_id, shout_input, publish }) .mutation(updateArticle, { shout_id, shout_input, publish })
.toPromise() .toPromise()
console.debug('[graphql.client.core] updateArticle:', response.data) console.debug('[graphql.client.core] updateArticle:', response.data)
return response.data.update_shout.shout return response.data.update_shout
}, },
deleteShout: async (params: MutationDelete_ShoutArgs): Promise<void> => { deleteShout: async (params: MutationDelete_ShoutArgs): Promise<void> => {
@ -178,7 +177,7 @@ export const apiClient = {
getDrafts: async (): Promise<Shout[]> => { getDrafts: async (): Promise<Shout[]> => {
const response = await apiClient.private.query(draftsLoad, {}).toPromise() const response = await apiClient.private.query(draftsLoad, {}).toPromise()
console.debug('[graphql.client.core] getDrafts:', response) console.debug('[graphql.client.core] getDrafts:', response)
return response.data.load_shouts_drafts return response.data.get_shouts_drafts
}, },
createReaction: async (input: ReactionInput) => { createReaction: async (input: ReactionInput) => {
const response = await apiClient.private.mutation(reactionCreate, { reaction: input }).toPromise() const response = await apiClient.private.mutation(reactionCreate, { reaction: input }).toPromise()
@ -188,7 +187,7 @@ export const apiClient = {
destroyReaction: async (reaction_id: number) => { destroyReaction: async (reaction_id: number) => {
const response = await apiClient.private.mutation(reactionDestroy, { reaction_id }).toPromise() const response = await apiClient.private.mutation(reactionDestroy, { reaction_id }).toPromise()
console.debug('[graphql.client.core] destroyReaction:', response) console.debug('[graphql.client.core] destroyReaction:', response)
return response.data.delete_reaction.reaction return response.data.delete_reaction
}, },
updateReaction: async (reaction: ReactionInput) => { updateReaction: async (reaction: ReactionInput) => {
const response = await apiClient.private.mutation(reactionUpdate, { reaction }).toPromise() const response = await apiClient.private.mutation(reactionUpdate, { reaction }).toPromise()
@ -200,15 +199,18 @@ export const apiClient = {
console.debug('[graphql.client.core] authorsLoadBy:', resp) console.debug('[graphql.client.core] authorsLoadBy:', resp)
return resp.data.load_authors_by return resp.data.load_authors_by
}, },
getShoutBySlug: async (slug: string) => { getShoutBySlug: async (slug: string) => {
const resp = await publicGraphQLClient.query(shoutLoad, { slug }).toPromise() const resp = await publicGraphQLClient.query(shoutLoad, { slug }).toPromise()
return resp.data.get_shout return resp.data.get_shout
}, },
getShoutById: async (shout_id: number) => {
const resp = await publicGraphQLClient.query(shoutLoad, { shout_id }).toPromise() getMyShout: async (shout_id: number) => {
await apiClient.private
const resp = await apiClient.private.query(getMyShout, { shout_id }).toPromise()
if (resp.error) console.error(resp) if (resp.error) console.error(resp)
return resp.data.get_shout return resp.data.get_my_shout
}, },
getShouts: async (options: LoadShoutsOptions) => { getShouts: async (options: LoadShoutsOptions) => {

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
query LoadShoutQuery($slug: String, $shout_id: Int) { query LoadShoutQuery($slug: String!) {
get_shout(slug: $slug, shout_id: $shout_id) { get_shout(slug: $slug) {
id id
title title
lead lead

View File

@ -0,0 +1,53 @@
import { gql } from '@urql/core'
export default gql`
query GetMyShout($shout_id: Int!) {
get_my_shout(shout_id: $shout_id) {
error
shout {
id
title
lead
description
subtitle
slug
layout
cover
cover_caption
body
media
updated_by {
id
name
slug
pic
created_at
}
# community
main_topic
topics {
id
title
body
slug
stat {
shouts
authors
followers
}
}
authors {
id
name
slug
pic
created_at
}
created_at
updated_at
published_at
featured_at
}
}
}
`

View File

@ -2,7 +2,7 @@ import { gql } from '@urql/core'
export default gql` export default gql`
query LoadDraftsQuery { query LoadDraftsQuery {
load_shouts_drafts { get_shouts_drafts {
id id
title title
subtitle subtitle
@ -35,7 +35,6 @@ export default gql`
featured_at featured_at
stat { stat {
viewed viewed
rating rating
commented commented
} }

View File

@ -13,6 +13,7 @@ export default gql`
shouts shouts
authors authors
followers followers
comments
# viewed # viewed
} }
} }

View File

@ -7,22 +7,42 @@ import { useLocalize } from '../context/localize'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { Shout } from '../graphql/schema/core.gen' import { Shout } from '../graphql/schema/core.gen'
import { useRouter } from '../stores/router' import { useRouter } from '../stores/router'
import { router } from '../stores/router'
import { redirectPage } from '@nanostores/router'
import { useSnackbar } from '../context/snackbar'
import { LayoutType } from './types' import { LayoutType } from './types'
const EditView = lazy(() => import('../components/Views/EditView/EditView')) const EditView = lazy(() => import('../components/Views/EditView/EditView'))
export const EditPage = () => { export const EditPage = () => {
const { page } = useRouter() const { page } = useRouter()
const snackbar = useSnackbar()
const { t } = useLocalize() const { t } = useLocalize()
const shoutId = createMemo(() => Number((page().params as Record<'shoutId', string>).shoutId))
const [shout, setShout] = createSignal<Shout>(null) const [shout, setShout] = createSignal<Shout>(null)
const loadMyShout = async (shout_id: number) => {
if (shout_id) {
const { shout: loadedShout, error } = await apiClient.getMyShout(shout_id)
console.log(loadedShout)
if (error) {
await snackbar?.showSnackbar({ type: 'error', body: t('This content is not published yet') })
redirectPage(router, 'drafts')
} else {
setShout(loadedShout)
}
}
}
onMount(async () => { onMount(async () => {
const loadedShout = await apiClient.getShoutById(shoutId()) const shout_id = window.location.pathname.split('/').pop()
setShout(loadedShout) if (shout_id) {
try {
await loadMyShout(parseInt(shout_id, 10))
} catch (e) {
console.error(e)
}
}
}) })
const title = createMemo(() => { const title = createMemo(() => {

View File

@ -464,7 +464,7 @@ form {
} }
.form-message--error { .form-message--error {
color: #d00820; color: var(--danger-color) !important;
} }
select { select {

View File

@ -7,7 +7,7 @@ export const byCreated = (a: Shout | Reaction, b: Shout | Reaction) => {
} }
export const byPublished = (a: Shout, b: Shout) => { export const byPublished = (a: Shout, b: Shout) => {
return a.published_at - b.published_at return (a?.published_at || 0) - (b?.published_at || 0)
} }
export const byLength = ( export const byLength = (