Implement rich text for project description

This commit is contained in:
Ahmed Al-Taiar
2024-10-08 20:36:48 -04:00
parent 708634fa68
commit e2dfb6f237
12 changed files with 1015 additions and 277 deletions

View File

@ -141,7 +141,7 @@ export const theme = {
}
export const darkMode = ['class', '[data-theme="dark"]']
export const plugins = [require('daisyui')]
export const plugins = [require('@tailwindcss/typography'), require('daisyui')]
export const daisyui = {
themes: [
'light',

View File

@ -19,6 +19,13 @@
"@redwoodjs/router": "8.3.0",
"@redwoodjs/web": "8.3.0",
"@redwoodjs/web-server": "8.3.0",
"@tailwindcss/typography": "^0.5.15",
"@tiptap/extension-link": "^2.8.0",
"@tiptap/extension-text-style": "^2.8.0",
"@tiptap/extension-underline": "^2.8.0",
"@tiptap/pm": "^2.8.0",
"@tiptap/react": "^2.8.0",
"@tiptap/starter-kit": "^2.8.0",
"@uppy/compressor": "^2.0.1",
"@uppy/core": "^4.1.0",
"@uppy/dashboard": "^4.0.2",
@ -33,12 +40,14 @@
"react": "18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "18.3.1",
"react-html-parser": "^2.0.2",
"react-typed": "^2.0.12"
},
"devDependencies": {
"@redwoodjs/vite": "8.3.0",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/react-html-parser": "^2",
"autoprefixer": "^10.4.20",
"daisyui": "^4.12.10",
"postcss": "^8.4.41",

View File

@ -5,7 +5,6 @@ import { AuthProvider, useAuth } from 'src/auth'
import FatalErrorPage from 'src/pages/FatalErrorPage'
import Routes from 'src/Routes'
import 'src/scaffold.css'
import 'src/index.css'
const App = () => (

View File

@ -1,3 +1,4 @@
import parseHtml from 'react-html-parser'
import type {
DeleteProjectMutation,
DeleteProjectMutationVariables,
@ -68,7 +69,11 @@ const AdminProject = ({ project }: Props) => {
</tr>
<tr>
<th>Description</th>
<td>{project.description}</td>
<td>
<article className="prose">
{parseHtml(project.description)}
</article>
</td>
</tr>
<tr>
<th>Date</th>

View File

@ -1,6 +1,7 @@
import { mdiLinkVariant } from '@mdi/js'
import Icon from '@mdi/react'
import { format, isAfter, startOfToday } from 'date-fns'
import parseHtml from 'react-html-parser'
import type { FindProjectById } from 'types/graphql'
import { calculateLuminance } from 'src/lib/color'
@ -41,7 +42,9 @@ const Project = ({ project }: Props) => {
))}
</div>
)}
{project.description && <p>{project.description}</p>}
{project.description && (
<article className="prose">{parseHtml(project.description)}</article>
)}
{project.links.length > 0 && (
<>
<h2 className="font-bold text-3xl w-fit">Links</h2>

View File

@ -2,6 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { mdiCalendar, mdiDelete, mdiFormatTitle, mdiLinkVariant } from '@mdi/js'
import Icon from '@mdi/react'
import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import { useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { Meta, UploadResult } from '@uppy/core'
import { format, isAfter, startOfToday } from 'date-fns'
import type {
@ -11,18 +15,12 @@ import type {
} from 'types/graphql'
import type { RWGqlError } from '@redwoodjs/forms'
import {
Form,
FieldError,
Label,
TextField,
Submit,
TextAreaField,
} from '@redwoodjs/forms'
import { Form, FieldError, Label, TextField, Submit } from '@redwoodjs/forms'
import { toast } from '@redwoodjs/web/toast'
import DatePicker from 'src/components/DatePicker'
import FormTextList from 'src/components/FormTextList'
import RichTextEditor from 'src/components/RichTextEditor/RichTextEditor'
import TagsSelectorCell from 'src/components/Tag/TagsSelectorCell'
import Uploader from 'src/components/Uploader'
import { batchDelete } from 'src/lib/tus'
@ -39,6 +37,20 @@ interface ProjectFormProps {
const ProjectForm = (props: ProjectFormProps) => {
const today = startOfToday()
const descEditor = useEditor({
extensions: [
StarterKit,
Underline,
Link.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
defaultProtocol: 'https',
}),
],
content: props.project?.description || '',
})
const [links, setLinks] = useState<string[]>(props.project?.links || [])
const [linkErrors, setLinkErrors] = useState<boolean[]>([])
const [pickerVisible, setPickerVisible] = useState<boolean>(false)
@ -87,7 +99,7 @@ const ProjectForm = (props: ProjectFormProps) => {
props.onSave(
{
title: data.title,
description: data.description,
description: descEditor.getHTML(),
date: date.toISOString(),
links: links.filter((link) => link.trim().length > 0),
images: fileIds,
@ -144,21 +156,7 @@ const ProjectForm = (props: ProjectFormProps) => {
</div>
</Label>
<Label name="description" className="form-control w-full">
<TextAreaField
name="description"
defaultValue={props.project?.description}
className="textarea textarea-bordered"
errorClassName="textarea textarea-bordered textarea-error"
placeholder="Description"
/>
<div className="label">
<FieldError
name="description"
className="text-xs font-semibold text-error"
/>
</div>
</Label>
<RichTextEditor editor={descEditor} />
<div className="form-control w-full">
<Label

View File

@ -1,6 +1,7 @@
import { mdiDotsVertical } from '@mdi/js'
import Icon from '@mdi/react'
import { isAfter } from 'date-fns'
import parseHtml from 'react-html-parser'
import type {
DeleteProjectMutation,
DeleteProjectMutationVariables,
@ -94,7 +95,11 @@ const ProjectsList = ({ projects }: FindProjects) => {
return (
<tr key={project.id}>
<td>{truncate(project.title)}</td>
<td className="max-w-72">{truncate(project.description)}</td>
<td className="max-w-72">
<article className="prose text-sm line-clamp-3">
{parseHtml(project.description)}
</article>
</td>
<td className="max-w-36">{timeTag(project.date)}</td>
<td>{`${project.images.length} ${project.images.length === 1 ? 'image' : 'images'}`}</td>
<td>

View File

@ -1,6 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { format, isAfter, startOfToday } from 'date-fns'
import parseHtml from 'react-html-parser'
import { FindProjects } from 'types/graphql'
import { Link, routes } from '@redwoodjs/router'
@ -56,7 +57,11 @@ const ProjectsShowcase = ({ projects }: FindProjects) => {
<div className="card-title overflow-auto">
<p className="whitespace-nowrap">{project.title}</p>
</div>
<div className="line-clamp-5">{project.description}</div>
<div className="line-clamp-5">
<article className="prose text-sm">
{parseHtml(project.description)}
</article>
</div>
<div className="card-actions justify-between">
<div className="flex gap-2">
{isAfter(new Date(project.date), startOfToday()) && (

View File

@ -0,0 +1,165 @@
import { useCallback } from 'react'
import {
mdiCodeBracesBox,
mdiFormatBold,
mdiFormatClear,
mdiFormatItalic,
mdiFormatListBulleted,
mdiFormatListNumbered,
mdiFormatQuoteClose,
mdiFormatStrikethrough,
mdiFormatUnderline,
mdiLinkVariant,
mdiLinkVariantOff,
mdiRedoVariant,
mdiUndoVariant,
mdiXml,
} from '@mdi/js'
import Icon from '@mdi/react'
import { EditorContent } from '@tiptap/react'
import type { Editor } from '@tiptap/react'
interface RichTextEditorProps {
editor: Editor
}
const RichTextEditor = ({ editor }: RichTextEditorProps) => {
const setLink = useCallback(() => {
const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
if (url === null) return
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}, [editor])
if (!editor) return null
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2 justify-center">
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('link') ? 'btn-primary' : ''}`}
onClick={setLink}
>
<Icon path={mdiLinkVariant} className="size-5" />
</button>
<button
type="button"
className="btn btn-sm btn-square"
onClick={() => editor.chain().focus().unsetLink().run()}
disabled={!editor.isActive('link')}
>
<Icon path={mdiLinkVariantOff} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('bulletList') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<Icon path={mdiFormatListBulleted} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('orderedList') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<Icon path={mdiFormatListNumbered} className="size-5" />
</button>
<button
type="button"
className="btn btn-sm btn-square"
onClick={() => editor.chain().focus().unsetAllMarks().run()}
>
<Icon path={mdiFormatClear} className="size-5" />
</button>
<button
type="button"
className="btn btn-sm btn-square"
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
>
<Icon path={mdiUndoVariant} className="size-5" />
</button>
<button
type="button"
className="btn btn-sm btn-square"
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
>
<Icon path={mdiRedoVariant} className="size-5" />
</button>
</div>
<div className="flex flex-wrap gap-2 justify-center">
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('bold') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
>
<Icon path={mdiFormatBold} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('italic') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
>
<Icon path={mdiFormatItalic} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('underline') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleUnderline().run()}
disabled={!editor.can().chain().focus().toggleUnderline().run()}
>
<Icon path={mdiFormatUnderline} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('static') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
>
<Icon path={mdiFormatStrikethrough} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('code') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
>
<Icon path={mdiXml} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('codeBlock') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
disabled={!editor.can().chain().focus().toggleCodeBlock().run()}
>
<Icon path={mdiCodeBracesBox} className="size-5" />
</button>
<button
type="button"
className={`btn btn-sm btn-square ${editor.isActive('blockquote') ? 'btn-primary' : ''}`}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<Icon path={mdiFormatQuoteClose} className="size-5" />
</button>
</div>
<EditorContent
editor={editor}
className="textarea textarea-bordered font-normal prose"
/>
</div>
)
}
export default RichTextEditor

View File

@ -19,3 +19,7 @@
.image-full-no-overlay::before {
background: none !important;
}
.ProseMirror:focus {
outline: none;
}

View File

@ -1,243 +0,0 @@
.rw-scaffold {
@apply bg-white text-gray-600;
}
.rw-scaffold h1,
.rw-scaffold h2 {
@apply m-0;
}
.rw-scaffold a {
@apply bg-transparent;
}
.rw-scaffold ul {
@apply m-0 p-0;
}
.rw-scaffold input:-ms-input-placeholder {
@apply text-gray-500;
}
.rw-scaffold input::-ms-input-placeholder {
@apply text-gray-500;
}
.rw-scaffold input::placeholder {
@apply text-gray-500;
}
.rw-header {
@apply flex justify-between px-8 py-4;
}
.rw-main {
@apply mx-4 pb-4;
}
.rw-segment {
@apply w-full overflow-hidden rounded-lg border border-gray-200;
scrollbar-color: theme('colors.zinc.400') transparent;
}
.rw-segment::-webkit-scrollbar {
height: initial;
}
.rw-segment::-webkit-scrollbar-track {
@apply rounded-b-[10px] rounded-t-none border-0 border-t border-solid border-gray-200 bg-transparent p-[2px];
}
.rw-segment::-webkit-scrollbar-thumb {
@apply rounded-full border-[3px] border-solid border-transparent bg-zinc-400 bg-clip-content;
}
.rw-segment-header {
@apply bg-gray-200 px-4 py-3 text-gray-700;
}
.rw-segment-main {
@apply bg-gray-100 p-4;
}
.rw-link {
@apply text-blue-400 underline;
}
.rw-link:hover {
@apply text-blue-500;
}
.rw-forgot-link {
@apply mt-1 text-right text-xs text-gray-400 underline;
}
.rw-forgot-link:hover {
@apply text-blue-500;
}
.rw-heading {
@apply font-semibold;
}
.rw-heading.rw-heading-primary {
@apply text-xl;
}
.rw-heading.rw-heading-secondary {
@apply text-sm;
}
.rw-heading .rw-link {
@apply text-gray-600 no-underline;
}
.rw-heading .rw-link:hover {
@apply text-gray-900 underline;
}
.rw-cell-error {
@apply text-sm font-semibold;
}
.rw-form-wrapper {
@apply -mt-4 text-sm;
}
.rw-cell-error,
.rw-form-error-wrapper {
@apply my-4 rounded border border-red-100 bg-red-50 p-4 text-red-600;
}
.rw-form-error-title {
@apply m-0 font-semibold;
}
.rw-form-error-list {
@apply mt-2 list-inside list-disc;
}
.rw-button {
@apply flex cursor-pointer justify-center rounded border-0 bg-gray-200 px-4 py-1 text-xs font-semibold uppercase leading-loose tracking-wide text-gray-500 no-underline transition duration-100;
}
.rw-button:hover {
@apply bg-gray-500 text-white;
}
.rw-button.rw-button-small {
@apply rounded-sm px-2 py-1 text-xs;
}
.rw-button.rw-button-green {
@apply bg-green-500 text-white;
}
.rw-button.rw-button-green:hover {
@apply bg-green-700;
}
.rw-button.rw-button-blue {
@apply bg-blue-500 text-white;
}
.rw-button.rw-button-blue:hover {
@apply bg-blue-700;
}
.rw-button.rw-button-red {
@apply bg-red-500 text-white;
}
.rw-button.rw-button-red:hover {
@apply bg-red-700 text-white;
}
.rw-button-icon {
@apply mr-1 text-xl leading-5;
}
.rw-button-group {
@apply mx-2 my-3 flex justify-center;
}
.rw-button-group .rw-button {
@apply mx-1;
}
.rw-form-wrapper .rw-button-group {
@apply mt-8;
}
.rw-label {
@apply mt-6 block text-left font-semibold text-gray-600;
}
.rw-label.rw-label-error {
@apply text-red-600;
}
.rw-input {
@apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none;
}
.rw-check-radio-items {
@apply flex justify-items-center;
}
.rw-check-radio-item-none {
@apply text-gray-600;
}
.rw-input[type='checkbox'],
.rw-input[type='radio'] {
@apply ml-0 mr-1 mt-1 inline w-4;
}
.rw-input:focus {
@apply border-gray-400;
}
.rw-input-error {
@apply border-red-600 text-red-600;
}
.rw-input-error:focus {
@apply border-red-600 outline-none;
box-shadow: 0 0 5px #c53030;
}
.rw-field-error {
@apply mt-1 block text-xs font-semibold uppercase text-red-600;
}
.rw-table-wrapper-responsive {
@apply overflow-x-auto;
}
.rw-table-wrapper-responsive .rw-table {
min-width: 48rem;
}
.rw-table {
@apply w-full text-sm;
}
.rw-table th,
.rw-table td {
@apply p-3;
}
.rw-table td {
@apply bg-white text-gray-900;
}
.rw-table tr:nth-child(odd) td,
.rw-table tr:nth-child(odd) th {
@apply bg-gray-50;
}
.rw-table thead tr {
@apply bg-gray-200 text-gray-600;
}
.rw-table th {
@apply text-left font-semibold;
}
.rw-table thead th {
@apply text-left;
}
.rw-table tbody th {
@apply text-right;
}
@media (min-width: 768px) {
.rw-table tbody th {
@apply w-1/5;
}
}
.rw-table tbody tr {
@apply border-t border-gray-200;
}
.rw-table input {
@apply ml-0;
}
.rw-table-actions {
@apply flex h-4 items-center justify-end pr-1;
}
.rw-table-actions .rw-button {
@apply bg-transparent;
}
.rw-table-actions .rw-button:hover {
@apply bg-gray-500 text-white;
}
.rw-table-actions .rw-button-blue {
@apply text-blue-500;
}
.rw-table-actions .rw-button-blue:hover {
@apply bg-blue-500 text-white;
}
.rw-table-actions .rw-button-red {
@apply text-red-600;
}
.rw-table-actions .rw-button-red:hover {
@apply bg-red-600 text-white;
}
.rw-text-center {
@apply text-center;
}
.rw-login-container {
@apply mx-auto my-16 flex w-96 flex-wrap items-center justify-center;
}
.rw-login-container .rw-form-wrapper {
@apply w-full text-center;
}
.rw-login-link {
@apply mt-4 w-full text-center text-sm text-gray-600;
}
.rw-webauthn-wrapper {
@apply mx-4 mt-6 leading-6;
}
.rw-webauthn-wrapper h2 {
@apply mb-4 text-xl font-bold;
}

796
yarn.lock

File diff suppressed because it is too large Load Diff