Add image by URL, Upload (choose file by button and D&D)

This commit is contained in:
ilia tapazukk 2023-05-04 04:43:52 +00:00
parent 821fb428de
commit 08cc22b93c
37 changed files with 548 additions and 181 deletions

69
package-lock.json generated
View File

@ -5723,6 +5723,18 @@
"solid-js": ">=1.4.0"
}
},
"node_modules/@soorria/solid-dropzone": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@soorria/solid-dropzone/-/solid-dropzone-0.0.5.tgz",
"integrity": "sha512-lIuCz33UuHZ/34jMLlhspzUZfpZyPvquJvUIZ4zDFZeaxIvgsspwDblKlk347K/qKu3+WNKhiDoIUodMpM7Yug==",
"dependencies": {
"attr-accept": "^2.2.2",
"file-selector": "^0.6.0"
},
"peerDependencies": {
"solid-js": ">=1.0.0"
}
},
"node_modules/@thisbeyond/solid-select": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-select/-/solid-select-0.13.0.tgz",
@ -7175,6 +7187,14 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
"integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==",
"engines": {
"node": ">=4"
}
},
"node_modules/auto-bind": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz",
@ -8376,8 +8396,7 @@
"node_modules/csstype": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==",
"dev": true
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw=="
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@ -10149,6 +10168,17 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-selector": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
"integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"dependencies": {
"tslib": "^2.4.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@ -18080,7 +18110,6 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-0.5.1.tgz",
"integrity": "sha512-ZfhQVB59hmIauJG5Ydynupy8KHyr5imGNtdDhbZG68Ufh1Ynkv9KOYOAABf71oVbQxJ8VkWnMHAjEHE7fWkH5g==",
"dev": true,
"engines": {
"node": ">=10"
}
@ -24782,6 +24811,15 @@
"dev": true,
"requires": {}
},
"@soorria/solid-dropzone": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@soorria/solid-dropzone/-/solid-dropzone-0.0.5.tgz",
"integrity": "sha512-lIuCz33UuHZ/34jMLlhspzUZfpZyPvquJvUIZ4zDFZeaxIvgsspwDblKlk347K/qKu3+WNKhiDoIUodMpM7Yug==",
"requires": {
"attr-accept": "^2.2.2",
"file-selector": "^0.6.0"
}
},
"@thisbeyond/solid-select": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@thisbeyond/solid-select/-/solid-select-0.13.0.tgz",
@ -25828,6 +25866,11 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"attr-accept": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
"integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg=="
},
"auto-bind": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz",
@ -26717,8 +26760,7 @@
"csstype": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==",
"dev": true
"integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw=="
},
"damerau-levenshtein": {
"version": "1.0.8",
@ -28031,6 +28073,14 @@
"flat-cache": "^3.0.4"
}
},
"file-selector": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
"integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"requires": {
"tslib": "^2.4.0"
}
},
"filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@ -33906,8 +33956,7 @@
"seroval": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-0.5.1.tgz",
"integrity": "sha512-ZfhQVB59hmIauJG5Ydynupy8KHyr5imGNtdDhbZG68Ufh1Ynkv9KOYOAABf71oVbQxJ8VkWnMHAjEHE7fWkH5g==",
"dev": true
"integrity": "sha512-ZfhQVB59hmIauJG5Ydynupy8KHyr5imGNtdDhbZG68Ufh1Ynkv9KOYOAABf71oVbQxJ8VkWnMHAjEHE7fWkH5g=="
},
"set-blocking": {
"version": "2.0.0",
@ -34043,9 +34092,9 @@
}
},
"solid-js": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.7.3.tgz",
"integrity": "sha512-4hwaF/zV/xbNeBBIYDyu3dcReOZBECbO//mrra6GqOrKy4Soyo+fnKjpZSa0nODm6j1aL0iQRh/7ofYowH+jzw==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.7.0.tgz",
"integrity": "sha512-tLG68KWlVRgzYeAW003G3E70emZqTcqCKJR9QoGr0rcuiLIuKrlUoezT8jLME1YSl3Wfu35jzgeY10iLEY4YQQ==",
"dev": true,
"requires": {
"csstype": "^3.1.0",

View File

@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.6642 15.2662L10.5312 19.4724C10.2967 19.7094 10.2967 20.0648 10.5312 20.3018L14.6642 24.4784C14.8987 24.7153 14.8987 25.0708 14.6642 25.3078L13.8435 26.1372C13.609 26.3741 13.2572 26.3741 13.0227 26.1372L7.21884 20.3018C6.98434 20.0648 6.98434 19.7094 7.21884 19.4724L13.0227 13.6074C13.2572 13.3704 13.609 13.3704 13.8435 13.6074L14.6642 14.4368C14.8987 14.6738 14.8987 15.0589 14.6642 15.2662Z" fill="currentColor"/>
<path d="M25.3946 24.508L29.5277 20.3314C29.7622 20.0944 29.7622 19.739 29.5277 19.502L25.3946 15.2662C25.1601 15.0292 25.1601 14.6738 25.3946 14.4368L26.2154 13.6074C26.4499 13.3704 26.8016 13.3704 27.0361 13.6074L32.84 19.4724C33.0745 19.7094 33.0745 20.0648 32.84 20.3018L27.0361 26.1668C26.8016 26.4037 26.4499 26.4037 26.2154 26.1668L25.3946 25.3374C25.1601 25.1004 25.1601 24.7153 25.3946 24.508Z" fill="currentColor"/>
<path d="M21.9333 11.8003L23.0472 12.1558C23.3696 12.2446 23.5162 12.6001 23.4282 12.8963L18.7675 27.5884C18.6796 27.9142 18.3278 28.0623 18.0347 27.9734L16.9208 27.5884C16.5984 27.4995 16.4518 27.144 16.5398 26.8478L21.2005 12.1854C21.3177 11.8892 21.6402 11.7114 21.9333 11.8003Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.3" d="M64 56.8889V7.11111C64 3.2 60.8 0 56.8889 0H7.11111C3.2 0 0 3.2 0 7.11111V56.8889C0 60.8 3.2 64 7.11111 64H56.8889C60.8 64 64 60.8 64 56.8889ZM19.5556 37.3333L28.4444 48.0356L40.8889 32L56.8889 53.3333H7.11111L19.5556 37.3333Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 369 B

View File

@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31 28.5556V11.4444C31 10.1 29.9 9 28.5556 9H11.4444C10.1 9 9 10.1 9 11.4444V28.5556C9 29.9 10.1 31 11.4444 31H28.5556C29.9 31 31 29.9 31 28.5556ZM15.7222 21.8333L18.7778 25.5122L23.0556 20L28.5556 27.3333H11.4444L15.7222 21.8333Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@ -53,6 +53,7 @@
"Discussion rules": "Discussion rules",
"Dogma": "Dogma",
"Drafts": "Drafts",
"Drag the image to this area": "Drag the image to this area",
"Edit": "Edit",
"Editing": "Editing",
"Email": "Mail",
@ -94,9 +95,11 @@
"I have an account": "I have an account!",
"I have no account yet": "I don't have an account yet",
"I know the password": "I know the password",
"Image format not supported": "Image format not supported",
"Independant magazine with an open horizontal cooperation about culture, science and society": "Independant magazine with an open horizontal cooperation about culture, science and society",
"Introduce": "Introduction",
"Invalid email": "Check if your email is correct",
"Invalid image link": "Invalid image link",
"Invalid url format": "Invalid url format",
"Invite co-authors": "Invite co-authors",
"Invite to collab": "Invite to Collab",
@ -115,6 +118,7 @@
"Loading": "Loading",
"Logout": "Logout",
"Manifest": "Manifest",
"Many files, choose only one": "Many files, choose only one",
"More": "More",
"Most commented": "Commented",
"Most read": "Readable",
@ -128,6 +132,7 @@
"Nothing here yet": "There's nothing here yet",
"Nothing is here": "There is nothing here",
"Or continue with social network": "Or continue with social network",
"Or paste a link to an image": "Or paste a link to an image",
"Our regular contributor": "Our regular contributor",
"Paragraphs": "Абзацев",
"Participating": "Participating",
@ -210,6 +215,7 @@
"Try to find another way": "Try to find another way",
"Unfollow": "Unfollow",
"Unfollow the topic": "Unfollow the topic",
"Upload": "Upload",
"Username": "Username",
"Userpic": "Userpic",
"Video": "Video",

View File

@ -55,6 +55,7 @@
"Discussion rules": "Правила сообществ самиздата в&nbsp;соцсетях",
"Dogma": "Догма",
"Drafts": "Черновики",
"Drag the image to this area": "Перетащите изображение в эту область",
"Edit": "Редактировать",
"Edited": "Отредактирован",
"Editing": "Редактирование",
@ -67,8 +68,8 @@
"Enter your new password": "Введите новый пароль",
"Error": "Ошибка",
"Everything is ok, please give us your email address": "Ничего страшного, просто укажите свою почту, чтобы получить ссылку для сброса пароля.",
"Favorite": "Избранное",
"FAQ": "Советы и предложения",
"Favorite": "Избранное",
"Favorite topics": "Избранные темы",
"Feed settings": "Настройки ленты",
"Feedback": "Обратная связь",
@ -99,11 +100,13 @@
"I have an account": "У меня есть аккаунт!",
"I have no account yet": "У меня еще нет аккаунта",
"I know the password": "Я знаю пароль",
"Image format not supported": "Тип изображения не поддерживается",
"Independant magazine with an open horizontal cooperation about culture, science and society": "Независимый журнал с открытой горизонтальной редакцией о культуре, науке и обществе",
"Introduce": "Представление",
"Invalid email": "Проверьте правильность ввода почты",
"Invite co-authors": "Пригласить соавторов",
"Invalid image link": "Некорректная ссылка на изображение",
"Invalid url format": "Неверный формат ссылки",
"Invite co-authors": "Пригласить соавторов",
"Invite experts": "Пригласить экспертов",
"Invite to collab": "Пригласить к участию",
"It does not look like url": "Это не похоже на ссылку",
@ -122,6 +125,7 @@
"Loading": "Загрузка",
"Logout": "Выход",
"Manifest": "Манифест",
"Many files, choose only one": "Много файлов, выберете один",
"More": "Ещё",
"Most commented": "Комментируемое",
"Most read": "Читаемое",
@ -135,6 +139,7 @@
"Nothing here yet": "Здесь пока ничего нет",
"Nothing is here": "Здесь ничего нет",
"Or continue with social network": "Или продолжите через соцсеть",
"Or paste a link to an image": "Или вставьте ссылку на изображение",
"Our regular contributor": "Наш постоянный автор",
"Paragraphs": "Абзацев",
"Participating": "Участвовать",
@ -159,8 +164,8 @@
"Profile": "Профиль",
"Profile settings": "Настройки профиля",
"Profile successfully saved": "Профиль успешно сохранён",
"Publications": "Публикации",
"Publication settings": "Настройки публикации",
"Publications": "Публикации",
"Publish": "Опубликовать",
"Quit": "Выйти",
"Quotes": "Цитаты",
@ -223,6 +228,7 @@
"Try to find another way": "Попробуйте найти по-другому",
"Unfollow": "Отписаться",
"Unfollow the topic": "Отписаться от темы",
"Upload": "Загрузить",
"Username": "Имя пользователя",
"Userpic": "Аватар",
"Video": "Видео",

View File

@ -26,10 +26,7 @@ import { Image } from '@tiptap/extension-image'
import { Paragraph } from '@tiptap/extension-paragraph'
import Focus from '@tiptap/extension-focus'
import { TrailingNode } from './extensions/TrailingNode'
import { EditorBubbleMenu } from './EditorBubbleMenu/EditorBubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
import * as Y from 'yjs'
// import { WebrtcProvider } from 'y-webrtc'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
import { Collaboration } from '@tiptap/extension-collaboration'
import './Prosemirror.scss'
@ -38,10 +35,12 @@ import { useSession } from '../../context/session'
import uniqolor from 'uniqolor'
import { HocuspocusProvider } from '@hocuspocus/provider'
import { Embed } from './extensions/embed'
import { EditorBubbleMenu } from './EditorBubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
import { useEditorContext } from '../../context/editor'
type EditorProps = {
shoutSlug: string
shoutId: number
initialContent?: string
onChange: (text: string) => void
}
@ -54,7 +53,7 @@ export const Editor = (props: EditorProps) => {
const { t } = useLocalize()
const { user } = useSession()
const docName = `shout-${props.shoutSlug}`
const docName = `shout-${props.shoutId}`
if (!providers[docName]) {
providers[docName] = new HocuspocusProvider({
@ -89,8 +88,6 @@ export const Editor = (props: EditorProps) => {
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
content: props.initialContent,
//onTransaction: handleEditorTransaction,
extensions: [
Document,
Text,
@ -111,7 +108,6 @@ export const Editor = (props: EditorProps) => {
BulletList,
OrderedList,
ListItem,
CharacterCount,
Collaboration.configure({
document: yDoc
}),
@ -129,9 +125,15 @@ export const Editor = (props: EditorProps) => {
Gapcursor,
HardBreak,
Highlight,
Image,
Image.configure({
HTMLAttributes: {
class: 'uploadedImage'
}
}),
TrailingNode,
Embed,
TrailingNode,
CharacterCount,
BubbleMenu.configure({
element: bubbleMenuRef.current
}),

View File

@ -6,7 +6,7 @@ import { clsx } from 'clsx'
import { createEditorTransaction } from 'solid-tiptap'
import { useLocalize } from '../../../context/localize'
import { InlineForm } from '../InlineForm'
import validateUrl from '../../../utils/validateUrl'
import validateImage from '../../../utils/validateUrl'
type BubbleMenuProps = {
editor: Editor
@ -80,12 +80,13 @@ export const EditorBubbleMenu = (props: BubbleMenuProps) => {
<Switch>
<Match when={linkEditorOpen()}>
<InlineForm
variant="inBubble"
placeholder={t('Enter URL address')}
initialValue={currentUrl() ?? ''}
onClear={handleClearLinkForm}
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
validate={(value) => (validateImage(value) ? '' : t('Invalid url format'))}
onSubmit={handleLinkFormSubmit}
onClose={() => setLinkEditorOpen(false)}
errorMessage={t('Error')}
/>
</Match>
<Match when={!linkEditorOpen()}>

View File

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

View File

@ -1,61 +0,0 @@
import { createSignal, Show } from 'solid-js'
import type { Editor } from '@tiptap/core'
import { Icon } from '../_shared/Icon'
import { InlineForm } from './InlineForm'
import styles from './EditorFloatingMenu.module.scss'
import HTMLParser from 'html-to-json-parser'
type FloatingMenuProps = {
editor: Editor
ref: (el: HTMLDivElement) => void
}
const embedData = async (data) => {
const result = await HTMLParser(data, false)
if (typeof result === 'string') {
return
}
if (result && 'type' in result && result.type === 'iframe') {
return result.attributes
}
}
const validateEmbed = async (value: string): Promise<string> => {
const iframeData = await HTMLParser(value, false)
if (typeof iframeData === 'string') {
return
}
if (iframeData && iframeData.type !== 'iframe') {
return
}
}
export const EditorFloatingMenu = (props: FloatingMenuProps) => {
const [inlineEditorOpen, setInlineEditorOpen] = createSignal<boolean>(false)
const handleEmbedFormSubmit = async (value: string) => {
// TODO: add support instagram embed (blockquote)
const { src } = (await embedData(value)) as { src: string }
props.editor.chain().focus().setIframe({ src }).run()
}
return (
<div ref={props.ref} class={styles.editorFloatingMenu}>
<button type="button" onClick={() => setInlineEditorOpen(true)}>
<Icon name="editor-plus" />
</button>
<Show when={inlineEditorOpen()}>
<InlineForm
variant="inFloating"
onClose={() => setInlineEditorOpen(false)}
validate={validateEmbed}
onSubmit={handleEmbedFormSubmit}
/>
</Show>
</div>
)
}

View File

@ -12,4 +12,12 @@
opacity: 1;
}
}
.menuHolder {
background: #fff;
left: calc(100% + 1rem);
box-shadow: 0 4px 10px rgba(#000, 0.25);
position: absolute;
top: -0.8rem;
min-width: 64vw;
}
}

View File

@ -0,0 +1,89 @@
import { createEffect, createSignal, Show } from 'solid-js'
import type { Editor, JSONContent } from '@tiptap/core'
import { Icon } from '../../_shared/Icon'
import { InlineForm } from '../InlineForm'
import styles from './EditorFloatingMenu.module.scss'
import HTMLParser from 'html-to-json-parser'
import { useLocalize } from '../../../context/localize'
import { Modal } from '../../Nav/Modal'
import { Menu } from './Menu'
import { showModal } from '../../../stores/ui'
import { UploadModalContent } from '../UploadModal'
type FloatingMenuProps = {
editor: Editor
ref: (el: HTMLDivElement) => void
}
const embedData = async (data) => {
const result = (await HTMLParser(data, false)) as JSONContent
if ('type' in result && result.type === 'iframe') {
return result.attributes
}
}
const validateEmbed = async (value) => {
const iframeData = (await HTMLParser(value, false)) as JSONContent
if (iframeData.type !== 'iframe') {
return
}
}
export const EditorFloatingMenu = (props: FloatingMenuProps) => {
const { t } = useLocalize()
const [selectedMenuItem, setSelectedMenuItem] = createSignal<string | null>(null)
const [menuOpen, setMenuOpen] = createSignal<boolean>(false)
const handleEmbedFormSubmit = async (value: string) => {
// TODO: add support instagram embed (blockquote)
const emb = await embedData(value)
props.editor.chain().focus().setIframe(emb).run()
}
createEffect(() => {
if (selectedMenuItem() === 'image') {
showModal('uploadImage')
}
})
const closeUploadModalHandler = () => {
setSelectedMenuItem(null)
setMenuOpen(false)
}
return (
<>
<div ref={props.ref} class={styles.editorFloatingMenu}>
<button
type="button"
onClick={() => {
console.log('!!! selectedMenuItem:', selectedMenuItem())
setMenuOpen(!menuOpen())
}}
>
<Icon name="editor-plus" />
</button>
<Show when={menuOpen()}>
<div class={styles.menuHolder}>
<Show when={!selectedMenuItem()}>
<Menu selectedItem={(value) => setSelectedMenuItem(value)} />
</Show>
<Show when={selectedMenuItem() === 'embed'}>
<InlineForm
placeholder={t('Paste Embed code')}
showInput={true}
onClose={closeUploadModalHandler}
onClear={() => setSelectedMenuItem(null)}
validate={validateEmbed}
onSubmit={handleEmbedFormSubmit}
errorMessage={t('Error')}
/>
</Show>
</div>
</Show>
</div>
<Modal variant="narrow" name="uploadImage" onClose={closeUploadModalHandler}>
<UploadModalContent editor={props.editor} />
</Modal>
</>
)
}

View File

@ -0,0 +1,13 @@
.Menu {
display: flex;
flex-direction: row;
.icon {
opacity: 0.5;
display: block;
transition: opacity 0.3s ease-in-out;
&:hover {
opacity: 1;
}
}
}

View File

@ -0,0 +1,19 @@
import styles from './Menu.module.scss'
import { Icon } from '../../../_shared/Icon'
type Props = {
selectedItem: (value: string) => void
}
export const Menu = (props: Props) => {
return (
<div class={styles.Menu}>
<button type="button" onClick={() => props.selectedItem('image')}>
<Icon class={styles.icon} name="editor-image" />
</button>
<button type="button" onClick={() => props.selectedItem('embed')}>
<Icon class={styles.icon} name="editor-embed" />
</button>
</div>
)
}

View File

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

View File

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

View File

@ -1,32 +1,13 @@
.InlineForm {
position: relative;
&.inBubble {
// ...
}
&.inFloating {
position: absolute;
left: calc(100% + 1rem);
top: -0.8rem;
min-width: 64vw;
background: #fff;
box-shadow: 0 4px 10px rgba(#000, 0.25);
button {
opacity: 1;
&:disabled,
&:disabled:hover {
opacity: 0.3;
}
}
}
width: 100%;
.form {
display: flex;
flex-flow: row nowrap;
flex-direction: row;
flex-wrap: nowrap;
padding: 6px 11px;
width: 100%;
input {
margin: 0 12px 0 0;
@ -56,7 +37,8 @@
right: 0;
height: 0;
background: #fff;
box-shadow: 0 4px 10px rgba(#000, 0.25);
border: 1px solid #e9e9ee;
border-radius: 2px;
opacity: 0;
transition: height 0.3s ease-in-out, opacity 0.3s ease-in-out;

View File

@ -1,35 +1,39 @@
import styles from './InlineForm.module.scss'
import { Icon } from '../../_shared/Icon'
import { createSignal, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { createSignal } from 'solid-js'
import { clsx } from 'clsx'
type Props = {
onClose: () => void
onClear?: () => void
onSubmit: (value: string) => void
variant: 'inBubble' | 'inFloating'
validate?: (value: string) => string | Promise<string>
validate?: (value: string) => string | Promise<string> | Promise<void> | Promise<boolean>
initialValue?: string
showInput?: boolean
placeholder: string
errorMessage: string
autoFocus?: boolean
}
export const InlineForm = (props: Props) => {
const { t } = useLocalize()
const [formValue, setFormValue] = createSignal(props.initialValue || '')
const [formValueError, setFormValueError] = createSignal('')
const [formValueError, setFormValueError] = createSignal<string | undefined>()
const handleFormInput = (value) => {
setFormValueError()
setFormValue(value)
}
const handleSaveButtonClick = async () => {
const errorMessage = await props.validate(formValue())
if (errorMessage) {
setFormValueError(errorMessage)
return
if (props.validate) {
const checkValid = await props.validate(formValue())
if (checkValid) {
props.onSubmit(formValue())
props.onClose()
} else {
setFormValueError(props.errorMessage)
}
}
props.onSubmit(formValue())
props.onClose()
}
const handleKeyPress = async (event) => {
@ -46,33 +50,16 @@ export const InlineForm = (props: Props) => {
}
return (
<div
class={clsx(styles.InlineForm, {
// [styles.inBubble]: props.variant === 'inBubble',
[styles.inFloating]: props.variant === 'inFloating'
})}
>
<div class={styles.InlineForm}>
<div class={styles.form}>
<Show when={props.variant === 'inBubble'}>
<input
type="text"
placeholder={t('Enter URL address')}
autofocus
value={props.initialValue}
onKeyPress={(e) => handleKeyPress(e)}
onInput={(e) => handleFormInput(e.currentTarget.value)}
/>
</Show>
<Show when={props.variant === 'inFloating'}>
<input
autofocus
type="text"
placeholder={t('Paste Embed code')}
onKeyPress={(e) => handleKeyPress(e)}
onInput={(e) => handleFormInput(e.currentTarget.value)}
/>
</Show>
<button type="button" onClick={handleSaveButtonClick} disabled={formValueError() !== ''}>
<input
autofocus={props.autoFocus ?? true}
type="text"
placeholder={props.placeholder}
onKeyPress={(e) => handleKeyPress(e)}
onInput={(e) => handleFormInput(e.currentTarget.value)}
/>
<button type="button" onClick={handleSaveButtonClick} disabled={Boolean(formValueError())}>
<Icon name="status-done" />
</button>
<button type="button" onClick={props.onClear}>

View File

@ -9,11 +9,11 @@ import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
import { getPagePath } from '@nanostores/router'
import { router } from '../../../stores/router'
type PanelProps = {
type Props = {
shoutSlug: string
}
export const Panel = (props: PanelProps) => {
export const Panel = (props: Props) => {
const { t } = useLocalize()
const {
isEditorPanelVisible,

View File

@ -61,3 +61,10 @@
overflow: hidden;
}
}
.uploadedImage {
max-height: 80vh;
margin: auto;
display: block;
width: unset !important;
}

View File

@ -0,0 +1,95 @@
.uploadModalContent {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 33px;
font-size: 18px;
min-height: 335px;
.dropZone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 24px;
gap: 24px;
padding: 24px;
text-align: center;
border-radius: 4px;
transition: background-color 0.3s ease-in-out;
width: 100%;
position: relative;
background-color: #e9e9ee;
overflow: hidden;
&.active {
background-color: #e9e9ee;
&::after {
content: '';
top: 0;
transform: translateX(100%);
width: 100%;
height: 100%;
position: absolute;
z-index: 0;
animation: slide 1.8s infinite;
background: linear-gradient(
to right,
rgba(#fff, 0) 0%,
rgba(#fff, 0.8) 50%,
rgba(128, 186, 232, 0) 99%,
rgba(125, 185, 232, 0) 100%
);
}
}
.text {
position: relative;
z-index: 1;
&.error {
color: red;
}
}
.icon {
width: 64px;
height: 64px;
position: relative;
z-index: 1;
}
.input {
display: none;
}
}
.uploadButton {
margin: 24px 0;
width: 100%;
max-width: 233px;
text-align: center !important;
}
.error {
color: red;
}
.formHolder {
width: 100%;
margin-top: 24px;
border-bottom: 1px solid #000;
}
}
@keyframes slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}

View File

@ -0,0 +1,116 @@
import styles from './UploadModalContent.module.scss'
import { clsx } from 'clsx'
import { Icon } from '../../_shared/Icon'
import { Button } from '../../_shared/Button'
import { createSignal, Show } from 'solid-js'
import { InlineForm } from '../InlineForm'
import { hideModal } from '../../../stores/ui'
import { createDropzone, createFileUploader } from '@solid-primitives/upload'
import { handleFileUpload } from '../../../utils/handleFileUpload'
import { useLocalize } from '../../../context/localize'
import { Editor } from '@tiptap/core'
import { Loading } from '../../_shared/Loading'
import { verifyImg } from '../../../utils/verifyImg'
type Props = {
editor: Editor
}
export const UploadModalContent = (props: Props) => {
const { t } = useLocalize()
const [isUploading, setIsUploading] = createSignal(false)
const [uploadError, setUploadError] = createSignal<string | undefined>()
const [dragActive, setDragActive] = createSignal(false)
const [dragError, setDragError] = createSignal<string | undefined>()
const renderImage = (src: string) => {
props.editor.chain().focus().extendMarkRange('link').setImage({ src: src }).run()
hideModal()
}
const handleImageFormSubmit = async (value: string) => {
renderImage(value)
}
const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' })
const runUpload = async (file) => {
try {
setIsUploading(true)
const fileUrl = await handleFileUpload(file)
setIsUploading(false)
renderImage(fileUrl)
} catch (error) {
console.error('[upload image] error', error)
setIsUploading(false)
setUploadError(t('Error'))
}
}
const handleUpload = async () => {
await selectFiles(async ([uploadFile]) => {
await runUpload(uploadFile)
})
}
const { setRef: dropzoneRef, files: droppedFiles } = createDropzone({
onDrop: async () => {
setDragActive(false)
if (droppedFiles().length > 1) {
setDragError(t('Many files, choose only one'))
} else if (droppedFiles()[0].file.type.startsWith('image/')) {
await runUpload(droppedFiles()[0])
} else {
setDragError(t('Image format not supported'))
}
}
})
const handleDrag = (event) => {
if (event.type === 'dragenter' || event.type === 'dragover') {
setDragActive(true)
} else if (event.type === 'dragleave') {
setDragActive(false)
}
}
return (
<div class={styles.uploadModalContent}>
<Show when={!isUploading()} fallback={<Loading />}>
<>
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
ref={dropzoneRef}
class={clsx(styles.dropZone, { [styles.active]: dragActive() })}
>
<Icon class={styles.icon} name="editor-image-dd" />
<div class={clsx(styles.text, { [styles.error]: dragError() })}>
{dragError() ?? t('Drag the image to this area')}
</div>
</div>
<Button
value={t('Upload')}
variant="bordered"
onClick={handleUpload}
class={styles.uploadButton}
/>
<Show when={uploadError()}>
<div class={styles.error}>{uploadError()}</div>
</Show>
<div class={styles.formHolder}>
<InlineForm
autoFocus={false}
placeholder={t('Or paste a link to an image')}
showInput={true}
onClose={() => {
hideModal()
}}
validate={(value) => verifyImg(value)}
onSubmit={handleImageFormSubmit}
errorMessage={t('Invalid image link')}
/>
</div>
</>
</Show>
</div>
)
}

View File

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

View File

@ -1,7 +1,7 @@
import { createEffect, createSignal, Show } from 'solid-js'
import type { JSX } from 'solid-js'
import { hideModal, useModalStore } from '../../stores/ui'
import { useEscKeyDownHandler } from '../../utils/useEscKeyDownHandler'
import { hideModal, useModalStore } from '../../../stores/ui'
import { useEscKeyDownHandler } from '../../../utils/useEscKeyDownHandler'
import { clsx } from 'clsx'
import styles from './Modal.module.scss'
@ -9,16 +9,18 @@ interface ModalProps {
name: string
variant: 'narrow' | 'wide'
children: JSX.Element
onClose?: () => void
}
export const Modal = (props: ModalProps) => {
const { modal } = useModalStore()
const backdropClick = () => {
const handleHide = () => {
hideModal()
props.onClose && props.onClose()
}
useEscKeyDownHandler(() => hideModal())
useEscKeyDownHandler(handleHide)
const [visible, setVisible] = createSignal(false)
@ -28,7 +30,7 @@ export const Modal = (props: ModalProps) => {
return (
<Show when={visible()}>
<div class={styles.backdrop} onClick={backdropClick}>
<div class={styles.backdrop} onClick={handleHide}>
<div
class={clsx(styles.modal, {
[styles.narrow]: props.variant === 'narrow'
@ -36,7 +38,7 @@ export const Modal = (props: ModalProps) => {
onClick={(event) => event.stopPropagation()}
>
{props.children}
<div class={styles.close} onClick={hideModal}>
<div class={styles.close} onClick={handleHide}>
<svg width="16" height="18" viewBox="0 0 16 18" xmlns="http://www.w3.org/2000/svg">
<path
d="M7.99987 7.52552L14.1871 0.92334L15.9548 2.80968L9.76764 9.41185L15.9548 16.014L14.1871 17.9004L7.99987 11.2982L1.81269 17.9004L0.0449219 16.014L6.23211 9.41185L0.0449225 2.80968L1.81269 0.92334L7.99987 7.52552Z"

View File

@ -1,6 +1,6 @@
import type { JSX } from 'solid-js/jsx-runtime'
import type { ModalType } from '../../stores/ui'
import { showModal } from '../../stores/ui'
import type { ModalType } from '../../../stores/ui'
import { showModal } from '../../../stores/ui'
export default (props: { name: ModalType; children: JSX.Element }) => {
return (

View File

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

View File

@ -128,9 +128,8 @@ export const EditView = (props: EditViewProps) => {
value={form.subtitle}
onChange={(e) => setForm('subtitle', e.currentTarget.value)}
/>
<Editor
shoutSlug={props.shout.slug}
shoutId={props.shout.id}
initialContent={props.shout.body}
onChange={(body) => setForm('body', body)}
/>

View File

@ -2,6 +2,7 @@
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
cursor: pointer;
@ -44,7 +45,8 @@
}
}
&.outline {
&.outline,
&.bordered {
border: 3px solid #f2f2f2;
border-radius: 1.2em;
cursor: pointer;
@ -72,6 +74,13 @@
}
}
&.bordered {
border-radius: 2px;
border: 2px solid #000;
font-size: 16px;
font-weight: 500;
}
&:disabled,
&:disabled:hover {
cursor: default;

View File

@ -5,7 +5,7 @@ import styles from './Button.module.scss'
type Props = {
value: string | JSX.Element
size?: 'S' | 'M' | 'L'
variant?: 'primary' | 'secondary' | 'inline' | 'outline'
variant?: 'primary' | 'secondary' | 'bordered' | 'inline' | 'outline'
type?: 'submit' | 'button'
loading?: boolean
disabled?: boolean

View File

@ -1,11 +1,11 @@
.icon {
line-height: 1;
position: relative;
}
img {
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
}
}
.notificationsCounter {

View File

@ -2,7 +2,7 @@
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 0);
transform: translate(-50%, -50%);
}
@keyframes spin {

View File

@ -3,7 +3,7 @@ import { PageLayout } from '../../components/_shared/PageLayout'
import { Modal } from '../../components/Nav/Modal'
import { Feedback } from '../../components/Discours/Feedback'
import Subscribe from '../../components/Discours/Subscribe'
import Opener from '../../components/Nav/Opener'
import Opener from '../../components/Nav/Modal/Opener'
import { Icon } from '../../components/_shared/Icon'
// title={t('Manifest')}

View File

@ -6,23 +6,14 @@ import { clsx } from 'clsx'
import styles from './Settings.module.scss'
import { useProfileForm } from '../../context/profile'
import validateUrl from '../../utils/validateUrl'
import { createFileUploader, UploadFile } from '@solid-primitives/upload'
import { createFileUploader } from '@solid-primitives/upload'
import { Loading } from '../../components/_shared/Loading'
import { useSession } from '../../context/session'
import { Button } from '../../components/_shared/Button'
import { useSnackbar } from '../../context/snackbar'
import { useLocalize } from '../../context/localize'
import { Image } from '../../components/_shared/Image'
const handleFileUpload = async (uploadFile: UploadFile) => {
const formData = new FormData()
formData.append('file', uploadFile.file, uploadFile.name)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
})
return response.json()
}
import { handleFileUpload } from '../../utils/handleFileUpload'
export const ProfileSettingsPage = () => {
const { t } = useLocalize()

View File

@ -3,7 +3,14 @@ import { useRouter } from './router'
import type { AuthModalSearchParams, ConfirmEmailSearchParams } from '../components/Nav/AuthModal/types'
import type { RootSearchParams } from '../pages/types'
export type ModalType = 'auth' | 'subscribe' | 'feedback' | 'thank' | 'donate' | 'inviteToChat'
export type ModalType =
| 'auth'
| 'subscribe'
| 'feedback'
| 'thank'
| 'donate'
| 'inviteToChat'
| 'uploadImage'
type WarnKind = 'error' | 'warn' | 'info'
export interface Warning {
@ -18,7 +25,8 @@ export const MODALS: Record<ModalType, ModalType> = {
feedback: 'feedback',
thank: 'thank',
donate: 'donate',
inviteToChat: 'inviteToChat'
inviteToChat: 'inviteToChat',
uploadImage: 'uploadImage'
}
const [modal, setModal] = createSignal<ModalType | null>(null)

View File

@ -0,0 +1,13 @@
import { UploadFile } from '@solid-primitives/upload'
import { isDev } from './config'
const api = isDev ? 'https://new.discours.io/api/upload' : '/api/upload'
export const handleFileUpload = async (uploadFile: UploadFile) => {
const formData = new FormData()
formData.append('file', uploadFile.file, uploadFile.name)
const response = await fetch(api, {
method: 'POST',
body: formData
})
return response.json()
}

10
src/utils/verifyImg.ts Normal file
View File

@ -0,0 +1,10 @@
export const verifyImg = (url: string) => {
return fetch(url, { method: 'HEAD' }).then((res) => {
return res.headers.get('Content-Type').startsWith('image')
})
}
const supportedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'tiff', 'bpg']
export const isImageExtension = (value: string) => {
return supportedExtensions.some((extension) => value.includes(extension))
}