diff --git a/.env b/.env index 8763eff34..c76459560 100644 --- a/.env +++ b/.env @@ -6,4 +6,4 @@ NEXT_PUBLIC_SENTRY_DSN = SENTRY_ORG = SENTRY_PROJECT = -LARK_API_HOST = https://open.larksuite.com/open-apis/ +LARK_API_HOST = https://open.feishu.cn/open-apis/ diff --git a/.env.development b/.env.development new file mode 100644 index 000000000..c4e20be09 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +GITHUB_OAUTH_CLIENT_ID = Ov23liENtE5UyofxyPka +GITHUB_OAUTH_CLIENT_SECRET = 09f4428949e221f30ba6d2e113d030f8e5b9173a diff --git a/components/Form/HTMLEditor.tsx b/components/Form/HTMLEditor.tsx new file mode 100644 index 000000000..813f07289 --- /dev/null +++ b/components/Form/HTMLEditor.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; +import { + AudioTool, + CopyMarkdownTool, + Editor, + EditorProps, + IFrameTool, + OriginalTools, + VideoTool, +} from 'react-bootstrap-editor'; +import { Constructor } from 'web-utility'; + +const ExcludeTools = [IFrameTool, AudioTool, VideoTool]; + +const CustomTools = OriginalTools.filter( + Tool => !ExcludeTools.includes(Tool as Constructor), +); + +export const HTMLEditor: FC = props => ( + +); diff --git a/components/Form/JSONEditor/AddBar.tsx b/components/Form/JSONEditor/AddBar.tsx new file mode 100644 index 000000000..5d169dfce --- /dev/null +++ b/components/Form/JSONEditor/AddBar.tsx @@ -0,0 +1,30 @@ +import { Icon } from 'idea-react'; +import { FC } from 'react'; +import { Button } from 'react-bootstrap'; + +const type_map = { + string: { title: 'Inline text', icon: 'input-cursor' }, + text: { title: 'Rows text', icon: 'text-left' }, + object: { title: 'Key-value list', icon: 'list-ul' }, + array: { title: 'Ordered list', icon: 'list-ol' }, +}; + +export interface AddBarProps { + onSelect: (type: string) => void; +} + +export const AddBar: FC = ({ onSelect }) => ( + +); diff --git a/components/Form/JSONEditor/index.tsx b/components/Form/JSONEditor/index.tsx new file mode 100644 index 000000000..8a9865247 --- /dev/null +++ b/components/Form/JSONEditor/index.tsx @@ -0,0 +1,186 @@ +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import { DataObject } from 'mobx-restful'; +import { ChangeEvent, Component, ReactNode } from 'react'; +import { Form } from 'react-bootstrap'; + +import { AddBar } from './AddBar'; + +export interface DataMeta { + type: string; + key?: string | number; + value: any; + // eslint-disable-next-line no-restricted-syntax + children?: DataMeta[]; +} + +export interface FieldProps { + value: DataObject | any[] | null; + onChange?: (event: FieldChangeEvent) => void; +} +export type FieldChangeEvent = ChangeEvent<{ value: FieldProps['value'] }>; + +@observer +export class ListField extends Component { + @observable + accessor innerValue = {} as DataMeta; + + componentDidMount() { + this.innerValue = ListField.metaOf(this.props.value); + } + + componentDidUpdate({ value }: Readonly) { + if (value !== this.props.value) this.componentDidMount(); + } + + static metaOf(value: any): DataMeta { + if (value instanceof Array) + return { + type: 'array', + value, + children: Array.from(value, (value, key) => ({ + ...this.metaOf(value), + key, + })), + }; + + if (value instanceof Object) + return { + type: 'object', + value, + children: Object.entries(value).map(([key, value]) => ({ + ...this.metaOf(value), + key, + })), + }; + + return { + type: /[\r\n]/.test(value) ? 'text' : 'string', + value, + }; + } + + addItem = (type: string) => { + let item: DataMeta = { type, value: [] }; + const { innerValue } = this; + + switch (type) { + case 'string': + item = ListField.metaOf(''); + break; + case 'text': + item = ListField.metaOf('\n'); + break; + case 'object': + item = ListField.metaOf({}); + break; + case 'array': + item = ListField.metaOf([]); + } + + this.innerValue = { + ...innerValue, + children: [...(innerValue.children || []), item], + }; + }; + + protected dataChange = + (method: (item: DataMeta, newKey: string) => any) => + (index: number, { currentTarget: { value: data } }: ChangeEvent) => { + const { children = [] } = this.innerValue; + + const item = children[index]; + + if (!item) return; + + method.call(this, item, data); + + this.props.onChange?.({ + currentTarget: { value: this.innerValue.value }, + } as FieldChangeEvent); + }; + + setKey = this.dataChange((item: DataMeta, newKey: string) => { + const { value, children = [] } = this.innerValue; + + item.key = newKey; + + for (const oldKey in value) + if (!children.some(({ key }) => key === oldKey)) { + value[newKey] = value[oldKey]; + + delete value[oldKey]; + + return; + } + + value[newKey] = item.value; + }); + + setValue = this.dataChange((item: DataMeta, newValue: any) => { + const { value } = this.innerValue; + + if (newValue instanceof Array) newValue = [...newValue]; + else if (typeof newValue === 'object') newValue = { ...newValue }; + + item.value = newValue; + + if (item.key != null) value[item.key + ''] = newValue; + else if (value instanceof Array) item.key = value.push(newValue) - 1; + }); + + fieldOf(index: number, type: string, value: any) { + switch (type) { + case 'string': + return ( + + ); + case 'text': + return ( + + ); + default: + return ; + } + } + + wrapper(slot: ReactNode) { + const Tag = this.innerValue.type === 'array' ? 'ol' : 'ul'; + + return {slot}; + } + + render() { + const { type: field_type, children = [] } = this.innerValue; + + return this.wrapper( + <> +
  • + +
  • + {children.map(({ type, key, value }, index) => ( +
  • + {field_type === 'object' && ( + + )} + {this.fieldOf(index, type, value)} +
  • + ))} + , + ); + } +} diff --git a/components/Git/ArticleEditor.tsx b/components/Git/ArticleEditor.tsx new file mode 100644 index 000000000..ab92f85b0 --- /dev/null +++ b/components/Git/ArticleEditor.tsx @@ -0,0 +1,301 @@ +import { Loading } from 'idea-react'; +import { readAs } from 'koajax'; +import { debounce } from 'lodash'; +import { marked } from 'marked'; +import { computed, observable } from 'mobx'; +import { GitContent } from 'mobx-github'; +import { observer } from 'mobx-react'; +import { ObservedComponent } from 'mobx-react-helper'; +import { DataObject } from 'mobx-restful'; +import { SearchableInput } from 'mobx-restful-table'; +import { ChangeEvent, FormEvent } from 'react'; +import { Button, Col, Form } from 'react-bootstrap'; +import { blobOf, formatDate, uniqueID } from 'web-utility'; +import YAML from 'yaml'; + +import { GitFileSearchModel } from '../../models/GitFile'; +import { GitRepositoryModel, RepositorySearchModel, userStore } from '../../models/Repository'; +import { i18n, I18nContext } from '../../models/Translation'; +import { HTMLEditor } from '../Form/HTMLEditor'; +import { ListField } from '../Form/JSONEditor'; + +export const fileType = { + MarkDown: ['md', 'markdown'], + JSON: ['json'], + YAML: ['yml', 'yaml'], +}; + +export const postMeta = /^---[\r\n]([\s\S]*?)[\r\n]---/; + +export interface PostMeta extends Record<'title' | 'date', string>, DataObject { + authors?: string[]; +} + +export type HyperLink = HTMLAnchorElement | HTMLImageElement; + +@observer +export class ArticleEditor extends ObservedComponent<{}, typeof i18n> { + static contextType = I18nContext; + + @observable + accessor repository = ''; + + @observable + accessor editorContent = ''; + + @computed + get currentRepository() { + const [owner, name] = this.repository.split('/'); + + return { owner, name }; + } + + @computed + get repositoryStore() { + const { owner } = this.currentRepository; + + return new GitRepositoryModel(owner === userStore.session?.login ? '' : owner); + } + + repositorySearchStore = new RepositorySearchModel(); + + gitFileStore = new GitFileSearchModel(); + + path = ''; + currentFileURL = ''; + + @observable + accessor meta: PostMeta | null = null; + + static contentFilter({ type, name }: GitContent) { + return ( + type === 'dir' || + (type === 'file' && Object.values(fileType).flat().includes(name.split('.').slice(-1)[0])) + ); + } + + async setPostMeta(raw?: string) { + const meta: PostMeta = { authors: [], ...(raw ? YAML.parse(raw) : null) }; + + const { login } = await userStore.getSession(); + + if (!meta.authors?.includes(login)) meta.authors?.push(login); + + const path = this.currentFileURL + .split('/') + .slice(7, -1) + .filter(name => !name.startsWith('_')); + + meta.categories = [...new Set([...path, ...(meta.categories || [])])]; + meta.tags = meta.tags || []; + + this.meta = { ...meta, title: '', date: formatDate() }; + } + + setContent = async (URL: string, data?: Blob) => { + this.currentFileURL = URL; + this.reset(); + + const type = URL.split('.').at(-1)!; + + if (!(data instanceof Blob)) { + if (fileType.MarkDown.includes(type)) await this.setPostMeta(); + + return; + } + let content = (await readAs(data, 'text').result) as string; + + if (fileType.JSON.includes(type)) return (this.meta = JSON.parse(content)); + + if (fileType.YAML.includes(type)) return (this.meta = YAML.parse(content)); + + const meta = postMeta.exec(content); + + if (!meta) await this.setPostMeta(); + else { + content = content.slice(meta[0].length); + + meta[1] = meta[1].trim(); + + if (meta[1]) await this.setPostMeta(meta[1]); + } + + this.editorContent = marked(content) as string; + }; + + reset = () => { + this.meta = null; + this.editorContent = ''; + }; + + onPathClear = ({ target: { value } }: ChangeEvent) => { + if (!value.trim()) this.reset(); + }; + + fixURL = debounce(() => { + const { repository } = this, + [pageURL] = window.location.href.split('?'), + root = document.querySelector('div[contenteditable]'); + + if (root) + for (const element of root.querySelectorAll('[href], [src]')) { + let URI = element instanceof HTMLAnchorElement ? element.href : element.src; + + if (URI.startsWith(pageURL)) URI = URI.slice(pageURL.length); + + URI = new URL(URI, this.currentFileURL || window.location.href) + ''; + + if (element instanceof HTMLImageElement) + element.src = URI.replace(repository + '/blob/', repository + '/raw/'); + else element.href = URI; + } + }); + + getContent() { + const type = this.currentFileURL.split('.').at(-1)!, + { meta, editorContent } = this; + + if (fileType.JSON.includes(type)) return JSON.stringify(meta); + + if (fileType.YAML.includes(type)) return YAML.stringify(meta); + + if (fileType.MarkDown.includes(type) && editorContent) { + if (!meta) return editorContent; + + meta.updated = formatDate(); + + return `--- + ${YAML.stringify(meta)} + --- + + ${editorContent}`; + } + } + + submit = async (event: FormEvent) => { + event.preventDefault(); + + const { currentRepository, repositoryStore, editorContent } = this, + // @ts-expect-error DOM API shortcut + { message } = event.currentTarget.elements; + + if (!editorContent) return; + + const root = document.querySelector('div[contenteditable]'); + const media: HTMLMediaElement[] = [].filter.call( + root!.querySelectorAll('img[src], audio[src], video[src]'), + ({ src }) => new URL(src).protocol === 'blob:', + ); + + for (const file of media) { + const blob = await blobOf(file.src); + + const filePath = this.path.replace(/\.\w+$/, `/${uniqueID()}.${blob.type.split('/')[1]}`); + const { download_url } = await repositoryStore.updateContent( + filePath, + blob, + '[Upload] from Git-Pager', + currentRepository.name, + ); + file.src = download_url!; + } + + await repositoryStore.updateContent( + this.path, + this.getContent() as string, + message.value.trim(), + currentRepository.name, + ); + window.alert('Submitted'); + }; + + loadFile = async (path: string) => { + const type = path.split('.').at(-1)?.toLowerCase(); + + if (!fileType.MarkDown.includes(type || '')) return; + + const { owner, name } = this.currentRepository; + + const buffer = await this.repositoryStore.downloadRaw(path, name), + { default_branch } = this.repositoryStore.currentOne; + + await this.setContent( + `https://github.com/${owner}/${name}/blob/${default_branch}/${path}`, + new Blob([buffer]), + ); + }; + + render() { + const i18n = this.observedContext, + { repository, meta, editorContent } = this, + { downloading, uploading } = this.repositoryStore; + const { t } = i18n, + loading = downloading > 0 || uploading > 0; + + return ( +
    + {loading && } + + + + (this.repository = label || '')} + /> + + + + + {repository && ( + this.loadFile(label)} + /> + )} + + + + + + + + + + + + + {meta && ( + + + (this.meta = value as PostMeta)} + /> + + )} + +
    + +
    + {(!repository || editorContent) && ( + (this.editorContent = value || '
    ')} + /> + )} +
    + + ); + } +} diff --git a/components/Navigator/MainNavigator.tsx b/components/Navigator/MainNavigator.tsx index 51e1ee862..328a3893b 100644 --- a/components/Navigator/MainNavigator.tsx +++ b/components/Navigator/MainNavigator.tsx @@ -22,13 +22,13 @@ export const MainNavigator: FC = observer(() => {