Merge branch 'dev' into feature/rating
This commit is contained in:
commit
cc951c305b
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -22,4 +22,5 @@ bun.lockb
|
|||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/plawright-report/
|
||||
target
|
||||
.venv
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
"Add signature": "Add signature",
|
||||
"Add subtitle": "Add subtitle",
|
||||
"Add url": "Add url",
|
||||
"try": "попробуйте",
|
||||
"Add": "Add",
|
||||
"Address on Discours": "Address on Discours",
|
||||
"Album name": "Название aльбома",
|
||||
|
@ -144,7 +145,6 @@
|
|||
"Enter your new password": "Enter your new password",
|
||||
"Enter": "Enter",
|
||||
"Error": "Error",
|
||||
"Please give us your email address": "Please provide us your email address to get the password reset link",
|
||||
"Experience": "Experience",
|
||||
"FAQ": "Tips and suggestions",
|
||||
"Favorite topics": "Favorite topics",
|
||||
|
@ -254,7 +254,6 @@
|
|||
"Nothing here yet": "There's nothing here yet",
|
||||
"Nothing is here": "There is nothing 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": "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",
|
||||
"Ordered list": "Ordered list",
|
||||
"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!",
|
||||
"Send link again": "Send link again",
|
||||
"Send": "Send",
|
||||
"Set the new password": "Set the new password",
|
||||
"Forgot password?": "Forgot password?",
|
||||
"Settings": "Settings",
|
||||
"Share publication": "Share publication",
|
||||
"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 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 content is not published yet": "This content is not published yet",
|
||||
"This email is": "This email is",
|
||||
"This email is not verified": "This email is not verified",
|
||||
"This email is verified": "This email is verified",
|
||||
|
@ -525,5 +525,8 @@
|
|||
"video": "video",
|
||||
"view": "view",
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -149,8 +149,8 @@
|
|||
"Enter the code or click the link from email to confirm": "Введите код из письма или пройдите по ссылке в письме для подтверждения регистрации",
|
||||
"Enter your new password": "Введите новый пароль",
|
||||
"Enter": "Войти",
|
||||
"This content is not published yet": "Содержимое ещё не опубликовано",
|
||||
"Error": "Ошибка",
|
||||
"Please give us your email address": "Пожалуйста, укажите свою почту, чтобы получить ссылку для сброса пароля",
|
||||
"Experience": "Личный опыт",
|
||||
"FAQ": "Советы и предложения",
|
||||
"Favorite topics": "Избранные темы",
|
||||
|
@ -266,7 +266,6 @@
|
|||
"Nothing here yet": "Здесь пока ничего нет",
|
||||
"Nothing is here": "Здесь ничего нет",
|
||||
"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": "Или вставьте ссылку на изображение",
|
||||
"Ordered list": "Нумерованный список",
|
||||
"Our regular contributor": "Наш постоянный автор",
|
||||
|
@ -287,7 +286,7 @@
|
|||
"Pin": "Закрепить",
|
||||
"Platform Guide": "Гид по дискурсу",
|
||||
"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 enter a name to sign your comments and publication": "Пожалуйста, введите имя, которое будет отображаться на сайте",
|
||||
"Please enter email": "Пожалуйста, введите почту",
|
||||
|
@ -328,7 +327,7 @@
|
|||
"Reports": "Репортажи",
|
||||
"Required": "Поле обязательно для заполнения",
|
||||
"Resend code": "Выслать подтверждение",
|
||||
"Set the new password": "Задать новый пароль",
|
||||
"Forgot password?": "Забыли пароль?",
|
||||
"Rules of the journal Discours": "Правила журнала Дискурс",
|
||||
"Save draft": "Сохранить черновик",
|
||||
"Save settings": "Сохранить настройки",
|
||||
|
@ -404,6 +403,7 @@
|
|||
"This email is": "Этот email",
|
||||
"This email is not verified": "Этот email не подтвержден",
|
||||
"This email is verified": "Этот email подтвержден",
|
||||
"try": "попробуйте",
|
||||
"This email is registered": "Этот email уже зарегистрирован",
|
||||
"This functionality is currently not available, we would like to work on this issue. Use the download link.": "В данный момент этот функционал не доступен, бы работаем над этой проблемой. Воспользуйтесь загрузкой по ссылке.",
|
||||
"This month": "За месяц",
|
||||
|
@ -531,6 +531,7 @@
|
|||
"repeat": "повторить",
|
||||
"resend confirmation link": "отправить ссылку ещё раз",
|
||||
"shout": "пост",
|
||||
"shout not found": "публикация не найдена",
|
||||
"shoutsWithCount": "{count} {count, plural, one {пост} few {поста} other {постов}}",
|
||||
"sign in": "войти",
|
||||
"sign up or sign in": "зарегистрироваться или войти",
|
||||
|
@ -551,5 +552,8 @@
|
|||
"video": "видео",
|
||||
"view": "просмотр",
|
||||
"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": "Восстановить пароль"
|
||||
}
|
||||
|
|
|
@ -64,14 +64,19 @@ export const Comment = (props: Props) => {
|
|||
})
|
||||
|
||||
if (isConfirmed) {
|
||||
await deleteReaction(props.comment.id)
|
||||
// TODO: Учесть то что deleteReaction может вернуть error
|
||||
if (props.onDelete) {
|
||||
const { error } = await deleteReaction(props.comment.id)
|
||||
const notificationType = error ? 'error' : 'success'
|
||||
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)
|
||||
}
|
||||
await showSnackbar({ body: t('Comment successfully deleted') })
|
||||
}
|
||||
} catch (error) {
|
||||
await showSnackbar({ body: 'error' })
|
||||
console.error('[deleteReaction]', error)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,8 +2,6 @@
|
|||
@include font-size(1.2rem);
|
||||
|
||||
color: var(--secondary-color);
|
||||
|
||||
// align-self: center;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
|
|
|
@ -141,7 +141,7 @@ export const FullArticle = (props: Props) => {
|
|||
|
||||
const media = createMemo<MediaItem[]>(() => {
|
||||
try {
|
||||
return JSON.parse(props.article.media)
|
||||
return JSON.parse(props.article?.media || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
|
|
@ -67,19 +67,19 @@ const getTitleAndSubtitle = (
|
|||
subtitle: string
|
||||
} => {
|
||||
let title = article.title
|
||||
let subtitle = article.subtitle
|
||||
let subtitle: string = article.subtitle || ''
|
||||
|
||||
if (!subtitle) {
|
||||
let tt = article.title?.split('. ') || []
|
||||
let titleParts = article.title?.split('. ') || []
|
||||
|
||||
if (tt?.length === 1) {
|
||||
tt = article.title?.split(/{!|\?|:|;}\s/) || []
|
||||
if (titleParts?.length === 1) {
|
||||
titleParts = article.title?.split(/{!|\?|:|;}\s/) || []
|
||||
}
|
||||
|
||||
if (tt && tt.length > 1) {
|
||||
const sep = article.title?.replace(tt[0], '').split(' ', 1)[0]
|
||||
title = tt[0] + (sep === '.' || sep === ':' ? '' : sep)
|
||||
subtitle = capitalize(article.title?.replace(tt[0] + sep, ''), true)
|
||||
if (titleParts && titleParts.length > 1) {
|
||||
const sep = article.title?.replace(titleParts[0], '').split(' ', 1)[0]
|
||||
title = titleParts[0] + (sep === '.' || sep === ':' ? '' : sep)
|
||||
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 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(
|
||||
|
@ -135,6 +135,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
scrollTo: 'comments',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
class={clsx(styles.shoutCard, props.settings?.additionalClass, {
|
||||
|
@ -153,7 +154,9 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
[aspectRatio()]: props.withAspectRatio,
|
||||
})}
|
||||
>
|
||||
{/* Cover Image */}
|
||||
<Show when={!(props.settings?.noimage || props.settings?.isFeedMode)}>
|
||||
{/* Cover Image Container */}
|
||||
<div class={styles.shoutCardCoverContainer}>
|
||||
<div
|
||||
class={clsx(styles.shoutCardCover, {
|
||||
|
@ -178,7 +181,10 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Shout Card Content */}
|
||||
<div class={styles.shoutCardContent}>
|
||||
{/* Shout Card Icon */}
|
||||
<Show
|
||||
when={
|
||||
props.article.layout &&
|
||||
|
@ -195,6 +201,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Main Topic */}
|
||||
<Show when={!props.settings?.isGroup && mainTopicSlug}>
|
||||
<CardTopic
|
||||
title={mainTopicTitle}
|
||||
|
@ -205,6 +212,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
/>
|
||||
</Show>
|
||||
|
||||
{/* Title and Subtitle */}
|
||||
<div
|
||||
class={clsx(styles.shoutCardTitlesContainer, {
|
||||
[styles.shoutCardTitlesContainerFeedMode]: props.settings?.isFeedMode,
|
||||
|
@ -224,22 +232,23 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
</Show>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<Show when={!(props.settings?.noauthor && props.settings?.nodate)}>
|
||||
{/* Author and Date */}
|
||||
<div
|
||||
class={clsx(styles.shoutDetails, { [styles.shoutDetailsFeedMode]: props.settings?.isFeedMode })}
|
||||
>
|
||||
<Show when={!props.settings?.noauthor}>
|
||||
<div class={styles.shoutAuthor}>
|
||||
<For each={props.article.authors}>
|
||||
{(a: Author) => {
|
||||
return (
|
||||
<AuthorLink
|
||||
size={'XS'}
|
||||
author={a}
|
||||
isFloorImportant={props.settings.isFloorImportant || props.settings?.isWithCover}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
{(a: Author) => (
|
||||
<AuthorLink
|
||||
size={'XS'}
|
||||
author={a}
|
||||
isFloorImportant={props.settings.isFloorImportant || props.settings?.isWithCover}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
@ -248,6 +257,8 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Description */}
|
||||
<Show when={props.article.description}>
|
||||
<section class={styles.shoutCardDescription} innerHTML={props.article.description} />
|
||||
</Show>
|
||||
|
|
|
@ -51,7 +51,15 @@ const DialogAvatar = (props: Props) => {
|
|||
<Show when={Boolean(props.url)} fallback={<div class={styles.letter}>{nameFirstLetter()}</div>}>
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.view {
|
||||
background: #fff;
|
||||
background: var(--background-color);
|
||||
min-height: 550px;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
|
@ -154,17 +154,6 @@
|
|||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.authInfo {
|
||||
font-weight: 400;
|
||||
font-size: smaller;
|
||||
margin-top: -2em;
|
||||
position: absolute;
|
||||
|
||||
.warn {
|
||||
color: #a00;
|
||||
}
|
||||
}
|
||||
|
||||
.authForm {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
@ -221,3 +210,7 @@
|
|||
line-height: 24px;
|
||||
margin-bottom: 52px;
|
||||
}
|
||||
|
||||
.submitError {
|
||||
margin: -1rem 0 -2rem;
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@ export const ChangePasswordForm = () => {
|
|||
event.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
if (newPassword()) {
|
||||
await changePassword(newPassword(), searchParams()?.token)
|
||||
changePassword(newPassword(), searchParams()?.token)
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false)
|
||||
setIsSuccess(true)
|
||||
|
@ -60,11 +60,6 @@ export const ChangePasswordForm = () => {
|
|||
>
|
||||
<div>
|
||||
<h4>{t('Enter a new password')}</h4>
|
||||
<div class={styles.authSubtitle}>
|
||||
{t(
|
||||
'Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password',
|
||||
)}
|
||||
</div>
|
||||
<Show when={validationErrors()}>
|
||||
<div>{validationErrors().password}</div>
|
||||
</Show>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -17,19 +17,20 @@ export const EmailConfirm = () => {
|
|||
const [emailConfirmed, setEmailConfirmed] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
const e = session()?.user?.email
|
||||
const v = session()?.user?.email_verified
|
||||
if (e) {
|
||||
setEmail(e.toLowerCase())
|
||||
if (v) setEmailConfirmed(v)
|
||||
const email = session()?.user?.email
|
||||
const isVerified = session()?.user?.email_verified
|
||||
|
||||
if (email) {
|
||||
setEmail(email.toLowerCase())
|
||||
if (isVerified) setEmailConfirmed(isVerified)
|
||||
if (authError()) {
|
||||
changeSearchParams({}, true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (authError()) console.debug('[AuthModal.EmailConfirm] auth error:', authError())
|
||||
if (authError()) {
|
||||
console.debug('[AuthModal.EmailConfirm] auth error:', authError())
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { AuthModalSearchParams } from './types'
|
||||
|
||||
import { clsx } from 'clsx'
|
||||
import { Show, createSignal } from 'solid-js'
|
||||
import { JSX, Show, createEffect, createSignal } from 'solid-js'
|
||||
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { useSession } from '../../../context/session'
|
||||
|
@ -27,12 +27,11 @@ type ValidationErrors = Partial<Record<keyof FormFields, string>>
|
|||
export const LoginForm = () => {
|
||||
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
|
||||
const { t } = useLocalize()
|
||||
const [submitError, setSubmitError] = createSignal('')
|
||||
const [submitError, setSubmitError] = createSignal<string | JSX.Element>()
|
||||
const [isSubmitting, setIsSubmitting] = createSignal(false)
|
||||
const [password, setPassword] = createSignal('')
|
||||
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
|
||||
// TODO: better solution for interactive error messages
|
||||
const [isEmailNotConfirmed, setIsEmailNotConfirmed] = createSignal(false)
|
||||
|
||||
const [isLinkSent, setIsLinkSent] = createSignal(false)
|
||||
const authFormRef: { current: HTMLFormElement } = { current: null }
|
||||
const { showSnackbar } = useSnackbar()
|
||||
|
@ -52,43 +51,43 @@ export const LoginForm = () => {
|
|||
event.preventDefault()
|
||||
|
||||
setIsLinkSent(true)
|
||||
setIsEmailNotConfirmed(false)
|
||||
setSubmitError('')
|
||||
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')
|
||||
*/
|
||||
setSubmitError()
|
||||
changeSearchParams({ mode: 'send-confirm-email' })
|
||||
}
|
||||
|
||||
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) => {
|
||||
event.preventDefault()
|
||||
|
||||
await preSendValidate(email(), 'email')
|
||||
await preSendValidate(password(), 'password')
|
||||
|
||||
setIsLinkSent(false)
|
||||
setIsEmailNotConfirmed(false)
|
||||
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)
|
||||
setSubmitError()
|
||||
|
||||
if (Object.keys(validationErrors()).length > 0) {
|
||||
authFormRef.current
|
||||
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
|
||||
.querySelector<HTMLInputElement>(`input[name="${Object.keys(validationErrors())[0]}"]`)
|
||||
?.focus()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -96,14 +95,27 @@ export const LoginForm = () => {
|
|||
|
||||
try {
|
||||
const { errors } = await signIn({ email: email(), password: password() })
|
||||
console.error('[signIn errors]', errors)
|
||||
if (errors?.length > 0) {
|
||||
if (errors.some((error) => error.message.includes('bad user credentials'))) {
|
||||
setValidationErrors((prev) => ({
|
||||
...prev,
|
||||
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 {
|
||||
setSubmitError(t('Error'))
|
||||
setSubmitError(t('Error', errors[0].message))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -121,19 +133,6 @@ export const LoginForm = () => {
|
|||
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}>
|
||||
<div>
|
||||
<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
|
||||
class={clsx('pretty-form__item', {
|
||||
'pretty-form__item--error': validationErrors().email,
|
||||
|
@ -154,11 +153,14 @@ export const LoginForm = () => {
|
|||
</Show>
|
||||
</div>
|
||||
|
||||
<PasswordField variant={'login'} onInput={(value) => handlePasswordInput(value)} />
|
||||
<Show when={validationErrors().password}>
|
||||
<div class={styles.validationError} style={{ position: 'static', 'font-size': '1.4rem' }}>
|
||||
{validationErrors().password}
|
||||
</div>
|
||||
<PasswordField
|
||||
variant={'login'}
|
||||
setError={validationErrors().password}
|
||||
onInput={(value) => handlePasswordInput(value)}
|
||||
/>
|
||||
|
||||
<Show when={submitError()}>
|
||||
<div class={clsx('form-message--error', styles.submitError)}>{submitError()}</div>
|
||||
</Show>
|
||||
|
||||
<div>
|
||||
|
@ -175,7 +177,7 @@ export const LoginForm = () => {
|
|||
})
|
||||
}
|
||||
>
|
||||
{t('Set the new password')}
|
||||
{t('Forgot password?')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -31,11 +31,11 @@
|
|||
}
|
||||
|
||||
/* Red/500 */
|
||||
color: #d00820;
|
||||
color: orange;
|
||||
|
||||
a {
|
||||
color: #d00820;
|
||||
border-color: #d00820;
|
||||
color: orange;
|
||||
border-color: orange;
|
||||
|
||||
&:hover {
|
||||
color: var(--default-color-invert);
|
||||
|
|
|
@ -11,21 +11,23 @@ type Props = {
|
|||
disabled?: boolean
|
||||
placeholder?: string
|
||||
errorMessage?: (error: string) => void
|
||||
setError?: string
|
||||
onInput: (value: string) => void
|
||||
onBlur?: (value: string) => void
|
||||
variant?: 'login' | 'registration'
|
||||
disableAutocomplete?: boolean
|
||||
}
|
||||
|
||||
const minLength = 8
|
||||
const hasNumber = /\d/
|
||||
const hasSpecial = /[!#$%&*@^]/
|
||||
|
||||
export const PasswordField = (props: Props) => {
|
||||
const { t } = useLocalize()
|
||||
const [showPassword, setShowPassword] = createSignal(false)
|
||||
const [error, setError] = createSignal<string>()
|
||||
|
||||
const validatePassword = (passwordToCheck) => {
|
||||
const minLength = 8
|
||||
const hasNumber = /\d/
|
||||
const hasSpecial = /[!#$%&*@^]/
|
||||
|
||||
if (passwordToCheck.length < minLength) {
|
||||
return t('Password should be at least 8 characters')
|
||||
}
|
||||
|
@ -35,11 +37,17 @@ export const PasswordField = (props: Props) => {
|
|||
if (!hasSpecial.test(passwordToCheck)) {
|
||||
return t('Password should contain at least one special character: !@#$%^&*')
|
||||
}
|
||||
|
||||
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)
|
||||
const errorValue = validatePassword(value)
|
||||
if (errorValue) {
|
||||
|
@ -58,14 +66,13 @@ export const PasswordField = (props: Props) => {
|
|||
{ defer: true },
|
||||
),
|
||||
)
|
||||
createEffect(() => {
|
||||
setError(props.setError)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class={clsx(styles.PassportField, props.class)}>
|
||||
<div
|
||||
class={clsx('pretty-form__item', {
|
||||
'pretty-form__item--error': error() && props.variant !== 'login',
|
||||
})}
|
||||
>
|
||||
<div class="pretty-form__item">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
|
@ -73,7 +80,7 @@ export const PasswordField = (props: Props) => {
|
|||
autocomplete={props.disableAutocomplete ? 'one-time-code' : 'current-password'}
|
||||
type={showPassword() ? 'text' : 'password'}
|
||||
placeholder={props.placeholder || t('Password')}
|
||||
onInput={(event) => handleInputChange(event.currentTarget.value)}
|
||||
onBlur={(event) => handleInputBlur(event.currentTarget.value)}
|
||||
/>
|
||||
<label for="password">{t('Password')}</label>
|
||||
<button
|
||||
|
@ -83,8 +90,14 @@ export const PasswordField = (props: Props) => {
|
|||
>
|
||||
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
|
||||
</button>
|
||||
<Show when={error() && props.variant !== 'login'}>
|
||||
<div class={clsx(styles.registerPassword, styles.validationError)}>{error()}</div>
|
||||
<Show when={error()}>
|
||||
<div
|
||||
class={clsx(styles.registerPassword, styles.validationError, {
|
||||
'form-message--error': props.setError,
|
||||
})}
|
||||
>
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -28,10 +28,6 @@ type FormFields = {
|
|||
|
||||
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
|
||||
|
||||
const handleEmailInput = (newEmail: string) => {
|
||||
setEmail(newEmail.toLowerCase())
|
||||
}
|
||||
|
||||
export const RegisterForm = () => {
|
||||
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
|
||||
const { t } = useLocalize()
|
||||
|
@ -137,7 +133,8 @@ export const RegisterForm = () => {
|
|||
setValidationErrors((prev) => ({
|
||||
email: (
|
||||
<>
|
||||
{t('This email is verified')}. {t('You can')}{' '}
|
||||
{t('This email is registered')}. {t('try')}
|
||||
{', '}
|
||||
<span class="link" onClick={() => changeSearchParams({ mode: 'login' })}>
|
||||
{t('enter')}
|
||||
</span>
|
||||
|
@ -150,9 +147,10 @@ export const RegisterForm = () => {
|
|||
...prev,
|
||||
email: (
|
||||
<>
|
||||
{t('This email is registered')}. {t('You can')}{' '}
|
||||
{t('This email is registered')}
|
||||
{'. '}
|
||||
<span class="link" onClick={() => changeSearchParams({ mode: 'send-reset-link' })}>
|
||||
{t('Set the new password').toLocaleLowerCase()}
|
||||
{t('Set the new password')}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
|
@ -172,17 +170,18 @@ export const RegisterForm = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const handleEmailInput = (newEmail: string) => {
|
||||
setEmailStatus('')
|
||||
setValidationErrors({})
|
||||
setEmail(newEmail.toLowerCase())
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={!isSuccess()}>
|
||||
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}>
|
||||
<div>
|
||||
<AuthModalHeader modalType="register" />
|
||||
<Show when={submitError()}>
|
||||
<div class={styles.authInfo}>
|
||||
<div class={styles.warn}>{submitError()}</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class={clsx('pretty-form__item', {
|
||||
'pretty-form__item--error': validationErrors().fullName,
|
||||
|
@ -194,7 +193,7 @@ export const RegisterForm = () => {
|
|||
disabled={Boolean(emailStatus())}
|
||||
placeholder={t('Full name')}
|
||||
autocomplete="one-time-code"
|
||||
onInput={(event) => handleNameInput(event.currentTarget.value)}
|
||||
onChange={(event) => handleNameInput(event.currentTarget.value)}
|
||||
/>
|
||||
<label for="name">{t('Full name')}</label>
|
||||
<Show when={validationErrors().fullName && !emailStatus()}>
|
||||
|
@ -217,16 +216,18 @@ export const RegisterForm = () => {
|
|||
onBlur={handleEmailBlur}
|
||||
/>
|
||||
<label for="email">{t('Email')}</label>
|
||||
<div class={clsx(styles.validationError, { info: Boolean(emailStatus()) })}>
|
||||
{validationErrors().email}
|
||||
</div>
|
||||
<Show when={validationErrors().email || emailStatus()}>
|
||||
<div class={clsx(styles.validationError, { info: Boolean(emailStatus()) })}>
|
||||
{validationErrors().email}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<PasswordField
|
||||
disableAutocomplete={true}
|
||||
disabled={Boolean(emailStatus())}
|
||||
errorMessage={(err) => setPasswordError(err)}
|
||||
onInput={(value) => setPassword(value)}
|
||||
errorMessage={(err) => !emailStatus() && setPasswordError(err)}
|
||||
onInput={(value) => setPassword(emailStatus() ? '' : value)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
@ -259,12 +260,14 @@ export const RegisterForm = () => {
|
|||
</form>
|
||||
</Show>
|
||||
<Show when={isSuccess()}>
|
||||
<div class={styles.title}>{t('Almost done! Check your email.')}</div>
|
||||
<div class={styles.text}>{t("We've sent you a message with a link to enter our website.")}</div>
|
||||
<div>
|
||||
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
|
||||
{t('Back to main page')}
|
||||
</button>
|
||||
<div style={{ 'justify-content': 'center' }}>
|
||||
<div class={styles.title}>{t('Almost done! Check your email.')}</div>
|
||||
<div class={styles.text}>{t("We've sent you a message with a link to enter our website.")}</div>
|
||||
<div>
|
||||
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
|
||||
{t('Back to main page')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
|
|
24
src/components/Nav/AuthModal/SendEmailConfirm.tsx
Normal file
24
src/components/Nav/AuthModal/SendEmailConfirm.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import type { AuthModalSearchParams } from './types'
|
||||
|
||||
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 { useSession } from '../../../context/session'
|
||||
|
@ -72,6 +72,12 @@ export const SendResetLinkForm = () => {
|
|||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (email()) {
|
||||
console.info('[SendResetLinkForm] email detected')
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
|
@ -79,8 +85,12 @@ export const SendResetLinkForm = () => {
|
|||
ref={(el) => (authFormRef.current = el)}
|
||||
>
|
||||
<div>
|
||||
<h4>{t('Set the new password')}</h4>
|
||||
<div class={styles.authSubtitle}>{t(message()) || t('Please give us your email address')}</div>
|
||||
<h4>{t('Forgot password?')}</h4>
|
||||
<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
|
||||
class={clsx('pretty-form__item', {
|
||||
'pretty-form__item--error': validationErrors().email,
|
||||
|
@ -94,7 +104,7 @@ export const SendResetLinkForm = () => {
|
|||
type="email"
|
||||
value={email()}
|
||||
placeholder={t('Email')}
|
||||
onInput={(event) => handleEmailInput(event.currentTarget.value)}
|
||||
onChange={(event) => handleEmailInput(event.currentTarget.value)}
|
||||
/>
|
||||
<label for="email">{t('Email')}</label>
|
||||
<Show when={isUserNotFound()}>
|
||||
|
@ -104,7 +114,7 @@ export const SendResetLinkForm = () => {
|
|||
class={'link'}
|
||||
onClick={() =>
|
||||
changeSearchParams({
|
||||
mode: 'login',
|
||||
mode: 'register',
|
||||
})
|
||||
}
|
||||
>
|
||||
|
@ -116,28 +126,31 @@ export const SendResetLinkForm = () => {
|
|||
<div class={styles.validationError}>{validationErrors().email}</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div style={{ 'margin-top': '5rem' }}>
|
||||
<button
|
||||
class={clsx('button', styles.submitButton)}
|
||||
disabled={isSubmitting() || Boolean(message())}
|
||||
type="submit"
|
||||
>
|
||||
{isSubmitting() ? '...' : t('Send')}
|
||||
</button>
|
||||
</div>
|
||||
<div class={styles.authControl}>
|
||||
<span
|
||||
class={styles.authLink}
|
||||
onClick={() =>
|
||||
changeSearchParams({
|
||||
mode: 'login',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('I know the password')}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={!message()} fallback={<div class={styles.authSubtitle}>{t(message())}</div>}>
|
||||
<>
|
||||
<div style={{ 'margin-top': '5rem' }}>
|
||||
<button
|
||||
class={clsx('button', styles.submitButton)}
|
||||
disabled={isSubmitting() || Boolean(message())}
|
||||
type="submit"
|
||||
>
|
||||
{isSubmitting() ? '...' : t('Restore password')}
|
||||
</button>
|
||||
</div>
|
||||
<div class={styles.authControl}>
|
||||
<span
|
||||
class={styles.authLink}
|
||||
onClick={() =>
|
||||
changeSearchParams({
|
||||
mode: 'login',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('I know the password')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { For } from 'solid-js'
|
||||
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { useSession } from '../../../context/session'
|
||||
import { Icon } from '../../_shared/Icon'
|
||||
import { useLocalize } from '../../../../context/localize'
|
||||
import { useSession } from '../../../../context/session'
|
||||
import { Icon } from '../../../_shared/Icon'
|
||||
|
||||
import styles from './SocialProviders.module.scss'
|
||||
|
||||
|
@ -18,7 +18,7 @@ export const SocialProviders = () => {
|
|||
<div class={styles.social}>
|
||||
<For each={PROVIDERS}>
|
||||
{(provider) => (
|
||||
<button class={styles[provider]} onClick={(_e) => oauth(provider)}>
|
||||
<button type="button" class={styles[provider]} onClick={(_e) => oauth(provider)}>
|
||||
<Icon name={provider} />
|
||||
</button>
|
||||
)}
|
1
src/components/Nav/AuthModal/SocialProviders/index.ts
Normal file
1
src/components/Nav/AuthModal/SocialProviders/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { SocialProviders } from './SocialProviders'
|
|
@ -16,12 +16,14 @@ import { RegisterForm } from './RegisterForm'
|
|||
import { SendResetLinkForm } from './SendResetLinkForm'
|
||||
|
||||
import styles from './AuthModal.module.scss'
|
||||
import { SendEmailConfirm } from './SendEmailConfirm'
|
||||
|
||||
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
|
||||
login: LoginForm,
|
||||
register: RegisterForm,
|
||||
'send-reset-link': SendResetLinkForm,
|
||||
'confirm-email': EmailConfirm,
|
||||
'send-confirm-email': SendEmailConfirm,
|
||||
'change-password': ChangePasswordForm,
|
||||
}
|
||||
|
||||
|
|
|
@ -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 =
|
||||
| 'discussions'
|
||||
| 'vote'
|
||||
|
|
|
@ -115,15 +115,17 @@ export const TopicBadge = (props: Props) => {
|
|||
</div>
|
||||
</div>
|
||||
<div class={styles.stats}>
|
||||
<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('shoutsWithCount', { count: props.topic?.stat?.shouts })}</span>
|
||||
<span class={styles.statsItem}>{t('authorsWithCount', { count: props.topic?.stat?.authors })}</span>
|
||||
<span class={styles.statsItem}>
|
||||
{t('FollowersWithCount', {count: props.topic?.stat?.followers})}
|
||||
{t('FollowersWithCount', { count: props.topic?.stat?.followers })}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -64,10 +64,11 @@ export const EditView = (props: Props) => {
|
|||
getDraftFromLocalStorage,
|
||||
} = useEditorContext()
|
||||
const shoutTopics = props.shout.topics || []
|
||||
const draft = getDraftFromLocalStorage(props.shout.id)
|
||||
|
||||
// TODO: проверить сохранение черновика в local storage (не работает)
|
||||
const draft = getDraftFromLocalStorage(props.shout.id)
|
||||
if (draft) {
|
||||
setForm(draft)
|
||||
setForm(Object.keys(draft).length !== 0 ? draft : { shoutId: props.shout.id })
|
||||
} else {
|
||||
setForm({
|
||||
slug: props.shout.slug,
|
||||
|
@ -179,6 +180,7 @@ export const EditView = (props: Props) => {
|
|||
|
||||
let autoSaveTimeOutId: number | string | NodeJS.Timeout
|
||||
|
||||
//TODO: add throttle
|
||||
const autoSaveRecursive = () => {
|
||||
autoSaveTimeOutId = setTimeout(async () => {
|
||||
const hasChanges = !deepEqual(form, prevForm)
|
||||
|
@ -307,10 +309,10 @@ export const EditView = (props: Props) => {
|
|||
subtitleInput.current = el
|
||||
}}
|
||||
allowEnterKey={false}
|
||||
value={(value) => setForm('subtitle', value)}
|
||||
value={(value) => setForm('subtitle', value || '')}
|
||||
class={styles.subtitleInput}
|
||||
placeholder={t('Subheader')}
|
||||
initialValue={form.subtitle}
|
||||
initialValue={form.subtitle || ''}
|
||||
maxLength={MAX_HEADER_LIMIT}
|
||||
/>
|
||||
</Show>
|
||||
|
|
|
@ -70,10 +70,10 @@ export const PublishSettings = (props: Props) => {
|
|||
return {
|
||||
coverImageUrl: props.form?.coverImageUrl,
|
||||
mainTopic: props.form?.mainTopic || EMPTY_TOPIC,
|
||||
slug: props.form?.slug,
|
||||
title: props.form?.title,
|
||||
subtitle: props.form?.subtitle,
|
||||
description: composeDescription(),
|
||||
slug: props.form?.slug || '',
|
||||
title: props.form?.title || '',
|
||||
subtitle: props.form?.subtitle || '',
|
||||
description: composeDescription() || '',
|
||||
selectedTopics: [],
|
||||
}
|
||||
})
|
||||
|
@ -100,7 +100,7 @@ export const PublishSettings = (props: Props) => {
|
|||
const handleTopicSelectChange = (newSelectedTopics) => {
|
||||
if (
|
||||
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) => {
|
||||
return {
|
||||
|
@ -176,7 +176,7 @@ export const PublishSettings = (props: Props) => {
|
|||
<div class={styles.mainTopic}>{settingsForm.mainTopic.title}</div>
|
||||
</Show>
|
||||
<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>
|
||||
</div>
|
||||
|
@ -203,7 +203,7 @@ export const PublishSettings = (props: Props) => {
|
|||
variant="bordered"
|
||||
fieldName={t('Subheader')}
|
||||
placeholder={t('Come up with a subtitle for your story')}
|
||||
initialValue={settingsForm.subtitle}
|
||||
initialValue={settingsForm.subtitle || ''}
|
||||
value={(value) => setSettingsForm('subtitle', value)}
|
||||
allowEnterKey={false}
|
||||
maxLength={100}
|
||||
|
|
|
@ -50,7 +50,7 @@ export const ConnectProvider = (props: { children: JSX.Element }) => {
|
|||
Authorization: token,
|
||||
},
|
||||
onmessage(event) {
|
||||
const m: SSEMessage = JSON.parse(event.data)
|
||||
const m: SSEMessage = JSON.parse(event.data || '{}')
|
||||
console.log('[context.connect] Received message:', m)
|
||||
|
||||
// Iterate over all registered handlers and call them
|
||||
|
|
|
@ -39,7 +39,7 @@ type EditorContextType = {
|
|||
wordCounter: Accessor<WordCounter>
|
||||
form: ShoutForm
|
||||
formErrors: Record<keyof ShoutForm, string>
|
||||
editorRef: { current: () => Editor }
|
||||
editorRef: { current: () => Editor | null }
|
||||
saveShout: (form: ShoutForm) => Promise<void>
|
||||
saveDraft: (form: ShoutForm) => Promise<void>
|
||||
saveDraftToLocalStorage: (form: ShoutForm) => void
|
||||
|
@ -72,7 +72,7 @@ const saveDraftToLocalStorage = (formToSave: ShoutForm) => {
|
|||
localStorage.setItem(`shout-${formToSave.shoutId}`, JSON.stringify(formToSave))
|
||||
}
|
||||
const getDraftFromLocalStorage = (shoutId: number) => {
|
||||
return JSON.parse(localStorage.getItem(`shout-${shoutId}`))
|
||||
return JSON.parse(localStorage.getItem(`shout-${shoutId}`) || '{}')
|
||||
}
|
||||
|
||||
const removeDraftFromLocalStorage = (shoutId: number) => {
|
||||
|
@ -80,13 +80,19 @@ const removeDraftFromLocalStorage = (shoutId: number) => {
|
|||
}
|
||||
|
||||
export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||
const { t } = useLocalize()
|
||||
const localize = useLocalize()
|
||||
const { page } = useRouter()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
const snackbar = useSnackbar()
|
||||
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
|
||||
const editorRef: { current: () => Editor } = { current: null }
|
||||
const [form, setForm] = createStore<ShoutForm>(null)
|
||||
const [formErrors, setFormErrors] = createStore<Record<keyof ShoutForm, string>>(null)
|
||||
const editorRef: { current: () => Editor | null } = { current: () => null }
|
||||
const [form, setForm] = createStore<ShoutForm>({
|
||||
body: '',
|
||||
slug: '',
|
||||
shoutId: 0,
|
||||
title: '',
|
||||
selectedTopics: [],
|
||||
})
|
||||
const [formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
|
||||
const [wordCounter, setWordCounter] = createSignal<WordCounter>({
|
||||
characters: 0,
|
||||
words: 0,
|
||||
|
@ -95,13 +101,16 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
const countWords = (value) => setWordCounter(value)
|
||||
const validate = () => {
|
||||
if (!form.title) {
|
||||
setFormErrors('title', t('Please, set the article title'))
|
||||
setFormErrors('title', localize?.t('Please, set the article title') || '')
|
||||
return false
|
||||
}
|
||||
|
||||
const parsedMedia = JSON.parse(form.media)
|
||||
const parsedMedia = JSON.parse(form.media || '[]')
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -110,7 +119,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
|
||||
const validateSettings = () => {
|
||||
if (form.selectedTopics.length === 0) {
|
||||
setFormErrors('selectedTopics', t('Required'))
|
||||
setFormErrors('selectedTopics', localize?.t('Required') || '')
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -118,6 +127,10 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
}
|
||||
|
||||
const updateShout = async (formToUpdate: ShoutForm, { publish }: { publish: boolean }) => {
|
||||
if (!formToUpdate.shoutId) {
|
||||
console.error(formToUpdate)
|
||||
return { error: 'not enought data' }
|
||||
}
|
||||
return await apiClient.updateArticle({
|
||||
shout_id: formToUpdate.shoutId,
|
||||
shout_input: {
|
||||
|
@ -143,48 +156,61 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
toggleEditorPanel()
|
||||
}
|
||||
|
||||
if (page().route === 'edit' && !validate()) {
|
||||
if (page()?.route === 'edit' && !validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (page().route === 'editSettings' && !validateSettings()) {
|
||||
if (page()?.route === 'editSettings' && !validateSettings()) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if (shout.published_at) {
|
||||
if (shout?.published_at) {
|
||||
openPage(router, 'article', { slug: shout.slug })
|
||||
} else {
|
||||
openPage(router, 'drafts')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[saveShout]', error)
|
||||
showSnackbar({ type: 'error', body: t('Error') })
|
||||
snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (isEditorPanelVisible()) {
|
||||
const editorPanelVisible = isEditorPanelVisible()
|
||||
const pageRoute = page()?.route
|
||||
|
||||
if (editorPanelVisible) {
|
||||
toggleEditorPanel()
|
||||
}
|
||||
|
||||
if (page().route === 'edit') {
|
||||
if (pageRoute === 'edit') {
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
await updateShout(formToPublish, { publish: false })
|
||||
|
||||
const slug = slugify(form.title)
|
||||
setForm('slug', slug)
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -193,20 +219,33 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
}
|
||||
|
||||
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')
|
||||
} catch (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) => {
|
||||
if (!shout_id) {
|
||||
console.error(`shout_id is ${shout_id}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const newShout = await apiClient.updateArticle({
|
||||
const { shout: newShout, error } = await apiClient.updateArticle({
|
||||
shout_id,
|
||||
publish: true,
|
||||
})
|
||||
if (error) {
|
||||
console.error(error)
|
||||
snackbar?.showSnackbar({ type: 'error', body: error })
|
||||
return
|
||||
}
|
||||
if (newShout) {
|
||||
addArticles([newShout])
|
||||
openPage(router, 'feed')
|
||||
|
@ -215,7 +254,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
|||
}
|
||||
} catch (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
|
||||
} catch {
|
||||
showSnackbar({ type: 'error', body: t('Error') })
|
||||
snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,8 @@ import { createStore, reconcile } from 'solid-js/store'
|
|||
|
||||
import { apiClient } from '../graphql/client/core'
|
||||
import { Reaction, ReactionBy, ReactionInput, ReactionKind } from '../graphql/schema/core.gen'
|
||||
import { useSession } from './session'
|
||||
import { useLocalize } from './localize'
|
||||
import { useSnackbar } from './snackbar'
|
||||
|
||||
type ReactionsContextType = {
|
||||
reactionEntities: Accessor<Record<number, Reaction>>
|
||||
|
@ -20,7 +21,7 @@ type ReactionsContextType = {
|
|||
}) => Promise<Reaction[]>
|
||||
createReaction: (reaction: ReactionInput) => Promise<void>
|
||||
updateReaction: (reaction: ReactionInput) => Promise<Reaction>
|
||||
deleteReaction: (id: number) => Promise<void>
|
||||
deleteReaction: (id: number) => Promise<{ error: string }>
|
||||
}
|
||||
|
||||
const ReactionsContext = createContext<ReactionsContextType>()
|
||||
|
@ -30,8 +31,9 @@ export function useReactions() {
|
|||
}
|
||||
|
||||
export const ReactionsProvider = (props: { children: JSX.Element }) => {
|
||||
const [reactionEntities, setReactionEntities] = createSignal<Record<number, Reaction> | undefined>()
|
||||
const { author } = useSession()
|
||||
const [reactionEntities, setReactionEntities] = createStore<Record<number, Reaction>>({})
|
||||
const { t } = useLocalize()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
|
||||
const loadReactionsBy = async ({
|
||||
by,
|
||||
|
@ -55,18 +57,8 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
|
|||
}
|
||||
|
||||
const createReaction = async (input: ReactionInput): Promise<void> => {
|
||||
const fakeId = Date.now() + Math.floor(Math.random() * 1000)
|
||||
setReactionEntities((rrr: Record<number, Reaction>) => ({
|
||||
...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 })
|
||||
const { error, reaction } = await apiClient.createReaction(input)
|
||||
if (error) await showSnackbar({ type: 'error', body: t(error) })
|
||||
if (!reaction) return
|
||||
const changes = {
|
||||
[reaction.id]: reaction,
|
||||
|
@ -92,19 +84,22 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
|
|||
setReactionEntities(changes)
|
||||
}
|
||||
|
||||
const deleteReaction = async (reaction: number): Promise<void> => {
|
||||
setReactionEntities({ [reaction]: undefined })
|
||||
await apiClient.destroyReaction(reaction)
|
||||
const deleteReaction = async (reaction_id: number): Promise<{ error: string; reaction?: string }> => {
|
||||
if (reaction_id) {
|
||||
const result = await apiClient.destroyReaction(reaction_id)
|
||||
if (!result.error) {
|
||||
setReactionEntities({
|
||||
[reaction_id]: undefined,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
const updateReaction = async (input: ReactionInput): Promise<Reaction> => {
|
||||
const reaction = await apiClient.updateReaction(input)
|
||||
if (reaction) {
|
||||
setReactionEntities((rrr) => {
|
||||
rrr[reaction.id] = reaction
|
||||
return rrr
|
||||
})
|
||||
}
|
||||
const { error, reaction } = await apiClient.updateReaction(input)
|
||||
if (error) await showSnackbar({ type: 'error', body: t(error) })
|
||||
if (reaction) setReactionEntities(reaction.id, reaction)
|
||||
return reaction
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import reactionDestroy from '../mutation/core/reaction-destroy'
|
|||
import reactionUpdate from '../mutation/core/reaction-update'
|
||||
import unfollowMutation from '../mutation/core/unfollow'
|
||||
import shoutLoad from '../query/core/article-load'
|
||||
import getMyShout from '../query/core/article-my'
|
||||
import shoutsLoadBy from '../query/core/articles-load-by'
|
||||
import draftsLoad from '../query/core/articles-load-drafts'
|
||||
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 authorsAll from '../query/core/authors-all'
|
||||
import authorsLoadBy from '../query/core/authors-load-by'
|
||||
import mySubscriptions from '../query/core/my-followed'
|
||||
import reactionsLoadBy from '../query/core/reactions-load-by'
|
||||
import topicBySlug from '../query/core/topic-by-slug'
|
||||
import topicsAll from '../query/core/topics-all'
|
||||
|
@ -135,7 +135,6 @@ export const apiClient = {
|
|||
user?: string
|
||||
}): Promise<AuthorFollows> => {
|
||||
const response = await publicGraphQLClient.query(authorFollows, params).toPromise()
|
||||
console.log('!!! response:', response)
|
||||
return response.data.get_author_follows
|
||||
},
|
||||
|
||||
|
@ -162,12 +161,12 @@ export const apiClient = {
|
|||
shout_id: number
|
||||
shout_input?: ShoutInput
|
||||
publish: boolean
|
||||
}): Promise<Shout> => {
|
||||
}): Promise<CommonResult> => {
|
||||
const response = await apiClient.private
|
||||
.mutation(updateArticle, { shout_id, shout_input, publish })
|
||||
.toPromise()
|
||||
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> => {
|
||||
|
@ -178,7 +177,7 @@ export const apiClient = {
|
|||
getDrafts: async (): Promise<Shout[]> => {
|
||||
const response = await apiClient.private.query(draftsLoad, {}).toPromise()
|
||||
console.debug('[graphql.client.core] getDrafts:', response)
|
||||
return response.data.load_shouts_drafts
|
||||
return response.data.get_shouts_drafts
|
||||
},
|
||||
createReaction: async (input: ReactionInput) => {
|
||||
const response = await apiClient.private.mutation(reactionCreate, { reaction: input }).toPromise()
|
||||
|
@ -188,7 +187,7 @@ export const apiClient = {
|
|||
destroyReaction: async (reaction_id: number) => {
|
||||
const response = await apiClient.private.mutation(reactionDestroy, { reaction_id }).toPromise()
|
||||
console.debug('[graphql.client.core] destroyReaction:', response)
|
||||
return response.data.delete_reaction.reaction
|
||||
return response.data.delete_reaction
|
||||
},
|
||||
updateReaction: async (reaction: ReactionInput) => {
|
||||
const response = await apiClient.private.mutation(reactionUpdate, { reaction }).toPromise()
|
||||
|
@ -200,15 +199,18 @@ export const apiClient = {
|
|||
console.debug('[graphql.client.core] authorsLoadBy:', resp)
|
||||
return resp.data.load_authors_by
|
||||
},
|
||||
|
||||
getShoutBySlug: async (slug: string) => {
|
||||
const resp = await publicGraphQLClient.query(shoutLoad, { slug }).toPromise()
|
||||
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)
|
||||
|
||||
return resp.data.get_shout
|
||||
return resp.data.get_my_shout
|
||||
},
|
||||
|
||||
getShouts: async (options: LoadShoutsOptions) => {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
query LoadShoutQuery($slug: String, $shout_id: Int) {
|
||||
get_shout(slug: $slug, shout_id: $shout_id) {
|
||||
query LoadShoutQuery($slug: String!) {
|
||||
get_shout(slug: $slug) {
|
||||
id
|
||||
title
|
||||
lead
|
||||
|
|
53
src/graphql/query/core/article-my.ts
Normal file
53
src/graphql/query/core/article-my.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
|
@ -2,7 +2,7 @@ import { gql } from '@urql/core'
|
|||
|
||||
export default gql`
|
||||
query LoadDraftsQuery {
|
||||
load_shouts_drafts {
|
||||
get_shouts_drafts {
|
||||
id
|
||||
title
|
||||
subtitle
|
||||
|
@ -35,7 +35,6 @@ export default gql`
|
|||
featured_at
|
||||
stat {
|
||||
viewed
|
||||
|
||||
rating
|
||||
commented
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ export default gql`
|
|||
shouts
|
||||
authors
|
||||
followers
|
||||
comments
|
||||
# viewed
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,22 +7,42 @@ import { useLocalize } from '../context/localize'
|
|||
import { apiClient } from '../graphql/client/core'
|
||||
import { Shout } from '../graphql/schema/core.gen'
|
||||
import { useRouter } from '../stores/router'
|
||||
import { router } from '../stores/router'
|
||||
|
||||
import { redirectPage } from '@nanostores/router'
|
||||
import { useSnackbar } from '../context/snackbar'
|
||||
import { LayoutType } from './types'
|
||||
|
||||
const EditView = lazy(() => import('../components/Views/EditView/EditView'))
|
||||
|
||||
export const EditPage = () => {
|
||||
const { page } = useRouter()
|
||||
const snackbar = useSnackbar()
|
||||
const { t } = useLocalize()
|
||||
|
||||
const shoutId = createMemo(() => Number((page().params as Record<'shoutId', string>).shoutId))
|
||||
|
||||
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 () => {
|
||||
const loadedShout = await apiClient.getShoutById(shoutId())
|
||||
setShout(loadedShout)
|
||||
const shout_id = window.location.pathname.split('/').pop()
|
||||
if (shout_id) {
|
||||
try {
|
||||
await loadMyShout(parseInt(shout_id, 10))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const title = createMemo(() => {
|
||||
|
|
|
@ -464,7 +464,7 @@ form {
|
|||
}
|
||||
|
||||
.form-message--error {
|
||||
color: #d00820;
|
||||
color: var(--danger-color) !important;
|
||||
}
|
||||
|
||||
select {
|
||||
|
|
|
@ -7,7 +7,7 @@ export const byCreated = (a: Shout | Reaction, b: Shout | Reaction) => {
|
|||
}
|
||||
|
||||
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 = (
|
||||
|
|
Loading…
Reference in New Issue
Block a user