mirror of
https://github.com/run-llama/chat-ui.git
synced 2026-07-21 11:25:22 -04:00
feat: use mdxeditor for document editor (#118)
* feat: use mdxeditor for document editor * document edior example * fix: format * update document about importing editor.css for document editor * Create lemon-papayas-attend.md * add note about dynamic import * missing css in doc
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@llamaindex/chat-ui': patch
|
||||
---
|
||||
|
||||
feat: use mdxeditor for document editor
|
||||
@@ -186,6 +186,7 @@ If your app is using code, latex or pdf files, you'll need to import their CSS f
|
||||
```tsx
|
||||
import '@llamaindex/chat-ui/styles/markdown.css' // code, latex and custom markdown styling
|
||||
import '@llamaindex/chat-ui/styles/pdf.css' // pdf styling
|
||||
import '@llamaindex/chat-ui/styles/editor.css' // document editor styling
|
||||
```
|
||||
|
||||
The `code.css` file uses the `atom-one-dark` theme from highlight.js by default. There are a lot of others to choose from: https://highlightjs.org/demo
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export const markdown = `
|
||||
# Welcome
|
||||
|
||||
This is a **live demo** of MDXEditor with all default features on.
|
||||
|
||||
[— Daring Fireball](https://daringfireball.net/projects/markdown/).
|
||||
|
||||
In here, you can find the following markdown elements:
|
||||
|
||||
* Headings
|
||||
* Lists
|
||||
* Unordered
|
||||
* Ordered
|
||||
* Check lists
|
||||
* And nested ;)
|
||||
* Links
|
||||
* Bold/Italic/Underline formatting
|
||||
* Tables
|
||||
* Code block editors
|
||||
* And much more.
|
||||
|
||||
The current editor content is styled using the \`@tailwindcss/typography\` [plugin](https://tailwindcss.com/docs/typography-plugin).
|
||||
|
||||
## What can you do here?
|
||||
|
||||
This is a great location for you to test how editing markdown feels. If you have an existing markdown source, you can switch to source mode using the toggle group in the top right, paste it in there, and go back to rich text mode.
|
||||
|
||||
If you need a few ideas, here's what you can try:
|
||||
|
||||
1. Add your own code sample
|
||||
2. Change the type of the headings
|
||||
3. Insert a table, add a few rows and columns
|
||||
4. Switch back to source markdown to see what you're going to get as an output
|
||||
5. Test the diff feature to see how the markdown has changed
|
||||
6. Add a frontmatter block through the toolbar button
|
||||
|
||||
## A table
|
||||
|
||||
Play with the table below - add rows, columns, change column alignment. When editing,
|
||||
you can navigate the cells with \`enter\`, \`shift+enter\`, \`tab\` and \`shift+tab\`.
|
||||
|
||||
| Item | In Stock | Price | Color | Size | Rating | Reviews |
|
||||
| :---------------- | :------: | ----: | :------- | :--: | -----: | ------: |
|
||||
| Python Hat | True | 23.99 | Blue | M | 4.5 | 128 |
|
||||
| SQL Hat | True | 23.99 | Black | L | 4.3 | 95 |
|
||||
| Codecademy Tee | False | 19.99 | Gray | XL | 4.8 | 256 |
|
||||
| Codecademy Hoodie | False | 42.99 | Navy | M | 4.7 | 312 |
|
||||
`
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { markdown } from './data'
|
||||
|
||||
const DocumentEditor = dynamic(
|
||||
() => import('@llamaindex/chat-ui/widgets').then(mod => mod.DocumentEditor),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
)
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="mx-auto h-screen w-1/2 py-4">
|
||||
<DocumentEditor content={markdown} onChange={console.log} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import './globals.css'
|
||||
import '@llamaindex/chat-ui/styles/editor.css'
|
||||
import '@llamaindex/chat-ui/styles/markdown.css'
|
||||
import '@llamaindex/chat-ui/styles/pdf.css'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/chat/chat.tsx",
|
||||
"content": "'use client'\r\n\r\nimport {\r\n ChatHandler,\r\n ChatSection as ChatSectionUI,\r\n Message,\r\n} from '@llamaindex/chat-ui'\r\n\r\nimport '@llamaindex/chat-ui/styles/markdown.css'\r\nimport '@llamaindex/chat-ui/styles/pdf.css'\r\nimport { useState } from 'react'\r\n\r\nconst initialMessages: Message[] = [\r\n {\r\n content: 'Write simple Javascript hello world code',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n 'Got it! Here\\'s the simplest JavaScript code to print \"Hello, World!\" to the console:\\n\\n```javascript\\nconsole.log(\"Hello, World!\");\\n```\\n\\nYou can run this code in any JavaScript environment, such as a web browser\\'s console or a Node.js environment. Just paste the code and execute it to see the output.',\r\n },\r\n {\r\n content: 'Write a simple math equation',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n \"Let's explore a simple mathematical equation using LaTeX:\\n\\n The quadratic formula is: $$x = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$$\\n\\nThis formula helps us solve quadratic equations in the form $ax^2 + bx + c = 0$. The solution gives us the x-values where the parabola intersects the x-axis.\",\r\n },\r\n]\r\n\r\nexport function ChatSection() {\r\n // You can replace the handler with a useChat hook from Vercel AI SDK\r\n const handler = useMockChat(initialMessages)\r\n return (\r\n <div className=\"flex max-h-[80vh] flex-col gap-6 overflow-y-auto\">\r\n <ChatSectionUI handler={handler} />\r\n </div>\r\n )\r\n}\r\n\r\nfunction useMockChat(initMessages: Message[]): ChatHandler {\r\n const [messages, setMessages] = useState<Message[]>(initMessages)\r\n const [input, setInput] = useState('')\r\n const [isLoading, setIsLoading] = useState(false)\r\n\r\n const append = async (message: Message) => {\r\n setIsLoading(true)\r\n\r\n const mockResponse: Message = {\r\n role: 'assistant',\r\n content: '',\r\n }\r\n setMessages(prev => [...prev, message, mockResponse])\r\n\r\n const mockContent =\r\n 'This is a mock response. In a real implementation, this would be replaced with an actual AI response.'\r\n\r\n let streamedContent = ''\r\n const words = mockContent.split(' ')\r\n\r\n for (const word of words) {\r\n await new Promise(resolve => setTimeout(resolve, 100))\r\n streamedContent += (streamedContent ? ' ' : '') + word\r\n setMessages(prev => {\r\n return [\r\n ...prev.slice(0, -1),\r\n {\r\n role: 'assistant',\r\n content: streamedContent,\r\n },\r\n ]\r\n })\r\n }\r\n\r\n setIsLoading(false)\r\n return mockContent\r\n }\r\n\r\n return {\r\n messages,\r\n input,\r\n setInput,\r\n isLoading,\r\n append,\r\n }\r\n}\r\n",
|
||||
"content": "'use client'\r\n\r\nimport {\r\n ChatHandler,\r\n ChatSection as ChatSectionUI,\r\n Message,\r\n} from '@llamaindex/chat-ui'\r\n\r\nimport '@llamaindex/chat-ui/styles/markdown.css'\r\nimport '@llamaindex/chat-ui/styles/pdf.css'\r\nimport '@llamaindex/chat-ui/styles/editor.css'\r\nimport { useState } from 'react'\r\n\r\nconst initialMessages: Message[] = [\r\n {\r\n content: 'Write simple Javascript hello world code',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n 'Got it! Here\\'s the simplest JavaScript code to print \"Hello, World!\" to the console:\\n\\n```javascript\\nconsole.log(\"Hello, World!\");\\n```\\n\\nYou can run this code in any JavaScript environment, such as a web browser\\'s console or a Node.js environment. Just paste the code and execute it to see the output.',\r\n },\r\n {\r\n content: 'Write a simple math equation',\r\n role: 'user',\r\n },\r\n {\r\n role: 'assistant',\r\n content:\r\n \"Let's explore a simple mathematical equation using LaTeX:\\n\\n The quadratic formula is: $$x = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$$\\n\\nThis formula helps us solve quadratic equations in the form $ax^2 + bx + c = 0$. The solution gives us the x-values where the parabola intersects the x-axis.\",\r\n },\r\n]\r\n\r\nexport function ChatSection() {\r\n // You can replace the handler with a useChat hook from Vercel AI SDK\r\n const handler = useMockChat(initialMessages)\r\n return (\r\n <div className=\"flex max-h-[80vh] flex-col gap-6 overflow-y-auto\">\r\n <ChatSectionUI handler={handler} />\r\n </div>\r\n )\r\n}\r\n\r\nfunction useMockChat(initMessages: Message[]): ChatHandler {\r\n const [messages, setMessages] = useState<Message[]>(initMessages)\r\n const [input, setInput] = useState('')\r\n const [isLoading, setIsLoading] = useState(false)\r\n\r\n const append = async (message: Message) => {\r\n setIsLoading(true)\r\n\r\n const mockResponse: Message = {\r\n role: 'assistant',\r\n content: '',\r\n }\r\n setMessages(prev => [...prev, message, mockResponse])\r\n\r\n const mockContent =\r\n 'This is a mock response. In a real implementation, this would be replaced with an actual AI response.'\r\n\r\n let streamedContent = ''\r\n const words = mockContent.split(' ')\r\n\r\n for (const word of words) {\r\n await new Promise(resolve => setTimeout(resolve, 100))\r\n streamedContent += (streamedContent ? ' ' : '') + word\r\n setMessages(prev => {\r\n return [\r\n ...prev.slice(0, -1),\r\n {\r\n role: 'assistant',\r\n content: streamedContent,\r\n },\r\n ]\r\n })\r\n }\r\n\r\n setIsLoading(false)\r\n return mockContent\r\n }\r\n\r\n return {\r\n messages,\r\n input,\r\n setInput,\r\n isLoading,\r\n append,\r\n }\r\n}\r\n",
|
||||
"type": "registry:block"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
|
||||
import '@llamaindex/chat-ui/styles/markdown.css'
|
||||
import '@llamaindex/chat-ui/styles/pdf.css'
|
||||
import '@llamaindex/chat-ui/styles/editor.css'
|
||||
import { useState } from 'react'
|
||||
|
||||
const initialMessages: Message[] = [
|
||||
|
||||
@@ -112,6 +112,7 @@ Import the CSS styles in your app's root file (e.g., `_app.tsx` or `layout.tsx`)
|
||||
```tsx
|
||||
import '@llamaindex/chat-ui/styles/markdown.css' // code, latex and custom markdown styling
|
||||
import '@llamaindex/chat-ui/styles/pdf.css' // pdf styling
|
||||
import '@llamaindex/chat-ui/styles/editor.css' // document editor styling
|
||||
```
|
||||
|
||||
The `markdown.css` file includes styling for code blocks using [highlight.js](https://highlightjs.org/) with the `atom-one-dark` theme by default, [katex](https://katex.org/) for latex, and [pdf-viewer](https://github.com/run-llama/pdf-viewer) for PDF files. You can use any highlight.js theme by copying [their CSS](https://github.com/highlightjs/highlight.js/tree/main/src/styles/) to your project and importing it.
|
||||
|
||||
@@ -110,23 +110,43 @@ function InteractiveCode({ initialCode, language, onChange }) {
|
||||
|
||||
### DocumentEditor
|
||||
|
||||
Rich text document editor with markdown support.
|
||||
Rich text document editor with markdown support. Please import editor styles from `@llamaindex/chat-ui/styles/editor.css` to ensure proper styling.
|
||||
|
||||
```tsx
|
||||
import { DocumentEditor } from '@llamaindex/chat-ui/widgets'
|
||||
import '@llamaindex/chat-ui/styles/editor.css'
|
||||
|
||||
function DocumentEdit({ content, onChange, title }) {
|
||||
return (
|
||||
<DocumentEditor
|
||||
value={content}
|
||||
content={content}
|
||||
onChange={onChange}
|
||||
title={title}
|
||||
placeholder="Start writing..."
|
||||
className="custom-css-class"
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** When using Next.js App Router, you need to dynamically import this component to avoid SSR issues:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import '@llamaindex/chat-ui/styles/editor.css'
|
||||
|
||||
const DocumentEditor = dynamic(
|
||||
() => import('@llamaindex/chat-ui/widgets').then(mod => mod.DocumentEditor),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<DocumentEditor content={"# Hello World"} onChange={console.log} />
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- **WYSIWYG Editing** - Visual editing with markdown output
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './globals.css'
|
||||
import '@llamaindex/chat-ui/styles/markdown.css'
|
||||
import '@llamaindex/chat-ui/styles/pdf.css'
|
||||
import '@llamaindex/chat-ui/styles/editor.css'
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './globals.css'
|
||||
import '@llamaindex/chat-ui/styles/markdown.css'
|
||||
import '@llamaindex/chat-ui/styles/pdf.css'
|
||||
import '@llamaindex/chat-ui/styles/editor.css'
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Located in `src/widgets/` and exported via `/widgets` entry point:
|
||||
- **Markdown**: `react-markdown` with math support (`katex`, `remark-math`, `rehype-katex`)
|
||||
- **Code Editing**: CodeMirror 6 with language support (JavaScript, Python, CSS, HTML)
|
||||
- **Syntax Highlighting**: `highlight.js`
|
||||
- **Document Editing**: ProseMirror with markdown support
|
||||
- **Document Editing**: Mdxeditor with markdown support
|
||||
- **PDF Viewing**: `@llamaindex/pdf-viewer`
|
||||
|
||||
### Styling & UI
|
||||
|
||||
@@ -71,14 +71,7 @@
|
||||
"highlight.js": "^11.10.0",
|
||||
"katex": "^0.16.21",
|
||||
"lucide-react": "^0.453.0",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.2",
|
||||
"prosemirror-menu": "^1.2.5",
|
||||
"prosemirror-schema-basic": "^1.2.4",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-view": "^1.39.2",
|
||||
"@mdxeditor/editor": "^3.35.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark": "^15.0.1",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -146,10 +146,3 @@ pre:has(.custom-renderer) {
|
||||
font-variation-settings: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* Custom CSS for code editor
|
||||
*/
|
||||
.ProseMirror:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -1,129 +1,16 @@
|
||||
import { baseKeymap, toggleMark } from 'prosemirror-commands'
|
||||
import { history, redo, undo } from 'prosemirror-history'
|
||||
import { keymap } from 'prosemirror-keymap'
|
||||
import {
|
||||
defaultMarkdownParser as markdownParser,
|
||||
defaultMarkdownSerializer as markdownSerializer,
|
||||
} from 'prosemirror-markdown'
|
||||
import { schema as basicSchema } from 'prosemirror-schema-basic'
|
||||
import { EditorState } from 'prosemirror-state'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Button } from '../ui/button'
|
||||
import { Bold, Italic, Code, Link, Undo, Redo } from 'lucide-react'
|
||||
|
||||
interface ToolbarProps {
|
||||
view: EditorView | null
|
||||
}
|
||||
|
||||
const Toolbar: React.FC<ToolbarProps> = ({ view }) => {
|
||||
const handleBold = () => {
|
||||
if (view) {
|
||||
toggleMark(basicSchema.marks.strong)(view.state, view.dispatch)
|
||||
view.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleItalic = () => {
|
||||
if (view) {
|
||||
toggleMark(basicSchema.marks.em)(view.state, view.dispatch)
|
||||
view.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCode = () => {
|
||||
if (view) {
|
||||
toggleMark(basicSchema.marks.code)(view.state, view.dispatch)
|
||||
view.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleLink = () => {
|
||||
if (view) {
|
||||
const { state, dispatch } = view
|
||||
if (state.selection.empty) return
|
||||
const href = prompt('Enter the URL', 'https://')
|
||||
if (href) {
|
||||
toggleMark(basicSchema.marks.link, { href })(state, dispatch)
|
||||
view.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUndo = () => {
|
||||
if (view) {
|
||||
undo(view.state, view.dispatch)
|
||||
view.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRedo = () => {
|
||||
if (view) {
|
||||
redo(view.state, view.dispatch)
|
||||
view.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
onClick={handleUndo}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Undo (Ctrl+Z)"
|
||||
className="size-7"
|
||||
>
|
||||
<Undo className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRedo}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Redo (Ctrl+Y)"
|
||||
className="size-7"
|
||||
>
|
||||
<Redo className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBold}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Bold (Ctrl+B)"
|
||||
className="size-7"
|
||||
>
|
||||
<Bold className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleItalic}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Italic (Ctrl+I)"
|
||||
className="size-7"
|
||||
>
|
||||
<Italic className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCode}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Code (Ctrl+Shift+C)"
|
||||
className="size-7"
|
||||
>
|
||||
<Code className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLink}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title="Link"
|
||||
className="size-7"
|
||||
>
|
||||
<Link className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BlockTypeSelect,
|
||||
BoldItalicUnderlineToggles,
|
||||
MDXEditor,
|
||||
UndoRedo,
|
||||
headingsPlugin,
|
||||
linkPlugin,
|
||||
linkDialogPlugin,
|
||||
listsPlugin,
|
||||
markdownShortcutPlugin,
|
||||
tablePlugin,
|
||||
toolbarPlugin,
|
||||
} from '@mdxeditor/editor'
|
||||
|
||||
export function DocumentEditor({
|
||||
content,
|
||||
@@ -136,59 +23,36 @@ export function DocumentEditor({
|
||||
className?: string
|
||||
showToolbar?: boolean
|
||||
}) {
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const plugins = [
|
||||
headingsPlugin(),
|
||||
listsPlugin(),
|
||||
linkPlugin(),
|
||||
linkDialogPlugin(),
|
||||
tablePlugin(),
|
||||
markdownShortcutPlugin(),
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current || initialized) return
|
||||
|
||||
const state = EditorState.create({
|
||||
doc:
|
||||
markdownParser.parse(content) ||
|
||||
basicSchema.topNodeType.createAndFill(),
|
||||
schema: basicSchema,
|
||||
plugins: [
|
||||
history(),
|
||||
keymap({
|
||||
'Mod-z': undo,
|
||||
'Mod-y': redo,
|
||||
'Mod-b': toggleMark(basicSchema.marks.strong),
|
||||
'Mod-i': toggleMark(basicSchema.marks.em),
|
||||
'Mod-Shift-s': toggleMark(basicSchema.marks.strike),
|
||||
'Mod-Shift-c': toggleMark(basicSchema.marks.code),
|
||||
}),
|
||||
keymap(baseKeymap),
|
||||
],
|
||||
})
|
||||
|
||||
const view = new EditorView(editorRef.current, {
|
||||
state,
|
||||
dispatchTransaction(transaction) {
|
||||
const newState = view.state.apply(transaction)
|
||||
view.updateState(newState)
|
||||
if (onChange && transaction.docChanged) {
|
||||
const markdown = markdownSerializer.serialize(newState.doc)
|
||||
onChange(markdown)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
viewRef.current = view
|
||||
setInitialized(true)
|
||||
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
}, [])
|
||||
if (showToolbar) {
|
||||
plugins.push(
|
||||
toolbarPlugin({
|
||||
toolbarContents: () => (
|
||||
<>
|
||||
<UndoRedo />
|
||||
<BoldItalicUnderlineToggles />
|
||||
<BlockTypeSelect />
|
||||
</>
|
||||
),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('custom-markdown flex h-full flex-col gap-3', className)}
|
||||
>
|
||||
{showToolbar && <Toolbar view={viewRef.current} />}
|
||||
<div ref={editorRef} className="min-h-0 flex-1 overflow-auto" />
|
||||
</div>
|
||||
<MDXEditor
|
||||
className={className}
|
||||
onChange={onChange}
|
||||
markdown={content}
|
||||
plugins={plugins}
|
||||
contentEditableClassName="custom-markdown"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Generated
+1502
-159
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user