mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2025-11-01 02:43:35 +08:00
501 lines
21 KiB
JavaScript
501 lines
21 KiB
JavaScript
import React from "react";
|
|
import { StickyContainer, Sticky } from "react-sticky";
|
|
|
|
import { Modal, NgIf, Icon, Dropdown, DropdownButton, DropdownList, DropdownItem, Input } from "../../components/";
|
|
import { extractEvents, extractTodos } from "../../helpers/org";
|
|
import { leftPad } from "../../helpers/common";
|
|
import { debounce, randomString } from "../../helpers/";
|
|
import { t } from "../../locales/";
|
|
import "./org_viewer.scss";
|
|
|
|
export class OrgEventsViewer extends React.Component {
|
|
shouldComponentUpdate(nextProps) {
|
|
if (this.props.content !== nextProps.content) return true;
|
|
if (this.props.isActive !== nextProps.isActive) return true;
|
|
return false;
|
|
}
|
|
render() {
|
|
const headlines = this.props.isActive ? extractEvents(this.props.content) : [];
|
|
return (
|
|
<OrgViewer
|
|
title="Agenda" headlines={headlines} content={this.props.content}
|
|
isActive={this.props.isActive} onQuit={this.props.onQuit}
|
|
goTo={this.props.goTo} onUpdate={this.props.onUpdate} />
|
|
);
|
|
}
|
|
}
|
|
|
|
export class OrgTodosViewer extends React.Component {
|
|
shouldComponentUpdate(nextProps) {
|
|
if (this.props.content !== nextProps.content) return true;
|
|
if (this.props.isActive !== nextProps.isActive) return true;
|
|
return false;
|
|
}
|
|
|
|
render() {
|
|
const headlines = this.props.isActive ? extractTodos(this.props.content) : [];
|
|
return (
|
|
<OrgViewer
|
|
title="Todos" headlines={headlines} content={this.props.content}
|
|
isActive={this.props.isActive} onQuit={this.props.onQuit}
|
|
goTo={this.props.goTo} onUpdate={this.props.onUpdate} />
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
class OrgViewer extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
headlines: this.buildHeadlines(props.headlines),
|
|
content: props.content,
|
|
search: "",
|
|
_: null,
|
|
};
|
|
this.rerender = () => this.setState({ _: randomString() });
|
|
this.findResults = debounce(this.findResults.bind(this), 150);
|
|
}
|
|
|
|
UNSAFE_componentWillReceiveProps(props) {
|
|
this.setState({
|
|
headlines: this.buildHeadlines(props.headlines),
|
|
content: props.content,
|
|
});
|
|
}
|
|
|
|
buildHeadlines(headlines) {
|
|
return headlines
|
|
.reduce((acc, headline) => {
|
|
if (!acc[headline["key"]]) {
|
|
acc[headline["key"]] = [];
|
|
}
|
|
acc[headline["key"]].push(headline);
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
|
|
onChange(i, j, state) {
|
|
const headlines = { ...this.state.headlines };
|
|
headlines[Object.keys(this.state.headlines)[i]][j].status = state;
|
|
|
|
this.setState({
|
|
headlines: this.state.headlines,
|
|
});
|
|
}
|
|
|
|
onTaskUpdate(type, line, value) {
|
|
const content = this.state.content.split("\n");
|
|
let head_line;
|
|
let item_line;
|
|
let head_status;
|
|
let deadline_line;
|
|
let scheduled_line;
|
|
let insertion_line;
|
|
switch (type) {
|
|
case "status":
|
|
content[line] = content[line].replace(/^(\*+\s)[A-Z]{3,}(\s.*)$/, "$1"+value+"$2");
|
|
break;
|
|
case "subtask":
|
|
if (value === "DONE") {
|
|
content[line] = content[line].replace(/\[.\]/, "[X]");
|
|
} else {
|
|
content[line] = content[line].replace(/\[.\]/, "[ ]");
|
|
}
|
|
break;
|
|
case "existing_scheduled":
|
|
[head_line, head_status, item_line] = line;
|
|
content[item_line] = content[item_line].replace(/SCHEDULED: <.*?>\s*/, value ? "SCHEDULED: "+orgdate(value)+" " : "");
|
|
this.state.headlines[head_status] = this.state.headlines[head_status]
|
|
.map((todo) => {
|
|
if (todo.line === head_line) {
|
|
if (value) todo.scheduled.timestamp = new Date(value).toISOString();
|
|
else todo.scheduled = null;
|
|
}
|
|
return todo;
|
|
});
|
|
this.setState({ headlines: this.state.headlines });
|
|
break;
|
|
case "existing_deadline":
|
|
[head_line, head_status, item_line] = line;
|
|
content[item_line] = content[item_line].replace(/DEADLINE: <.*?>\s*/, value ? "DEADLINE: "+orgdate(value) : "");
|
|
this.state.headlines[head_status] = this.state.headlines[head_status]
|
|
.map((todo) => {
|
|
if (todo.line === head_line) {
|
|
if (value) todo.deadline.timestamp = new Date(value).toISOString();
|
|
else todo.deadline = null;
|
|
}
|
|
return todo;
|
|
});
|
|
this.setState({ headlines: this.state.headlines });
|
|
break;
|
|
case "new_scheduled":
|
|
[head_line, head_status, deadline_line] = line;
|
|
if (deadline_line !== null) {
|
|
insertion_line = deadline_line;
|
|
content[deadline_line] = "SCHEDULED: "+orgdate(value)+" "+content[deadline_line];
|
|
} else {
|
|
insertion_line = head_line + 1;
|
|
if (content[insertion_line] === "" && content[insertion_line + 1] === "") {
|
|
content[insertion_line] = "SCHEDULED: "+orgdate(value);
|
|
} else {
|
|
content.splice(
|
|
insertion_line,
|
|
0,
|
|
"SCHEDULED: "+orgdate(value),
|
|
);
|
|
}
|
|
}
|
|
this.state.headlines[head_status] = this.state.headlines[head_status]
|
|
.map((todo) => {
|
|
if (todo.line === head_line) {
|
|
todo.scheduled = {
|
|
line: insertion_line,
|
|
keyword: "SCHEDULED",
|
|
active: true,
|
|
range: null,
|
|
repeat: null,
|
|
timestamp: new Date(value).toISOString(),
|
|
};
|
|
}
|
|
return todo;
|
|
});
|
|
this.setState({ headlines: this.state.headlines });
|
|
break;
|
|
case "new_deadline":
|
|
[head_line, head_status, scheduled_line] = line;
|
|
if (scheduled_line !== null) {
|
|
insertion_line = scheduled_line;
|
|
content[scheduled_line] = content[scheduled_line]+" DEADLINE: "+orgdate(value);
|
|
} else {
|
|
insertion_line = head_line + 1;
|
|
if (content[insertion_line] === "" && content[insertion_line + 1] === "") {
|
|
content[insertion_line] = "DEADLINE: "+orgdate(value);
|
|
} else {
|
|
content.splice(
|
|
insertion_line,
|
|
0,
|
|
"DEADLINE: "+orgdate(value),
|
|
);
|
|
}
|
|
this.state.headlines[head_status] = this.state.headlines[head_status]
|
|
.map((todo) => {
|
|
if (todo.line === head_line) {
|
|
todo.deadline = {
|
|
line: insertion_line,
|
|
keyword: "DEADLINE",
|
|
active: true,
|
|
range: null,
|
|
repeat: null,
|
|
timestamp: new Date(value).toISOString(),
|
|
};
|
|
}
|
|
return todo;
|
|
});
|
|
this.setState({ headlines: this.state.headlines });
|
|
}
|
|
break;
|
|
}
|
|
this.setState({ content: content.join("\n") });
|
|
|
|
function orgdate(_date) {
|
|
const date = new Date(_date);
|
|
return "<"+date.getFullYear()+"-"+leftPad((date.getMonth() + 1).toString(), 2)+"-"+leftPad(date.getDate().toString(), 2)+" "+day(date.getDay())+">";
|
|
|
|
function day(n) {
|
|
switch (navigator.language.split("-")[0]) {
|
|
case "de": ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"][n];
|
|
break;
|
|
default: return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][n];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
navigate(line) {
|
|
this.props.goTo(line);
|
|
this.onQuit();
|
|
}
|
|
|
|
onQuit() {
|
|
this.props.onUpdate(this.state.content);
|
|
this.props.onQuit();
|
|
}
|
|
|
|
componentDidMount() {
|
|
window.addEventListener("resize", this.rerender);
|
|
}
|
|
componentWillUnmount() {
|
|
window.removeEventListener("resize", this.rerender);
|
|
}
|
|
|
|
search(terms) {
|
|
this.setState({ search: terms }, () => {
|
|
this.findResults(terms);
|
|
});
|
|
}
|
|
|
|
findResults(terms) {
|
|
let headlines = this.props.headlines;
|
|
if (terms) {
|
|
headlines = this.props.headlines.filter((headline) => {
|
|
const keywords = terms.split(" ");
|
|
const head = function() {
|
|
let str = " ";
|
|
str += headline["status"] + " ";
|
|
str += headline["title"] + " ";
|
|
str += headline.tags.map((tag) => "#"+tag).join(" ") + " ";
|
|
str += headline.scheduled ? "scheduled "+headline.scheduled.timestamp + " ": "";
|
|
str += headline.deadline ? "deadline "+headline.deadline.timestamp + " ": "";
|
|
str += headline.priority ? "priority #"+headline.priority+" " : "";
|
|
str += headline.is_overdue ? "overdue " : "";
|
|
str += headline.tasks.map((task) => task.title).join(" ")+ " ";
|
|
return str;
|
|
}(headline);
|
|
return keywords.filter((keyword) => new RegExp(" "+keyword, "i").test(head)).length === keywords.length ? true : false;
|
|
});
|
|
}
|
|
this.setState({ headlines: this.buildHeadlines(headlines) });
|
|
}
|
|
|
|
render() {
|
|
return (
|
|
<Modal className="todo-modal" isActive={this.props.isActive} onQuit={this.onQuit.bind(this)}>
|
|
<div className="modal-top no-select">
|
|
<span onClick={this.onQuit.bind(this)}>
|
|
<Icon name="close"/>
|
|
</span>
|
|
<h1>{this.props.title}</h1>
|
|
<NgIf className="search" cond={this.props.headlines.length > 0}>
|
|
<label className={this.state.search.length > 0 ? "active" : ""}>
|
|
<input type="text" onChange={(e) => this.search(e.target.value)} placeholder={t("Search")+" ..."}/>
|
|
<Icon name="search" />
|
|
</label>
|
|
</NgIf>
|
|
</div>
|
|
<NgIf cond={this.props.headlines.length === 0} className="nothing">
|
|
{ t("empty") }
|
|
</NgIf>
|
|
<NgIf cond={this.props.headlines.length > 0}>
|
|
<StickyContainer className="container" style={{ height: window.innerHeight > 750 ? 545 : window.innerHeight - 202 }}>
|
|
{
|
|
Object.keys(this.state.headlines).map((list, i) => {
|
|
return (
|
|
<div key={i}>
|
|
<Sticky relative>
|
|
{
|
|
({ style }) => {
|
|
return (
|
|
<div className="sticky_header no-select" style={{ ...style, overflow: "auto", background: "white", zIndex: 4 }}>
|
|
<h2>{list} <span>{this.state.headlines[list].length}</span></h2>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
</Sticky>
|
|
<div className="list">
|
|
{
|
|
this.state.headlines[list].map((headline, j) => {
|
|
return (
|
|
<Headline
|
|
type={this.props.title.toLowerCase()}
|
|
onTaskUpdate={this.onTaskUpdate.bind(this)}
|
|
onChange={this.onChange.bind(this, i, j)}
|
|
tasks={headline.tasks}
|
|
key={j} title={headline.title}
|
|
tags={headline.tags}
|
|
line={headline.line}
|
|
date={headline.date}
|
|
overdue={headline.is_overdue}
|
|
scheduled={headline.scheduled}
|
|
deadline={headline.deadline}
|
|
sortKey={headline.key}
|
|
status={headline.status || null}
|
|
todo_status={headline.todo_status}
|
|
todo_priority={headline.priority}
|
|
goTo={this.navigate.bind(this, headline.line)} />
|
|
);
|
|
})
|
|
}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
}
|
|
</StickyContainer>
|
|
</NgIf>
|
|
</Modal>
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
class Headline extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
status: props.todo_status,
|
|
properties: false,
|
|
};
|
|
}
|
|
|
|
onMenuAction(key) {
|
|
if (key === "navigate") {
|
|
this.props.goTo();
|
|
} else if (key === "properties") {
|
|
this.setState({ properties: !this.state.properties });
|
|
}
|
|
}
|
|
|
|
onStatusToggle() {
|
|
if (!this.props.todo_status) return;
|
|
|
|
const new_status = this.state.status === "todo" ? "done" : "todo";
|
|
this.setState({ status: new_status });
|
|
|
|
const new_status_label = function(new_status, initial_status, initial_keyword) {
|
|
if (new_status === initial_status) return initial_keyword;
|
|
return new_status === "todo" ? "TODO" : "DONE";
|
|
}(new_status, this.props.todo_status, this.props.status);
|
|
this.props.onTaskUpdate("status", this.props.line, new_status_label);
|
|
}
|
|
|
|
onTimeSet(keyword, existing, value) {
|
|
if (existing === true) {
|
|
this.props.onTaskUpdate(
|
|
"existing_"+keyword,
|
|
[
|
|
this.props.line,
|
|
this.props.sortKey,
|
|
this.props[keyword].line,
|
|
],
|
|
value,
|
|
);
|
|
} else {
|
|
const opposite_keyword = keyword === "scheduled" ? "deadline" : "scheduled";
|
|
this.props.onTaskUpdate(
|
|
"new_"+keyword,
|
|
[
|
|
this.props.line,
|
|
this.props.sortKey,
|
|
this.props[opposite_keyword] && this.props[opposite_keyword].line || null,
|
|
],
|
|
value,
|
|
);
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const dateInput = (obj) => {
|
|
if (!obj || !obj.timestamp) return "";
|
|
const d = new Date(obj.timestamp);
|
|
return d.getFullYear()+"-"+leftPad((d.getMonth() + 1).toString(), 2)+"-"+leftPad(d.getDate().toString(), 2);
|
|
};
|
|
return (
|
|
<div className="component_headline">
|
|
<div className={"no-select headline-main "+(this.state.status || "")}>
|
|
<div className="title" onClick={this.onStatusToggle.bind(this)}>
|
|
<div className={(this.props.todo_priority? this.props.todo_priority + " " : "")+(this.props.overdue === true ? "overdue" : "")}>
|
|
<span className="label">{this.props.title}</span>
|
|
<NgIf cond={this.props.scheduled !== null && this.props.scheduled.timestamp === this.props.date} type="inline">
|
|
<Icon name="schedule" />
|
|
</NgIf>
|
|
<NgIf cond={this.props.deadline !== null && this.props.deadline.timestamp === this.props.date} type="inline">
|
|
<Icon name="deadline" />
|
|
</NgIf>
|
|
<div className="tags">
|
|
{
|
|
this.props.tags.map((tag, i) => {
|
|
return (
|
|
<span className="tag" key={i}>{tag}</span>
|
|
);
|
|
})
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Dropdown onChange={this.onMenuAction.bind(this)}>
|
|
<DropdownButton>
|
|
<Icon name="more" />
|
|
</DropdownButton>
|
|
<DropdownList>
|
|
<DropdownItem name="navigate" icon="arrow_right"> { t("Navigate") } </DropdownItem>
|
|
<DropdownItem name="properties"> { t("Properties") } </DropdownItem>
|
|
</DropdownList>
|
|
</Dropdown>
|
|
</div>
|
|
<NgIf className="headline-properties" cond={this.state.properties}>
|
|
<div>
|
|
<label> <Icon name="schedule" />
|
|
<NgIf cond={this.props.scheduled !== null}>
|
|
<input type="date" value={dateInput(this.props.scheduled)} onChange={(e) => this.onTimeSet("scheduled", true, e.target.value)}/>
|
|
</NgIf>
|
|
<NgIf cond={this.props.scheduled === null}>
|
|
<input type="date" onChange={(e) => this.onTimeSet("scheduled", false, e.target.value)}/>
|
|
</NgIf>
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label> <Icon name="deadline" />
|
|
<NgIf cond={this.props.deadline !== null}>
|
|
<input type="date" value={dateInput(this.props.deadline)} onChange={(e) => this.onTimeSet("deadline", true, e.target.value)}/>
|
|
</NgIf>
|
|
<NgIf cond={this.props.deadline === null}>
|
|
<input type="date" onChange={(e) => this.onTimeSet("deadline", false, e.target.value)}/>
|
|
</NgIf>
|
|
</label>
|
|
</div>
|
|
</NgIf>
|
|
<NgIf cond={this.props.tasks.length > 0 && this.state.status === "todo" && this.props.type === "todos"} className="subtask_container">
|
|
{
|
|
this.props.tasks.map((task, i) => {
|
|
return (
|
|
<Subtask key={i} label={task.title} status={task.status}
|
|
onStatusChange={this.props.onTaskUpdate.bind(this, "subtask", task.line)} />
|
|
);
|
|
})
|
|
}
|
|
</NgIf>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
class Subtask extends React.Component {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = this.calculateState();
|
|
}
|
|
|
|
calculateState() {
|
|
return { checked: this.props.status === "DONE" };
|
|
}
|
|
|
|
updateState(e) {
|
|
const checked = e.target.checked;
|
|
this.setState({ checked: checked }, () => {
|
|
// We don't want the interface to feel laggy while a task is beeing updated. Updating
|
|
// the content and reparsing the result is an expensive operation, this makes it feel
|
|
// like a piece of cake
|
|
window.setTimeout(() => {
|
|
window.requestAnimationFrame(() => {
|
|
this.props.onStatusChange(checked ? "DONE" : "TODO");
|
|
});
|
|
}, 0);
|
|
});
|
|
}
|
|
|
|
render() {
|
|
return (
|
|
<div className="component_subtask no-select">
|
|
<label>
|
|
<Input type="checkbox" checked={this.state.checked}
|
|
onChange={this.updateState.bind(this)} />
|
|
<span>{this.props.label}</span>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
}
|