1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-08-31 14:42:10 +00:00

Rewrite wiki page with react (#2258)

This commit is contained in:
Daniel Pan
2018-08-06 18:29:12 +08:00
committed by GitHub
parent ee8e4135ea
commit fa38dd9151
36 changed files with 2810 additions and 408 deletions

View File

@@ -2,14 +2,14 @@ import React from 'react';
import SeafileEditor from '@seafile/seafile-editor';
import 'whatwg-fetch';
import { SeafileAPI } from 'seafile-js';
import cookie from 'react-cookies'
import cookie from 'react-cookies';
let repoID = window.app.pageOptions.repoID;
let filePath = window.app.pageOptions.filePath;
let fileName = window.app.pageOptions.fileName;
let siteRoot = window.app.config.siteRoot;
let domain = window.app.pageOptions.domain;
let protocol = window.app.pageOptions.protocol;
let mode = window.app.pageOptions.mode;
let dirPath = '/';
const serviceUrl = window.app.config.serviceUrl;
@@ -18,7 +18,7 @@ const userInfo = window.app.userInfo;
// init seafileAPI
let seafileAPI = new SeafileAPI();
let xcsrfHeaders = cookie.load('csrftoken');
seafileAPI.initForSeahubUsage({ xcsrfHeaders });
seafileAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders });
function getImageFileNameWithTimestamp() {
var d = Date.now();
@@ -114,6 +114,18 @@ class EditorUtilities {
return files;
})
}
getFileHistory() {
return (
seafileAPI.getFileHistory(repoID, filePath)
)
}
getFileInfo() {
return (
seafileAPI.getFileInfo(repoID, filePath)
)
}
}
@@ -134,6 +146,8 @@ class App extends React.Component {
mtime: null,
size: 0,
starred: false,
permission: '',
lastModifier: '',
},
collabServer: seafileCollabServer ? seafileCollabServer : null,
};
@@ -142,13 +156,17 @@ class App extends React.Component {
componentDidMount() {
seafileAPI.getFileInfo(repoID, filePath).then((res) => {
let { mtime, size, starred } = res.data;
let { mtime, size, starred, permission, last_modifier_name } = res.data;
let lastModifier = last_modifier_name
this.setState((prevState, props) => ({
fileInfo: {
...prevState.fileInfo,
mtime,
size,
starred
starred,
permission,
lastModifier
}
}));
@@ -179,6 +197,7 @@ class App extends React.Component {
editorUtilities={editorUtilities}
userInfo={this.state.collabServer ? userInfo : null}
collabServer={this.state.collabServer}
mode={mode}
/>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,66 @@
import React, { Component } from 'react';
import Search from './search';
import MarkdownViewer from './markdown-viewer';
import Account from './account';
import { repoID, serviceUrl, slug, siteRoot } from './constance';
class MainPanel extends Component {
onMenuClick = () => {
this.props.onMenuClick();
}
onEditClick = (e) => {
// const w=window.open('about:blank')
e.preventDefault();
window.location.href= serviceUrl + '/lib/' + repoID + '/file' + this.props.filePath + '?mode=edit';
}
render() {
var filePathList = this.props.filePath.split('/');
var pathElem = filePathList.map((item, index) => {
if (item == "") {
return;
} else {
return (
<span key={index}><span className="path-split">/</span>{item}</span>
)
}
});
return (
<div className="main-panel o-hidden">
<div className="main-panel-top panel-top">
<span className="sf2-icon-menu side-nav-toggle hidden-md-up d-md-none" title="Side Nav Menu" onClick={this.onMenuClick}></span>
<div className={`wiki-page-ops ${this.props.permission === 'rw' ? '' : 'hide'}`}>
<a className="sf-btn-link btn-white" onClick={this.onEditClick}>Edit Page</a>
</div>
<div className="common-toolbar">
<Search />
<Account seafileAPI={this.props.seafileAPI} />
</div>
</div>
<div className="cur-view-main">
<div className="cur-view-path">
<div className="path-containter">
<a href={siteRoot + 'wikis/'} className="normal">Wikis</a>
<span className="path-split">/</span>
<a href={siteRoot + 'wikis/' + slug} className="normal">{slug}</a>
{pathElem}
</div>
</div>
<div className="cur-view-main-con">
<MarkdownViewer
markdownContent={this.props.content}
onLinkClick={this.props.onLinkClick}
/>
<p id="wiki-page-last-modified">Last modified by {this.props.latestContributor}, <span>{this.props.lastModified}</span></p>
</div>
</div>
</div>
)
}
}
export default MainPanel;

View File

@@ -0,0 +1,36 @@
import React, { Component } from 'react';
import TreeView from './tree-view/tree-view';
import { siteRoot, logoPath, mediaUrl, siteTitle, logoWidth, logoHeight } from './constance';
class SidePanel extends Component {
closeSide = () => {
this.props.onCloseSide();
}
onFileClick = (e, node) => {
this.props.onFileClick(e, node)
}
render() {
return (
<div className={`side-panel ${this.props.closeSideBar ? "": "left-zero"}`}>
<div className="side-panel-top panel-top">
<a href={siteRoot} id="logo">
<img src={mediaUrl + logoPath} title={siteTitle} alt="logo" width={logoWidth} height={logoHeight} />
</a>
<a title="Close" aria-label="Close" onClick={this.closeSide} className="sf2-icon-x1 sf-popover-close side-panel-close op-icon d-md-none "></a>
</div>
<div id="side-nav" className="wiki-side-nav" role="navigation">
<h3 className="wiki-pages-heading">Pages</h3>
<div className="wiki-pages-container">
<TreeView
editorUtilities={this.props.editorUtilities}
onClick={this.onFileClick}
/>
</div>
</div>
</div>
)
}
}
export default SidePanel;

View File

@@ -0,0 +1,135 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import cookie from 'react-cookies';
import { keyCodes, bytesToSize } from './utils';
import { siteRoot, avatarInfo } from './constance';
class Account extends Component {
constructor(props) {
super(props);
this.state = {
showInfo: false,
userName: '',
contactEmail: '',
quotaUsage: '',
quotaTotal: '',
isStaff: false,
usageRate: '',
avatarURL: '',
}
}
componentDidMount(){
this.getAccountInfo();
}
componentDidUpdate(prevProps) {
this.handleProps();
}
getContainer = () => {
return ReactDOM.findDOMNode(this);
}
handleProps = () => {
if (this.state.showInfo) {
this.addEvents();
} else {
this.removeEvents();
}
}
addEvents = () => {
['click', 'touchstart', 'keyup'].forEach(event =>
document.addEventListener(event, this.handleDocumentClick, true)
);
}
removeEvents = () => {
['click', 'touchstart', 'keyup'].forEach(event =>
document.removeEventListener(event, this.handleDocumentClick, true)
);
}
handleDocumentClick = (e) => {
if (e && (e.which === 3 || (e.type === 'keyup' && e.which !== keyCodes.tab))) return;
const container = this.getContainer();
if (container.contains(e.target) && container !== e.target && (e.type !== 'keyup' || e.which === keyCodes.tab)) {
return;
}
this.setState({
showInfo: !this.state.showInfo,
})
}
onClickAccount = () => {
this.setState({
showInfo: !this.state.showInfo,
})
}
getAccountInfo = () => {
this.props.seafileAPI.getAccountInfo().then(resp => {
this.setState({
userName: resp.data.name,
contactEmail: resp.data.email,
usageRate: resp.data.space_usage,
quotaUsage: bytesToSize(resp.data.usage),
quotaTotal: bytesToSize(resp.data.total),
isStaff: resp.data.is_staff,
avatarURL: resp.data.avatar_url
})
})
}
renderMenu = () => {
if(this.state.isStaff){
return (
<a href={siteRoot + 'sys/useradmin/'} title="System Admin" className="item">System Admin</a>
)
}
}
render() {
return (
<div id="account">
<a id="my-info" onClick={this.onClickAccount} className="account-toggle no-deco d-none d-md-block" aria-label="View profile and more">
<span>
<img src={this.state.avatarURL} width="36" height="36" className="avatar" />
</span> <span className="icon-caret-down vam"></span>
</a>
<span className="account-toggle sf2-icon-more mobile-icon d-md-none" aria-label="View profile and more" onClick={this.onClickAccount}></span>
<div id="user-info-popup" className={`account-popup sf-popover ${this.state.showInfo? '':'hide'}`}>
<div className="outer-caret up-outer-caret"><div className="inner-caret"></div></div>
<div className="sf-popover-con">
<div className="item o-hidden">
<span>
<img src={this.state.avatarURL} width="36" height="36" className="avatar" />
</span>
<div className="txt">
{this.state.userName} <br/>
{this.state.contactEmail}
</div>
</div>
<div id="space-traffic">
<div className="item">
<p>Used: {this.state.quotaUsage} / {this.state.quotaTotal}</p>
<div id="quota-bar">
<span id="quota-usage" className="usage" style={{width: this.state.usageRate}}></span>
</div>
</div>
</div>
<a href={siteRoot + 'profile/'} className="item">Settings</a>
{this.renderMenu()}
<a href={siteRoot + 'accounts/logout/'} className="item">Log out</a>
</div>
</div>
</div>
)
}
}
export default Account;

View File

@@ -0,0 +1,14 @@
export const dirPath = '/';
export const siteRoot = window.app.config.siteRoot;
export const avatarInfo = window.app.config.avatarInfo;
export const logoPath = window.app.config.logoPath;
export const mediaUrl = window.app.config.mediaUrl;
export const siteTitle = window.app.config.siteTitle;
export const logoWidth = window.app.config.logoWidth;
export const logoHeight = window.app.config.logoHeight;
export const slug = window.wiki.config.slug;
export const repoID = window.wiki.config.repoId;
export const serviceUrl = window.wiki.config.serviceUrl;
export const initialFilePath = window.wiki.config.initial_file_path;

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { processor } from '@seafile/seafile-editor/src/lib/seafile-markdown2html';
import TreeView from './tree-view/tree-view';
import Prism from 'prismjs';
var URL = require('url-parse');
require('@seafile/seafile-editor/src/lib/code-hight-package');
const contentClass = "wiki-md-viewer-rendered-content";
class MarkdownViewerContent extends React.Component {
constructor(props) {
super(props);
}
componentDidUpdate () {
Prism.highlightAll();
var links = document.querySelectorAll(`.${contentClass} a`);
links.forEach((li) => {li.addEventListener("click", this.onLinkClick); });
}
onLinkClick = (event) => {
this.props.onLinkClick(event);
event.preventDefault();
}
render() {
return (
<div>
{ this.props.renderingContent ? (
<div className={contentClass + " article"}>Loading...</div>
) : (
<div ref={(mdContent) => {this.mdContentRef = mdContent;} }
className={contentClass + " article"}
dangerouslySetInnerHTML={{ __html: this.props.html }}/>
)}
</div>
)
}
}
class MarkdownViewer extends React.Component {
state = {
renderingContent: true,
renderingOutline: true,
html: '',
outlineTreeRoot: null
}
scrollToNode(node) {
let url = new URL(window.location.href);
url.set('hash', 'user-content-' + node.data.id);
window.location.href = url.toString();
}
setContent(markdownContent) {
let that = this;
processor.process(markdownContent, function(err, file) {
that.setState({
html: String(file),
renderingContent: false
});
})
}
componentWillReceiveProps(nextProps) {
this.setContent(nextProps.markdownContent);
}
componentDidMount() {
let that = this;
processor.process(this.props.markdownContent, function(err, file) {
that.setState({
html: String(file),
renderingContent: false
});
});
}
render() {
return (
<MarkdownViewerContent
renderingContent={this.state.renderingContent} html={this.state.html}
onLinkClick={this.props.onLinkClick}
/>
)
}
}
export default MarkdownViewer;

View File

@@ -0,0 +1,19 @@
import React, { Component } from 'react';
import { repoID, siteRoot } from './constance';
class Search extends Component {
render() {
return (
<form id="top-search-form" method="get" action={siteRoot + 'search/'} className="hidden-sm-down search-form">
<input
type="text" className="search-input" name="q"
placeholder="Search files in this wiki"
/>
<input type="hidden" name="search_repo" value={repoID} />
<button type="submit" className="search-submit" aria-label="Submit"><span className="icon-search"></span></button>
</form>
)
}
}
export default Search;

View File

@@ -0,0 +1,127 @@
class Node {
static create(attrs = {}) {
}
/**
* Create a `Node` from a JSON `object`.
*
* @param {Object} object
* @return {Node}
*/
static fromJSON(object) {
const {
name,
type,
isExpanded = true,
children = [],
} = object;
const node = new Node({
name,
type,
isExpanded,
children: children.map(Node.fromJSON),
});
return node;
}
constructor({ name, type, isExpanded, children }) {
this.name = name;
this.type = type;
this.children = children ? children : [];
this.isExpanded = isExpanded !== undefined ? isExpanded : true;
}
get path() {
if (!this.parent) {
return this.name;
} else {
var p = this.parent.path;
if (p === "/")
return p + this.name;
else
return p + "/" + this.name;
}
}
copy() {
var n = new Node({
name: this.name,
type: this.type,
isExpanded: this.isExpanded
});
n.children = this.children.map(child => { var newChild = child.copy(); newChild.parent = n; return newChild; });
return n;
}
isRoot() {
return this.parent === undefined;
}
hasChildren() {
return this.children.length > 0;
}
isImage() {
let index = this.name.lastIndexOf(".");
if (index == -1) {
return false;
} else {
let type = this.name.substring(index).toLowerCase();
if (type == ".png" || type == ".jpg") {
return true;
} else {
return false;
}
}
}
isMarkdown() {
let index = this.name.lastIndexOf(".");
if (index == -1) {
return false;
} else {
let type = this.name.substring(index).toLowerCase();
if (type == ".md" || type == ".markdown") {
return true;
} else {
return false;
}
}
}
isDir() {
return this.type == "dir";
}
/**
* Return a JSON representation of the node.
*
* @return {Object}
*/
toJSON() {
var children = []
if (this.hasChildren()) {
children = this.children.map(m => m.toJSON());
}
const object = {
name: this.name,
type: this.type,
isExpanded: this.isExpanded,
children: children
}
return object
}
}
export { Node }

View File

@@ -0,0 +1,135 @@
import React from 'react';
function sortByType(a, b) {
if (a.type == "dir" && b.type != "dir") {
return -1;
} else if (a.type != "dir" && b.type == "dir") {
return 1;
} else {
return a.name.localeCompare(b.name);
}
}
class TreeNodeView extends React.Component {
renderCollapse = () => {
const { node } = this.props;
if (node.hasChildren()) {
const { isExpanded } = node;
return (
<i
className={isExpanded ? 'folder-toggle-icon fa fa-caret-down' : 'folder-toggle-icon fa fa-caret-right'}
onMouseDown={e => e.stopPropagation()}
onClick={this.handleCollapse}
/>
);
}
return null;
}
renderChildren = () => {
const { node } = this.props;
if (node.children && node.children.length) {
const childrenStyles = {
paddingLeft: this.props.paddingLeft
};
var l = node.children.sort(sortByType);
l = l.filter((node) => { return node.type == "dir" || node.isMarkdown(); })
/*
the `key` property is needed. Otherwise there is a warning in the console
*/
return (
<div className="children" style={childrenStyles}>
{l.map(child => {
return (
<TreeNodeView
node={child}
key={child.path}
paddingLeft={this.props.paddingLeft}
treeView={this.props.treeView}
/>
);
})}
</div>
);
}
return null;
}
render() {
const { node } = this.props;
const styles = {};
var icon, type;
if (node.type === "dir") {
icon = <i className="far fa-folder"/>;
type = 'dir';
} else {
let index = node.name.lastIndexOf(".");
if (index === -1) {
icon = <i className="far fa-file"/>;
type = 'file';
} else {
type = node.name.substring(index).toLowerCase();
if (type === ".png" || type === ".jpg") {
icon = <i className="far fa-image"/>;
type = 'image';
} else {
icon = <i className="far fa-file"/>;
type = 'file';
}
}
}
return (
<div type={type}
className="tree-node"
style={styles}
>
<div onMouseLeave={this.onMouseLeave} onMouseEnter={this.onMouseEnter}
onClick={this.onClick}
type={type} className={`tree-node-inner text-nowrap ${node.name === '/'? 'hide': ''}`}>
{this.renderCollapse()}
<span type={type} className="tree-node-icon">
{icon}
</span>
<span type={type} draggable="true" onDragStart={this.onDragStart}>{node.name}</span>
</div>
{node.isExpanded ? this.renderChildren() : null}
</div>
);
}
onClick = e => {
let { node } = this.props;
this.props.treeView.onClick(e, node);
}
onMouseEnter = e => {
let { node } = this.props;
this.props.treeView.showImagePreview(e, node);
}
onMouseLeave = e => {
this.props.treeView.hideImagePreview(e);
}
handleCollapse = e => {
e.stopPropagation();
const { node } = this.props;
if (this.props.treeView.toggleCollapse) {
this.props.treeView.toggleCollapse(node);
}
}
onDragStart = e => {
const { node } = this.props;
this.props.treeView.onDragStart(e, node);
}
}
export default TreeNodeView;

View File

@@ -0,0 +1,143 @@
import React from 'react';
import TreeNodeView from './tree-node-view';
import Tree from './tree';
class TreeView extends React.PureComponent {
static defaultProps = {
paddingLeft: 20
};
imagePreviewTimeout = null
state = {
tree: new Tree(),
loadingFailed: false,
imagePreviewPosition: {
left: 10+'px',
top: 10+'px'
},
isShowImagePreview: false,
imagePreviewLoading: false,
imageSrc: '',
}
showImagePreview = (e, node) => {
e.persist();
let type = e.target.getAttribute('type');
if (type === 'image') {
this.imagePreviewTimeout = setTimeout(() => {
let X = e.clientX + 20;
let Y = e.clientY - 55;
if (e.view.innerHeight < e.clientY + 150) {
Y = e.clientY - 219;
}
this.setState({
isShowImagePreview: true,
imagePreviewLoading: true,
imageSrc: this.props.editorUtilities.getFileURL(node),
imagePreviewPosition: {
left: X + 'px',
top: Y + 'px'
}
});
}, 1000)
}
}
hideImagePreview = (e) => {
clearTimeout(this.imagePreviewTimeout);
this.setState({
isShowImagePreview: false,
imagePreviewLoading: false,
});
}
imageLoaded = () => {
this.setState({
imagePreviewLoading: false,
});
}
componentDidMount() {
this.props.editorUtilities.getFiles().then((files) => {
// construct the tree object
var rootObj = {
name: '/',
type: 'dir',
isExpanded: true
}
var treeData = new Tree();
treeData.parseFromList(rootObj, files);
this.setState({
tree: treeData
})
}, () => {
console.log("failed to load files");
this.setState({
loadingFailed: true
})
})
}
render() {
const tree = this.state.tree;
if (!tree.root) {
return <div>Loading...</div>
}
return (
<div className="tree-view tree">
<TreeNodeView
node={tree.root}
paddingLeft={20}
treeView={this}
/>
{ this.state.isShowImagePreview &&
<div style={this.state.imagePreviewPosition} className={'image-view'}>
{ this.state.imagePreviewLoading && <i className={'rotate fa fa-spinner'}/> }
<img src={this.state.imageSrc} onLoad={this.imageLoaded} alt=""/>
</div>
}
</div>
);
}
change = (tree) => {
/*
this._updated = true;
if (this.props.onChange) this.props.onChange(tree.obj);
*/
}
toggleCollapse = (node) => {
const tree = this.state.tree;
node.isExpanded = !node.isExpanded;
// copy the tree to make PureComponent work
this.setState({
tree: tree.copy()
});
this.change(tree);
}
onDragStart = (e, node) => {
const url = this.props.editorUtilities.getFileURL(node);
e.dataTransfer.setData("text/uri-list", url);
e.dataTransfer.setData("text/plain", url);
}
onClick = (e, node) => {
if (node.isDir()) {
this.toggleCollapse(node);
return;
}
this.props.onClick(e, node);
}
}
export default TreeView;

View File

@@ -0,0 +1,113 @@
import { Node } from './node'
class Tree {
constructor() {
this.root = null;
}
copy() {
var t = new Tree();
if (this.root)
t.root = this.root.copy();
return t;
}
setRoot(dir) {
this.root = dir;
}
addChildToNode(node, child) {
child.parent = node;
node.children.push(child);
return child;
}
addChild(node, child, insertIndex) {
if (!(child instanceof Node)) {
throw new TypeError('Child must be of type Node.');
}
if (insertIndex < 0 || insertIndex > node.children.length) {
throw new Error('Invalid index.');
}
child.parent = node;
node.children.splice(insertIndex, 0, child);
}
/*
* parse tree from javascript object
*/
parse(model) {
var node = new Node({
name: model.name,
type: model.type,
isExpanded: model.isExpanded
});
this.root = node;
for (let child of model.children) {
this.addChildToNode(node, this.parseNode(child));
}
}
parseFromList(rootObj, nodeList) {
var root = new Node({
name: rootObj.name,
type: rootObj.type,
isExpanded: rootObj.isExpanded
});
this.root = root;
var map = new Map();
map.set(root.name, root);
function joinPath(parent_path, name) {
if (parent_path === "/")
return parent_path + name;
else
return parent_path + "/" + name;
}
var treeNodeList = []
for (let nodeObj of nodeList) {
var node = new Node({
name: nodeObj.name,
type: nodeObj.type,
isExpanded: false
});
node.parent_path = nodeObj.parent_path;
treeNodeList.push(node);
if (nodeObj.type === "dir") {
map.set(joinPath(nodeObj.parent_path, nodeObj.name), node);
}
}
for (let node of treeNodeList) {
let p = map.get(node.parent_path);
if (p === undefined) {
console.log("warning: node " + node.parent_path + " not exist");
} else {
this.addChildToNode(p, node);
}
}
}
parseNode(model) {
var node = new Node({
name: model.name,
type: model.type,
isExpanded: model.isExpanded
});
if (model.children instanceof Array) {
for (let child of model.children) {
this.addChildToNode(node, this.parseNode(child));
}
}
return node;
}
}
export default Tree;

View File

@@ -0,0 +1,16 @@
export const keyCodes = {
esc: 27,
space: 32,
tab: 9,
up: 38,
down: 40
}
export function bytesToSize(bytes) {
if(bytes < 0) return '--'
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']
if (bytes === 0) return bytes + sizes[0]
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1000)), 10)
if (i === 0) return bytes + ' ' + sizes[i]
return (bytes / (1000 ** i)).toFixed(1) + ' ' + sizes[i]
}

View File

@@ -0,0 +1,144 @@
/**** article ****/
.article {
padding:40px 60px 40px 60px;
font-size:14px;
line-height:1.6;
color:#333;
}
.article h2,
.article h3,
.article h4,
.article h5,
.article h6 {
margin: 1.2em 0 0.4em;
color:#333;
font-weight:bold;
}
.article h2 {
border-bottom: 1px solid #ccc;
line-height: 1.5;
}
.article h1 + p, .article h2 + p, .article h3 + p, .article h4 + p, .article h5 + p, .article h6 + p,
.article h1 + pre, .article h2 + pre, .article h3 + pre, .article h4 + pre, .article h5 + pre, .article h6 + pre,
.article h1 + ul, .article h2 + ul, .article h3 + ul, .article h4 + ul, .article h5 + ul, .article h6 + ul,
.article h1 + ol, .article h2 + ol, .article h3 + ol, .article h4 + ol, .article h5 + ol, .article h6 + ol {
margin-top: 0;
}
.article p {
margin:0.8em 0;
}
.article ul {
list-style-type:disc;
}
.article ul,
.article ol {
padding-left:2em;
margin:0.5em 0;
}
.article li p:first-child {
margin-top: 0px;
margin-bottom: 0px;
}
.article li.task-list-item p:nth-child(2) {
margin-top: 0px;
margin-bottom: 0px;
}
.article li.task-list-item {
position: relative;
list-style: none;
}
.article li.task-list-item input[type="checkbox"] {
position: absolute;
left: -1.8em;
top: 0.4em;
display: inline-block;
}
.article input[type="checkbox"] {
width: auto;
}
.article pre {
font-size:13px;
padding:16px;
background:#f5f7fa;
border-radius:3px;
-moz-border-radius:3px;
-webkit-border-radius:3px;
margin:1em 0;
white-space: pre-wrap;
line-height: 1.45;
}
.article .code p {
white-space:pre-wrap;
padding:0;
margin:0;
border:none;
}
.article .html-element.active {
border: 1px solid #eb8205;
}
.article span.html-element {
display:inline-block;
margin-left:1px;
margin-right:1px;
background: #f4f4f4;
border: 1px solid #bbb;
border-radius: 2px;
}
.article div.html-element {
background: #f4f4f4;
border: 1px solid #bbb;
border-radius: 2px;
margin:0.8em 0;
}
.article a {
font-weight:normal;
}
.article blockquote {
color: #777;
padding: 0 15px;
border-left: 4px solid #DDD;
margin: 1.2em 0;
font-style: italic;
}
.article table {
width: 100%;
border-collapse: collapse;
border-left: 1px solid #ddd;
border-top: 1px solid #ddd;
margin: 10px 0;
}
.article tr:nth-child(2n) {
background-color: #F8F8F8;
}
.article th,
.article td {
padding:6px 13px;
}
.article table p {
margin: 0;
}
.article table tr, .article table th {
display: flex;
}
.article table td, .article table th {
flex: 1;
padding: 10px 10px;
border-width: 0 1px 1px 0;
border-style: solid;
border-color: #ccc;
word-break: break-all;
line-height: 1.5;
}
.article hr.active {
border-top: 1px solid #eb8205;
}

View File

@@ -0,0 +1,125 @@
/*tree view */
.tree-node:not([type = 'dir']):hover {
background-color: rgb(255,239,178);
}
.tree-node {
min-width: -moz-max-content;
min-width: -webkit-max-content;
min-width: max-content;
}
.tree-node-inner {
position: relative;
padding-left: 12px;
}
/*
the main reason to icon can not be align is that .folder has a real width it take the place
of .tree-node-inner causing tree-node-icon not aligned , use absolute can make sure .tree-node-icon
is always at the far left of .tree-node-inner
*/
.folder-toggle-icon {
position: absolute;
left: 0;
line-height: 1.5;
}
.tree-node-icon {
margin-right: 0.4rem;
margin-left: 0.1rem;
display: inline-block;
width: 1rem;
text-align: center;
}
.side-panel {
user-select: none;
height:100%;
}
.side-panel .nav {
border-bottom: 1px solid #eee;
padding-top: 10px;
padding-bottom: 10px;
min-width: 125px;
height: 36px;
}
.side-panel .nav-link {
color: #888;
}
.side-panel .nav-link.active {
color: #eb8205;
}
.side-panel-content {
padding-top: 8px;
padding-bottom: 8px;
height: calc(100% - 36px);
overflow: auto;
font-size: 0.875rem;
}
.image-view {
width: 200px;
height: 150px;
position: absolute;
background-color: #fff;
z-index: 1004;
box-shadow: 0 0 10px #aaa;
border-radius: 3px;
line-height: 150px;
overflow: hidden;
font-size: 0;
text-align: center;
}
.image-view img {
max-width: 100%;
max-height: 100%;
}
.image-view i {
width: 100%;
height: 100%;
text-align: center;
line-height: 150px;
font-size: 30px;
color: #eb8205;
-moz-animation: rotate 1.5s ease infinite;
-webkit-animation: rotate 1.5s ease infinite;
animation: rotate 1.5s ease infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.outline-h2 {
margin-left: 20px;
line-height: 2.5;
color:#364149;
white-space: nowrap;
cursor:pointer;
}
.outline-h2:hover {
color: #eb8205;
}
.outline-h3 {
margin-left: 40px;
line-height: 2.5;
color:#364149;
white-space: nowrap;
cursor:pointer;
}
.outline-h3:hover {
color: #eb8205;
}
.tree-view {
padding-left: 10px;
line-height: 1.5;
}

89
frontend/src/css/wiki.css Normal file
View File

@@ -0,0 +1,89 @@
.wiki-side-nav {
flex:auto;
display:flex;
flex-direction:column;
overflow:hidden; /* for ff */
border-right:1px solid #eee;
background: #f5f7fa;
}
.wiki-pages-heading {
font-size: 1rem;
font-weight: normal;
text-align:center;
padding:.5rem 0rem;
border-bottom:1px solid #e8e8e8;
line-height: 1.5;
height:40px;
}
.wiki-pages-container {
overflow: hidden;
padding-bottom: 10px;
}
.wiki-pages-container:hover {
overflow: auto;
}
.wiki-pages-container .tree-view {
padding-left:0;
}
.wiki-md-viewer-rendered-content {
padding: 30px 0 0;
}
.wiki-pages-container .tree-node-inner {
line-height: 1.625;
}
.wiki-pages-container .folder-toggle-icon {
color: #c0c0c0;
line-height: 1.625;
}
.wiki-pages-container .tree-node-icon {
color: #b0b0b0;
}
.wiki-main .cur-view-path {
border-bottom: 1px solid #e8e8e8;
margin-bottom: 8px;
}
.wiki-main .cur-view-path::after {
display:none;
}
.wiki-main .cur-view-main-con {
padding-left: 40px;
padding-right: 40px;
}
.wiki-page-ops {
position:fixed;
top:10px;
}
@media (min-width: 768px) {
.wiki-page-ops:before {
content:'';
border-left:1px solid #ddd;
position:absolute;
top:3px;
left:-16px;
bottom:3px;
}
}
.wiki-page-list-item {
word-break:break-all;
line-height:1.6;
margin:3px 0;
}
.wiki-page-link,
.wiki-page-link:hover {
font-size:1.15em;
font-weight:normal;
color:#444;
margin-left:5px;
}
#wiki-page-last-modified {
margin-top:40px;
font-size:12px;
color: #666;
}

View File

@@ -7,7 +7,7 @@ import i18n from './i18n';
import './assets/css/fa-solid.css';
import './assets/css/fa-regular.css';
import './assets/css/fontawesome.css';
import './assets/css/seafile-ui.css';
import 'seafile-ui';
import './index.css';
let lang = window.app.pageOptions.lang

154
frontend/src/wiki.js Normal file
View File

@@ -0,0 +1,154 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import SidePanel from './components/SidePanel';
import MainPanel from './components/MainPanel';
import moment from 'moment';
import cookie from 'react-cookies';
import { SeafileAPI } from 'seafile-js';
import { slug, repoID, serviceUrl, initialFilePath, siteRoot } from './components/constance';
import './assets/css/fa-solid.css';
import './assets/css/fa-regular.css';
import './assets/css/fontawesome.css';
import 'seafile-ui';
import './css/side-panel.css';
import './css/wiki.css';
// init seafileAPI
let seafileAPI = new SeafileAPI();
let xcsrfHeaders = cookie.load('csrftoken');
seafileAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders });
class EditorUtilities {
getFiles() {
return seafileAPI.listWikiDir(slug).then(items => {
const files = items.data.dir_file_list.map(item => {
return {
name: item.name,
type: item.type === 'dir' ? 'dir' : 'file',
isExpanded: item.type === 'dir' ? true : false,
parent_path: item.parent_dir,
}
})
return files;
})
}
}
const editorUtilities = new EditorUtilities();
class Wiki extends Component {
constructor(props) {
super(props);
this.state = {
content: '',
closeSideBar: false,
fileName: '',
filePath: '',
latestContributor: '',
lastModified: '',
permission: ''
};
window.onpopstate = this.onpopstate;
}
componentDidMount() {
this.loadFile(initialFilePath);
}
fileNameFromPath(filePath) {
let index = filePath.lastIndexOf("/");
if (index == -1) {
return "";
} else {
return filePath.substring(index + 1);
}
}
isInternalMarkdownLink(url) {
var re = new RegExp(serviceUrl + '/lib/' + repoID + '/file' + '.*\.md');
return re.test(url);
}
getPathFromInternalMarkdownLink(url) {
var re = new RegExp(serviceUrl + '/lib/' + repoID + '/file' + "(.*\.md)");
var array = re.exec(url);
var path = decodeURIComponent(array[1]);
return path;
}
onLinkClick = (event) => {
const url = event.target.href;
if (this.isInternalMarkdownLink(url)) {
let path = this.getPathFromInternalMarkdownLink(url);
this.loadFile(path);
}
}
onFileClick = (e, node) => {
if (node.isMarkdown()) {
this.loadFile(node.path);
}
}
loadFile(filePath) {
seafileAPI.getWikiFileContent(slug, filePath)
.then(res => {
this.setState({
content: res.data.content,
latestContributor: res.data.latest_contributor,
lastModified: moment.unix(res.data.last_modified).fromNow(),
permission: res.data.permission,
fileName: this.fileNameFromPath(filePath),
filePath: filePath
})
})
let fileUrl = '/wikis/' + slug + filePath;
window.history.pushState({urlPath: fileUrl, filePath: filePath}, filePath, fileUrl);
}
onpopstate = (event) => {
this.loadFile(event.state.filePath);
}
onMenuClick = () => {
this.setState({
closeSideBar: !this.state.closeSideBar,
})
}
onCloseSide = () => {
this.setState({
closeSideBar: !this.state.closeSideBar,
})
}
render() {
return (
<div id="main" className="wiki-main">
<SidePanel
onFileClick={this.onFileClick}
closeSideBar={this.state.closeSideBar}
onCloseSide ={this.onCloseSide}
editorUtilities={editorUtilities}
/>
<MainPanel
content={this.state.content}
fileName={this.state.fileName}
filePath={this.state.filePath}
onLinkClick={this.onLinkClick}
onMenuClick={this.onMenuClick}
latestContributor={this.state.latestContributor}
lastModified={this.state.lastModified}
seafileAPI={seafileAPI}
permission={this.state.permission}
/>
</div>
)
}
}
ReactDOM.render (
<Wiki />,
document.getElementById('wrapper')
)