mirror of
https://github.com/ilyakooo0/urbit.git
synced 2025-01-05 22:03:50 +03:00
publish: begin rewrite
This commit is contained in:
parent
1af45f9fa1
commit
135d9ceb20
@ -7,14 +7,15 @@ import "./css/custom.css";
|
|||||||
|
|
||||||
import { Skeleton } from "./components/skeleton";
|
import { Skeleton } from "./components/skeleton";
|
||||||
import { NewScreen } from "./components/lib/new";
|
import { NewScreen } from "./components/lib/new";
|
||||||
import { JoinScreen } from "./components/lib/join";
|
import { JoinScreen } from "./components/lib/Join";
|
||||||
import { Notebook } from "./components/lib/notebook";
|
import { Notebook } from "./components/lib/Notebook";
|
||||||
import { Note } from "./components/lib/note";
|
import { Note } from "./components/lib/Note";
|
||||||
import { NewPost } from "./components/lib/new-post";
|
import { NewPost } from "./components/lib/new-post";
|
||||||
import { EditPost } from "./components/lib/edit-post";
|
import { EditPost } from "./components/lib/edit-post";
|
||||||
import { StoreState } from "../../store/type";
|
import { StoreState } from "../../store/type";
|
||||||
import GlobalApi from "../../api/global";
|
import GlobalApi from "../../api/global";
|
||||||
import GlobalSubscription from "../../subscription/global";
|
import GlobalSubscription from "../../subscription/global";
|
||||||
|
import {NotebookRoutes} from "./components/lib/NotebookRoutes";
|
||||||
|
|
||||||
type PublishAppProps = StoreState & {
|
type PublishAppProps = StoreState & {
|
||||||
api: GlobalApi;
|
api: GlobalApi;
|
||||||
@ -76,8 +77,8 @@ export default function PublishApp(props: PublishAppProps) {
|
|||||||
return (
|
return (
|
||||||
<Route
|
<Route
|
||||||
path={[
|
path={[
|
||||||
"/~publish/notebook/:popout?/:ship/:notebook",
|
"/~publish/notebook/:ship/:notebook/*",
|
||||||
"/~publish/:popout?/note/:ship/:notebook/:note/:edit?",
|
"/~publish/notebook/:ship/:notebook",
|
||||||
"/~publish",
|
"/~publish",
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@ -116,7 +117,7 @@ export default function PublishApp(props: PublishAppProps) {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path="/~publish/join/:ship?/:notebook?"
|
path="/~publish/join/:ship/:notebook"
|
||||||
render={(props) => {
|
render={(props) => {
|
||||||
const ship = props.match.params.ship || "";
|
const ship = props.match.params.ship || "";
|
||||||
const notebook = props.match.params.notebook || "";
|
const notebook = props.match.params.notebook || "";
|
||||||
@ -124,7 +125,7 @@ export default function PublishApp(props: PublishAppProps) {
|
|||||||
<JoinScreen
|
<JoinScreen
|
||||||
notebooks={notebooks}
|
notebooks={notebooks}
|
||||||
ship={ship}
|
ship={ship}
|
||||||
notebook={notebook}
|
book={notebook}
|
||||||
api={api}
|
api={api}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@ -132,8 +133,7 @@ export default function PublishApp(props: PublishAppProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
exact
|
path="/~publish/notebook/:ship/:notebook"
|
||||||
path="/~publish/:popout?/notebook/:ship/:notebook/:view?"
|
|
||||||
render={(props) => {
|
render={(props) => {
|
||||||
const view = props.match.params.view
|
const view = props.match.params.view
|
||||||
? props.match.params.view
|
? props.match.params.view
|
||||||
@ -142,7 +142,7 @@ export default function PublishApp(props: PublishAppProps) {
|
|||||||
const popout = Boolean(props.match.params.popout) || false;
|
const popout = Boolean(props.match.params.popout) || false;
|
||||||
|
|
||||||
const ship = props.match.params.ship || "";
|
const ship = props.match.params.ship || "";
|
||||||
const notebook = props.match.params.notebook || "";
|
const book = props.match.params.notebook || "";
|
||||||
|
|
||||||
const bookGroupPath =
|
const bookGroupPath =
|
||||||
notebooks?.[ship]?.[notebook]?.["subscribers-group-path"];
|
notebooks?.[ship]?.[notebook]?.["subscribers-group-path"];
|
||||||
@ -150,25 +150,15 @@ export default function PublishApp(props: PublishAppProps) {
|
|||||||
const notebookContacts =
|
const notebookContacts =
|
||||||
bookGroupPath in contacts ? contacts[bookGroupPath] : {};
|
bookGroupPath in contacts ? contacts[bookGroupPath] : {};
|
||||||
|
|
||||||
if (view === "new") {
|
const notebook = notebooks[ship][book];
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NewPost
|
<NotebookRoutes
|
||||||
notebooks={notebooks}
|
notebook={notebook}
|
||||||
ship={ship}
|
|
||||||
book={notebook}
|
|
||||||
sidebarShown={sidebarShown}
|
|
||||||
popout={popout}
|
|
||||||
api={api}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Notebook
|
|
||||||
notebooks={notebooks}
|
|
||||||
view={view}
|
view={view}
|
||||||
ship={ship}
|
ship={ship}
|
||||||
book={notebook}
|
book={book}
|
||||||
groups={groups}
|
groups={groups}
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
notebookContacts={notebookContacts}
|
notebookContacts={notebookContacts}
|
||||||
@ -179,55 +169,6 @@ export default function PublishApp(props: PublishAppProps) {
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
exact
|
|
||||||
path="/~publish/:popout?/note/:ship/:notebook/:note/:edit?"
|
|
||||||
render={(props) => {
|
|
||||||
const ship = props.match.params.ship || "";
|
|
||||||
const notebook = props.match.params.notebook || "";
|
|
||||||
const note = props.match.params.note || "";
|
|
||||||
|
|
||||||
const popout = Boolean(props.match.params.popout) || false;
|
|
||||||
|
|
||||||
const bookGroupPath =
|
|
||||||
notebooks?.[ship]?.[notebook]?.["subscribers-group-path"];
|
|
||||||
const notebookContacts =
|
|
||||||
bookGroupPath in contacts ? contacts[bookGroupPath] : {};
|
|
||||||
|
|
||||||
const edit = Boolean(props.match.params.edit) || false;
|
|
||||||
|
|
||||||
if (edit) {
|
|
||||||
return (
|
|
||||||
<EditPost
|
|
||||||
notebooks={notebooks}
|
|
||||||
book={notebook}
|
|
||||||
note={note}
|
|
||||||
ship={ship}
|
|
||||||
sidebarShown={sidebarShown}
|
|
||||||
popout={popout}
|
|
||||||
api={api}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Note
|
|
||||||
notebooks={notebooks}
|
|
||||||
book={notebook}
|
|
||||||
groups={groups}
|
|
||||||
contacts={notebookContacts}
|
|
||||||
ship={ship}
|
|
||||||
note={note}
|
|
||||||
sidebarShown={sidebarShown}
|
|
||||||
popout={popout}
|
|
||||||
api={api}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import GlobalApi from "../../../../api/global";
|
||||||
|
import { Formik, FormikHelpers, Form } from "formik";
|
||||||
|
import { AsyncButton } from '../../../../components/AsyncButton';
|
||||||
|
import { TextArea, Button } from "@tlon/indigo-react";
|
||||||
|
|
||||||
|
interface FormSchema {
|
||||||
|
comment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formSchema = Yup.object({
|
||||||
|
comment: Yup.string().required("Comment can't be empty"),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CommentInputProps {
|
||||||
|
onSubmit: (
|
||||||
|
values: FormSchema,
|
||||||
|
actions: FormikHelpers<FormSchema>
|
||||||
|
) => Promise<void>;
|
||||||
|
initial?: string;
|
||||||
|
loadingText?: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentInput(props: CommentInputProps) {
|
||||||
|
const initialValues: FormSchema = { comment: props.initial || "" };
|
||||||
|
const label = props.label || "Add Comment";
|
||||||
|
const loading = props.loadingText || "Commenting...";
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
validationSchema={formSchema}
|
||||||
|
onSubmit={props.onSubmit}
|
||||||
|
initialValues={initialValues}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<TextArea id="comment" placeholder={props.placeholder || ""} />
|
||||||
|
<AsyncButton loadingText={loading} border type="submit">
|
||||||
|
{label}
|
||||||
|
</AsyncButton>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
132
pkg/interface/src/apps/publish/components/lib/CommentItem.tsx
Normal file
132
pkg/interface/src/apps/publish/components/lib/CommentItem.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import React, { Component, useState, useCallback } from "react";
|
||||||
|
import moment from "moment";
|
||||||
|
import { Sigil } from "../../../../lib/sigil";
|
||||||
|
import CommentInput from "./CommentInput";
|
||||||
|
import { uxToHex, cite } from "../../../../lib/util";
|
||||||
|
import { Comment, NoteId } from "../../../../types/publish-update";
|
||||||
|
import { Contacts } from "../../../../types/contact-update";
|
||||||
|
import GlobalApi from "../../../../api/global";
|
||||||
|
import { Button } from "@tlon/indigo-react";
|
||||||
|
|
||||||
|
interface CommentItemProps {
|
||||||
|
pending?: boolean;
|
||||||
|
comment: Comment;
|
||||||
|
contacts: Contacts;
|
||||||
|
book: string;
|
||||||
|
ship: string;
|
||||||
|
api: GlobalApi;
|
||||||
|
note: NoteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentItem(props: CommentItemProps) {
|
||||||
|
const [editing, setEditing] = useState<boolean>(false);
|
||||||
|
const pending = props.pending ? "o-60" : "";
|
||||||
|
const commentPath = Object.keys(props.comment)[0];
|
||||||
|
const commentData = props.comment[commentPath];
|
||||||
|
const content = commentData.content.split("\n").map((line, i) => {
|
||||||
|
return (
|
||||||
|
<p className="mb2" key={i}>
|
||||||
|
{line}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const date = moment(commentData["date-created"]).fromNow();
|
||||||
|
|
||||||
|
const contact =
|
||||||
|
commentData.author.substr(1) in props.contacts
|
||||||
|
? props.contacts[commentData.author.substr(1)]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let name = commentData.author;
|
||||||
|
let color = "#000000";
|
||||||
|
let classes = "mix-blend-diff";
|
||||||
|
let avatar: string | null = null;
|
||||||
|
if (contact) {
|
||||||
|
name = contact.nickname.length > 0 ? contact.nickname : commentData.author;
|
||||||
|
color = `#${uxToHex(contact.color)}`;
|
||||||
|
classes = "";
|
||||||
|
avatar = contact.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img =
|
||||||
|
avatar !== null ? (
|
||||||
|
<img src={avatar} height={24} width={24} className="dib" />
|
||||||
|
) : (
|
||||||
|
<Sigil
|
||||||
|
ship={commentData.author}
|
||||||
|
size={24}
|
||||||
|
color={color}
|
||||||
|
classes={classes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (name === commentData.author) {
|
||||||
|
name = cite(commentData.author);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = props.pending || window.ship !== commentData.author.slice(1);
|
||||||
|
|
||||||
|
const onUpdate = useCallback(
|
||||||
|
async ({ comment }) => {
|
||||||
|
const action = {
|
||||||
|
"edit-comment": {
|
||||||
|
who: props.ship.slice(1),
|
||||||
|
book: props.book,
|
||||||
|
note: props.note,
|
||||||
|
body: comment,
|
||||||
|
comment: commentPath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await props.api.publish.publishAction(action);
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
},
|
||||||
|
[props.api, props.ship, props.note, props.book, commentPath, setEditing]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"mb8 " + pending}>
|
||||||
|
<div className="flex mv3 bg-white bg-gray0-d">
|
||||||
|
{img}
|
||||||
|
<div
|
||||||
|
className={"f9 mh2 pt1 " + (contact?.nickname ? null : "mono")}
|
||||||
|
title={commentData.author}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div className="f9 gray3 pt1">{date}</div>
|
||||||
|
{!disabled && !editing && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="green2 pointer ml2 f9 pt1"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</div>
|
||||||
|
<div className="red2 pointer ml2 f9 pt1">Delete</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<div
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
className="red2 pointer ml2 f9 pt1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="f8 lh-solid mb2">
|
||||||
|
{!editing && content}
|
||||||
|
{editing && (
|
||||||
|
<CommentInput
|
||||||
|
onSubmit={onUpdate}
|
||||||
|
initial={commentData.content}
|
||||||
|
label="Update"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommentItem;
|
157
pkg/interface/src/apps/publish/components/lib/Comments.tsx
Normal file
157
pkg/interface/src/apps/publish/components/lib/Comments.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { CommentItem } from "./CommentItem";
|
||||||
|
import CommentInput from "./CommentInput";
|
||||||
|
import { dateToDa } from "../../../../lib/util";
|
||||||
|
import { Spinner } from "../../../../components/Spinner";
|
||||||
|
import { Comment, Note, NoteId } from "../../../../types/publish-update";
|
||||||
|
import { Contacts } from "../../../../types/contact-update";
|
||||||
|
import _ from "lodash";
|
||||||
|
import GlobalApi from "../../../../api/global";
|
||||||
|
import { FormikHelpers } from "formik";
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
commentUpdate(idx, body) {
|
||||||
|
const path = Object.keys(this.props.comments[idx])[0];
|
||||||
|
const comment = {
|
||||||
|
'edit-comment': {
|
||||||
|
who: this.props.ship.slice(1),
|
||||||
|
book: this.props.book,
|
||||||
|
note: this.props.note,
|
||||||
|
body: body,
|
||||||
|
comment: path
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setState({ awaiting: 'edit' });
|
||||||
|
|
||||||
|
this.props.api.publish
|
||||||
|
.publishAction(comment)
|
||||||
|
.then(() => {
|
||||||
|
this.setState({ awaiting: null, editing: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
commentDelete(idx) {
|
||||||
|
const path = Object.keys(this.props.comments[idx])[0];
|
||||||
|
const comment = {
|
||||||
|
'del-comment': {
|
||||||
|
who: this.props.ship.slice(1),
|
||||||
|
book: this.props.book,
|
||||||
|
note: this.props.note,
|
||||||
|
comment: path
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setState({ awaiting: { kind: 'del', what: idx } });
|
||||||
|
this.props.api.publish
|
||||||
|
.publishAction(comment)
|
||||||
|
.then(() => {
|
||||||
|
this.setState({ awaiting: null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CommentsProps {
|
||||||
|
comments: Comment[];
|
||||||
|
book: string;
|
||||||
|
noteId: NoteId;
|
||||||
|
note: Note;
|
||||||
|
ship: string;
|
||||||
|
contacts: Contacts;
|
||||||
|
api: GlobalApi;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Comments(props: CommentsProps) {
|
||||||
|
const { comments, ship, book, note, api, noteId } = props;
|
||||||
|
const [pending, setPending] = useState<string[]>([]);
|
||||||
|
const [awaiting, setAwaiting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_.forEach(comments, (comment: Comment) => {
|
||||||
|
const { content } = comment[Object.keys(comment)[0]];
|
||||||
|
setPending((p) => p.filter((p) => p === content));
|
||||||
|
});
|
||||||
|
}, [comments.length]);
|
||||||
|
|
||||||
|
const onSubmit = async (
|
||||||
|
{ comment },
|
||||||
|
actions: FormikHelpers<{ comment: string }>
|
||||||
|
) => {
|
||||||
|
setPending((p) => [...p, comment]);
|
||||||
|
const action = {
|
||||||
|
"new-comment": {
|
||||||
|
who: ship.slice(1),
|
||||||
|
book: book,
|
||||||
|
note: noteId,
|
||||||
|
body: comment,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await api.publish.publishAction(action);
|
||||||
|
actions.resetForm();
|
||||||
|
actions.setStatus({ success: null });
|
||||||
|
} catch (e) {
|
||||||
|
actions.setStatus({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!props.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingArray = Array.from(pending).map((com, i) => {
|
||||||
|
const da = dateToDa(new Date());
|
||||||
|
const comment = {
|
||||||
|
[da]: {
|
||||||
|
author: `~${window.ship}`,
|
||||||
|
content: com,
|
||||||
|
"date-created": Math.round(new Date().getTime()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<CommentItem
|
||||||
|
comment={comment}
|
||||||
|
key={i}
|
||||||
|
contacts={props.contacts}
|
||||||
|
pending={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const commentArray = props.comments.map((com, i) => {
|
||||||
|
return (
|
||||||
|
<CommentItem
|
||||||
|
comment={com}
|
||||||
|
key={i}
|
||||||
|
contacts={props.contacts}
|
||||||
|
api={api}
|
||||||
|
book={book}
|
||||||
|
ship={ship}
|
||||||
|
note={note}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const spinnerText =
|
||||||
|
awaiting === "new"
|
||||||
|
? "Posting commment..."
|
||||||
|
: awaiting === "edit"
|
||||||
|
? "Updating comment..."
|
||||||
|
: "Deleting comment...";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mv8 relative">
|
||||||
|
<CommentInput onSubmit={onSubmit} />
|
||||||
|
</div>
|
||||||
|
{pendingArray}
|
||||||
|
{commentArray}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Comments;
|
@ -1,5 +1,6 @@
|
|||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { NotebookItem } from './notebook-item';
|
import { Box, Text } from "@tlon/indigo-react";
|
||||||
|
import { NotebookItem } from './NotebookItem';
|
||||||
|
|
||||||
export class GroupItem extends Component {
|
export class GroupItem extends Component {
|
||||||
render() {
|
render() {
|
||||||
@ -33,10 +34,10 @@ export class GroupItem extends Component {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className={first}>
|
<Box className={first}>
|
||||||
<p className="f9 ph4 pb2 fw6 gray3">{title}</p>
|
<Box fontSize={0} px={3} fontWeight="700" pb={2} color="lightGray">{title}</Box>
|
||||||
{notebookItems}
|
{notebookItems}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
59
pkg/interface/src/apps/publish/components/lib/Join.tsx
Normal file
59
pkg/interface/src/apps/publish/components/lib/Join.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import React, { useCallback, useState, useRef, useEffect } from "react";
|
||||||
|
import { Col, Text, ErrorMessage } from "@tlon/indigo-react";
|
||||||
|
import { Spinner } from "../../../../components/Spinner";
|
||||||
|
import { Notebooks } from "../../../../types/publish-update";
|
||||||
|
import { useWaitForProps } from "../../../../lib/useWaitForProps";
|
||||||
|
import { RouteComponentProps } from "react-router-dom";
|
||||||
|
|
||||||
|
interface JoinScreenProps {
|
||||||
|
api: any; // GlobalApi;
|
||||||
|
ship: string;
|
||||||
|
book: string;
|
||||||
|
notebooks: Notebooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JoinScreen(props: JoinScreenProps & RouteComponentProps) {
|
||||||
|
const { book, ship, api } = props;
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const joining = useRef(false);
|
||||||
|
|
||||||
|
const waiter = useWaitForProps(props, 10000);
|
||||||
|
|
||||||
|
const onJoin = useCallback(async () => {
|
||||||
|
joining.current = true;
|
||||||
|
const action = {
|
||||||
|
subscribe: {
|
||||||
|
who: ship.replace("~", ""),
|
||||||
|
book,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.publish.publishAction(action);
|
||||||
|
await waiter((p) => !!p.notebooks?.[ship]?.[book]);
|
||||||
|
props.history.push(`/~publish/notebook/${ship}/${book}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
joining.current = false;
|
||||||
|
}
|
||||||
|
}, [waiter, api, ship, book]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (joining.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onJoin();
|
||||||
|
}, [onJoin]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col p={4}>
|
||||||
|
<Text fontSize={1}>Joining Notebook</Text>
|
||||||
|
<Spinner awaiting text="Joining..." />
|
||||||
|
{error && <ErrorMessage>Unable to join notebook</ErrorMessage>}
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JoinScreen;
|
@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { MarkdownEditor as _MarkdownEditor, Box, ErrorMessage } from '@tlon/indigo-react';
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
const MarkdownEditor = styled(_MarkdownEditor)`
|
||||||
|
border: 1px solid ${(p) => p.theme.colors.lightGray};
|
||||||
|
border-radius: ${(p) => p.theme.radii[2]}px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MarkdownField = ({ id, ...rest }: { id: string; } & Parameters<typeof Box>[0]) => {
|
||||||
|
const [{ value }, { error, touched }, { setValue, setTouched }] = useField(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box width="100%" display="flex" flexDirection="column" {...rest}>
|
||||||
|
<MarkdownEditor
|
||||||
|
onFocus={() => setTouched(true)}
|
||||||
|
onBlur={() => setTouched(false)}
|
||||||
|
value={value}
|
||||||
|
onBeforeChange={(e, d, v) => setValue(v)}
|
||||||
|
/>
|
||||||
|
<ErrorMessage>{touched && error}</ErrorMessage>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
176
pkg/interface/src/apps/publish/components/lib/Note.tsx
Normal file
176
pkg/interface/src/apps/publish/components/lib/Note.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import React, { Component, useState, useEffect } from "react";
|
||||||
|
import { Box, Text, Col } from "@tlon/indigo-react";
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Link, RouteComponentProps } from "react-router-dom";
|
||||||
|
import { SidebarSwitcher } from "../../../../components/SidebarSwitch";
|
||||||
|
import { Spinner } from "../../../../components/Spinner";
|
||||||
|
import { Comments } from "./Comments";
|
||||||
|
import { NoteNavigation } from "./NoteNavigation";
|
||||||
|
import moment from "moment";
|
||||||
|
import { cite } from "../../../../lib/util";
|
||||||
|
import { PublishContent } from "./PublishContent";
|
||||||
|
import { NoteId, Note as INote, Notebook } from "../../../../types/publish-update";
|
||||||
|
import { Contacts } from "../../../../types/contact-update";
|
||||||
|
import GlobalApi from "../../../../api/global";
|
||||||
|
|
||||||
|
interface NoteProps {
|
||||||
|
ship: string;
|
||||||
|
book: string;
|
||||||
|
noteId: NoteId;
|
||||||
|
note: INote;
|
||||||
|
notebook: Notebook;
|
||||||
|
contacts: Contacts;
|
||||||
|
api: GlobalApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Note(props: NoteProps & RouteComponentProps) {
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const { notebook, note, contacts, ship, book, noteId, api } = props;
|
||||||
|
// fetch note and read actions
|
||||||
|
//
|
||||||
|
useEffect(() => {
|
||||||
|
api.publish.fetchNote(ship, book, noteId);
|
||||||
|
}, [ship, book, noteId]);
|
||||||
|
|
||||||
|
const baseUrl = `/~publish/notebook/${props.ship}/${props.book}`;
|
||||||
|
|
||||||
|
const deletePost = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
const deleteAction = {
|
||||||
|
"del-note": {
|
||||||
|
who: props.ship.slice(1),
|
||||||
|
book: props.book,
|
||||||
|
note: props.noteId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await api.publish.publishAction(deleteAction);
|
||||||
|
props.history.push(baseUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const comments = note?.comments || false;
|
||||||
|
const title = note?.title || "";
|
||||||
|
const author = note?.author || "";
|
||||||
|
const file = note?.file || "";
|
||||||
|
const date = moment(note?.["date-created"]).fromNow() || 0;
|
||||||
|
|
||||||
|
const contact =
|
||||||
|
author.substr(1) in props.contacts
|
||||||
|
? props.contacts[author.substr(1)]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let name = author;
|
||||||
|
if (contact) {
|
||||||
|
name = contact.nickname.length > 0 ? contact.nickname : author;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === author) {
|
||||||
|
name = cite(author);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newfile = file.slice(file.indexOf(";>") + 2);
|
||||||
|
const prevId = note?.["prev-note"] || null;
|
||||||
|
const nextId = note?.["next-note"] || null;
|
||||||
|
const prevDate =
|
||||||
|
prevId ? moment(notebook?.notes?.[prevId]?.["date-created"]).fromNow() : 0;
|
||||||
|
const nextDate =
|
||||||
|
nextId ? moment(notebook?.notes?.[nextId]?.["date-created"]).fromNow() : 0;
|
||||||
|
|
||||||
|
const prev =
|
||||||
|
prevId === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
id: prevId,
|
||||||
|
title: notebook?.notes?.[prevId]?.title,
|
||||||
|
date: prevDate,
|
||||||
|
};
|
||||||
|
const next =
|
||||||
|
nextId === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
id: nextId,
|
||||||
|
title: notebook?.notes?.[nextId]?.title,
|
||||||
|
date: nextDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
let editPost = null;
|
||||||
|
const editUrl = props.location.pathname + "/edit";
|
||||||
|
if (`~${window.ship}` === author) {
|
||||||
|
editPost = (
|
||||||
|
<div className="dib">
|
||||||
|
<Link className="green2 f9" to={editUrl}>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<p
|
||||||
|
className="dib f9 red2 ml2 pointer"
|
||||||
|
onClick={() => deletePost()}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
my={3}
|
||||||
|
display="grid"
|
||||||
|
gridTemplateColumns="1fr"
|
||||||
|
gridAutoRows="auto"
|
||||||
|
maxWidth="500px"
|
||||||
|
width="100%"
|
||||||
|
gridRowGap={4}
|
||||||
|
>
|
||||||
|
<Link to={baseUrl}>
|
||||||
|
<Text>{"<- Notebook Index"}</Text>
|
||||||
|
</Link>
|
||||||
|
<Col>
|
||||||
|
<Box mb={2}>{title}</Box>
|
||||||
|
<Box display="flex">
|
||||||
|
<Text
|
||||||
|
color="gray"
|
||||||
|
mr={2}
|
||||||
|
fontFamily={contact?.nickname ? "sans" : "mono"}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
<Text className="di" style={{ lineHeight: 1 }}>
|
||||||
|
<Text color="gray">{date}</Text>
|
||||||
|
<Text ml={2}>{editPost}</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Col>
|
||||||
|
<Box className="md" style={{ overflowWrap: "break-word" }}>
|
||||||
|
<ReactMarkdown source={newfile} linkTarget={"_blank"} />
|
||||||
|
</Box>
|
||||||
|
<NoteNavigation
|
||||||
|
prev={prev}
|
||||||
|
next={next}
|
||||||
|
ship={props.ship}
|
||||||
|
book={props.book}
|
||||||
|
/>
|
||||||
|
<Comments
|
||||||
|
enabled={notebook.comments}
|
||||||
|
ship={props.ship}
|
||||||
|
book={props.book}
|
||||||
|
noteId={props.noteId}
|
||||||
|
note={props.note}
|
||||||
|
comments={comments}
|
||||||
|
numComments={props.note?.["num-comments"]}
|
||||||
|
contacts={props.contacts}
|
||||||
|
api={props.api}
|
||||||
|
/>
|
||||||
|
<Spinner
|
||||||
|
text="Deleting post..."
|
||||||
|
awaiting={deleting}
|
||||||
|
classes="absolute bottom-1 right-1 ba b--gray1-d pa2"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Note;
|
@ -0,0 +1,83 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { Col, Box } from "@tlon/indigo-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {Note} from "../../../../types/publish-update";
|
||||||
|
|
||||||
|
function NavigationItem(props: {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
prev?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
justifySelf={props.prev ? "start" : "end"}
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
justifyContent="flex-end"
|
||||||
|
textAlign={props.prev ? "left" : "right"}
|
||||||
|
>
|
||||||
|
<Link to={props.url}>
|
||||||
|
<Box color="gray" mb={2}>
|
||||||
|
{props.prev ? "Previous" : "Next"}
|
||||||
|
</Box>
|
||||||
|
<Box mb={1}>{props.title}</Box>
|
||||||
|
<Box color="gray">{props.date}</Box>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface NoteNavigationProps {
|
||||||
|
book: string;
|
||||||
|
next: any;
|
||||||
|
prev: any;
|
||||||
|
ship: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function NoteNavigation(props: NoteNavigationProps) {
|
||||||
|
let nextComponent = <div />;
|
||||||
|
let prevComponent = <div />;
|
||||||
|
let nextUrl = "";
|
||||||
|
let prevUrl = "";
|
||||||
|
const { next, prev } = props;
|
||||||
|
|
||||||
|
if(!next && !prev) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
nextUrl = `/~publish/notebook/${props.ship}/${props.book}/note/${props.next.id}`;
|
||||||
|
nextComponent = (
|
||||||
|
<NavigationItem title={next.title} date={next.date} url={nextUrl} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (prev) {
|
||||||
|
prevUrl = `/~publish/notebook/${props.ship}/${props.book}/note/${props.prev.id}`;
|
||||||
|
|
||||||
|
prevComponent = (
|
||||||
|
<NavigationItem title={prev.title} date={prev.date} url={prevUrl} prev />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px={2}
|
||||||
|
borderTop={1}
|
||||||
|
borderBottom={1}
|
||||||
|
borderColor="washedGray"
|
||||||
|
display="grid"
|
||||||
|
alignItems="center"
|
||||||
|
gridTemplateColumns="1fr 1px 1fr"
|
||||||
|
gridTemplateRows="100px"
|
||||||
|
>
|
||||||
|
{prevComponent}
|
||||||
|
<Box borderRight={1} borderColor="washedGray" height="100%" />
|
||||||
|
{nextComponent}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoteNavigation;
|
@ -37,7 +37,7 @@ export function NotePreview(props: NotePreviewProps) {
|
|||||||
}
|
}
|
||||||
const date = moment(note["date-created"]).fromNow();
|
const date = moment(note["date-created"]).fromNow();
|
||||||
//const popout = props.popout ? "popout/" : "";
|
//const popout = props.popout ? "popout/" : "";
|
||||||
const url = `/~publish/note/${props.host}/${props.book}/${note["note-id"]}`;
|
const url = `/~publish/notebook/${props.host}/${props.book}/note/${note["note-id"]}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={url}>
|
<Link to={url}>
|
||||||
|
42
pkg/interface/src/apps/publish/components/lib/NoteRoutes.tsx
Normal file
42
pkg/interface/src/apps/publish/components/lib/NoteRoutes.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Route, Switch } from "react-router-dom";
|
||||||
|
|
||||||
|
import { NoteId, Note as INote, Notebook } from "../../../../types/publish-update";
|
||||||
|
import { Contacts } from "../../../../types/contact-update";
|
||||||
|
import GlobalApi from "../../../../api/global";
|
||||||
|
import { RouteComponentProps } from "react-router-dom";
|
||||||
|
import Note from "./Note";
|
||||||
|
import EditPost from "./EditPost";
|
||||||
|
|
||||||
|
interface NoteRoutesProps {
|
||||||
|
ship: string;
|
||||||
|
book: string;
|
||||||
|
noteId: NoteId;
|
||||||
|
note: INote;
|
||||||
|
notebook: Notebook;
|
||||||
|
contacts: Contacts;
|
||||||
|
api: GlobalApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) {
|
||||||
|
const { ship, book, api, notebook, notebookContacts, noteId } = props;
|
||||||
|
|
||||||
|
const baseUrl = `/~publish/notebook/${ship}/${book}/note/${noteId}`;
|
||||||
|
|
||||||
|
const relativePath = (path: string) => `${baseUrl}${path}`;
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route
|
||||||
|
path={baseUrl}
|
||||||
|
exact
|
||||||
|
render={(routeProps) => {
|
||||||
|
return <Note {...routeProps} {...props} />;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={relativePath("/edit")}
|
||||||
|
render={(routeProps) => <EditPost {...routeProps} {...props} />}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
146
pkg/interface/src/apps/publish/components/lib/Notebook.tsx
Normal file
146
pkg/interface/src/apps/publish/components/lib/Notebook.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import React, { Component, useEffect } from "react";
|
||||||
|
import { Link, RouteComponentProps, Route, Switch } from "react-router-dom";
|
||||||
|
import { SidebarSwitcher } from "../../../../components/SidebarSwitch";
|
||||||
|
import { NotebookPosts } from "./NotebookPosts";
|
||||||
|
import { Subscribers } from "./subscribers";
|
||||||
|
import { Settings } from "./Settings";
|
||||||
|
import { cite } from "../../../../lib/util";
|
||||||
|
import { roleForShip } from "../../../../lib/group";
|
||||||
|
import { PublishContent } from "./PublishContent";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
Tab as _Tab,
|
||||||
|
Tabs,
|
||||||
|
TabList as _TabList,
|
||||||
|
TabPanels,
|
||||||
|
TabPanel,
|
||||||
|
Row,
|
||||||
|
} from "@tlon/indigo-react";
|
||||||
|
import { Notebook as INotebook } from "../../../../types/publish-update";
|
||||||
|
import { Groups } from "../../../../types/group-update";
|
||||||
|
import { Contacts, Rolodex } from "../../../../types/contact-update";
|
||||||
|
import GlobalApi from "../../../../api/global";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const TabList = styled(_TabList)`
|
||||||
|
margin-bottom: ${(p) => p.theme.space[4]}px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Tab = styled(_Tab)`
|
||||||
|
flex-grow: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface NotebookProps {
|
||||||
|
api: GlobalApi;
|
||||||
|
ship: string;
|
||||||
|
book: string;
|
||||||
|
notebook: INotebook;
|
||||||
|
notebookContacts: Contacts;
|
||||||
|
contacts: Rolodex;
|
||||||
|
groups: Groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Notebook(props: NotebookProps & RouteComponentProps) {
|
||||||
|
const { api, ship, book, notebook, notebookContacts, groups } = props;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.publish.fetchNotesPage(ship, book, 1, 50);
|
||||||
|
api.publish.fetchNotebook(ship, book);
|
||||||
|
}, [ship, book, api]);
|
||||||
|
|
||||||
|
const contact = notebookContacts[ship];
|
||||||
|
const group = groups[notebook?.["writers-group-path"]];
|
||||||
|
|
||||||
|
const role = group ? roleForShip(group, window.ship) : undefined;
|
||||||
|
|
||||||
|
const isOwn = `~${window.ship}` === ship;
|
||||||
|
const isAdmin = role === "admin" || isOwn;
|
||||||
|
|
||||||
|
const isWriter =
|
||||||
|
isOwn || group.tags?.publish?.[`writers-${book}`]?.has(window.ship);
|
||||||
|
|
||||||
|
const notesList = notebook?.["notes-by-date"] || [];
|
||||||
|
const notes = notebook?.notes || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
pt={4}
|
||||||
|
display="grid"
|
||||||
|
gridAutoRows="min-content"
|
||||||
|
gridTemplateColumns="1fr 1fr"
|
||||||
|
width="100%"
|
||||||
|
maxWidth="500px"
|
||||||
|
gridRowGap={6}
|
||||||
|
gridColumnGap={3}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
{notebook?.title}
|
||||||
|
<br />
|
||||||
|
<Text color="lightGray">by </Text>
|
||||||
|
<Text fontFamily={contact?.nickname ? "sans" : "mono"}>
|
||||||
|
{contact?.nickname || ship}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Row justifyContent="flex-end">
|
||||||
|
{isWriter && (
|
||||||
|
<Link to={`/~publish/notebook/${ship}/${book}/new`}>
|
||||||
|
<Button primary border>
|
||||||
|
New Post
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!isOwn && (
|
||||||
|
<Button ml={2} error border>
|
||||||
|
Unsubscribe
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
<Box gridColumn="1/4">
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab>All Posts</Tab>
|
||||||
|
<Tab>About</Tab>
|
||||||
|
{isAdmin && <Tab>Subscribers</Tab>}
|
||||||
|
{isOwn && <Tab>Settings</Tab>}
|
||||||
|
</TabList>
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel>
|
||||||
|
<NotebookPosts
|
||||||
|
notes={notes}
|
||||||
|
list={notesList}
|
||||||
|
host={ship}
|
||||||
|
book={book}
|
||||||
|
contacts={notebookContacts}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<Box>{notebook?.about}</Box>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<Subscribers
|
||||||
|
host={ship}
|
||||||
|
book={book}
|
||||||
|
notebook={notebook}
|
||||||
|
api={api}
|
||||||
|
groups={groups}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<Settings
|
||||||
|
host={ship}
|
||||||
|
book={book}
|
||||||
|
api={api}
|
||||||
|
notebook={notebook}
|
||||||
|
contacts={notebookContacts}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Notebook;
|
@ -0,0 +1,51 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { Box, Text } from "@tlon/indigo-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import { HoverBox } from "../../../../components/HoverBox";
|
||||||
|
|
||||||
|
interface NotebookItemProps {
|
||||||
|
selected: boolean;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnreadCount(props: { unread: number }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px={1}
|
||||||
|
fontWeight="700"
|
||||||
|
py={1}
|
||||||
|
borderRadius={1}
|
||||||
|
color="white"
|
||||||
|
bg="lightGray"
|
||||||
|
>
|
||||||
|
{props.unread}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotebookItem(props: NotebookItemProps) {
|
||||||
|
return (
|
||||||
|
<Link to={"/~publish/notebook/" + props.path}>
|
||||||
|
<HoverBox
|
||||||
|
bg="white"
|
||||||
|
bgActive="washedGray"
|
||||||
|
selected={props.selected}
|
||||||
|
width="100%"
|
||||||
|
fontSize={0}
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
display="flex"
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Box py={1}>{props.title}</Box>
|
||||||
|
{props.unreadCount > 0 && <UnreadCount unread={props.unreadCount} />}
|
||||||
|
</HoverBox>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotebookItem;
|
@ -1,13 +1,13 @@
|
|||||||
import React, { Component } from "react";
|
import React, { Component } from "react";
|
||||||
import { Col } from '@tlon/indigo-react';
|
import { Col } from '@tlon/indigo-react';
|
||||||
import { Note, NoteId } from "../../../../types/publish-update";
|
import { Notes, NoteId } from "../../../../types/publish-update";
|
||||||
import { NotePreview } from "./NotePreview";
|
import { NotePreview } from "./NotePreview";
|
||||||
import { Contacts } from "../../../../types/contact-update";
|
import { Contacts } from "../../../../types/contact-update";
|
||||||
|
|
||||||
interface NotebookPostsProps {
|
interface NotebookPostsProps {
|
||||||
list: NoteId[];
|
list: NoteId[];
|
||||||
contacts: Contacts;
|
contacts: Contacts;
|
||||||
notes: Note[];
|
notes: Notes;
|
||||||
host: string;
|
host: string;
|
||||||
book: string;
|
book: string;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,74 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { RouteComponentProps, Link, Route } from "react-router-dom";
|
||||||
|
import { Box, Text } from "@tlon/indigo-react";
|
||||||
|
import GlobalApi from "../../../../api/global";
|
||||||
|
import { PublishContent } from "./PublishContent";
|
||||||
|
import { Notebook as INotebook } from "../../../../types/publish-update";
|
||||||
|
import { Groups } from "../../../../types/group-update";
|
||||||
|
import { Contacts, Rolodex } from "../../../../types/contact-update";
|
||||||
|
import Notebook from "./Notebook";
|
||||||
|
import NewPost from "./new-post";
|
||||||
|
import Note from "./Note";
|
||||||
|
|
||||||
|
interface NotebookRoutesProps {
|
||||||
|
api: GlobalApi;
|
||||||
|
ship: string;
|
||||||
|
book: string;
|
||||||
|
notes: any;
|
||||||
|
notebook: INotebook;
|
||||||
|
notebookContacts: Contacts;
|
||||||
|
contacts: Rolodex;
|
||||||
|
groups: Groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotebookRoutes(
|
||||||
|
props: NotebookRoutesProps & RouteComponentProps
|
||||||
|
) {
|
||||||
|
const { ship, book, api, notebook, notebookContacts } = props;
|
||||||
|
|
||||||
|
const baseUrl = `/~publish/notebook/${ship}/${book}`;
|
||||||
|
|
||||||
|
const relativePath = (path: string) => `${baseUrl}${path}`;
|
||||||
|
return (
|
||||||
|
<PublishContent sidebarShown api={api}>
|
||||||
|
<Route
|
||||||
|
path={baseUrl}
|
||||||
|
exact
|
||||||
|
render={(routeProps) => {
|
||||||
|
return <Notebook {...props} />;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={relativePath("/new")}
|
||||||
|
render={(routeProps) => (
|
||||||
|
<NewPost
|
||||||
|
{...routeProps}
|
||||||
|
api={api}
|
||||||
|
book={book}
|
||||||
|
ship={ship}
|
||||||
|
notebook={notebook}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={relativePath("/note/:noteId")}
|
||||||
|
render={(routeProps) => {
|
||||||
|
const { noteId } = routeProps.match.params;
|
||||||
|
const note = notebook?.notes[noteId];
|
||||||
|
return (
|
||||||
|
<Note
|
||||||
|
api={api}
|
||||||
|
book={book}
|
||||||
|
ship={ship}
|
||||||
|
noteId={noteId}
|
||||||
|
notebook={notebook}
|
||||||
|
note={note}
|
||||||
|
contacts={notebookContacts}
|
||||||
|
{...routeProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PublishContent>
|
||||||
|
);
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode, forwardRef } from "react";
|
||||||
import { Box } from "@tlon/indigo-react";
|
import { Box } from "@tlon/indigo-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { SidebarSwitcher } from "../../../../components/SidebarSwitch";
|
import { SidebarSwitcher } from "../../../../components/SidebarSwitch";
|
||||||
@ -11,7 +11,7 @@ interface PublishContentProps {
|
|||||||
api: GlobalApi;
|
api: GlobalApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublishContent(props: PublishContentProps) {
|
export const PublishContent = (props: PublishContentProps) => {
|
||||||
const { children, sidebarShown, api } = props;
|
const { children, sidebarShown, api } = props;
|
||||||
|
|
||||||
const { query } = useQuery();
|
const { query } = useQuery();
|
||||||
@ -27,16 +27,18 @@ export function PublishContent(props: PublishContentProps) {
|
|||||||
height="100%"
|
height="100%"
|
||||||
width="100%"
|
width="100%"
|
||||||
display="grid"
|
display="grid"
|
||||||
gridTemplateColumns="16px 1fr 16px"
|
gridTemplateColumns="1fr"
|
||||||
gridTemplateRows="1fr"
|
gridAutoRows="auto"
|
||||||
|
justifyItems="center"
|
||||||
|
overflowY="scroll"
|
||||||
>
|
>
|
||||||
<SidebarSwitcher popout={popout} sidebarShown={sidebarShown} api={api} />
|
{/*<SidebarSwitcher popout={popout} sidebarShown={sidebarShown} api={api} />*/}
|
||||||
{children}
|
{children}
|
||||||
<Box pt={2} justifySelf="end" display={popoutDisplay}>
|
{/*<Box pt={2} justifySelf="end" display={popoutDisplay}>
|
||||||
<Link target="_blank" to="">
|
<Link target="_blank" to="">
|
||||||
<img src="/~landscape/img/popout.png" height={16} width={16} />
|
<img src="/~landscape/img/popout.png" height={16} width={16} />
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>*/}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
199
pkg/interface/src/apps/publish/components/lib/Settings.tsx
Normal file
199
pkg/interface/src/apps/publish/components/lib/Settings.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import React, { Component, useEffect } from "react";
|
||||||
|
import { Spinner } from "../../../../components/Spinner";
|
||||||
|
import { AsyncButton } from "../../../../components/AsyncButton";
|
||||||
|
import { InviteSearch } from "../../../../components/InviteSearch";
|
||||||
|
import Toggle from "../../../../components/toggle";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Input,
|
||||||
|
Checkbox,
|
||||||
|
Col,
|
||||||
|
InputLabel,
|
||||||
|
InputCaption,
|
||||||
|
Button,
|
||||||
|
} from "@tlon/indigo-react";
|
||||||
|
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
|
||||||
|
import GlobalApi from "../../../../api/global";
|
||||||
|
import { Notebook } from "../../../../types/publish-update";
|
||||||
|
import { Contacts } from "../../../../types/contact-update";
|
||||||
|
|
||||||
|
/*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
renderGroupify() {
|
||||||
|
const { props, state } = this;
|
||||||
|
|
||||||
|
const owner = (props.host.slice(1) === window.ship);
|
||||||
|
|
||||||
|
const ownedUnmanaged =
|
||||||
|
owner &&
|
||||||
|
!props.contacts[props.notebook?.['writers-group-path']];
|
||||||
|
|
||||||
|
if (!ownedUnmanaged) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
// don't give the option to make inclusive if we don't own the target
|
||||||
|
// group
|
||||||
|
const targetOwned = (state.targetGroup)
|
||||||
|
? Boolean(state.targetGroup.includes(`/~${window.ship}/`))
|
||||||
|
: false;
|
||||||
|
let inclusiveToggle = <div />;
|
||||||
|
if (targetOwned) {
|
||||||
|
inclusiveToggle = (
|
||||||
|
<div className="mt4">
|
||||||
|
<Toggle
|
||||||
|
boolean={state.inclusive}
|
||||||
|
change={this.changeInclusive}
|
||||||
|
/>
|
||||||
|
<span className="dib f9 white-d inter ml3">
|
||||||
|
Add all members to group
|
||||||
|
</span>
|
||||||
|
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
|
||||||
|
Add notebook members to the group if they aren't in it yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={'w-100 fl mt3 mb3'} style={{ maxWidth: '29rem' }}>
|
||||||
|
{this.renderHeader(
|
||||||
|
'Convert Notebook',
|
||||||
|
'Convert this notebook into a group with associated chat, or select a group to add this notebook to.')}
|
||||||
|
<InviteSearch
|
||||||
|
groups={props.groups}
|
||||||
|
contacts={props.contacts}
|
||||||
|
associations={props.associations}
|
||||||
|
groupResults={true}
|
||||||
|
shipResults={false}
|
||||||
|
invites={{
|
||||||
|
groups: state.targetGroup ? [state.targetGroup] : [],
|
||||||
|
ships: []
|
||||||
|
}}
|
||||||
|
setInvite={this.changeTargetGroup}
|
||||||
|
/>
|
||||||
|
{inclusiveToggle}
|
||||||
|
<button
|
||||||
|
onClick={this.groupifyNotebook.bind(this)}
|
||||||
|
className={'dib f9 black gray4-d bg-gray0-d ba pa2 mt4 b--black b--gray1-d pointer'}
|
||||||
|
disabled={this.state.disabled}
|
||||||
|
>
|
||||||
|
{state.targetGroup ? 'Add to group' : 'Convert to group'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
{this.renderHeader(
|
||||||
|
'Delete Notebook',
|
||||||
|
'Permanently delete this notebook. (All current members will no longer see this notebook)')}
|
||||||
|
{() => {
|
||||||
|
this.setState({ disabled: true });
|
||||||
|
this.props.api.publish
|
||||||
|
.publishAction({
|
||||||
|
'edit-book': {
|
||||||
|
book: this.props.book,
|
||||||
|
title: this.state.title,
|
||||||
|
about: this.props.notebook.about,
|
||||||
|
coms: this.props.notebook.comments,
|
||||||
|
group: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.setState({ disabled: false });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SettingsProps {
|
||||||
|
host: string;
|
||||||
|
book: string;
|
||||||
|
notebook: Notebook;
|
||||||
|
contacts: Contacts;
|
||||||
|
api: GlobalApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormSchema {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
comments: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResetOnPropsChange = (props: { init: FormSchema; book: string }) => {
|
||||||
|
const { resetForm } = useFormikContext<FormSchema>();
|
||||||
|
useEffect(() => {
|
||||||
|
resetForm({ values: props.init });
|
||||||
|
}, [props.book]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Settings(props: SettingsProps) {
|
||||||
|
const { host, notebook, api, book } = props;
|
||||||
|
const initialValues: FormSchema = {
|
||||||
|
name: notebook?.title,
|
||||||
|
description: notebook?.about,
|
||||||
|
comments: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (
|
||||||
|
values: FormSchema,
|
||||||
|
actions: FormikHelpers<FormSchema>
|
||||||
|
) => {
|
||||||
|
await api.publish.publishAction({
|
||||||
|
"edit-book": {
|
||||||
|
book,
|
||||||
|
title: values.name,
|
||||||
|
about: values.description,
|
||||||
|
coms: values.comments,
|
||||||
|
group: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
api.publish.fetchNotebook(host, book)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.host.slice(1) !== window.ship) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
||||||
|
<Form>
|
||||||
|
<Box maxWidth="300px" mb={4} gridTemplateColumns="1fr" gridAutoRows="auto" display="grid">
|
||||||
|
<Col mb={4}>
|
||||||
|
<InputLabel>Delete Notebook</InputLabel>
|
||||||
|
<InputCaption>
|
||||||
|
Permanently delete this notebook. (All current members will no
|
||||||
|
longer see this notebook
|
||||||
|
</InputCaption>
|
||||||
|
|
||||||
|
<Button mt={1} border error>
|
||||||
|
Delete this notebook
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
label="Rename"
|
||||||
|
caption="Change the name of this notebook"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
label="Change description"
|
||||||
|
caption="Change the description of this notebook"
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
id="comments"
|
||||||
|
label="Comments"
|
||||||
|
caption="Subscribers may comment when enabled"
|
||||||
|
/>
|
||||||
|
<ResetOnPropsChange init={initialValues} book={book} />
|
||||||
|
<AsyncButton loadingText="Updating.." border>
|
||||||
|
Save
|
||||||
|
</AsyncButton>
|
||||||
|
</Box>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Settings;
|
133
pkg/interface/src/apps/publish/components/lib/Sidebar.tsx
Normal file
133
pkg/interface/src/apps/publish/components/lib/Sidebar.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import React, { Component } from "react";
|
||||||
|
import { Box, Text, Col } from "@tlon/indigo-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import SidebarInvite from "../../../../components/SidebarInvite";
|
||||||
|
import { Welcome } from "./Welcome";
|
||||||
|
import { GroupItem } from "./GroupItem";
|
||||||
|
import { alphabetiseAssociations } from "../../../../lib/util";
|
||||||
|
|
||||||
|
export function Sidebar(props: any) {
|
||||||
|
const sidebarInvites = !(props.invites && props.invites["/publish"])
|
||||||
|
? null
|
||||||
|
: Object.keys(props.invites["/publish"]).map((uid) => {
|
||||||
|
return (
|
||||||
|
<SidebarInvite
|
||||||
|
key={uid}
|
||||||
|
invite={props.invites["/publish"][uid]}
|
||||||
|
onAccept={() => props.api.invite.accept("/publish", uid)}
|
||||||
|
onDecline={() => props.api.invite.decline("/publish", uid)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const associations =
|
||||||
|
props.associations && "contacts" in props.associations
|
||||||
|
? alphabetiseAssociations(props.associations.contacts)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const notebooks = {};
|
||||||
|
Object.keys(props.notebooks).map((host) => {
|
||||||
|
Object.keys(props.notebooks[host]).map((notebook) => {
|
||||||
|
const title = `${host}/${notebook}`;
|
||||||
|
notebooks[title] = props.notebooks[host][notebook];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedNotebooks = {};
|
||||||
|
Object.keys(notebooks).map((book) => {
|
||||||
|
const path = notebooks[book]["subscribers-group-path"]
|
||||||
|
? notebooks[book]["subscribers-group-path"]
|
||||||
|
: book;
|
||||||
|
if (path in associations) {
|
||||||
|
if (groupedNotebooks[path]) {
|
||||||
|
const array = groupedNotebooks[path];
|
||||||
|
array.push(book);
|
||||||
|
groupedNotebooks[path] = array;
|
||||||
|
} else {
|
||||||
|
groupedNotebooks[path] = [book];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (groupedNotebooks["/~/"]) {
|
||||||
|
const array = groupedNotebooks["/~/"];
|
||||||
|
array.push(book);
|
||||||
|
groupedNotebooks["/~/"] = array;
|
||||||
|
} else {
|
||||||
|
groupedNotebooks["/~/"] = [book];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedGroups = props.selectedGroups ? props.selectedGroups : [];
|
||||||
|
const groupedItems = Object.keys(associations)
|
||||||
|
.filter((each) => {
|
||||||
|
if (selectedGroups.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const selectedPaths = selectedGroups.map((e) => {
|
||||||
|
return e[0];
|
||||||
|
});
|
||||||
|
return selectedPaths.includes(each);
|
||||||
|
})
|
||||||
|
.map((each, i) => {
|
||||||
|
const books = groupedNotebooks[each] || [];
|
||||||
|
if (books.length === 0) return;
|
||||||
|
if (
|
||||||
|
selectedGroups.length === 0 &&
|
||||||
|
groupedNotebooks["/~/"] &&
|
||||||
|
groupedNotebooks["/~/"].length !== 0
|
||||||
|
) {
|
||||||
|
i = i + 1;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<GroupItem
|
||||||
|
key={i}
|
||||||
|
index={i}
|
||||||
|
association={associations[each]}
|
||||||
|
groupedBooks={books}
|
||||||
|
notebooks={notebooks}
|
||||||
|
path={props.path}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
selectedGroups.length === 0 &&
|
||||||
|
groupedNotebooks["/~/"] &&
|
||||||
|
groupedNotebooks["/~/"].length !== 0
|
||||||
|
) {
|
||||||
|
groupedItems.unshift(
|
||||||
|
<GroupItem
|
||||||
|
key={"/~/"}
|
||||||
|
index={0}
|
||||||
|
association={"/~/"}
|
||||||
|
groupedBooks={groupedNotebooks["/~/"]}
|
||||||
|
notebooks={notebooks}
|
||||||
|
path={props.path}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col
|
||||||
|
borderRight={[0, 1]}
|
||||||
|
borderRightColor={["washedGray", "washedGray"]}
|
||||||
|
height="100%"
|
||||||
|
pt={[3, 0]}
|
||||||
|
overflowY="auto"
|
||||||
|
maxWidth={["none", "250px"]}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Link to="/~publish/new" className="green2 pa4 f9 dib">
|
||||||
|
<Box color="green">New Notebook</Box>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
className="overflow-y-auto pb1"
|
||||||
|
>
|
||||||
|
<Welcome mx={2} />
|
||||||
|
{sidebarInvites}
|
||||||
|
{groupedItems}
|
||||||
|
</Box>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar;
|
32
pkg/interface/src/apps/publish/components/lib/Welcome.tsx
Normal file
32
pkg/interface/src/apps/publish/components/lib/Welcome.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box } from "@tlon/indigo-react";
|
||||||
|
import { useLocalStorageState } from "../../../../lib/useLocalStorageState";
|
||||||
|
|
||||||
|
export function Welcome(props: Parameters<typeof Box>[0]) {
|
||||||
|
const [wasWelcomed, setWasWelcomed] = useLocalStorageState(
|
||||||
|
"urbit-publish:wasWelcomed",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wasWelcomed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box {...props} p={2} border={1} >
|
||||||
|
<Box lineHeight="1.6" fontSize={0}>
|
||||||
|
Notebooks are for longer-form writing and discussion. Each Notebook is a
|
||||||
|
collection of Markdown-formatted notes with optional comments.
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
fontSize={0}
|
||||||
|
mt={2}
|
||||||
|
className="f8 pt2 dib pointer bb"
|
||||||
|
onClick={() => { setWasWelcomed(true) }}
|
||||||
|
>
|
||||||
|
Close this
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Welcome;
|
@ -1,29 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const CommentInput = React.forwardRef((props, ref) => (
|
|
||||||
<textarea
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
id="comment"
|
|
||||||
name="comment"
|
|
||||||
placeholder="Leave a comment here"
|
|
||||||
className={
|
|
||||||
'f9 db border-box w-100 ba b--gray3 pt2 ph2 br1 ' +
|
|
||||||
'b--gray2-d mb2 focus-b--black focus-b--white-d white-d bg-gray0-d'
|
|
||||||
}
|
|
||||||
aria-describedby="comment-desc"
|
|
||||||
style={{ height: '4rem', resize: 'vertical' }}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (
|
|
||||||
(e.getModifierState('Control') || event.metaKey) &&
|
|
||||||
e.key === 'Enter'
|
|
||||||
) {
|
|
||||||
props.onSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></textarea>
|
|
||||||
));
|
|
||||||
|
|
||||||
CommentInput.displayName = 'commentInput';
|
|
||||||
|
|
||||||
export default CommentInput;
|
|
@ -1,155 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import moment from 'moment';
|
|
||||||
import { Sigil } from '../../../../lib/sigil';
|
|
||||||
import CommentInput from './comment-input';
|
|
||||||
import { uxToHex, cite } from '../../../../lib/util';
|
|
||||||
|
|
||||||
export class CommentItem extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
commentBody: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
this.commentChange = this.commentChange.bind(this);
|
|
||||||
this.commentEdit = this.commentEdit.bind(this);
|
|
||||||
moment.updateLocale('en', {
|
|
||||||
relativeTime: {
|
|
||||||
past: function(input) {
|
|
||||||
return input === 'just now'
|
|
||||||
? input
|
|
||||||
: input + ' ago';
|
|
||||||
},
|
|
||||||
s : 'just now',
|
|
||||||
future : 'in %s',
|
|
||||||
m : '1m',
|
|
||||||
mm : '%dm',
|
|
||||||
h : '1h',
|
|
||||||
hh : '%dh',
|
|
||||||
d : '1d',
|
|
||||||
dd : '%dd',
|
|
||||||
M : '1 month',
|
|
||||||
MM : '%d months',
|
|
||||||
y : '1 year',
|
|
||||||
yy : '%d years'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
commentEdit() {
|
|
||||||
const commentPath = Object.keys(this.props.comment)[0];
|
|
||||||
const commentBody = this.props.comment[commentPath].content;
|
|
||||||
this.setState({ commentBody });
|
|
||||||
this.props.onEdit();
|
|
||||||
}
|
|
||||||
|
|
||||||
focusTextArea(text) {
|
|
||||||
text && text.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
commentChange(e) {
|
|
||||||
this.setState({
|
|
||||||
commentBody: e.target.value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdate() {
|
|
||||||
this.props.onUpdate(this.state.commentBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const pending = this.props.pending ? 'o-60' : '';
|
|
||||||
const commentData = this.props.comment[Object.keys(this.props.comment)[0]];
|
|
||||||
const content = commentData.content.split('\n').map((line, i) => {
|
|
||||||
return (
|
|
||||||
<p className="mb2" key={i}>{line}</p>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const date = moment(commentData['date-created']).fromNow();
|
|
||||||
|
|
||||||
const contact = commentData.author.substr(1) in this.props.contacts
|
|
||||||
? this.props.contacts[commentData.author.substr(1)] : false;
|
|
||||||
|
|
||||||
let name = commentData.author;
|
|
||||||
let color = '#000000';
|
|
||||||
let classes = 'mix-blend-diff';
|
|
||||||
let avatar = null;
|
|
||||||
if (contact) {
|
|
||||||
name = (contact.nickname.length > 0)
|
|
||||||
? contact.nickname : commentData.author;
|
|
||||||
color = `#${uxToHex(contact.color)}`;
|
|
||||||
classes = '';
|
|
||||||
avatar = contact.avatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
const img = (avatar !== null)
|
|
||||||
? <img src={avatar} height={24} width={24} className="dib" />
|
|
||||||
: <Sigil
|
|
||||||
ship={commentData.author}
|
|
||||||
size={24}
|
|
||||||
color={color}
|
|
||||||
classes={classes}
|
|
||||||
/>;
|
|
||||||
|
|
||||||
if (name === commentData.author) {
|
|
||||||
name = cite(commentData.author);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { editing } = this.props;
|
|
||||||
|
|
||||||
const disabled = this.props.pending
|
|
||||||
|| window.ship !== commentData.author.slice(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'mb8 ' + pending}>
|
|
||||||
<div className="flex mv3 bg-white bg-gray0-d">
|
|
||||||
{img}
|
|
||||||
<div className={'f9 mh2 pt1 ' +
|
|
||||||
(contact.nickname ? null : 'mono')}
|
|
||||||
title={commentData.author}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
<div className="f9 gray3 pt1">{date}</div>
|
|
||||||
{ !editing && !disabled && (
|
|
||||||
<>
|
|
||||||
<div onClick={this.commentEdit.bind(this)} className="green2 pointer ml2 f9 pt1">
|
|
||||||
Edit
|
|
||||||
</div>
|
|
||||||
<div onClick={this.props.onDelete} className="red2 pointer ml2 f9 pt1">
|
|
||||||
Delete
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) }
|
|
||||||
</div>
|
|
||||||
<div className="f8 lh-solid mb2">
|
|
||||||
{ !editing && content }
|
|
||||||
{ editing && (
|
|
||||||
<CommentInput style={{ resize:'vertical' }}
|
|
||||||
ref={(el) => {
|
|
||||||
this.focusTextArea(el);
|
|
||||||
}}
|
|
||||||
onChange={this.commentChange}
|
|
||||||
value={this.state.commentBody}
|
|
||||||
onSubmit={this.onUpdate.bind(this)}
|
|
||||||
>
|
|
||||||
</CommentInput>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{ editing && (
|
|
||||||
<div className="flex">
|
|
||||||
<div onClick={this.onUpdate.bind(this)} className="br1 green2 pointer f9 pt1 b--green2 ba pa2 dib">
|
|
||||||
Submit
|
|
||||||
</div>
|
|
||||||
<div onClick={this.props.onEditCancel} className="br1 black white-d pointer f9 b--gray2 ba pa2 dib ml2">
|
|
||||||
Cancel
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CommentItem;
|
|
@ -1,200 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import { CommentItem } from './comment-item';
|
|
||||||
import CommentInput from './comment-input';
|
|
||||||
import { dateToDa } from '../../../../lib/util';
|
|
||||||
import { Spinner } from '../../../../components/Spinner';
|
|
||||||
|
|
||||||
export class Comments extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
commentBody: '',
|
|
||||||
pending: new Set(),
|
|
||||||
awaiting: null,
|
|
||||||
editing: null
|
|
||||||
};
|
|
||||||
this.commentSubmit = this.commentSubmit.bind(this);
|
|
||||||
this.commentChange = this.commentChange.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const previousComments = prevProps.comments[0] || {};
|
|
||||||
const currentComments = this.props.comments[0] || {};
|
|
||||||
const previous = Object.keys(previousComments) || [];
|
|
||||||
const current = Object.keys(currentComments) || [];
|
|
||||||
if ((prevProps.comments && this.props.comments) &&
|
|
||||||
(previous !== current)) {
|
|
||||||
const pendingSet = this.state.pending;
|
|
||||||
Object.keys(currentComments).map((com) => {
|
|
||||||
const obj = currentComments[com];
|
|
||||||
for (const each of pendingSet.values()) {
|
|
||||||
if (obj.content === each['new-comment'].body) {
|
|
||||||
pendingSet.delete(each);
|
|
||||||
this.setState({ pending: pendingSet });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
commentSubmit(evt) {
|
|
||||||
const comment = {
|
|
||||||
'new-comment': {
|
|
||||||
who: this.props.ship.slice(1),
|
|
||||||
book: this.props.book,
|
|
||||||
note: this.props.note,
|
|
||||||
body: this.state.commentBody
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pendingState = this.state.pending;
|
|
||||||
pendingState.add(comment);
|
|
||||||
this.setState({ pending: pendingState });
|
|
||||||
|
|
||||||
this.textArea.value = '';
|
|
||||||
this.setState({ commentBody: '', awaiting: 'new' });
|
|
||||||
const submit = this.props.api.publish.publishAction(comment);
|
|
||||||
submit.then(() => {
|
|
||||||
this.setState({ awaiting: null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
commentChange(evt) {
|
|
||||||
this.setState({
|
|
||||||
commentBody: evt.target.value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
commentEdit(idx) {
|
|
||||||
this.setState({ editing: idx });
|
|
||||||
}
|
|
||||||
|
|
||||||
commentEditCancel() {
|
|
||||||
this.setState({ editing: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
commentUpdate(idx, body) {
|
|
||||||
const path = Object.keys(this.props.comments[idx])[0];
|
|
||||||
const comment = {
|
|
||||||
'edit-comment': {
|
|
||||||
who: this.props.ship.slice(1),
|
|
||||||
book: this.props.book,
|
|
||||||
note: this.props.note,
|
|
||||||
body: body,
|
|
||||||
comment: path
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setState({ awaiting: 'edit' });
|
|
||||||
|
|
||||||
this.props.api.publish
|
|
||||||
.publishAction(comment)
|
|
||||||
.then(() => {
|
|
||||||
this.setState({ awaiting: null, editing: null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
commentDelete(idx) {
|
|
||||||
const path = Object.keys(this.props.comments[idx])[0];
|
|
||||||
const comment = {
|
|
||||||
'del-comment': {
|
|
||||||
who: this.props.ship.slice(1),
|
|
||||||
book: this.props.book,
|
|
||||||
note: this.props.note,
|
|
||||||
comment: path
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setState({ awaiting: { kind: 'del', what: idx } });
|
|
||||||
this.props.api.publish
|
|
||||||
.publishAction(comment)
|
|
||||||
.then(() => {
|
|
||||||
this.setState({ awaiting: null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (!this.props.enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { editing } = this.state;
|
|
||||||
|
|
||||||
const pendingArray = Array.from(this.state.pending).map((com, i) => {
|
|
||||||
const da = dateToDa(new Date());
|
|
||||||
const comment = {
|
|
||||||
[da]: {
|
|
||||||
author: `~${window.ship}`,
|
|
||||||
content: com['new-comment'].body,
|
|
||||||
'date-created': Math.round(new Date().getTime())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<CommentItem
|
|
||||||
comment={comment}
|
|
||||||
key={i}
|
|
||||||
contacts={this.props.contacts}
|
|
||||||
pending={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const commentArray = this.props.comments.map((com, i) => {
|
|
||||||
return (
|
|
||||||
<CommentItem
|
|
||||||
comment={com}
|
|
||||||
key={i}
|
|
||||||
contacts={this.props.contacts}
|
|
||||||
onUpdate={u => this.commentUpdate(i, u)}
|
|
||||||
onDelete={() => this.commentDelete(i)}
|
|
||||||
onEdit={() => this.commentEdit(i)}
|
|
||||||
onEditCancel={this.commentEditCancel.bind(this)}
|
|
||||||
editing={i === editing}
|
|
||||||
disabled={Boolean(this.state.awaiting) || editing}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const disableComment = ((this.state.commentBody === '') || (Boolean(this.state.awaiting)));
|
|
||||||
const commentClass = (disableComment)
|
|
||||||
? 'bg-transparent f9 pa2 br1 ba b--gray2 gray2'
|
|
||||||
: 'bg-transparent f9 pa2 br1 ba b--gray2 black white-d pointer';
|
|
||||||
|
|
||||||
const spinnerText =
|
|
||||||
this.state.awaiting === 'new'
|
|
||||||
? 'Posting commment...'
|
|
||||||
: this.state.awaiting === 'edit'
|
|
||||||
? 'Updating comment...'
|
|
||||||
: 'Deleting comment...';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mv8 relative">
|
|
||||||
<div>
|
|
||||||
<CommentInput style={{ resize:'vertical' }}
|
|
||||||
ref={(el) => {
|
|
||||||
this.textArea = el;
|
|
||||||
}}
|
|
||||||
onChange={this.commentChange}
|
|
||||||
value={this.state.commentBody}
|
|
||||||
disabled={Boolean(this.state.editing)}
|
|
||||||
onSubmit={this.commentSubmit}
|
|
||||||
>
|
|
||||||
</CommentInput>
|
|
||||||
</div>
|
|
||||||
<button disabled={disableComment}
|
|
||||||
onClick={this.commentSubmit}
|
|
||||||
className={commentClass}
|
|
||||||
>
|
|
||||||
Add comment
|
|
||||||
</button>
|
|
||||||
<Spinner text={spinnerText} awaiting={this.state.awaiting} classes="absolute bottom-0 right-0 pb2" />
|
|
||||||
</div>
|
|
||||||
{pendingArray}
|
|
||||||
{commentArray}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Comments;
|
|
@ -1,89 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
|
|
||||||
export class Dropdown extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.toggleDropdown = this.toggleDropdown.bind(this);
|
|
||||||
this.handleClickOutside = this.handleClickOutside.bind(this);
|
|
||||||
this.collapseAndDispatch = this.collapseAndDispatch.bind(this);
|
|
||||||
this.state = {
|
|
||||||
open: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
document.addEventListener('mousedown', this.handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
document.removeEventListener('mousedown', this.handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClickOutside(evt) {
|
|
||||||
if (this.optsList && !this.optsList.contains(evt.target) &&
|
|
||||||
this.optsButton && !this.optsButton.contains(evt.target)) {
|
|
||||||
this.setState({ open: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleDropdown() {
|
|
||||||
this.setState({ open: !this.state.open });
|
|
||||||
}
|
|
||||||
|
|
||||||
collapseAndDispatch(action) {
|
|
||||||
this.setState({ open: false }, action);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const alignment = (this.props.align)
|
|
||||||
? this.props.align : 'right';
|
|
||||||
|
|
||||||
const display = (this.state.open)
|
|
||||||
? 'block' : 'none';
|
|
||||||
|
|
||||||
const optionsClass = (this.state.open)
|
|
||||||
? 'open' : 'closed';
|
|
||||||
|
|
||||||
let leftAlign = '';
|
|
||||||
let rightAlign = '0';
|
|
||||||
|
|
||||||
if (alignment === 'left') {
|
|
||||||
leftAlign = '0';
|
|
||||||
rightAlign = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionsList = this.props.options.map((val, i) => {
|
|
||||||
return (
|
|
||||||
<button key={i} className={val.cls}
|
|
||||||
onClick={() => this.collapseAndDispatch(val.action)}
|
|
||||||
>
|
|
||||||
{val.txt}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'options relative dib pr3 pointer ' + optionsClass}
|
|
||||||
ref={(el) => {
|
|
||||||
this.optsButton = el;
|
|
||||||
}}
|
|
||||||
onClick={this.toggleDropdown}
|
|
||||||
>
|
|
||||||
<button className="bg-transparent white-d pointer mb1 br2 pa2 pr4">
|
|
||||||
{this.props.buttonText}
|
|
||||||
</button>
|
|
||||||
<div className="absolute flex flex-column pv2 ba b--gray4 br2 z-1 bg-white bg-gray0-d"
|
|
||||||
ref={(el) => {
|
|
||||||
this.optsList = el;
|
|
||||||
}}
|
|
||||||
style={{ left: leftAlign, right: rightAlign, width:this.props.width, display: display }}
|
|
||||||
>
|
|
||||||
{optionsList}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Dropdown;
|
|
@ -1,176 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Spinner } from '../../../../components/Spinner';
|
|
||||||
import urbitOb from 'urbit-ob';
|
|
||||||
|
|
||||||
export class JoinScreen extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
book: '',
|
|
||||||
error: false,
|
|
||||||
awaiting: null,
|
|
||||||
disable: false
|
|
||||||
};
|
|
||||||
|
|
||||||
this.bookChange = this.bookChange.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.componentDidUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if ((this.props.ship) && (this.props.notebook)) {
|
|
||||||
const incomingBook = `${this.props.ship}/${this.props.notebook}`;
|
|
||||||
if (this.props.api && (prevProps?.api !== this.props.api)) {
|
|
||||||
this.setState({ book: incomingBook }, () => {
|
|
||||||
this.onClickJoin();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// redirect to notebook when we have it
|
|
||||||
if (this.props.notebooks) {
|
|
||||||
if (this.state.awaiting) {
|
|
||||||
const book = this.state.awaiting.split('/');
|
|
||||||
const ship = book[0];
|
|
||||||
const notebook = book[1];
|
|
||||||
if ((ship in this.props.notebooks) &&
|
|
||||||
(notebook in this.props.notebooks[ship])) {
|
|
||||||
this.setState({ disable: false, book: '/' });
|
|
||||||
this.props.history.push(`/~publish/notebook/${ship}/${notebook}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notebooksInclude(text, notebookObj) {
|
|
||||||
let verdict = false;
|
|
||||||
let keyPair = [];
|
|
||||||
// validate that it's a worthwhile thing to check
|
|
||||||
// certainly a unit would be nice here
|
|
||||||
if (text.indexOf('/') === -1) {
|
|
||||||
return verdict;
|
|
||||||
} else {
|
|
||||||
keyPair = text.split('/');
|
|
||||||
};
|
|
||||||
// check both levels of object
|
|
||||||
if (keyPair[0] in notebookObj) {
|
|
||||||
if (keyPair[1] in notebookObj[keyPair[0]]) {
|
|
||||||
verdict = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return verdict;
|
|
||||||
}
|
|
||||||
|
|
||||||
onClickJoin() {
|
|
||||||
const { props, state } = this;
|
|
||||||
|
|
||||||
const text = state.book;
|
|
||||||
|
|
||||||
let book = text.split('/');
|
|
||||||
const ship = book[0];
|
|
||||||
book.splice(0, 1);
|
|
||||||
book = '/' + book.join('/');
|
|
||||||
|
|
||||||
if (this.notebooksInclude(state.book, props.notebooks)) {
|
|
||||||
const href = `/~publish/notebook/${ship}${book}`;
|
|
||||||
return props.history.push(href);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (book.length < 2 || !urbitOb.isValidPatp(ship)) {
|
|
||||||
this.setState({
|
|
||||||
error: true
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionData = {
|
|
||||||
subscribe: {
|
|
||||||
who: ship.replace('~',''),
|
|
||||||
book: /\/?(.*)/.exec(book)[1]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: askHistory setting
|
|
||||||
this.setState({ disable: true });
|
|
||||||
this.props.api.publish.publishAction(actionData).catch((err) => {
|
|
||||||
console.log(err);
|
|
||||||
}).then(() => {
|
|
||||||
this.setState({ awaiting: text });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bookChange(event) {
|
|
||||||
this.setState({
|
|
||||||
book: event.target.value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { state } = this;
|
|
||||||
|
|
||||||
let joinClasses = 'db f9 green2 ba pa2 b--green2 bg-gray0-d pointer';
|
|
||||||
if ((state.disable) || (!state.book) || (state.book === '/')) {
|
|
||||||
joinClasses = 'db f9 gray2 ba pa2 b--gray3 bg-gray0-d';
|
|
||||||
}
|
|
||||||
|
|
||||||
let errElem = (<span />);
|
|
||||||
if (state.error) {
|
|
||||||
errElem = (
|
|
||||||
<span className="f9 inter red2 db">
|
|
||||||
Notebook must have a valid name.
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'h-100 w-100 pt4 overflow-x-hidden flex flex-column ' +
|
|
||||||
'bg-gray0-d white-d pa3'}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-100 dn-m dn-l dn-xl inter pt1 pb6 f8"
|
|
||||||
>
|
|
||||||
<Link to="/~publish/">{'⟵ All Notebooks'}</Link>
|
|
||||||
</div>
|
|
||||||
<h2 className="mb3 f8">Subscribe to an Existing Notebook</h2>
|
|
||||||
<div className="w-100">
|
|
||||||
<p className="f8 lh-copy mt3 db">Enter a <span className="mono">~ship/notebook-name</span></p>
|
|
||||||
<p className="f9 gray2 mb4">Notebook names use lowercase, hyphens, and slashes.</p>
|
|
||||||
<textarea
|
|
||||||
ref={ (e) => {
|
|
||||||
this.textarea = e;
|
|
||||||
} }
|
|
||||||
className={'f7 mono ba bg-gray0-d white-d pa3 mb2 db ' +
|
|
||||||
'focus-b--black focus-b--white-d b--gray3 b--gray2-d nowrap '}
|
|
||||||
placeholder="~zod/dream-journal"
|
|
||||||
spellCheck="false"
|
|
||||||
rows={1}
|
|
||||||
onKeyPress={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.onClickJoin();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
resize: 'none'
|
|
||||||
}}
|
|
||||||
onChange={this.bookChange}
|
|
||||||
value={this.state.book}
|
|
||||||
/>
|
|
||||||
{errElem}
|
|
||||||
<br />
|
|
||||||
<button
|
|
||||||
disabled={(this.state.disable) || (!state.book) || (state.book === '/')}
|
|
||||||
onClick={this.onClickJoin.bind(this)}
|
|
||||||
className={joinClasses}
|
|
||||||
>Join Notebook</button>
|
|
||||||
<Spinner awaiting={this.state.disable} classes="mt4" text="Joining notebook..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default JoinScreen;
|
|
@ -1,196 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import { SidebarSwitcher } from '../../../../components/SidebarSwitch';
|
|
||||||
import { Spinner } from '../../../../components/Spinner';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Controlled as CodeMirror } from 'react-codemirror2';
|
|
||||||
import { dateToDa, stringToSymbol } from '../../../../lib/util';
|
|
||||||
|
|
||||||
import 'codemirror/mode/markdown/markdown';
|
|
||||||
|
|
||||||
export class NewPost extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
body: '',
|
|
||||||
title: '',
|
|
||||||
submit: false,
|
|
||||||
awaiting: null,
|
|
||||||
disabled: false
|
|
||||||
};
|
|
||||||
|
|
||||||
this.postSubmit = this.postSubmit.bind(this);
|
|
||||||
this.titleChange = this.titleChange.bind(this);
|
|
||||||
this.bodyChange = this.bodyChange.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
postSubmit() {
|
|
||||||
const { state } = this;
|
|
||||||
|
|
||||||
// perf testing:
|
|
||||||
/*let closure = () => {
|
|
||||||
let x = 0;
|
|
||||||
for (var i = 0; i < 5; i++) {
|
|
||||||
x++;
|
|
||||||
let rand = Math.floor(Math.random() * 1000);
|
|
||||||
const newNote = {
|
|
||||||
'new-note': {
|
|
||||||
who: this.props.ship.slice(1),
|
|
||||||
book: this.props.book,
|
|
||||||
note: stringToSymbol(this.state.title + '-' + Date.now() + '-' + rand),
|
|
||||||
title: 'asdf-' + rand + '-' + Date.now(),
|
|
||||||
body: 'asdf-' + Date.now()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.props.api.publishAction(newNote);
|
|
||||||
}
|
|
||||||
setTimeout(closure, 3000);
|
|
||||||
};
|
|
||||||
setTimeout(closure, 2000);*/
|
|
||||||
|
|
||||||
if (state.submit && !state.disabled) {
|
|
||||||
const newNote = {
|
|
||||||
'new-note': {
|
|
||||||
who: this.props.ship.slice(1),
|
|
||||||
book: this.props.book,
|
|
||||||
note: stringToSymbol(this.state.title),
|
|
||||||
title: this.state.title,
|
|
||||||
body: this.state.body
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setState({ disabled: true });
|
|
||||||
this.props.api.publish.publishAction(newNote).then(() => {
|
|
||||||
this.setState({ awaiting: newNote['new-note'].note });
|
|
||||||
}).catch((err) => {
|
|
||||||
if (err.includes('note already exists')) {
|
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
|
||||||
newNote['new-note'].note += '-' + timestamp;
|
|
||||||
this.setState({ awaiting: newNote['new-note'].note });
|
|
||||||
this.props.api.publish.publishAction(newNote);
|
|
||||||
} else {
|
|
||||||
this.setState({ disabled: false, awaiting: null });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.componentDidUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (prevProps && prevProps.api !== this.props.api) {
|
|
||||||
this.props.api.publish.fetchNotebook(this.props.ship, this.props.book);
|
|
||||||
}
|
|
||||||
|
|
||||||
const notebook = this.props.notebooks[this.props.ship][this.props.book];
|
|
||||||
if (notebook.notes[this.state.awaiting]) {
|
|
||||||
this.setState({ disabled: false, awaiting: null });
|
|
||||||
const popout = (this.props.popout) ? 'popout/' : '';
|
|
||||||
const redirect =
|
|
||||||
`/~publish/${popout}note/${this.props.ship}/${this.props.book}/${this.state.awaiting}`;
|
|
||||||
this.props.history.push(redirect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
titleChange(evt) {
|
|
||||||
const submit = !(evt.target.value === '' || this.state.body === '');
|
|
||||||
this.setState({ title: evt.target.value, submit: submit });
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyChange(editor, data, value) {
|
|
||||||
const submit = !(value === '' || this.state.title === '');
|
|
||||||
this.setState({ body: value, submit: submit });
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { props, state } = this;
|
|
||||||
|
|
||||||
const notebook = props.notebooks[props.ship][props.book];
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
mode: 'markdown',
|
|
||||||
theme: 'tlon',
|
|
||||||
lineNumbers: false,
|
|
||||||
lineWrapping: true,
|
|
||||||
scrollbarStyle: null,
|
|
||||||
cursorHeight: 0.85
|
|
||||||
};
|
|
||||||
|
|
||||||
const date = dateToDa(new Date()).slice(1, -10);
|
|
||||||
|
|
||||||
const submitStyle = ((!state.disabled && state.submit) && (state.awaiting === null))
|
|
||||||
? { color: '#2AA779', cursor: 'pointer' }
|
|
||||||
: { color: '#B1B2B3', cursor: 'auto' };
|
|
||||||
|
|
||||||
const hrefIndex = props.location.pathname.indexOf('/notebook/');
|
|
||||||
const publishsubStr = props.location.pathname.substr(hrefIndex);
|
|
||||||
const popoutHref = `/~publish/popout${publishsubStr}`;
|
|
||||||
|
|
||||||
const hiddenOnPopout = (props.popout)
|
|
||||||
? '' : 'dib-m dib-l dib-xl';
|
|
||||||
|
|
||||||
const newIndex = props.location.pathname.indexOf('/new');
|
|
||||||
const backHref = props.location.pathname.slice(0, newIndex);
|
|
||||||
return (
|
|
||||||
<div className='f9 h-100 relative publish'>
|
|
||||||
<div className='w-100 dn-m dn-l dn-xl inter pt4 pb4 f9 pl4'>
|
|
||||||
<Link to={backHref}>{'<- Back'}</Link>
|
|
||||||
</div>
|
|
||||||
<SidebarSwitcher
|
|
||||||
popout={props.popout}
|
|
||||||
sidebarShown={props.sidebarShown}
|
|
||||||
api={this.props.api}
|
|
||||||
classes="absolute top-1 pl4"
|
|
||||||
/>
|
|
||||||
<div className='w-100 tl pv4 flex justify-center'>
|
|
||||||
<button
|
|
||||||
className={'bg-transparent v-mid w-100 w-90-l w-80-m mw6 tl h1 pl4'}
|
|
||||||
disabled={(!state.submit && state.disabled) || (state.awaiting !== null)}
|
|
||||||
style={submitStyle}
|
|
||||||
onClick={this.postSubmit}
|
|
||||||
>
|
|
||||||
Publish To {notebook.title}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
className={'dn absolute right-1 top-1 ' + hiddenOnPopout}
|
|
||||||
to={popoutHref}
|
|
||||||
target='_blank'
|
|
||||||
>
|
|
||||||
<img src='/~landscape/img/popout.png' height={16} width={16} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className='mw6 center'>
|
|
||||||
<div className='pa4'>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
type='text'
|
|
||||||
className='bg-transparent white-d w-100 pb2'
|
|
||||||
onChange={this.titleChange}
|
|
||||||
placeholder='New Post'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='gray2'>{date}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='NewPost'>
|
|
||||||
<CodeMirror
|
|
||||||
value={state.body}
|
|
||||||
options={options}
|
|
||||||
onBeforeChange={(e, d, v) => this.bodyChange(e, d, v)}
|
|
||||||
onChange={(editor, data, value) => {}}
|
|
||||||
/>
|
|
||||||
<Spinner
|
|
||||||
text='Creating post...'
|
|
||||||
awaiting={this.state.disabled}
|
|
||||||
classes='absolute bottom-1 right-1 ba b--gray1-d pa2'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NewPost;
|
|
125
pkg/interface/src/apps/publish/components/lib/new-post.tsx
Normal file
125
pkg/interface/src/apps/publish/components/lib/new-post.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import * as Yup from "yup";
|
||||||
|
import {
|
||||||
|
MarkdownEditor as _MarkdownEditor,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
ErrorMessage,
|
||||||
|
} from "@tlon/indigo-react";
|
||||||
|
import { dateToDa, stringToSymbol } from "../../../../lib/util";
|
||||||
|
import { AsyncButton } from '../../../../components/AsyncButton';
|
||||||
|
|
||||||
|
import { Formik, Form, FormikHelpers } from "formik";
|
||||||
|
import GlobalApi from "../../../../api/global";
|
||||||
|
import { useWaitForProps } from "../../../../lib/useWaitForProps";
|
||||||
|
import { Notebooks, Notebook } from "../../../../types/publish-update";
|
||||||
|
import { MarkdownField } from './MarkdownField';
|
||||||
|
import { RouteComponentProps } from "react-router-dom";
|
||||||
|
|
||||||
|
interface FormSchema {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formSchema = Yup.object({
|
||||||
|
title: Yup.string().required("Title cannot be blank"),
|
||||||
|
body: Yup.string().required("Post cannot be blank"),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface NewPostProps {
|
||||||
|
api: GlobalApi;
|
||||||
|
book: string;
|
||||||
|
ship: string;
|
||||||
|
notebook: Notebook;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewPost(props: NewPostProps & RouteComponentProps) {
|
||||||
|
const { api, book, notebook, ship, history } = props;
|
||||||
|
|
||||||
|
const waiter = useWaitForProps(props, 20000);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
|
||||||
|
const noteId = stringToSymbol(values.title);
|
||||||
|
const newNote = {
|
||||||
|
"new-note": {
|
||||||
|
who: window.ship,
|
||||||
|
book: book,
|
||||||
|
note: noteId,
|
||||||
|
title: values.title,
|
||||||
|
body: values.body,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await api.publish.publishAction(newNote);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.includes("note already exists")) {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
newNote["new-note"].note += "-" + timestamp;
|
||||||
|
api.publish.publishAction(newNote);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await waiter((p) => {
|
||||||
|
return !!p?.notebook?.notes[newNote["new-note"].note];
|
||||||
|
});
|
||||||
|
history.push(
|
||||||
|
`/~publish/notebook/${ship}/${book}/note/${newNote["new-note"].note}`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
actions.setStatus({ error: "Posting note failed" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, waiter, book]
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialValues: FormSchema = {
|
||||||
|
title: "",
|
||||||
|
body: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const date = dateToDa(new Date()).slice(1, -10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
width="100%"
|
||||||
|
px={4}
|
||||||
|
pb={4}
|
||||||
|
display="grid"
|
||||||
|
justifyItems="start"
|
||||||
|
gridTemplateRows="64px 1fr"
|
||||||
|
gridTemplateColumns="1fr 1fr"
|
||||||
|
gridRowGap={2}
|
||||||
|
>
|
||||||
|
<Formik
|
||||||
|
validationSchema={formSchema}
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid }) => (
|
||||||
|
<Form style={{ display: "contents" }}>
|
||||||
|
<Input width="100%" placeholder="Post Title" id="title" />
|
||||||
|
<Box mt={1} justifySelf="end">
|
||||||
|
<AsyncButton
|
||||||
|
loadingText="Posting..."
|
||||||
|
disabled={!isValid}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Publish To {notebook.title}
|
||||||
|
</AsyncButton>
|
||||||
|
</Box>
|
||||||
|
<MarkdownField gridColumn="1/3" id="body" />
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewPost;
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useState, useEffect } from "react";
|
import React, { Component, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Input,
|
Input,
|
||||||
@ -10,15 +10,15 @@ import {
|
|||||||
import { Formik, Form } from "formik";
|
import { Formik, Form } from "formik";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import GlobalApi from "../../../../api/global";
|
import GlobalApi from "../../../../api/global";
|
||||||
import { useWaitForProps } from "../../../../lib/useWaitForProps";
|
|
||||||
import DropdownSearch, {
|
import DropdownSearch, {
|
||||||
InviteSearch,
|
InviteSearch,
|
||||||
} from "../../../../components/InviteSearch";
|
} from "../../../../components/InviteSearch";
|
||||||
import { Spinner } from "../../../../components/Spinner";
|
import { AsyncButton } from "../../../../components/AsyncButton";
|
||||||
import { Link, RouteComponentProps } from "react-router-dom";
|
import { Link, RouteComponentProps } from "react-router-dom";
|
||||||
import { stringToSymbol } from "../../../../lib/util";
|
import { stringToSymbol } from "../../../../lib/util";
|
||||||
import GroupSearch from "../../../../components/GroupSearch";
|
import GroupSearch from "../../../../components/GroupSearch";
|
||||||
import { Associations } from "../../../../types/metadata-update";
|
import { Associations } from "../../../../types/metadata-update";
|
||||||
|
import { useWaitForProps } from "../../../../lib/useWaitForProps";
|
||||||
import { Notebooks } from "../../../../types/publish-update";
|
import { Notebooks } from "../../../../types/publish-update";
|
||||||
|
|
||||||
interface FormSchema {
|
interface FormSchema {
|
||||||
@ -33,16 +33,15 @@ const formSchema = Yup.object({
|
|||||||
group: Yup.string().required("Notebook must be part of a group"),
|
group: Yup.string().required("Notebook must be part of a group"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type NewScreenProps = RouteComponentProps<{}> & {
|
interface NewScreenProps {
|
||||||
api: GlobalApi;
|
api: GlobalApi;
|
||||||
associations: Associations;
|
associations: Associations;
|
||||||
notebooks: Notebooks;
|
notebooks: Notebooks;
|
||||||
};
|
}
|
||||||
|
|
||||||
export function NewScreen(props: NewScreenProps) {
|
export function NewScreen(props: NewScreenProps & RouteComponentProps) {
|
||||||
const { api } = props;
|
const { api, history } = props;
|
||||||
|
const waiter = useWaitForProps(props, 5000);
|
||||||
const waiter = useWaitForProps(props, 10000);
|
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
async (values: FormSchema, actions) => {
|
async (values: FormSchema, actions) => {
|
||||||
@ -64,24 +63,22 @@ export function NewScreen(props: NewScreenProps) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await api.publish.publishAction(action);
|
await props.api.publish.publishAction(action);
|
||||||
await waiter((p) => {
|
await waiter((p) => !!p?.notebooks?.[`~${window.ship}`]?.[bookId]);
|
||||||
return Boolean(p?.notebooks?.[`~${window.ship}`]?.[bookId]);
|
|
||||||
});
|
|
||||||
actions.setSubmitting(false);
|
actions.setSubmitting(false);
|
||||||
props.history.push(`/~publish/notebook/~${window.ship}/${bookId}`);
|
actions.setStatus({ success: null });
|
||||||
|
history.push(`/~publish/notebook/~${window.ship}/${bookId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
actions.setSubmitting(false);
|
actions.setSubmitting(false);
|
||||||
actions.setStatus({ error: "Notebook creation failed" });
|
actions.setStatus({ error: "Notebook creation failed" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, waiter, props.history]
|
[api]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col p={3}>
|
<Col p={3}>
|
||||||
<Box fontSize={0} mb={4}>New Notebook</Box>
|
<Box mb={4}>New Notebook</Box>
|
||||||
<Formik
|
<Formik
|
||||||
validationSchema={formSchema}
|
validationSchema={formSchema}
|
||||||
initialValues={{ name: "", description: "", group: "" }}
|
initialValues={{ name: "", description: "", group: "" }}
|
||||||
@ -108,21 +105,17 @@ export function NewScreen(props: NewScreenProps) {
|
|||||||
placeholder="Notebook description"
|
placeholder="Notebook description"
|
||||||
/>
|
/>
|
||||||
<GroupSearch
|
<GroupSearch
|
||||||
caption="Provide a group to associate to the notebook"
|
|
||||||
associations={props.associations}
|
|
||||||
label="Group"
|
|
||||||
id="group"
|
id="group"
|
||||||
|
label="Group"
|
||||||
|
caption="What group is the notebook for?"
|
||||||
|
associations={props.associations}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box justifySelf="start">
|
<Box justifySelf="start">
|
||||||
<Button type="submit" border>
|
<AsyncButton loadingText="Creating..." type="submit" border>
|
||||||
Create Notebook
|
Create Notebook
|
||||||
</Button>
|
</AsyncButton>
|
||||||
</Box>
|
</Box>
|
||||||
<Spinner
|
|
||||||
awaiting={isSubmitting}
|
|
||||||
classes="mt3"
|
|
||||||
text="Creating notebook..."
|
|
||||||
/>
|
|
||||||
{status && status.error && (
|
{status && status.error && (
|
||||||
<ErrorMessage>{status.error}</ErrorMessage>
|
<ErrorMessage>{status.error}</ErrorMessage>
|
||||||
)}
|
)}
|
||||||
|
@ -48,7 +48,7 @@ export class NoteNavigation extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex pt4">
|
<div className="flex">
|
||||||
{prevComponent}
|
{prevComponent}
|
||||||
{nextComponent}
|
{nextComponent}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,264 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { SidebarSwitcher } from '../../../../components/SidebarSwitch';
|
|
||||||
import { Spinner } from '../../../../components/Spinner';
|
|
||||||
import { Comments } from './comments';
|
|
||||||
import { NoteNavigation } from './note-navigation';
|
|
||||||
import moment from 'moment';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { cite } from '../../../../lib/util';
|
|
||||||
|
|
||||||
export class Note extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
deleting: false
|
|
||||||
};
|
|
||||||
moment.updateLocale('en', {
|
|
||||||
relativeTime: {
|
|
||||||
past: function(input) {
|
|
||||||
return input === 'just now'
|
|
||||||
? input
|
|
||||||
: input + ' ago';
|
|
||||||
},
|
|
||||||
s : 'just now',
|
|
||||||
future : 'in %s',
|
|
||||||
m : '1m',
|
|
||||||
mm : '%dm',
|
|
||||||
h : '1h',
|
|
||||||
hh : '%dh',
|
|
||||||
d : '1d',
|
|
||||||
dd : '%dd',
|
|
||||||
M : '1 month',
|
|
||||||
MM : '%d months',
|
|
||||||
y : '1 year',
|
|
||||||
yy : '%d years'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.scrollElement = React.createRef();
|
|
||||||
this.onScroll = this.onScroll.bind(this);
|
|
||||||
this.deletePost = this.deletePost.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.componentDidUpdate();
|
|
||||||
this.onScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { props } = this;
|
|
||||||
if ((prevProps && prevProps.api !== props.api) || props.api) {
|
|
||||||
if (!(props.notebooks[props.ship]?.[props.book]?.notes?.[props.note]?.file)) {
|
|
||||||
props.api.publish.fetchNote(props.ship, props.book, props.note);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevProps) {
|
|
||||||
if ((prevProps.book !== props.book) ||
|
|
||||||
(prevProps.note !== props.note) ||
|
|
||||||
(prevProps.ship !== props.ship)) {
|
|
||||||
const readAction = {
|
|
||||||
read: {
|
|
||||||
who: props.ship.slice(1),
|
|
||||||
book: props.book,
|
|
||||||
note: props.note
|
|
||||||
}
|
|
||||||
};
|
|
||||||
props.api.publish.publishAction(readAction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onScroll() {
|
|
||||||
const notebook = this.props.notebooks?.[this.props.ship]?.[this.props.book];
|
|
||||||
const note = notebook?.notes?.[this.props.note];
|
|
||||||
|
|
||||||
if (!note?.comments) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollTop = this.scrollElement.scrollTop;
|
|
||||||
const clientHeight = this.scrollElement.clientHeight;
|
|
||||||
const scrollHeight = this.scrollElement.scrollHeight;
|
|
||||||
|
|
||||||
let atBottom = false;
|
|
||||||
if (scrollHeight - scrollTop - clientHeight < 40) {
|
|
||||||
atBottom = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadedComments = note.comments.length;
|
|
||||||
const allComments = note['num-comments'];
|
|
||||||
|
|
||||||
const fullyLoaded = (loadedComments === allComments);
|
|
||||||
|
|
||||||
if (atBottom && !fullyLoaded) {
|
|
||||||
this.props.api.publish.fetchCommentsPage(this.props.ship,
|
|
||||||
this.props.book, this.props.note, loadedComments, 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deletePost() {
|
|
||||||
const { props } = this;
|
|
||||||
const deleteAction = {
|
|
||||||
'del-note': {
|
|
||||||
who: this.props.ship.slice(1),
|
|
||||||
book: this.props.book,
|
|
||||||
note: this.props.note
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const popout = (props.popout) ? 'popout/' : '';
|
|
||||||
const baseUrl = `/~publish/${popout}notebook/${props.ship}/${props.book}`;
|
|
||||||
this.setState({ deleting: true });
|
|
||||||
this.props.api.publish.publishAction(deleteAction)
|
|
||||||
.then(() => {
|
|
||||||
props.history.push(baseUrl);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { props } = this;
|
|
||||||
const notebook = props.notebooks?.[props.ship]?.[props.book] || {};
|
|
||||||
const comments = notebook?.notes?.[props.note]?.comments || false;
|
|
||||||
const title = notebook?.notes?.[props.note]?.title || '';
|
|
||||||
const author = notebook?.notes?.[props.note]?.author || '';
|
|
||||||
const file = notebook?.notes?.[props.note]?.file || '';
|
|
||||||
const date = moment(notebook.notes?.[props.note]?.['date-created']).fromNow() || 0;
|
|
||||||
|
|
||||||
const contact = author.substr(1) in props.contacts
|
|
||||||
? props.contacts[author.substr(1)] : false;
|
|
||||||
|
|
||||||
let name = author;
|
|
||||||
if (contact) {
|
|
||||||
name = (contact.nickname.length > 0)
|
|
||||||
? contact.nickname : author;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === author) {
|
|
||||||
name = cite(author);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newfile = file.slice(file.indexOf(';>')+2);
|
|
||||||
const prevId = notebook?.notes?.[props.note]?.['prev-note'] || null;
|
|
||||||
const nextId = notebook?.notes?.[props.note]?.['next-note'] || null;
|
|
||||||
const prevDate = moment(notebook?.notes?.[prevId]?.['date-created']).fromNow() || 0;
|
|
||||||
const nextDate = moment(notebook?.notes?.[nextId]?.['date-created']).fromNow() || 0;
|
|
||||||
|
|
||||||
const prev = (prevId === null)
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
id: prevId,
|
|
||||||
title: notebook?.notes?.[prevId]?.title,
|
|
||||||
date: prevDate
|
|
||||||
};
|
|
||||||
const next = (nextId === null)
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
id: nextId,
|
|
||||||
title: notebook?.notes?.[nextId]?.title,
|
|
||||||
date: nextDate
|
|
||||||
};
|
|
||||||
|
|
||||||
let editPost = null;
|
|
||||||
const editUrl = props.location.pathname + '/edit';
|
|
||||||
if (`~${window.ship}` === author) {
|
|
||||||
editPost = <div className="dib">
|
|
||||||
<Link className="green2 f9" to={editUrl}>Edit</Link>
|
|
||||||
<p className="dib f9 red2 ml2 pointer"
|
|
||||||
onClick={(() => this.deletePost())}
|
|
||||||
>Delete</p>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const popout = (props.popout) ? 'popout/' : '';
|
|
||||||
|
|
||||||
const hrefIndex = props.location.pathname.indexOf('/note/');
|
|
||||||
const publishsubStr = props.location.pathname.substr(hrefIndex);
|
|
||||||
const popoutHref = `/~publish/popout${publishsubStr}`;
|
|
||||||
|
|
||||||
const hiddenOnPopout = props.popout ? '' : 'dib-m dib-l dib-xl';
|
|
||||||
|
|
||||||
const baseUrl = `/~publish/${popout}notebook/${props.ship}/${props.book}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className='h-100 overflow-y-scroll'
|
|
||||||
onScroll={this.onScroll}
|
|
||||||
ref={(el) => {
|
|
||||||
this.scrollElement = el;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SidebarSwitcher
|
|
||||||
popout={props.popout}
|
|
||||||
sidebarShown={props.sidebarShown}
|
|
||||||
api={this.props.api}
|
|
||||||
classes="absolute top-1 pl4"
|
|
||||||
/>
|
|
||||||
<div className='h-100 flex flex-column items-center pa4'>
|
|
||||||
<div className='w-100 flex justify-center pb6'>
|
|
||||||
<Link className='f9 w-100 w-90-m w-90-l mw6 tl' to={baseUrl}>
|
|
||||||
{'<- Notebook index'}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to={popoutHref}
|
|
||||||
className={'dn absolute right-1 top-1 ' + hiddenOnPopout}
|
|
||||||
target='_blank'
|
|
||||||
>
|
|
||||||
<img src='/~landscape/img/popout.png' height={16} width={16} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className='w-100 mw6'>
|
|
||||||
<div className='flex flex-column'>
|
|
||||||
<div className='f9 mb1' style={{ overflowWrap: 'break-word' }}>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
<div className='flex mb6'>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'di f9 gray2 mr2 ' + (contact.nickname ? null : 'mono')
|
|
||||||
}
|
|
||||||
title={author}
|
|
||||||
style={{ lineHeight: 1.6 }}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
<div className='di' style={{ lineHeight: 1 }}>
|
|
||||||
<span className='f9 gray2 dib'>{date}</span>
|
|
||||||
<span className='ml2 dib'>{editPost}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='md' style={{ overflowWrap: 'break-word' }}>
|
|
||||||
<ReactMarkdown source={newfile} linkTarget={'_blank'} />
|
|
||||||
</div>
|
|
||||||
<NoteNavigation
|
|
||||||
popout={props.popout}
|
|
||||||
prev={prev}
|
|
||||||
next={next}
|
|
||||||
ship={props.ship}
|
|
||||||
book={props.book}
|
|
||||||
/>
|
|
||||||
<Comments
|
|
||||||
enabled={notebook.comments}
|
|
||||||
ship={props.ship}
|
|
||||||
book={props.book}
|
|
||||||
note={props.note}
|
|
||||||
comments={comments}
|
|
||||||
contacts={props.contacts}
|
|
||||||
api={this.props.api}
|
|
||||||
/>
|
|
||||||
<Spinner
|
|
||||||
text='Deleting post...'
|
|
||||||
awaiting={this.state.deleting}
|
|
||||||
classes='absolute bottom-1 right-1 ba b--gray1-d pa2'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Note;
|
|
@ -1,28 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
export class NotebookItem extends Component {
|
|
||||||
render() {
|
|
||||||
const { props } = this;
|
|
||||||
|
|
||||||
const selectedClass = (props.selected) ? 'bg-gray5 bg-gray1-d c-default' : 'pointer hover-bg-gray5 hover-bg-gray1-d';
|
|
||||||
|
|
||||||
const unread = (props.unreadCount > 0)
|
|
||||||
? <p className="dib f9 fr"><span className="dib white bg-gray3 bg-gray2-d fw6 br1" style={{ padding: '1px 5px' }}>
|
|
||||||
{props.unreadCount}
|
|
||||||
</span></p> : <span />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={'/~publish/notebook/' + props.path}
|
|
||||||
>
|
|
||||||
<div className={'w-100 v-mid f9 ph5 pv1 ' + selectedClass}>
|
|
||||||
<p className="dib f9">{props.title}</p>
|
|
||||||
{unread}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NotebookItem;
|
|
@ -1,103 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import moment from 'moment';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { cite } from '../../../../lib/util';
|
|
||||||
|
|
||||||
export class NotebookPosts extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
moment.updateLocale('en', {
|
|
||||||
relativeTime: {
|
|
||||||
past: function(input) {
|
|
||||||
return input === 'just now'
|
|
||||||
? input
|
|
||||||
: input + ' ago';
|
|
||||||
},
|
|
||||||
s : 'just now',
|
|
||||||
future : 'in %s',
|
|
||||||
m : '1m',
|
|
||||||
mm : '%dm',
|
|
||||||
h : '1h',
|
|
||||||
hh : '%dh',
|
|
||||||
d : '1d',
|
|
||||||
dd : '%dd',
|
|
||||||
M : '1 month',
|
|
||||||
MM : '%d months',
|
|
||||||
y : '1 year',
|
|
||||||
yy : '%d years'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { props } = this;
|
|
||||||
const notes = [];
|
|
||||||
|
|
||||||
for (let i=0; i<props.list.length; i++) {
|
|
||||||
const noteId = props.list[i];
|
|
||||||
const note = props.notes[noteId];
|
|
||||||
if (!note) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contact = note.author.substr(1) in props.contacts
|
|
||||||
? props.contacts[note.author.substr(1)] : false;
|
|
||||||
|
|
||||||
let name = note.author;
|
|
||||||
if (contact) {
|
|
||||||
name = (contact.nickname.length > 0)
|
|
||||||
? contact.nickname : note.author;
|
|
||||||
}
|
|
||||||
if (name === note.author) {
|
|
||||||
name = cite(note.author);
|
|
||||||
}
|
|
||||||
let comment = 'No Comments';
|
|
||||||
if (note['num-comments'] == 1) {
|
|
||||||
comment = '1 Comment';
|
|
||||||
} else if (note['num-comments'] > 1) {
|
|
||||||
comment = `${note['num-comments']} Comments`;
|
|
||||||
}
|
|
||||||
const date = moment(note['date-created']).fromNow();
|
|
||||||
const popout = (props.popout) ? 'popout/' : '';
|
|
||||||
const url = `/~publish/${popout}note/${props.host}/${props.notebookName}/${noteId}`;
|
|
||||||
|
|
||||||
notes.push(
|
|
||||||
<Link key={i} to={url}>
|
|
||||||
<div className="mv6">
|
|
||||||
<div className="mb1"
|
|
||||||
style={{ overflowWrap: 'break-word' }}
|
|
||||||
>
|
|
||||||
{note.title}
|
|
||||||
</div>
|
|
||||||
<p className="mb1"
|
|
||||||
style={{ overflowWrap: 'break-word' }}
|
|
||||||
>
|
|
||||||
<ReactMarkdown
|
|
||||||
unwrapDisallowed
|
|
||||||
allowedTypes={['text', 'root', 'break', 'paragraph']}
|
|
||||||
source={note.snippet}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<div className="flex">
|
|
||||||
<div className={(contact.nickname ? null : 'mono') +
|
|
||||||
' gray2 mr3'}
|
|
||||||
title={note.author}
|
|
||||||
>{name}</div>
|
|
||||||
<div className="gray2 mr3">{date}</div>
|
|
||||||
<div className="gray2">{comment}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-col">
|
|
||||||
{notes}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NotebookPosts;
|
|
@ -1,276 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { SidebarSwitcher } from '../../../../components/SidebarSwitch';
|
|
||||||
import { NotebookPosts } from './NotebookPosts';
|
|
||||||
import { Subscribers } from './subscribers';
|
|
||||||
import { Settings } from './settings';
|
|
||||||
import { cite } from '../../../../lib/util';
|
|
||||||
import { roleForShip } from '../../../../lib/group';
|
|
||||||
|
|
||||||
export class Notebook extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.onScroll = this.onScroll.bind(this);
|
|
||||||
this.unsubscribe = this.unsubscribe.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
onScroll() {
|
|
||||||
const notebook = this.props.notebooks[this.props.ship][this.props.book];
|
|
||||||
const scrollTop = this.scrollElement.scrollTop;
|
|
||||||
const clientHeight = this.scrollElement.clientHeight;
|
|
||||||
const scrollHeight = this.scrollElement.scrollHeight;
|
|
||||||
|
|
||||||
let atBottom = false;
|
|
||||||
if (scrollHeight - scrollTop - clientHeight < 40) {
|
|
||||||
atBottom = true;
|
|
||||||
}
|
|
||||||
if (!notebook.notes && this.props.api) {
|
|
||||||
this.props.api.publish.fetchNotebook(this.props.ship, this.props.book);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadedNotes = Object.keys(notebook?.notes).length || 0;
|
|
||||||
const allNotes = notebook?.['notes-by-date'].length || 0;
|
|
||||||
|
|
||||||
const fullyLoaded = (loadedNotes === allNotes);
|
|
||||||
|
|
||||||
if (atBottom && !fullyLoaded) {
|
|
||||||
this.props.api.publish.fetchNotesPage(this.props.ship, this.props.book, loadedNotes, 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { props } = this;
|
|
||||||
if ((prevProps && (prevProps.api !== props.api)) || props.api) {
|
|
||||||
const notebook = props.notebooks?.[props.ship]?.[props.book];
|
|
||||||
if (!notebook?.subscribers) {
|
|
||||||
props.api.publish.fetchNotebook(props.ship, props.book);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.componentDidUpdate();
|
|
||||||
const notebook = this.props.notebooks?.[this.props.ship]?.[this.props.book];
|
|
||||||
if (notebook?.notes) {
|
|
||||||
this.onScroll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribe() {
|
|
||||||
const action = {
|
|
||||||
unsubscribe: {
|
|
||||||
who: this.props.ship.slice(1),
|
|
||||||
book: this.props.book
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.props.api.publish.publishAction(action);
|
|
||||||
this.props.history.push('/~publish');
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { props } = this;
|
|
||||||
|
|
||||||
// popout logic
|
|
||||||
const hrefIndex = props.location.pathname.indexOf('/notebook/');
|
|
||||||
const publishsubStr = props.location.pathname.substr(hrefIndex);
|
|
||||||
const popoutHref = `/~publish/popout${publishsubStr}`;
|
|
||||||
|
|
||||||
const hiddenOnPopout = props.popout ? '' : 'dib-m dib-l dib-xl';
|
|
||||||
|
|
||||||
const notebook = props.notebooks?.[props.ship]?.[props.book];
|
|
||||||
|
|
||||||
const tabStyles = {
|
|
||||||
posts: 'bb b--gray4 b--gray2-d gray2 pv4 ph2',
|
|
||||||
about: 'bb b--gray4 b--gray2-d gray2 pv4 ph2',
|
|
||||||
subscribers: 'bb b--gray4 b--gray2-d gray2 pv4 ph2',
|
|
||||||
settings: 'bb b--gray4 b--gray2-d pr2 gray2 pv4 ph2'
|
|
||||||
};
|
|
||||||
tabStyles[props.view] = 'bb b--black b--white-d black white-d pv4 ph2';
|
|
||||||
|
|
||||||
let inner = null;
|
|
||||||
switch (props.view) {
|
|
||||||
case 'posts': {
|
|
||||||
const notesList = notebook?.['notes-by-date'] || [];
|
|
||||||
const notes = notebook?.notes || null;
|
|
||||||
inner = <NotebookPosts
|
|
||||||
notes={notes}
|
|
||||||
popout={props.popout}
|
|
||||||
list={notesList}
|
|
||||||
host={props.ship}
|
|
||||||
book={props.book}
|
|
||||||
contacts={props.notebookContacts}
|
|
||||||
/>;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'about':
|
|
||||||
inner = <p className="f8 lh-solid">{notebook?.about}</p>;
|
|
||||||
break;
|
|
||||||
case 'subscribers':
|
|
||||||
inner = <Subscribers
|
|
||||||
host={this.props.ship}
|
|
||||||
book={this.props.book}
|
|
||||||
notebook={notebook}
|
|
||||||
contacts={this.props.contacts}
|
|
||||||
associations={this.props.associations}
|
|
||||||
groups={this.props.groups}
|
|
||||||
api={this.props.api}
|
|
||||||
/>;
|
|
||||||
break;
|
|
||||||
case 'settings':
|
|
||||||
inner = <Settings
|
|
||||||
host={this.props.ship}
|
|
||||||
book={this.props.book}
|
|
||||||
notebook={notebook}
|
|
||||||
groups={this.props.groups}
|
|
||||||
contacts={this.props.contacts}
|
|
||||||
associations={this.props.associations}
|
|
||||||
history={this.props.history}
|
|
||||||
api={this.props.api}
|
|
||||||
/>;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// displaying nicknames, sigil colors for contacts
|
|
||||||
const contact = props.ship.substr(1) in props.notebookContacts
|
|
||||||
? props.notebookContacts[props.ship.substr(1)] : false;
|
|
||||||
let name = props.ship;
|
|
||||||
if (contact) {
|
|
||||||
name = (contact.nickname.length > 0)
|
|
||||||
? contact.nickname : props.ship;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === props.ship) {
|
|
||||||
name = cite(props.ship);
|
|
||||||
}
|
|
||||||
|
|
||||||
const popout = (props.popout) ? 'popout/' : '';
|
|
||||||
const base = `/~publish/${popout}notebook/${props.ship}/${props.book}`;
|
|
||||||
const about = base + '/about';
|
|
||||||
const subs = base + '/subscribers';
|
|
||||||
const settings = base + '/settings';
|
|
||||||
const newUrl = base + '/new';
|
|
||||||
|
|
||||||
let newPost = null;
|
|
||||||
if (notebook?.['writers-group-path'] in props.groups) {
|
|
||||||
const group = props.groups[notebook?.['writers-group-path']];
|
|
||||||
const writers = group.tags?.publish?.[`writers-${props.book}`] || new Set();
|
|
||||||
if (props.ship === `~${window.ship}` || writers.has(ship)) {
|
|
||||||
newPost = (
|
|
||||||
<Link
|
|
||||||
to={newUrl}
|
|
||||||
className='NotebookButton bg-light-green green2 pa2'
|
|
||||||
>
|
|
||||||
New Post
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsub = (window.ship === props.ship.slice(1))
|
|
||||||
? null
|
|
||||||
: <button onClick={this.unsubscribe}
|
|
||||||
className="NotebookButton bg-white bg-gray0-d black white-d ba b--black b--gray2-d ml3 ph1"
|
|
||||||
>
|
|
||||||
Unsubscribe
|
|
||||||
</button>;
|
|
||||||
|
|
||||||
|
|
||||||
const group = props.groups[notebook?.['writers-group-path']];
|
|
||||||
const role = group ? roleForShip(group, window.ship) : undefined;
|
|
||||||
|
|
||||||
const subsComponent = (this.props.ship.slice(1) === window.ship) || (role === 'admin')
|
|
||||||
? (<Link to={subs} className={tabStyles.subscribers}>
|
|
||||||
Subscribers
|
|
||||||
</Link>)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const settingsComponent = (this.props.ship.slice(1) !== window.ship)
|
|
||||||
? null
|
|
||||||
: <Link to={settings} className={tabStyles.settings}>
|
|
||||||
Settings
|
|
||||||
</Link>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className='overflow-y-scroll h-100'
|
|
||||||
style={{ paddingLeft: 16, paddingRight: 16 }}
|
|
||||||
onScroll={this.onScroll}
|
|
||||||
ref={(el) => {
|
|
||||||
this.scrollElement = el;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='w-100 dn-m dn-l dn-xl inter pt4 pb6 f9'>
|
|
||||||
<Link to='/~publish'>{'<- All Notebooks'}</Link>
|
|
||||||
</div>
|
|
||||||
<div style={{ paddingTop: 11 }}>
|
|
||||||
<SidebarSwitcher
|
|
||||||
popout={props.popout}
|
|
||||||
sidebarShown={props.sidebarShown}
|
|
||||||
api={this.props.api}
|
|
||||||
classes="absolute top-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className='center mw6 f9 h-100'
|
|
||||||
style={{ paddingLeft: 16, paddingRight: 16 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
className={'dn absolute right-1 top-1 ' + hiddenOnPopout}
|
|
||||||
to={popoutHref}
|
|
||||||
target='_blank'
|
|
||||||
>
|
|
||||||
<img src='/~landscape/img/popout.png' height={16} width={16} />
|
|
||||||
</Link>
|
|
||||||
<div className='h-100 pt0 pt8-m pt8-l pt8-xl no-scrollbar'>
|
|
||||||
<div className='flex justify-between' style={{ marginBottom: 32 }}>
|
|
||||||
<div className='flex-col'>
|
|
||||||
<div className='mb1'>{notebook?.title}</div>
|
|
||||||
<span>
|
|
||||||
<span className='gray3 mr1'>by</span>
|
|
||||||
<span
|
|
||||||
className={contact.nickname ? null : 'mono'}
|
|
||||||
title={props.ship}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='flex'>
|
|
||||||
{newPost}
|
|
||||||
{unsub}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex' style={{ marginBottom: 24 }}>
|
|
||||||
<Link to={base} className={tabStyles.posts}>
|
|
||||||
All Posts
|
|
||||||
</Link>
|
|
||||||
<Link to={about} className={tabStyles.about}>
|
|
||||||
About
|
|
||||||
</Link>
|
|
||||||
{subsComponent}
|
|
||||||
{settingsComponent}
|
|
||||||
<div
|
|
||||||
className='bb b--gray4 b--gray2-d gray2 pv4 ph2'
|
|
||||||
style={{ flexGrow: 1 }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{ height: 'calc(100% - 188px)' }}
|
|
||||||
className='f9 lh-solid'
|
|
||||||
>
|
|
||||||
{inner}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Notebook;
|
|
@ -1,289 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import { Spinner } from '../../../../components/Spinner';
|
|
||||||
import { InviteSearch } from '../../../../components/InviteSearch';
|
|
||||||
import Toggle from '../../../../components/toggle';
|
|
||||||
|
|
||||||
export class Settings extends Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
comments: false,
|
|
||||||
disabled: false,
|
|
||||||
type: 'Editing',
|
|
||||||
targetGroup: null,
|
|
||||||
inclusive: false
|
|
||||||
};
|
|
||||||
this.deleteNotebook = this.deleteNotebook.bind(this);
|
|
||||||
this.changeTitle = this.changeTitle.bind(this);
|
|
||||||
this.changeDescription = this.changeDescription.bind(this);
|
|
||||||
this.changeComments = this.changeComments.bind(this);
|
|
||||||
this.changeTargetGroup = this.changeTargetGroup.bind(this);
|
|
||||||
this.changeInclusive = this.changeInclusive.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { props } = this;
|
|
||||||
if (props.notebook) {
|
|
||||||
this.setState({
|
|
||||||
title: props.notebook.title,
|
|
||||||
description: props.notebook.about,
|
|
||||||
comments: props.notebook.comments
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { props } = this;
|
|
||||||
if (prevProps !== props) {
|
|
||||||
if (props.notebook) {
|
|
||||||
if (prevProps.notebook && prevProps.notebook !== props.notebook) {
|
|
||||||
if (prevProps.notebook.title !== props.notebook.title) {
|
|
||||||
this.setState({ title: props.notebook.title });
|
|
||||||
}
|
|
||||||
if (prevProps.notebook.about !== props.notebook.about) {
|
|
||||||
this.setState({ description: props.notebook.about });
|
|
||||||
}
|
|
||||||
if (prevProps.notebook.comments !== props.notebook.comments) {
|
|
||||||
this.setState({ comments: props.notebook.comments });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
changeTitle(event) {
|
|
||||||
this.setState({ title: event.target.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
changeDescription(event) {
|
|
||||||
this.setState({ description: event.target.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
changeComments() {
|
|
||||||
this.setState({ comments: !this.state.comments, disabled: true }, (() => {
|
|
||||||
this.props.api.publish.publishAction({
|
|
||||||
'edit-book': {
|
|
||||||
book: this.props.book,
|
|
||||||
title: this.props.notebook.title,
|
|
||||||
about: this.props.notebook.about,
|
|
||||||
coms: this.state.comments,
|
|
||||||
group: null
|
|
||||||
}
|
|
||||||
}).then(() => {
|
|
||||||
this.setState({ disabled: false });
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteNotebook() {
|
|
||||||
const action = {
|
|
||||||
'del-book': {
|
|
||||||
book: this.props.book
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.setState({ disabled: true, type: 'Deleting' });
|
|
||||||
this.props.api.publish.publishAction(action).then(() => {
|
|
||||||
this.props.history.push('/~publish');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
changeTargetGroup(target) {
|
|
||||||
if (target.groups.length === 1) {
|
|
||||||
this.setState({ targetGroup: target.groups[0] });
|
|
||||||
} else {
|
|
||||||
this.setState({ targetGroup: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
changeInclusive(event) {
|
|
||||||
this.setState({ inclusive: Boolean(event.target.checked) });
|
|
||||||
}
|
|
||||||
|
|
||||||
groupifyNotebook() {
|
|
||||||
const { props, state } = this;
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
disabled: true,
|
|
||||||
type: 'Converting'
|
|
||||||
}, (() => {
|
|
||||||
this.props.api.publish.publishAction({
|
|
||||||
groupify: {
|
|
||||||
book: props.book,
|
|
||||||
target: state.targetGroup,
|
|
||||||
inclusive: state.inclusive
|
|
||||||
}
|
|
||||||
}).then(() => this.setState({ disabled: false }));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
renderGroupify() {
|
|
||||||
const { props, state } = this;
|
|
||||||
|
|
||||||
const owner = (props.host.slice(1) === window.ship);
|
|
||||||
|
|
||||||
const ownedUnmanaged =
|
|
||||||
owner &&
|
|
||||||
!props.contacts[props.notebook?.['writers-group-path']];
|
|
||||||
|
|
||||||
if (!ownedUnmanaged) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
// don't give the option to make inclusive if we don't own the target
|
|
||||||
// group
|
|
||||||
const targetOwned = (state.targetGroup)
|
|
||||||
? Boolean(state.targetGroup.includes(`/~${window.ship}/`))
|
|
||||||
: false;
|
|
||||||
let inclusiveToggle = <div />;
|
|
||||||
if (targetOwned) {
|
|
||||||
inclusiveToggle = (
|
|
||||||
<div className="mt4">
|
|
||||||
<Toggle
|
|
||||||
boolean={state.inclusive}
|
|
||||||
change={this.changeInclusive}
|
|
||||||
/>
|
|
||||||
<span className="dib f9 white-d inter ml3">
|
|
||||||
Add all members to group
|
|
||||||
</span>
|
|
||||||
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
|
|
||||||
Add notebook members to the group if they aren't in it yet
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={'w-100 fl mt3 mb3'} style={{ maxWidth: '29rem' }}>
|
|
||||||
{this.renderHeader(
|
|
||||||
'Convert Notebook',
|
|
||||||
'Convert this notebook into a group with associated chat, or select a group to add this notebook to.')}
|
|
||||||
<InviteSearch
|
|
||||||
groups={props.groups}
|
|
||||||
contacts={props.contacts}
|
|
||||||
associations={props.associations}
|
|
||||||
groupResults={true}
|
|
||||||
shipResults={false}
|
|
||||||
invites={{
|
|
||||||
groups: state.targetGroup ? [state.targetGroup] : [],
|
|
||||||
ships: []
|
|
||||||
}}
|
|
||||||
setInvite={this.changeTargetGroup}
|
|
||||||
/>
|
|
||||||
{inclusiveToggle}
|
|
||||||
<button
|
|
||||||
onClick={this.groupifyNotebook.bind(this)}
|
|
||||||
className={'dib f9 black gray4-d bg-gray0-d ba pa2 mt4 b--black b--gray1-d pointer'}
|
|
||||||
disabled={this.state.disabled}
|
|
||||||
>
|
|
||||||
{state.targetGroup ? 'Add to group' : 'Convert to group'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderHeader(title, subtitle) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p className="f9 mt6 lh-copy">{title}</p>
|
|
||||||
<p className="f9 gray2 db mb4">{subtitle}</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.props.host.slice(1) === window.ship) {
|
|
||||||
return (
|
|
||||||
<div className="flex-column">
|
|
||||||
{this.renderGroupify()}
|
|
||||||
{this.renderHeader(
|
|
||||||
'Delete Notebook',
|
|
||||||
'Permanently delete this notebook. (All current members will no longer see this notebook)')}
|
|
||||||
<button
|
|
||||||
className="bg-transparent b--red2 red2 pointer dib f9 ba pa2"
|
|
||||||
onClick={this.deleteNotebook}
|
|
||||||
>
|
|
||||||
Delete this notebook
|
|
||||||
</button>
|
|
||||||
{this.renderHeader('Rename', 'Change the name of this notebook')}
|
|
||||||
<div className="relative w-100 flex" style={{ maxWidth: '29rem' }}>
|
|
||||||
<input
|
|
||||||
className={
|
|
||||||
'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
|
|
||||||
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'
|
|
||||||
}
|
|
||||||
value={this.state.title}
|
|
||||||
onChange={this.changeTitle}
|
|
||||||
disabled={this.state.disabled}
|
|
||||||
onBlur={() => {
|
|
||||||
this.setState({ disabled: true });
|
|
||||||
this.props.api.publish
|
|
||||||
.publishAction({
|
|
||||||
'edit-book': {
|
|
||||||
book: this.props.book,
|
|
||||||
title: this.state.title,
|
|
||||||
about: this.props.notebook.about,
|
|
||||||
coms: this.props.notebook.comments,
|
|
||||||
group: null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
this.setState({ disabled: false });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{this.renderHeader("Change description", "Change the description of this notebook")}
|
|
||||||
<div className="relative w-100 flex" style={{ maxWidth: '29rem' }}>
|
|
||||||
<input
|
|
||||||
className={
|
|
||||||
'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
|
|
||||||
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'
|
|
||||||
}
|
|
||||||
value={this.state.description}
|
|
||||||
onChange={this.changeDescription}
|
|
||||||
onBlur={() => {
|
|
||||||
this.setState({ disabled: true });
|
|
||||||
this.props.api.publish
|
|
||||||
.publishAction({
|
|
||||||
'edit-book': {
|
|
||||||
book: this.props.book,
|
|
||||||
title: this.props.notebook.title,
|
|
||||||
about: this.state.description,
|
|
||||||
coms: this.props.notebook.comments,
|
|
||||||
group: null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
this.setState({ disabled: false });
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mv6">
|
|
||||||
<Toggle
|
|
||||||
boolean={this.state.comments}
|
|
||||||
change={this.changeComments}
|
|
||||||
/>
|
|
||||||
<span className="dib f9 white-d inter ml3">Comments</span>
|
|
||||||
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
|
|
||||||
Subscribers may comment when enabled
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Spinner
|
|
||||||
awaiting={this.state.disabled}
|
|
||||||
classes="absolute right-1 bottom-1 pa2 ba b--black b--gray0-d white-d"
|
|
||||||
text={`${this.state.type} notebook...`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Settings;
|
|
@ -1,139 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import SidebarInvite from '../../../../components/SidebarInvite';
|
|
||||||
import { Welcome } from './welcome';
|
|
||||||
import { GroupItem } from './group-item';
|
|
||||||
import { alphabetiseAssociations } from '../../../../lib/util';
|
|
||||||
|
|
||||||
export class Sidebar extends Component {
|
|
||||||
render() {
|
|
||||||
const { props } = this;
|
|
||||||
const activeClasses = (props.active === 'sidebar') ? ' ' : 'dn-s ';
|
|
||||||
let hiddenClasses = true;
|
|
||||||
if (props.popout) {
|
|
||||||
hiddenClasses = false;
|
|
||||||
} else {
|
|
||||||
hiddenClasses = props.sidebarShown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sidebarInvites = !(props.invites && props.invites['/publish'])
|
|
||||||
? null
|
|
||||||
: Object.keys(props.invites['/publish'])
|
|
||||||
.map((uid) => {
|
|
||||||
return (
|
|
||||||
<SidebarInvite
|
|
||||||
key={uid}
|
|
||||||
invite={props.invites['/publish'][uid]}
|
|
||||||
onAccept={() => props.api.invite.accept('/publish', uid)}
|
|
||||||
onDecline={() => props.api.invite.decline('/publish', uid)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const associations =
|
|
||||||
(props.associations && 'contacts' in props.associations)
|
|
||||||
? alphabetiseAssociations(props.associations.contacts) : {};
|
|
||||||
|
|
||||||
const notebooks = {};
|
|
||||||
Object.keys(props.notebooks).map((host) => {
|
|
||||||
Object.keys(props.notebooks[host]).map((notebook) => {
|
|
||||||
const title = `${host}/${notebook}`;
|
|
||||||
notebooks[title] = props.notebooks[host][notebook];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupedNotebooks = {};
|
|
||||||
Object.keys(notebooks).map((book) => {
|
|
||||||
const path = notebooks[book]['subscribers-group-path']
|
|
||||||
? notebooks[book]['subscribers-group-path'] : book;
|
|
||||||
if (path in associations) {
|
|
||||||
if (groupedNotebooks[path]) {
|
|
||||||
const array = groupedNotebooks[path];
|
|
||||||
array.push(book);
|
|
||||||
groupedNotebooks[path] = array;
|
|
||||||
} else {
|
|
||||||
groupedNotebooks[path] = [book];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (groupedNotebooks['/~/']) {
|
|
||||||
const array = groupedNotebooks['/~/'];
|
|
||||||
array.push(book);
|
|
||||||
groupedNotebooks['/~/'] = array;
|
|
||||||
} else {
|
|
||||||
groupedNotebooks['/~/'] = [book];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedGroups = props.selectedGroups ? props.selectedGroups: [];
|
|
||||||
const groupedItems = Object.keys(associations)
|
|
||||||
.filter((each) => {
|
|
||||||
if (selectedGroups.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const selectedPaths = selectedGroups.map((e) => {
|
|
||||||
return e[0];
|
|
||||||
});
|
|
||||||
return (selectedPaths.includes(each));
|
|
||||||
})
|
|
||||||
.map((each, i) => {
|
|
||||||
const books = groupedNotebooks[each] || [];
|
|
||||||
if (books.length === 0)
|
|
||||||
return;
|
|
||||||
if ((selectedGroups.length === 0) &&
|
|
||||||
groupedNotebooks['/~/'] &&
|
|
||||||
groupedNotebooks['/~/'].length !== 0) {
|
|
||||||
i = i + 1;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<GroupItem
|
|
||||||
key={i}
|
|
||||||
index={i}
|
|
||||||
association={associations[each]}
|
|
||||||
groupedBooks={books}
|
|
||||||
notebooks={notebooks}
|
|
||||||
path={props.path}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if ((selectedGroups.length === 0) &&
|
|
||||||
groupedNotebooks['/~/'] &&
|
|
||||||
groupedNotebooks['/~/'].length !== 0) {
|
|
||||||
groupedItems.unshift(
|
|
||||||
<GroupItem
|
|
||||||
key={'/~/'}
|
|
||||||
index={0}
|
|
||||||
association={'/~/'}
|
|
||||||
groupedBooks={groupedNotebooks['/~/']}
|
|
||||||
notebooks={notebooks}
|
|
||||||
path={props.path}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'bn br-m br-l br-xl b--gray4 b--gray1-d lh-copy h-100 ' +
|
|
||||||
'flex-shrink-0 pt3 pt0-m pt0-l pt0-xl relative ' +
|
|
||||||
'overflow-y-hidden ' + activeClasses +
|
|
||||||
(hiddenClasses ? 'flex-basis-100-s flex-basis-250-ns' : 'dn')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="w-100 f9">
|
|
||||||
<Link to="/~publish/new" className="green2 pa4 f9 dib">
|
|
||||||
New Notebook
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-y-auto pb1"
|
|
||||||
style={{ height: 'calc(100% - 82px)' }}
|
|
||||||
>
|
|
||||||
<Welcome notebooks={props.notebooks} />
|
|
||||||
{sidebarInvites}
|
|
||||||
{groupedItems}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Sidebar;
|
|
@ -1,41 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
|
|
||||||
export class Welcome extends Component {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.state = {
|
|
||||||
show: true
|
|
||||||
};
|
|
||||||
this.disableWelcome = this.disableWelcome.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
disableWelcome() {
|
|
||||||
this.setState({ show: false });
|
|
||||||
localStorage.setItem('urbit-publish:wasWelcomed', JSON.stringify(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
let wasWelcomed = localStorage.getItem('urbit-publish:wasWelcomed');
|
|
||||||
if (wasWelcomed === null) {
|
|
||||||
localStorage.setItem('urbit-publish:wasWelcomed', JSON.stringify(false));
|
|
||||||
return wasWelcomed = false;
|
|
||||||
} else {
|
|
||||||
wasWelcomed = JSON.parse(wasWelcomed);
|
|
||||||
}
|
|
||||||
|
|
||||||
const notebooks = this.props.notebooks ? this.props.notebooks : {};
|
|
||||||
|
|
||||||
return ((!wasWelcomed && this.state.show) && (notebooks.length !== 0)) ? (
|
|
||||||
<div className="ma4 pa2 white-d bg-welcome-green bg-gray1-d">
|
|
||||||
<p className="f8 lh-copy">Notebooks are for longer-form writing and discussion. Each Notebook is a collection of Markdown-formatted notes with optional comments.</p>
|
|
||||||
<p className="f8 pt2 dib pointer bb"
|
|
||||||
onClick={(() => this.disableWelcome())}
|
|
||||||
>
|
|
||||||
Close this
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : <div />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Welcome;
|
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Box } from "@tlon/indigo-react";
|
import { Box } from "@tlon/indigo-react";
|
||||||
import { Sidebar } from "./lib/sidebar";
|
import { Sidebar } from "./lib/Sidebar";
|
||||||
import ErrorBoundary from "../../../components/ErrorBoundary";
|
import ErrorBoundary from "../../../components/ErrorBoundary";
|
||||||
import { Notebooks } from "../../../types/publish-update";
|
import { Notebooks } from "../../../types/publish-update";
|
||||||
import { Path } from "../../../types/noun";
|
import { Path } from "../../../types/noun";
|
||||||
@ -22,7 +22,7 @@ type SkeletonProps = RouteComponentProps<{ ship: string; notebook: string }> & {
|
|||||||
contacts: Rolodex;
|
contacts: Rolodex;
|
||||||
api: GlobalApi;
|
api: GlobalApi;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function Skeleton(props: SkeletonProps) {
|
export function Skeleton(props: SkeletonProps) {
|
||||||
const popout = props.popout ? props.popout : false;
|
const popout = props.popout ? props.popout : false;
|
||||||
|
50
pkg/interface/src/components/AsyncButton.tsx
Normal file
50
pkg/interface/src/components/AsyncButton.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React, { ReactNode, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@tlon/indigo-react";
|
||||||
|
|
||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
import { useFormikContext } from "formik";
|
||||||
|
|
||||||
|
interface AsyncButtonProps {
|
||||||
|
loadingText: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
export function AsyncButton({
|
||||||
|
loadingText,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: AsyncButtonProps & Parameters<typeof Button>[0]) {
|
||||||
|
const { isSubmitting, status, isValid } = useFormikContext();
|
||||||
|
const [success, setSuccess] = useState<boolean | undefined>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const s = status || {};
|
||||||
|
let done = false;
|
||||||
|
if ("success" in s) {
|
||||||
|
setSuccess(true);
|
||||||
|
done = true;
|
||||||
|
} else if ("error" in s) {
|
||||||
|
setSuccess(false);
|
||||||
|
done = true;
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setSuccess(undefined);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button border disabled={!isValid || isSubmitting} type="submit" {...rest}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Spinner awaiting text={loadingText} />
|
||||||
|
) : success === true ? (
|
||||||
|
"Done"
|
||||||
|
) : success === false ? (
|
||||||
|
"Errored"
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
@ -21,6 +21,7 @@ class ErrorBoundary extends Component<
|
|||||||
|
|
||||||
componentDidCatch(error) {
|
componentDidCatch(error) {
|
||||||
this.setState({ error });
|
this.setState({ error });
|
||||||
|
debugger;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ const Candidate = ({ title, selected, onClick }) => (
|
|||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
InviteSearch</CandidateBox>
|
</CandidateBox>
|
||||||
);
|
);
|
||||||
|
|
||||||
function renderCandidate(
|
function renderCandidate(
|
||||||
|
16
pkg/interface/src/components/HoverBox.tsx
Normal file
16
pkg/interface/src/components/HoverBox.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import styled from 'styled-components';
|
||||||
|
import { Box } from "@tlon/indigo-react";
|
||||||
|
interface HoverBoxProps {
|
||||||
|
selected: boolean;
|
||||||
|
bg: string;
|
||||||
|
bgActive: string;
|
||||||
|
}
|
||||||
|
export const HoverBox = styled(Box)<HoverBoxProps>`
|
||||||
|
background-color: ${ p => p.selected ? p.theme.colors[p.bgActive] : p.theme.colors[p.bg] };
|
||||||
|
pointer: cursor;
|
||||||
|
&:hover {
|
||||||
|
background-color: ${ p => p.theme.colors[p.bgActive] };
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
@ -8,10 +8,12 @@ export class SidebarSwitcher extends Component {
|
|||||||
|
|
||||||
const classes = this.props.classes ? this.props.classes : '';
|
const classes = this.props.classes ? this.props.classes : '';
|
||||||
|
|
||||||
|
const style = this.props.style || {};
|
||||||
|
|
||||||
const paddingTop = this.props.classes ? '0px' : '8px';
|
const paddingTop = this.props.classes ? '0px' : '8px';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes} style={{ paddingTop: paddingTop }}>
|
<div className={classes} style={{ paddingTop: paddingTop, ...style }}>
|
||||||
<a
|
<a
|
||||||
className='pointer flex-shrink-0'
|
className='pointer flex-shrink-0'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
22
pkg/interface/src/lib/useLocalStorageState.ts
Normal file
22
pkg/interface/src/lib/useLocalStorageState.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useLocalStorageState<T>(key: string, initial: T) {
|
||||||
|
const [state, _setState] = useState(() => {
|
||||||
|
const s = localStorage.getItem(key);
|
||||||
|
if(s) {
|
||||||
|
return JSON.parse(s) as T;
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
const setState = useCallback((s: T) => {
|
||||||
|
_setState(s);
|
||||||
|
localStorage.setItem(key, JSON.stringify(s));
|
||||||
|
|
||||||
|
}, [_setState]);
|
||||||
|
|
||||||
|
return [state, setState] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
30
pkg/interface/src/lib/useQuery.ts
Normal file
30
pkg/interface/src/lib/useQuery.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useMemo, useCallback } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
export function useQuery() {
|
||||||
|
const { search } = useLocation();
|
||||||
|
|
||||||
|
const query = useMemo(() => new URLSearchParams(search), [search]);
|
||||||
|
|
||||||
|
const appendQuery = useCallback(
|
||||||
|
(q: Record<string, string>) => {
|
||||||
|
const newQuery = new URLSearchParams(search);
|
||||||
|
_.forIn(q, (value, key) => {
|
||||||
|
if (!value) {
|
||||||
|
newQuery.delete(key);
|
||||||
|
} else {
|
||||||
|
newQuery.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newQuery.toString();
|
||||||
|
},
|
||||||
|
[search]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
appendQuery,
|
||||||
|
};
|
||||||
|
}
|
@ -78,6 +78,8 @@ export default class PublishResponseReducer<S extends PublishState> {
|
|||||||
json.data.notebook["subscribers-group-path"];
|
json.data.notebook["subscribers-group-path"];
|
||||||
state.notebooks[json.host][json.notebook]["writers-group-path"] =
|
state.notebooks[json.host][json.notebook]["writers-group-path"] =
|
||||||
json.data.notebook["writers-group-path"];
|
json.data.notebook["writers-group-path"];
|
||||||
|
state.notebooks[json.host][json.notebook].about =
|
||||||
|
json.data.notebook.about;
|
||||||
if (state.notebooks[json.host][json.notebook].notes) {
|
if (state.notebooks[json.host][json.notebook].notes) {
|
||||||
for (var key in json.data.notebook.notes) {
|
for (var key in json.data.notebook.notes) {
|
||||||
let oldNote = state.notebooks[json.host][json.notebook].notes[key];
|
let oldNote = state.notebooks[json.host][json.notebook].notes[key];
|
||||||
|
@ -129,7 +129,7 @@ export interface Notebook {
|
|||||||
'writers-group-path': Path;
|
'writers-group-path': Path;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notes = {
|
export type Notes = {
|
||||||
[id in NoteId]: Note;
|
[id in NoteId]: Note;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -148,7 +148,7 @@ export interface Note {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Comment {
|
export interface Comment {
|
||||||
[date: string]: {
|
[date: string]: {
|
||||||
author: Patp;
|
author: Patp;
|
||||||
content: string;
|
content: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user