kopia lustrzana https://github.com/wagtail/wagtail
Merge Wagtail Comment Frontend (#6953)
* Copy code from wagtail-comment-frontend
Exact copy of the src directory from:
4486c2fc32
* Integrate commenting code
* Linting
pull/7050/head
rodzic
daada5b4c8
commit
bbbc31ff60
|
@ -98,6 +98,7 @@ These are classes for components.
|
|||
@import '../src/components/LoadingSpinner/LoadingSpinner';
|
||||
@import '../src/components/PublicationStatus/PublicationStatus';
|
||||
@import '../src/components/Explorer/Explorer';
|
||||
@import '../src/components/CommentApp/main';
|
||||
|
||||
// Legacy
|
||||
@import 'components/icons';
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import type { Comment, CommentReply, CommentsState } from '../state/comments';
|
||||
|
||||
|
||||
const remoteReply: CommentReply = {
|
||||
localId: 2,
|
||||
remoteId: 2,
|
||||
mode: 'default',
|
||||
author: { id: 1, name: 'test user' },
|
||||
date: 0,
|
||||
text: 'a reply',
|
||||
newText: '',
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
const localReply: CommentReply = {
|
||||
localId: 3,
|
||||
remoteId: null,
|
||||
mode: 'default',
|
||||
author: { id: 1, name: 'test user' },
|
||||
date: 0,
|
||||
text: 'another reply',
|
||||
newText: '',
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
const remoteComment: Comment = {
|
||||
contentpath: 'test_contentpath',
|
||||
position: '',
|
||||
localId: 1,
|
||||
annotation: null,
|
||||
remoteId: 1,
|
||||
mode: 'default',
|
||||
deleted: false,
|
||||
author: { id: 1, name: 'test user' },
|
||||
date: 0,
|
||||
text: 'test text',
|
||||
newReply: '',
|
||||
newText: '',
|
||||
remoteReplyCount: 1,
|
||||
replies: new Map([[remoteReply.localId, remoteReply], [localReply.localId, localReply]]),
|
||||
};
|
||||
|
||||
const localComment: Comment = {
|
||||
contentpath: 'test_contentpath_2',
|
||||
position: '',
|
||||
localId: 4,
|
||||
annotation: null,
|
||||
remoteId: null,
|
||||
mode: 'default',
|
||||
deleted: false,
|
||||
author: { id: 1, name: 'test user' },
|
||||
date: 0,
|
||||
text: 'unsaved comment',
|
||||
newReply: '',
|
||||
newText: '',
|
||||
replies: new Map(),
|
||||
remoteReplyCount: 0,
|
||||
};
|
||||
|
||||
export const basicCommentsState: CommentsState = {
|
||||
focusedComment: 1,
|
||||
pinnedComment: 1,
|
||||
remoteCommentCount: 1,
|
||||
comments: new Map([[remoteComment.localId, remoteComment], [localComment.localId, localComment]]),
|
||||
};
|
|
@ -0,0 +1,147 @@
|
|||
import type {
|
||||
Comment,
|
||||
CommentUpdate,
|
||||
CommentReply,
|
||||
CommentReplyUpdate,
|
||||
} from '../state/comments';
|
||||
|
||||
export const ADD_COMMENT = 'add-comment';
|
||||
export const UPDATE_COMMENT = 'update-comment';
|
||||
export const DELETE_COMMENT = 'delete-comment';
|
||||
export const SET_FOCUSED_COMMENT = 'set-focused-comment';
|
||||
export const SET_PINNED_COMMENT = 'set-pinned-comment';
|
||||
|
||||
export const ADD_REPLY = 'add-reply';
|
||||
export const UPDATE_REPLY = 'update-reply';
|
||||
export const DELETE_REPLY = 'delete-reply';
|
||||
|
||||
export interface AddCommentAction {
|
||||
type: typeof ADD_COMMENT;
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface UpdateCommentAction {
|
||||
type: typeof UPDATE_COMMENT;
|
||||
commentId: number;
|
||||
update: CommentUpdate;
|
||||
}
|
||||
|
||||
export interface DeleteCommentAction {
|
||||
type: typeof DELETE_COMMENT;
|
||||
commentId: number;
|
||||
}
|
||||
|
||||
export interface SetFocusedCommentAction {
|
||||
type: typeof SET_FOCUSED_COMMENT;
|
||||
commentId: number | null;
|
||||
updatePinnedComment: boolean;
|
||||
}
|
||||
|
||||
export interface AddReplyAction {
|
||||
type: typeof ADD_REPLY;
|
||||
commentId: number;
|
||||
reply: CommentReply;
|
||||
}
|
||||
|
||||
export interface UpdateReplyAction {
|
||||
type: typeof UPDATE_REPLY;
|
||||
commentId: number;
|
||||
replyId: number;
|
||||
update: CommentReplyUpdate;
|
||||
}
|
||||
|
||||
export interface DeleteReplyAction {
|
||||
type: typeof DELETE_REPLY;
|
||||
commentId: number;
|
||||
replyId: number;
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| AddCommentAction
|
||||
| UpdateCommentAction
|
||||
| DeleteCommentAction
|
||||
| SetFocusedCommentAction
|
||||
| AddReplyAction
|
||||
| UpdateReplyAction
|
||||
| DeleteReplyAction;
|
||||
|
||||
export function addComment(comment: Comment): AddCommentAction {
|
||||
return {
|
||||
type: ADD_COMMENT,
|
||||
comment,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateComment(
|
||||
commentId: number,
|
||||
update: CommentUpdate
|
||||
): UpdateCommentAction {
|
||||
return {
|
||||
type: UPDATE_COMMENT,
|
||||
commentId,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteComment(commentId: number): DeleteCommentAction {
|
||||
return {
|
||||
type: DELETE_COMMENT,
|
||||
commentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function setFocusedComment(
|
||||
commentId: number | null,
|
||||
{ updatePinnedComment } = { updatePinnedComment: false }
|
||||
): SetFocusedCommentAction {
|
||||
return {
|
||||
type: SET_FOCUSED_COMMENT,
|
||||
commentId,
|
||||
updatePinnedComment
|
||||
};
|
||||
}
|
||||
|
||||
export function addReply(
|
||||
commentId: number,
|
||||
reply: CommentReply
|
||||
): AddReplyAction {
|
||||
return {
|
||||
type: ADD_REPLY,
|
||||
commentId,
|
||||
reply,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateReply(
|
||||
commentId: number,
|
||||
replyId: number,
|
||||
update: CommentReplyUpdate
|
||||
): UpdateReplyAction {
|
||||
return {
|
||||
type: UPDATE_REPLY,
|
||||
commentId,
|
||||
replyId,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteReply(
|
||||
commentId: number,
|
||||
replyId: number
|
||||
): DeleteReplyAction {
|
||||
return {
|
||||
type: DELETE_REPLY,
|
||||
commentId,
|
||||
replyId,
|
||||
};
|
||||
}
|
||||
|
||||
export const commentActionFunctions = {
|
||||
addComment,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
setFocusedComment,
|
||||
addReply,
|
||||
updateReply,
|
||||
deleteReply,
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
import type { Action as CommentsAction } from './comments';
|
||||
import type { Action as SettingsActon } from './settings';
|
||||
|
||||
export type Action = CommentsAction | SettingsActon;
|
|
@ -0,0 +1,19 @@
|
|||
import type { SettingsStateUpdate } from '../state/settings';
|
||||
|
||||
export const UPDATE_GLOBAL_SETTINGS = 'update-global-settings';
|
||||
|
||||
export interface UpdateGlobalSettingsAction {
|
||||
type: typeof UPDATE_GLOBAL_SETTINGS;
|
||||
update: SettingsStateUpdate;
|
||||
}
|
||||
|
||||
export type Action = UpdateGlobalSettingsAction;
|
||||
|
||||
export function updateGlobalSettings(
|
||||
update: SettingsStateUpdate
|
||||
): UpdateGlobalSettingsAction {
|
||||
return {
|
||||
type: UPDATE_GLOBAL_SETTINGS,
|
||||
update,
|
||||
};
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { initCommentApp } from 'wagtail-comment-frontend';
|
||||
import { initCommentApp } from './main';
|
||||
import { STRINGS } from '../../config/wagtailConfig';
|
||||
|
||||
function initComments() {
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
import React from 'react';
|
||||
import { createStore } from 'redux';
|
||||
|
||||
import { Store, reducer } from '../../state';
|
||||
|
||||
import {
|
||||
RenderCommentsForStorybook,
|
||||
addTestComment,
|
||||
} from '../../utils/storybook';
|
||||
|
||||
export default { title: 'Comment' };
|
||||
|
||||
export function addNewComment() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'creating',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function comment() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function commentFromSomeoneElse() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
author: {
|
||||
id: 2,
|
||||
name: 'Someone else',
|
||||
avatarUrl: 'https://gravatar.com/avatar/31c3d5cc27d1faa321c2413589e8a53f?s=200&d=robohash&r=x',
|
||||
},
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function commentFromSomeoneElseWithoutAvatar() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
author: {
|
||||
id: 2,
|
||||
name: 'Someone else',
|
||||
},
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function commentFromSomeoneWithAReallyLongName() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
author: {
|
||||
id: 1,
|
||||
name: 'This person has a really long name and it should wrap to the next line',
|
||||
avatarUrl: 'https://gravatar.com/avatar/31c3d5cc27d1faa321c2413589e8a53f?s=200&d=robohash&r=x',
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function focused() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function saving() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'saving',
|
||||
text: 'An example comment',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function saveError() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'save_error',
|
||||
text: 'An example comment',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function deleteConfirm() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'delete_confirm',
|
||||
text: 'An example comment',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function deleting() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
addTestComment(store, {
|
||||
mode: 'deleting',
|
||||
text: 'An example comment',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function deleteError() {
|
||||
const store: Store = createStore(reducer);
|
||||
addTestComment(store, {
|
||||
mode: 'delete_error',
|
||||
text: 'An example comment',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
|
@ -0,0 +1,596 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import type { Store } from '../../state';
|
||||
import { Author, Comment, newCommentReply } from '../../state/comments';
|
||||
import {
|
||||
updateComment,
|
||||
deleteComment,
|
||||
setFocusedComment,
|
||||
addReply
|
||||
} from '../../actions/comments';
|
||||
import { LayoutController } from '../../utils/layout';
|
||||
import { getNextReplyId } from '../../utils/sequences';
|
||||
import CommentReplyComponent from '../CommentReply';
|
||||
import type { TranslatableStrings } from '../../main';
|
||||
import { CommentHeader } from '../CommentHeader';
|
||||
|
||||
async function saveComment(comment: Comment, store: Store) {
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'saving',
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'default',
|
||||
text: comment.newText,
|
||||
remoteId: comment.remoteId,
|
||||
author: comment.author,
|
||||
date: comment.date,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error(err);
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'save_error',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDeleteComment(comment: Comment, store: Store) {
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'deleting',
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
store.dispatch(deleteComment(comment.localId));
|
||||
} catch (err) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error(err);
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'delete_error',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommentProps {
|
||||
store: Store;
|
||||
comment: Comment;
|
||||
isFocused: boolean;
|
||||
layout: LayoutController;
|
||||
user: Author | null;
|
||||
strings: TranslatableStrings;
|
||||
}
|
||||
|
||||
export default class CommentComponent extends React.Component<CommentProps> {
|
||||
renderReplies({ hideNewReply = false } = {}): React.ReactFragment {
|
||||
const { comment, isFocused, store, user, strings } = this.props;
|
||||
|
||||
if (!comment.remoteId) {
|
||||
// Hide replies UI if the comment itself isn't saved yet
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const onChangeNewReply = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
newReply: e.target.value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const sendReply = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const replyId = getNextReplyId();
|
||||
const reply = newCommentReply(replyId, user, Date.now(), {
|
||||
text: comment.newReply,
|
||||
mode: 'default',
|
||||
});
|
||||
store.dispatch(addReply(comment.localId, reply));
|
||||
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
newReply: '',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onClickCancelReply = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
newReply: '',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const replies: React.ReactNode[] = [];
|
||||
let replyBeingEdited = false;
|
||||
for (const reply of comment.replies.values()) {
|
||||
if (reply.mode === 'saving' || reply.mode === 'editing') {
|
||||
replyBeingEdited = true;
|
||||
}
|
||||
|
||||
if (!reply.deleted) {
|
||||
replies.push(
|
||||
<CommentReplyComponent
|
||||
key={reply.localId}
|
||||
store={store}
|
||||
user={user}
|
||||
comment={comment}
|
||||
reply={reply}
|
||||
strings={strings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide new reply if a reply is being edited as well
|
||||
const newReplyHidden = hideNewReply || replyBeingEdited;
|
||||
|
||||
let replyActions = <></>;
|
||||
if (!newReplyHidden && isFocused && comment.newReply.length > 0) {
|
||||
replyActions = (
|
||||
<div className="comment__reply-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment__button comment__button--primary"
|
||||
>
|
||||
{strings.REPLY}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClickCancelReply}
|
||||
className="comment__button"
|
||||
>
|
||||
{strings.CANCEL}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let replyTextarea = <></>;
|
||||
if (!newReplyHidden && (isFocused || comment.newReply)) {
|
||||
replyTextarea = (
|
||||
<textarea
|
||||
className="comment__reply-input"
|
||||
placeholder="Enter your reply..."
|
||||
value={comment.newReply}
|
||||
onChange={onChangeNewReply}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="comment__replies">{replies}</ul>
|
||||
<form onSubmit={sendReply}>
|
||||
{replyTextarea}
|
||||
{replyActions}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderCreating(): React.ReactFragment {
|
||||
const { comment, store, strings } = this.props;
|
||||
|
||||
const onChangeText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
newText: e.target.value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await saveComment(comment, store);
|
||||
};
|
||||
|
||||
const onCancel = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(deleteComment(comment.localId));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={comment} store={store} strings={strings} />
|
||||
<form onSubmit={onSave}>
|
||||
<textarea
|
||||
className="comment__input"
|
||||
value={comment.newText}
|
||||
onChange={onChangeText}
|
||||
style={{ resize: 'none' }}
|
||||
placeholder="Enter your comments..."
|
||||
/>
|
||||
<div className="comment__actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment__button comment__button--primary"
|
||||
>
|
||||
{strings.COMMENT}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="comment__button"
|
||||
>
|
||||
{strings.CANCEL}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderEditing(): React.ReactFragment {
|
||||
const { comment, store, strings } = this.props;
|
||||
|
||||
const onChangeText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
newText: e.target.value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await saveComment(comment, store);
|
||||
};
|
||||
|
||||
const onCancel = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'default',
|
||||
newText: comment.text,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={comment} store={store} strings={strings} />
|
||||
<form onSubmit={onSave}>
|
||||
<textarea
|
||||
className="comment__input"
|
||||
value={comment.newText}
|
||||
onChange={onChangeText}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
<div className="comment__actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment__button comment__button--primary"
|
||||
>
|
||||
{strings.SAVE}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="comment__button"
|
||||
>
|
||||
{strings.CANCEL}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{this.renderReplies({ hideNewReply: true })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderSaving(): React.ReactFragment {
|
||||
const { comment, store, strings } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={comment} store={store} strings={strings} />
|
||||
<p className="comment__text">{comment.text}</p>
|
||||
<div className="comment__progress">{strings.SAVING}</div>
|
||||
{this.renderReplies({ hideNewReply: true })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderSaveError(): React.ReactFragment {
|
||||
const { comment, store, strings } = this.props;
|
||||
|
||||
const onClickRetry = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await saveComment(comment, store);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={comment} store={store} strings={strings} />
|
||||
<p className="comment__text">{comment.text}</p>
|
||||
{this.renderReplies({ hideNewReply: true })}
|
||||
<div className="comment__error">
|
||||
{strings.SAVE_ERROR}
|
||||
<button
|
||||
type="button"
|
||||
className="comment__button"
|
||||
onClick={onClickRetry}
|
||||
>
|
||||
{strings.RETRY}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderDeleteConfirm(): React.ReactFragment {
|
||||
const { comment, store, strings } = this.props;
|
||||
|
||||
const onClickDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await doDeleteComment(comment, store);
|
||||
};
|
||||
|
||||
const onClickCancel = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'default',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={comment} store={store} strings={strings} />
|
||||
<p className="comment__text">{comment.text}</p>
|
||||
<div className="comment__confirm-delete">
|
||||
{strings.CONFIRM_DELETE_COMMENT}
|
||||
<button
|
||||
type="button"
|
||||
className="comment__button comment__button--red"
|
||||
onClick={onClickDelete}
|
||||
>
|
||||
{strings.DELETE}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comment__button"
|
||||
onClick={onClickCancel}
|
||||
>
|
||||
{strings.CANCEL}
|
||||
</button>
|
||||
</div>
|
||||
{this.renderReplies({ hideNewReply: true })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderDeleting(): React.ReactFragment {
|
||||
const { comment, store, strings } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={comment} store={store} strings={strings} />
|
||||
<p className="comment__text">{comment.text}</p>
|
||||
<div className="comment__progress">{strings.DELETING}</div>
|
||||
{this.renderReplies({ hideNewReply: true })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderDeleteError(): React.ReactFragment {
|
||||
const { comment, store, strings } = this.props;
|
||||
|
||||
const onClickRetry = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await doDeleteComment(comment, store);
|
||||
};
|
||||
|
||||
const onClickCancel = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'default',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={comment} store={store} strings={strings} />
|
||||
<p className="comment__text">{comment.text}</p>
|
||||
{this.renderReplies({ hideNewReply: true })}
|
||||
<div className="comment__error">
|
||||
{strings.DELETE_ERROR}
|
||||
<button
|
||||
type="button"
|
||||
className="comment__button"
|
||||
onClick={onClickCancel}
|
||||
>
|
||||
{strings.CANCEL}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comment__button"
|
||||
onClick={onClickRetry}
|
||||
>
|
||||
{strings.RETRY}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderDefault(): React.ReactFragment {
|
||||
const { comment, store, strings } = this.props;
|
||||
|
||||
// Show edit/delete buttons if this comment was authored by the current user
|
||||
let onEdit;
|
||||
let onDelete;
|
||||
if (comment.author === null || this.props.user && this.props.user.id === comment.author.id) {
|
||||
onEdit = () => {
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'editing',
|
||||
newText: comment.text,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
onDelete = () => {
|
||||
store.dispatch(
|
||||
updateComment(comment.localId, {
|
||||
mode: 'delete_confirm',
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader
|
||||
commentReply={comment}
|
||||
store={store}
|
||||
strings={strings}
|
||||
onResolve={doDeleteComment}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<p className="comment__text">{comment.text}</p>
|
||||
{this.renderReplies()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
let inner: React.ReactFragment;
|
||||
|
||||
switch (this.props.comment.mode) {
|
||||
case 'creating':
|
||||
inner = this.renderCreating();
|
||||
break;
|
||||
|
||||
case 'editing':
|
||||
inner = this.renderEditing();
|
||||
break;
|
||||
|
||||
case 'saving':
|
||||
inner = this.renderSaving();
|
||||
break;
|
||||
|
||||
case 'save_error':
|
||||
inner = this.renderSaveError();
|
||||
break;
|
||||
|
||||
case 'delete_confirm':
|
||||
inner = this.renderDeleteConfirm();
|
||||
break;
|
||||
|
||||
case 'deleting':
|
||||
inner = this.renderDeleting();
|
||||
break;
|
||||
|
||||
case 'delete_error':
|
||||
inner = this.renderDeleteError();
|
||||
break;
|
||||
|
||||
default:
|
||||
inner = this.renderDefault();
|
||||
break;
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
this.props.store.dispatch(setFocusedComment(this.props.comment.localId));
|
||||
};
|
||||
|
||||
const onDoubleClick = () => {
|
||||
this.props.store.dispatch(setFocusedComment(this.props.comment.localId, { updatePinnedComment: true }));
|
||||
};
|
||||
|
||||
const top = this.props.layout.getCommentPosition(
|
||||
this.props.comment.localId
|
||||
);
|
||||
const right = this.props.isFocused ? 50 : 0;
|
||||
return (
|
||||
<li
|
||||
key={this.props.comment.localId}
|
||||
className={`comment comment--mode-${this.props.comment.mode} ${this.props.isFocused ? 'comment--focused' : ''}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${top}px`,
|
||||
right: `${right}px`,
|
||||
}}
|
||||
data-comment-id={this.props.comment.localId}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{inner}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const element = ReactDOM.findDOMNode(this);
|
||||
|
||||
if (element instanceof HTMLElement) {
|
||||
// If this is a new comment, focus in the edit box
|
||||
if (this.props.comment.mode === 'creating') {
|
||||
const textAreaElement = element.querySelector('textarea');
|
||||
|
||||
if (textAreaElement instanceof HTMLTextAreaElement) {
|
||||
textAreaElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
this.props.layout.setCommentElement(this.props.comment.localId, element);
|
||||
this.props.layout.setCommentHeight(
|
||||
this.props.comment.localId,
|
||||
element.offsetHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.layout.setCommentElement(this.props.comment.localId, null);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const element = ReactDOM.findDOMNode(this);
|
||||
|
||||
// Keep height up to date so that other comments will be moved out of the way
|
||||
if (element instanceof HTMLElement) {
|
||||
this.props.layout.setCommentHeight(
|
||||
this.props.comment.localId,
|
||||
element.offsetHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
.comment {
|
||||
@include box;
|
||||
|
||||
font-size: 1.5em;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: block;
|
||||
transition: top 0.5s ease 0s, right 0.5s ease 0s, height 0.5s ease 0s;
|
||||
pointer-events: auto;
|
||||
|
||||
&__text {
|
||||
color: $color-box-text;
|
||||
font-size: 0.8em;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 0;
|
||||
|
||||
&--mode-deleting {
|
||||
color: $color-grey-1;
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
&--mode-deleting &__text {
|
||||
color: $color-grey-3;
|
||||
}
|
||||
|
||||
&__replies {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__button {
|
||||
@include button;
|
||||
}
|
||||
|
||||
&__actions &__button,
|
||||
&__confirm-delete &__button,
|
||||
&__reply-actions &__button {
|
||||
margin-right: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&__confirm-delete,
|
||||
&__error {
|
||||
color: $color-box-text;
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
|
||||
button {
|
||||
margin-left: 10px;
|
||||
/* stylelint-disable-next-line declaration-no-important */
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: $color-white;
|
||||
background-color: $color-red-dark;
|
||||
border-radius: 3px;
|
||||
padding: 5px;
|
||||
padding-left: 10px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
vertical-align: middle;
|
||||
|
||||
button {
|
||||
height: 26px;
|
||||
float: right;
|
||||
margin-left: 5px;
|
||||
color: $color-white;
|
||||
background-color: $color-red-very-dark;
|
||||
border-color: $color-red-very-dark;
|
||||
padding: 2px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
font-size: 0.65em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
content: '';
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress {
|
||||
margin-top: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
&__reply-input {
|
||||
/* stylelint-disable-next-line declaration-no-important */
|
||||
margin-top: 20px !important;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
|
||||
import dateFormat from 'dateformat';
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import type { Store } from '../../state';
|
||||
import { TranslatableStrings } from '../../main';
|
||||
|
||||
import { Author } from '../../state/comments';
|
||||
|
||||
|
||||
interface CommentReply {
|
||||
author: Author | null;
|
||||
date: number;
|
||||
}
|
||||
|
||||
interface CommentHeaderProps {
|
||||
commentReply: CommentReply;
|
||||
store: Store;
|
||||
strings: TranslatableStrings;
|
||||
onResolve?(commentReply: CommentReply, store: Store): void;
|
||||
onEdit?(commentReply: CommentReply, store: Store): void;
|
||||
onDelete?(commentReply: CommentReply, store: Store): void;
|
||||
}
|
||||
|
||||
export const CommentHeader: FunctionComponent<CommentHeaderProps> = ({
|
||||
commentReply, store, strings, onResolve, onEdit, onDelete
|
||||
}) => {
|
||||
const { author, date } = commentReply;
|
||||
|
||||
const onClickResolve = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (onResolve) {
|
||||
onResolve(commentReply, store);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickEdit = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (onEdit) {
|
||||
onEdit(commentReply, store);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (onDelete) {
|
||||
onDelete(commentReply, store);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="comment-header">
|
||||
<div className="comment-header__actions">
|
||||
{onResolve &&
|
||||
<div className="comment-header__action comment-header__action--resolve">
|
||||
<button type="button" aria-label={strings.RESOLVE} onClick={onClickResolve}>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
{(onEdit || onDelete) &&
|
||||
<div className="comment-header__action comment-header__action--more">
|
||||
<details>
|
||||
<summary aria-label={strings.MORE_ACTIONS} aria-haspopup="menu" role="button">
|
||||
</summary>
|
||||
|
||||
<div className="comment-header__more-actions">
|
||||
{onEdit && <button type="button" role="menuitem" onClick={onClickEdit}>{strings.EDIT}</button>}
|
||||
{onDelete && <button type="button" role="menuitem" onClick={onClickDelete}>{strings.DELETE}</button>}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{author && author.avatarUrl &&
|
||||
<img className="comment-header__avatar" src={author.avatarUrl} role="presentation" />}
|
||||
<p className="comment-header__author">{author ? author.name : ''}</p>
|
||||
<p className="comment-header__date">{dateFormat(date, 'h:MM mmmm d')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,136 @@
|
|||
.comment-header {
|
||||
position: relative;
|
||||
|
||||
&__avatar {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
&__author,
|
||||
&__date {
|
||||
max-width: calc(100% - 160px); // Leave room for actions to the right and avatar to the left
|
||||
margin: 0;
|
||||
margin-left: 57px;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
&__date {
|
||||
color: $color-grey-25;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&__action {
|
||||
float: left;
|
||||
margin-left: 10px;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-grey-7;
|
||||
}
|
||||
|
||||
> button,
|
||||
> details > summary {
|
||||
// Hides triangle on Firefox
|
||||
list-style-type: none;
|
||||
// Hides triangle on Chrome
|
||||
&::-webkit-details-marker { display: none; }
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
background-color: unset;
|
||||
border: unset;
|
||||
-moz-outline-radius: 10px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
mask-position: center;
|
||||
mask-size: 25px 25px;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
> details {
|
||||
position: relative;
|
||||
|
||||
> div {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
&--resolve {
|
||||
> button::before,
|
||||
> details > summary::before {
|
||||
background-color: $color-teal;
|
||||
mask-image: url('./icons/check-solid.svg');
|
||||
}
|
||||
}
|
||||
|
||||
&--more {
|
||||
> button::before,
|
||||
> details > summary::before {
|
||||
background-color: $color-grey-25;
|
||||
background-position: center;
|
||||
mask-image: url('./icons/ellipsis-v-solid.svg');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__more-actions {
|
||||
background-color: #333;
|
||||
padding: 0.75rem 1rem;
|
||||
min-width: 8rem;
|
||||
text-transform: none;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
border: 0.35rem solid transparent;
|
||||
border-bottom-color: #333;
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
background: unset;
|
||||
border: unset;
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
font-size: 20px;
|
||||
width: 120px;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment--mode-deleting .comment-header,
|
||||
.comment-reply--mode-deleting .comment-header {
|
||||
opacity: 0.5;
|
||||
}
|
|
@ -0,0 +1,184 @@
|
|||
import React from 'react';
|
||||
import { createStore } from 'redux';
|
||||
|
||||
import { Store, reducer } from '../../state';
|
||||
|
||||
import {
|
||||
RenderCommentsForStorybook,
|
||||
addTestComment,
|
||||
addTestReply,
|
||||
} from '../../utils/storybook';
|
||||
|
||||
export default { title: 'CommentReply' };
|
||||
|
||||
export function reply() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'default',
|
||||
text: 'An example reply',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function replyFromSomeoneElse() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'default',
|
||||
text: 'An example reply',
|
||||
author: {
|
||||
id: 2,
|
||||
name: 'Someone else',
|
||||
avatarUrl: 'https://gravatar.com/avatar/31c3d5cc27d1faa321c2413589e8a53f?s=200&d=robohash&r=x',
|
||||
},
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function focused() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'default',
|
||||
text: 'An example reply',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function editing() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'editing',
|
||||
text: 'An example reply',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function saving() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'saving',
|
||||
text: 'An example reply',
|
||||
});
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function saveError() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'save_error',
|
||||
text: 'An example reply',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function deleteConfirm() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'delete_confirm',
|
||||
text: 'An example reply',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function deleting() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'deleting',
|
||||
text: 'An example reply',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function deleteError() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'delete_error',
|
||||
text: 'An example reply',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
||||
|
||||
export function deleted() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
const commentId = addTestComment(store, {
|
||||
mode: 'default',
|
||||
text: 'An example comment',
|
||||
focused: true,
|
||||
});
|
||||
|
||||
addTestReply(store, commentId, {
|
||||
mode: 'deleted',
|
||||
text: 'An example reply',
|
||||
});
|
||||
|
||||
return <RenderCommentsForStorybook store={store} />;
|
||||
}
|
|
@ -0,0 +1,346 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { Store } from '../../state';
|
||||
import type { Comment, CommentReply, Author } from '../../state/comments';
|
||||
import { updateReply, deleteReply } from '../../actions/comments';
|
||||
import type { TranslatableStrings } from '../../main';
|
||||
import { CommentHeader } from '../CommentHeader';
|
||||
|
||||
export async function saveCommentReply(
|
||||
comment: Comment,
|
||||
reply: CommentReply,
|
||||
store: Store
|
||||
) {
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'saving',
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'default',
|
||||
text: reply.newText,
|
||||
author: reply.author,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
/* eslint-disable-next-line no-console */
|
||||
console.error(err);
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'save_error',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCommentReply(
|
||||
comment: Comment,
|
||||
reply: CommentReply,
|
||||
store: Store
|
||||
) {
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'deleting',
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
store.dispatch(deleteReply(comment.localId, reply.localId));
|
||||
} catch (err) {
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'delete_error',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommentReplyProps {
|
||||
comment: Comment;
|
||||
reply: CommentReply;
|
||||
store: Store;
|
||||
user: Author | null;
|
||||
strings: TranslatableStrings;
|
||||
}
|
||||
|
||||
export default class CommentReplyComponent extends React.Component<CommentReplyProps> {
|
||||
renderEditing(): React.ReactFragment {
|
||||
const { comment, reply, store, strings } = this.props;
|
||||
|
||||
const onChangeText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
newText: e.target.value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await saveCommentReply(comment, reply, store);
|
||||
};
|
||||
|
||||
const onCancel = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'default',
|
||||
newText: reply.text,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={reply} store={store} strings={strings} />
|
||||
<form onSubmit={onSave}>
|
||||
<textarea
|
||||
className="comment-reply__input"
|
||||
value={reply.newText}
|
||||
onChange={onChangeText}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
<div className="comment-reply__actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="comment-reply__button comment-reply__button--primary"
|
||||
>
|
||||
{strings.SAVE}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-reply__button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{strings.CANCEL}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderSaving(): React.ReactFragment {
|
||||
const { reply, store, strings } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={reply} store={store} strings={strings} />
|
||||
<p className="comment-reply__text">{reply.text}</p>
|
||||
<div className="comment-reply__progress">{strings.SAVING}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderSaveError(): React.ReactFragment {
|
||||
const { comment, reply, store, strings } = this.props;
|
||||
|
||||
const onClickRetry = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await saveCommentReply(comment, reply, store);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={reply} store={store} strings={strings} />
|
||||
<p className="comment-reply__text">{reply.text}</p>
|
||||
<div className="comment-reply__error">
|
||||
{strings.SAVE_ERROR}
|
||||
<button
|
||||
type="button"
|
||||
className="comment-reply__button"
|
||||
onClick={onClickRetry}
|
||||
>
|
||||
{strings.RETRY}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderDeleteConfirm(): React.ReactFragment {
|
||||
const { comment, reply, store, strings } = this.props;
|
||||
|
||||
const onClickDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await deleteCommentReply(comment, reply, store);
|
||||
};
|
||||
|
||||
const onClickCancel = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'default',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={reply} store={store} strings={strings} />
|
||||
<p className="comment-reply__text">{reply.text}</p>
|
||||
<div className="comment-reply__confirm-delete">
|
||||
{strings.CONFIRM_DELETE_COMMENT}
|
||||
<button
|
||||
type="button"
|
||||
className="comment-reply__button comment-reply__button--red"
|
||||
onClick={onClickDelete}
|
||||
>
|
||||
{strings.DELETE}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-reply__button"
|
||||
onClick={onClickCancel}
|
||||
>
|
||||
{strings.CANCEL}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderDeleting(): React.ReactFragment {
|
||||
const { reply, store, strings } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={reply} store={store} strings={strings} />
|
||||
<p className="comment-reply__text">{reply.text}</p>
|
||||
<div className="comment-reply__progress">{strings.DELETING}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderDeleteError(): React.ReactFragment {
|
||||
const { comment, reply, store, strings } = this.props;
|
||||
|
||||
const onClickRetry = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
await deleteCommentReply(comment, reply, store);
|
||||
};
|
||||
|
||||
const onClickCancel = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'default',
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={reply} store={store} strings={strings} />
|
||||
<p className="comment-reply__text">{reply.text}</p>
|
||||
<div className="comment-reply__error">
|
||||
{strings.DELETE_ERROR}
|
||||
<button
|
||||
type="button"
|
||||
className="comment-reply__button"
|
||||
onClick={onClickCancel}
|
||||
>
|
||||
{strings.CANCEL}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-reply__button"
|
||||
onClick={onClickRetry}
|
||||
>
|
||||
{strings.RETRY}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderDefault(): React.ReactFragment {
|
||||
const { comment, reply, store, strings } = this.props;
|
||||
|
||||
// Show edit/delete buttons if this reply was authored by the current user
|
||||
let onEdit;
|
||||
let onDelete;
|
||||
if (reply.author === null || this.props.user && this.props.user.id === reply.author.id) {
|
||||
onEdit = () => {
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'editing',
|
||||
newText: reply.text,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
onDelete = () => {
|
||||
store.dispatch(
|
||||
updateReply(comment.localId, reply.localId, {
|
||||
mode: 'delete_confirm',
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommentHeader commentReply={reply} store={store} strings={strings} onEdit={onEdit} onDelete={onDelete} />
|
||||
<p className="comment-reply__text">{reply.text}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
let inner: React.ReactFragment;
|
||||
|
||||
switch (this.props.reply.mode) {
|
||||
case 'editing':
|
||||
inner = this.renderEditing();
|
||||
break;
|
||||
|
||||
case 'saving':
|
||||
inner = this.renderSaving();
|
||||
break;
|
||||
|
||||
case 'save_error':
|
||||
inner = this.renderSaveError();
|
||||
break;
|
||||
|
||||
case 'delete_confirm':
|
||||
inner = this.renderDeleteConfirm();
|
||||
break;
|
||||
|
||||
case 'deleting':
|
||||
inner = this.renderDeleting();
|
||||
break;
|
||||
|
||||
case 'delete_error':
|
||||
inner = this.renderDeleteError();
|
||||
break;
|
||||
|
||||
default:
|
||||
inner = this.renderDefault();
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={this.props.reply.localId}
|
||||
className={`comment-reply comment-reply--mode-${this.props.reply.mode}`}
|
||||
data-reply-id={this.props.reply.localId}
|
||||
>
|
||||
{inner}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
.comment-reply {
|
||||
margin-top: 40px;
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
|
||||
&__text {
|
||||
color: $color-box-text;
|
||||
font-size: 0.8em;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 0;
|
||||
|
||||
&--mode-deleting {
|
||||
color: $color-grey-1;
|
||||
}
|
||||
}
|
||||
|
||||
&--mode-deleting &__avatar {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&--mode-deleting &__text {
|
||||
color: $color-grey-3;
|
||||
}
|
||||
|
||||
&__button {
|
||||
@include button;
|
||||
}
|
||||
|
||||
&__actions,
|
||||
&__confirm-delete,
|
||||
&__progress,
|
||||
&__error {
|
||||
&::after {
|
||||
display: block;
|
||||
content: '';
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions &__button,
|
||||
&__confirm-delete &__button {
|
||||
margin-top: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
&__confirm-delete,
|
||||
&__error {
|
||||
color: $color-box-text;
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
|
||||
button {
|
||||
margin-left: 10px;
|
||||
/* stylelint-disable-next-line declaration-no-important */
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: $color-white;
|
||||
background-color: $color-red-dark;
|
||||
border-radius: 3px;
|
||||
padding: 5px;
|
||||
padding-left: 10px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
vertical-align: middle;
|
||||
|
||||
button {
|
||||
height: 26px;
|
||||
float: right;
|
||||
margin-left: 5px;
|
||||
color: $color-white;
|
||||
background-color: $color-red-very-dark;
|
||||
border-color: $color-red-very-dark;
|
||||
padding: 2px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
font-size: 0.65em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__progress {
|
||||
margin-top: 20px;
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
import React from 'react';
|
||||
|
||||
import type { Comment, CommentReply } from '../../state/comments';
|
||||
|
||||
interface PrefixedHiddenInputProps {
|
||||
prefix: string;
|
||||
value: number | string | null;
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
function PrefixedHiddenInput({
|
||||
prefix,
|
||||
value,
|
||||
fieldName,
|
||||
}: PrefixedHiddenInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="hidden"
|
||||
name={`${prefix}-${fieldName}`}
|
||||
value={value === null ? '' : value}
|
||||
id={`id_${prefix}-${fieldName}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CommentReplyFormComponentProps {
|
||||
reply: CommentReply;
|
||||
prefix: string;
|
||||
formNumber: number;
|
||||
}
|
||||
|
||||
export function CommentReplyFormComponent({
|
||||
reply,
|
||||
formNumber,
|
||||
prefix,
|
||||
}: CommentReplyFormComponentProps) {
|
||||
const fullPrefix = `${prefix}-${formNumber}`;
|
||||
return (
|
||||
<fieldset>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="DELETE"
|
||||
value={reply.deleted ? 1 : ''}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="id"
|
||||
value={reply.remoteId}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="text"
|
||||
value={reply.text}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CommentReplyFormSetProps {
|
||||
replies: CommentReply[];
|
||||
prefix: string;
|
||||
remoteReplyCount: number;
|
||||
}
|
||||
|
||||
export function CommentReplyFormSetComponent({
|
||||
replies,
|
||||
prefix,
|
||||
remoteReplyCount,
|
||||
}: CommentReplyFormSetProps) {
|
||||
const fullPrefix = `${prefix}-replies`;
|
||||
|
||||
const commentForms = replies.map((reply, formNumber) => (
|
||||
<CommentReplyFormComponent
|
||||
key={reply.localId}
|
||||
formNumber={formNumber}
|
||||
reply={reply}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="TOTAL_FORMS"
|
||||
value={replies.length}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="INITIAL_FORMS"
|
||||
value={remoteReplyCount}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="MIN_NUM_FORMS"
|
||||
value="0"
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="MAX_NUM_FORMS"
|
||||
value=""
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
{commentForms}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CommentFormProps {
|
||||
comment: Comment;
|
||||
formNumber: number;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export function CommentFormComponent({
|
||||
comment,
|
||||
formNumber,
|
||||
prefix,
|
||||
}: CommentFormProps) {
|
||||
const fullPrefix = `${prefix}-${formNumber}`;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="DELETE"
|
||||
value={comment.deleted ? 1 : ''}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="id"
|
||||
value={comment.remoteId}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="contentpath"
|
||||
value={comment.contentpath}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="text"
|
||||
value={comment.text}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="position"
|
||||
value={comment.position}
|
||||
prefix={fullPrefix}
|
||||
/>
|
||||
<CommentReplyFormSetComponent
|
||||
replies={Array.from(comment.replies.values())}
|
||||
prefix={fullPrefix}
|
||||
remoteReplyCount={comment.remoteReplyCount}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CommentFormSetProps {
|
||||
comments: Comment[];
|
||||
remoteCommentCount: number;
|
||||
}
|
||||
|
||||
export function CommentFormSetComponent({
|
||||
comments,
|
||||
remoteCommentCount,
|
||||
}: CommentFormSetProps) {
|
||||
const prefix = 'comments';
|
||||
|
||||
const commentForms = comments.map((comment, formNumber) => (
|
||||
<CommentFormComponent
|
||||
key={comment.localId}
|
||||
comment={comment}
|
||||
formNumber={formNumber}
|
||||
prefix={prefix}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="TOTAL_FORMS"
|
||||
value={comments.length}
|
||||
prefix={prefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="INITIAL_FORMS"
|
||||
value={remoteCommentCount}
|
||||
prefix={prefix}
|
||||
/>
|
||||
<PrefixedHiddenInput
|
||||
fieldName="MIN_NUM_FORMS"
|
||||
value="0"
|
||||
prefix={prefix}
|
||||
/>
|
||||
<PrefixedHiddenInput fieldName="MAX_NUM_FORMS" value="" prefix={prefix} />
|
||||
{commentForms}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import { createStore } from 'redux';
|
||||
|
||||
import { Store, reducer } from '../../state';
|
||||
import { Styling } from '../../utils/storybook';
|
||||
|
||||
import TopBarComponent from './index';
|
||||
|
||||
import { defaultStrings } from '../../main';
|
||||
|
||||
export default { title: 'TopBar' };
|
||||
|
||||
function RenderTopBarForStorybook({ store }: { store: Store }) {
|
||||
const [state, setState] = React.useState(store.getState());
|
||||
store.subscribe(() => {
|
||||
setState(store.getState());
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Styling />
|
||||
<TopBarComponent
|
||||
store={store}
|
||||
strings={defaultStrings}
|
||||
{...state.settings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function topBar() {
|
||||
const store: Store = createStore(reducer);
|
||||
|
||||
return <RenderTopBarForStorybook store={store} />;
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
|
||||
import type { Store } from '../../state';
|
||||
import { updateGlobalSettings } from '../../actions/settings';
|
||||
|
||||
import Checkbox from '../widgets/Checkbox';
|
||||
import type { TranslatableStrings } from '../../main';
|
||||
|
||||
export interface TopBarProps {
|
||||
commentsEnabled: boolean;
|
||||
store: Store;
|
||||
strings: TranslatableStrings;
|
||||
}
|
||||
|
||||
export default function TopBarComponent({
|
||||
commentsEnabled,
|
||||
store,
|
||||
strings,
|
||||
}: TopBarProps) {
|
||||
const onChangeCommentsEnabled = (checked: boolean) => {
|
||||
store.dispatch(
|
||||
updateGlobalSettings({
|
||||
commentsEnabled: checked,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="comments-topbar">
|
||||
<ul className="comments-topbar__settings">
|
||||
<li>
|
||||
<Checkbox
|
||||
id="show-comments"
|
||||
label={strings.SHOW_COMMENTS}
|
||||
onChange={onChangeCommentsEnabled}
|
||||
checked={commentsEnabled}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
.comments-topbar {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
background-color: $color-box-background;
|
||||
color: $color-white;
|
||||
padding: 5px;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
||||
&__settings {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
float: left;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Styling } from '../../../utils/storybook';
|
||||
|
||||
import Checkbox from '.';
|
||||
|
||||
export default { title: 'Checkbox' };
|
||||
|
||||
export function checkbox() {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Styling />
|
||||
<Checkbox
|
||||
id="id"
|
||||
label="Checkbox"
|
||||
checked={checked}
|
||||
onChange={setChecked}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
|
||||
export interface CheckboxProps {
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange?: (checked: boolean) => any;
|
||||
}
|
||||
|
||||
const Checkbox = (props: CheckboxProps) => {
|
||||
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (props.onChange) {
|
||||
props.onChange(e.target.checked);
|
||||
}
|
||||
};
|
||||
|
||||
if (props.disabled) {
|
||||
return (
|
||||
<div className="checkbox">
|
||||
<input
|
||||
id={props.id}
|
||||
type="checkbox"
|
||||
checked={props.checked}
|
||||
disabled={true}
|
||||
/>
|
||||
<label htmlFor={props.id}>{props.label}</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="checkbox">
|
||||
<input
|
||||
id={props.id}
|
||||
type="checkbox"
|
||||
onChange={onChange}
|
||||
checked={props.checked}
|
||||
/>
|
||||
<label htmlFor={props.id}>{props.label}</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
|
@ -0,0 +1,62 @@
|
|||
$checkbox-size: 26px;
|
||||
$checkbox-check-size: $checkbox-size * 0.75;
|
||||
$checkbox-check-padding: ($checkbox-size - $checkbox-check-size) / 2;
|
||||
|
||||
.checkbox {
|
||||
display: inline-block;
|
||||
line-height: $checkbox-size;
|
||||
position: relative;
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
padding-right: $checkbox-size + 10px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
padding-top: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
label::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: $checkbox-size;
|
||||
width: $checkbox-size;
|
||||
margin-left: 5px;
|
||||
background-color: $color-white;
|
||||
border: 1px solid #333;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
label::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
background-color: $color-box-background;
|
||||
mask-image: url('./icons/check-solid.svg');
|
||||
mask-repeat: no-repeat;
|
||||
width: $checkbox-check-size;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: $checkbox-check-padding * 1.25;
|
||||
top: $checkbox-check-padding * 2.5;
|
||||
}
|
||||
|
||||
input[type='checkbox'] + label::after {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
input[type='checkbox']:checked + label::after {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
input[type='checkbox']:focus + label::before {
|
||||
@include focus-outline;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Styling } from '../../../utils/storybook';
|
||||
|
||||
import Radio from '.';
|
||||
|
||||
export default { title: 'Radio' };
|
||||
|
||||
export function radio() {
|
||||
const [value, setValue] = React.useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Styling />
|
||||
<Radio
|
||||
id="option-1"
|
||||
name="test"
|
||||
value="option-1"
|
||||
label="Option one"
|
||||
checked={value === 'option-1'}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<Radio
|
||||
id="option-2"
|
||||
name="test"
|
||||
value="option-2"
|
||||
label="Option two"
|
||||
checked={value === 'option-2'}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import React from 'react';
|
||||
|
||||
export interface RadioProps {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange?: (value: string) => any;
|
||||
}
|
||||
|
||||
const Radio = (props: RadioProps) => {
|
||||
const onChange = () => {
|
||||
if (props.onChange) {
|
||||
props.onChange(props.value);
|
||||
}
|
||||
};
|
||||
|
||||
if (props.disabled) {
|
||||
return (
|
||||
<div className="radio">
|
||||
<input
|
||||
id={props.id}
|
||||
type="radio"
|
||||
name={props.name}
|
||||
checked={props.checked}
|
||||
disabled={true}
|
||||
/>
|
||||
<label htmlFor={props.id}>{props.label}</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="radio">
|
||||
<input
|
||||
id={props.id}
|
||||
type="radio"
|
||||
name={props.name}
|
||||
onChange={onChange}
|
||||
checked={props.checked}
|
||||
/>
|
||||
<label htmlFor={props.id}>{props.label}</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Radio;
|
|
@ -0,0 +1,60 @@
|
|||
$radio-size: 26px;
|
||||
$radio-dot-size: $radio-size * 0.4;
|
||||
|
||||
.radio {
|
||||
display: block;
|
||||
line-height: $radio-size;
|
||||
position: relative;
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
padding-left: 10px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
label::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: $radio-size;
|
||||
width: $radio-size;
|
||||
background-color: $color-white;
|
||||
border: 2px solid #333;
|
||||
border-radius: 500rem;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
label::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
background-color: $color-box-background;
|
||||
border: 0;
|
||||
border-radius: 500rem;
|
||||
width: $radio-dot-size;
|
||||
height: $radio-dot-size;
|
||||
position: absolute;
|
||||
left: $radio-size / 2 - $radio-dot-size / 2;
|
||||
top: $radio-size / 2 - $radio-dot-size / 2;
|
||||
}
|
||||
|
||||
input[type='radio'] + label::after {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
input[type='radio']:checked + label::after {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
input[type='radio']:focus + label::before {
|
||||
@include focus-outline;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
declare module 'react-shadow';
|
||||
declare module '*.scss';
|
|
@ -0,0 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="check" class="svg-inline--fa fa-check fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"></path></svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 504 B |
|
@ -0,0 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="ellipsis-v" class="svg-inline--fa fa-ellipsis-v fa-w-6" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path fill="currentColor" d="M96 184c39.8 0 72 32.2 72 72s-32.2 72-72 72-72-32.2-72-72 32.2-72 72-72zM24 80c0 39.8 32.2 72 72 72s72-32.2 72-72S135.8 8 96 8 24 40.2 24 80zm0 352c0 39.8 32.2 72 72 72s72-32.2 72-72-32.2-72-72-72-72 32.2-72 72z"></path></svg>
|
Po Szerokość: | Wysokość: | Rozmiar: 450 B |
|
@ -0,0 +1,158 @@
|
|||
$color-teal: #007d7e;
|
||||
$color-teal-darker: darken(adjust-hue($color-teal, 1), 4);
|
||||
$color-teal-dark: darken(adjust-hue($color-teal, 1), 7);
|
||||
|
||||
$color-blue: #71b2d4;
|
||||
$color-red: #cd3238;
|
||||
$color-red-dark: #b4191f;
|
||||
$color-red-very-dark: #901419;
|
||||
$color-orange: #e9b04d;
|
||||
$color-orange-dark: #bb5b03;
|
||||
$color-green: #189370;
|
||||
$color-green-dark: #157b57;
|
||||
$color-salmon: #f37e77;
|
||||
$color-salmon-light: #fcf2f2;
|
||||
$color-white: #fff;
|
||||
$color-black: #000;
|
||||
|
||||
// darker to lighter
|
||||
$color-grey-1: darken($color-white, 80);
|
||||
$color-grey-2: darken($color-white, 70);
|
||||
$color-grey-25: #626262;
|
||||
$color-grey-3: darken($color-white, 15);
|
||||
$color-grey-4: darken($color-white, 10);
|
||||
$color-grey-5: darken($color-white, 2);
|
||||
$color-grey-7: #f2f2f2;
|
||||
$color-grey-8: #fbfbfb;
|
||||
|
||||
$color-fieldset-hover: $color-grey-5;
|
||||
$color-input-border: $color-grey-4;
|
||||
$color-input-focus: lighten(desaturate($color-teal, 40), 72);
|
||||
$color-input-focus-border: lighten(saturate($color-teal, 12), 10);
|
||||
$color-input-error-bg: lighten(saturate($color-red, 28), 45);
|
||||
|
||||
$color-link: $color-teal-darker;
|
||||
$color-link-hover: $color-teal-dark;
|
||||
|
||||
// The focus outline color is defined without reusing a named color variable
|
||||
// because it shouldn’t be reused for anything else in the UI.
|
||||
$color-focus-outline: #ffbf47;
|
||||
|
||||
$color-text-base: darken($color-white, 85);
|
||||
$color-text-input: darken($color-white, 90);
|
||||
|
||||
// Color states
|
||||
$color-state-live: #59b524;
|
||||
$color-state-draft: #808080;
|
||||
$color-state-absent: #ff8f11;
|
||||
$color-state-live-draft: #43b1b0;
|
||||
|
||||
$color-box-background: $color-white;
|
||||
$color-box-border: $color-grey-3;
|
||||
$color-box-border-focused: $color-grey-2;
|
||||
$color-box-text: $color-black;
|
||||
$color-textarea-background: $color-grey-8;
|
||||
$color-textarea-background-focused: #f2fcfc;
|
||||
$color-textarea-border: #ccc;
|
||||
$color-textarea-border-focused: #00b0b1;
|
||||
$color-textarea-placeholder-text: $color-grey-2;
|
||||
|
||||
@mixin focus-outline {
|
||||
outline: $color-focus-outline solid 3px;
|
||||
}
|
||||
|
||||
@mixin box {
|
||||
background-color: $color-box-background;
|
||||
border: 1px solid $color-box-border;
|
||||
padding: 25px;
|
||||
font-size: 16px;
|
||||
border-radius: 10px;
|
||||
color: $color-box-text;
|
||||
|
||||
&--focused {
|
||||
border: 1px solid $color-box-border-focused;
|
||||
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 0.8em;
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
background-color: $color-textarea-background;
|
||||
border: 2px solid $color-textarea-border;
|
||||
box-sizing: border-box;
|
||||
border-radius: 7px;
|
||||
-moz-outline-radius: 10px;
|
||||
color: $color-box-text;
|
||||
|
||||
&::placeholder {
|
||||
font-style: italic;
|
||||
color: $color-textarea-placeholder-text;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: $color-textarea-background-focused;
|
||||
border-color: $color-textarea-border-focused;
|
||||
outline: unset;
|
||||
}
|
||||
}
|
||||
|
||||
*:focus {
|
||||
@include focus-outline;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin button {
|
||||
background-color: inherit;
|
||||
border: 1px solid $color-grey-3;
|
||||
border-radius: 5px;
|
||||
-moz-outline-radius: 7px;
|
||||
color: $color-teal;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
height: 35px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
|
||||
&--primary {
|
||||
color: $color-white;
|
||||
border: 1px solid $color-teal;
|
||||
background-color: $color-teal;
|
||||
}
|
||||
|
||||
&--red {
|
||||
color: $color-white;
|
||||
border: 1px solid $color-red-very-dark;
|
||||
background-color: $color-red-very-dark;
|
||||
}
|
||||
|
||||
// Disable Firefox's focus styling becase we add our own.
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
height: 100%;
|
||||
width: 400px;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
z-index: 1000;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@import 'components/CommentHeader/style';
|
||||
@import 'components/Comment/style';
|
||||
@import 'components/CommentReply/style';
|
||||
@import 'components/TopBar/style';
|
||||
@import 'components/widgets/Checkbox/style';
|
||||
@import 'components/widgets/Radio/style';
|
|
@ -0,0 +1,341 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { createStore } from 'redux';
|
||||
|
||||
import type { Annotation } from './utils/annotation';
|
||||
import { LayoutController } from './utils/layout';
|
||||
import { getOrDefault } from './utils/maps';
|
||||
import { getNextCommentId, getNextReplyId } from './utils/sequences';
|
||||
import { Store, reducer } from './state';
|
||||
import { Comment, newCommentReply, newComment, Author } from './state/comments';
|
||||
import {
|
||||
addComment,
|
||||
addReply,
|
||||
setFocusedComment,
|
||||
updateComment,
|
||||
commentActionFunctions
|
||||
} from './actions/comments';
|
||||
import { updateGlobalSettings } from './actions/settings';
|
||||
import {
|
||||
selectComments,
|
||||
selectCommentsForContentPathFactory,
|
||||
selectCommentFactory,
|
||||
selectEnabled,
|
||||
selectFocused
|
||||
} from './selectors';
|
||||
import CommentComponent from './components/Comment';
|
||||
import { CommentFormSetComponent } from './components/Form';
|
||||
import TopBarComponent from './components/TopBar';
|
||||
import { INITIAL_STATE as INITIAL_SETTINGS_STATE } from './state/settings';
|
||||
|
||||
export interface TranslatableStrings {
|
||||
COMMENT: string;
|
||||
SAVE: string;
|
||||
SAVING: string;
|
||||
CANCEL: string;
|
||||
DELETE: string;
|
||||
DELETING: string;
|
||||
SHOW_COMMENTS: string;
|
||||
EDIT: string;
|
||||
REPLY: string;
|
||||
RESOLVE: string;
|
||||
RETRY: string;
|
||||
DELETE_ERROR: string;
|
||||
CONFIRM_DELETE_COMMENT: string;
|
||||
SAVE_ERROR: string;
|
||||
MORE_ACTIONS: string;
|
||||
}
|
||||
|
||||
export const defaultStrings = {
|
||||
COMMENT: 'Comment',
|
||||
SAVE: 'Save',
|
||||
SAVING: 'Saving...',
|
||||
CANCEL: 'Cancel',
|
||||
DELETE: 'Delete',
|
||||
DELETING: 'Deleting...',
|
||||
SHOW_COMMENTS: 'Show comments',
|
||||
EDIT: 'Edit',
|
||||
REPLY: 'Reply',
|
||||
RESOLVE: 'Resolve',
|
||||
RETRY: 'Retry',
|
||||
DELETE_ERROR: 'Delete error',
|
||||
CONFIRM_DELETE_COMMENT: 'Are you sure?',
|
||||
SAVE_ERROR: 'Save error',
|
||||
MORE_ACTIONS: 'More actions',
|
||||
};
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
// This is done as this is serialized pretty directly from the Django model
|
||||
export interface InitialCommentReply {
|
||||
pk: number;
|
||||
user: any;
|
||||
text: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface InitialComment {
|
||||
pk: number;
|
||||
user: any;
|
||||
text: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
replies: InitialCommentReply[];
|
||||
contentpath: string;
|
||||
position: string;
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
const getAuthor = (authors: Map<string, {name: string, avatar_url: string}>, id: any): Author => {
|
||||
const authorData = getOrDefault(authors, String(id), { name: '', avatar_url: '' });
|
||||
|
||||
return {
|
||||
id,
|
||||
name: authorData.name,
|
||||
avatarUrl: authorData.avatar_url,
|
||||
};
|
||||
};
|
||||
|
||||
function renderCommentsUi(
|
||||
store: Store,
|
||||
layout: LayoutController,
|
||||
comments: Comment[],
|
||||
strings: TranslatableStrings
|
||||
): React.ReactElement {
|
||||
const state = store.getState();
|
||||
const { commentsEnabled, user } = state.settings;
|
||||
const focusedComment = state.comments.focusedComment;
|
||||
let commentsToRender = comments;
|
||||
|
||||
if (!commentsEnabled || !user) {
|
||||
commentsToRender = [];
|
||||
}
|
||||
// Hide all resolved/deleted comments
|
||||
commentsToRender = commentsToRender.filter(({ deleted }) => !deleted);
|
||||
const commentsRendered = commentsToRender.map((comment) => (
|
||||
<CommentComponent
|
||||
key={comment.localId}
|
||||
store={store}
|
||||
layout={layout}
|
||||
user={user}
|
||||
comment={comment}
|
||||
isFocused={comment.localId === focusedComment}
|
||||
strings={strings}
|
||||
/>
|
||||
));
|
||||
return (
|
||||
<>
|
||||
<TopBarComponent
|
||||
commentsEnabled={commentsEnabled}
|
||||
store={store}
|
||||
strings={strings}
|
||||
/>
|
||||
<ol className="comments-list">{commentsRendered}</ol>
|
||||
</>
|
||||
);
|
||||
/* eslint-enable react/no-danger */
|
||||
}
|
||||
|
||||
export class CommentApp {
|
||||
store: Store;
|
||||
layout: LayoutController;
|
||||
utils = {
|
||||
selectCommentsForContentPathFactory,
|
||||
selectCommentFactory
|
||||
}
|
||||
selectors = {
|
||||
selectComments,
|
||||
selectEnabled,
|
||||
selectFocused
|
||||
}
|
||||
actions = commentActionFunctions;
|
||||
|
||||
constructor() {
|
||||
this.store = createStore(reducer, {
|
||||
settings: INITIAL_SETTINGS_STATE
|
||||
});
|
||||
this.layout = new LayoutController();
|
||||
}
|
||||
// eslint-disable-next-line camelcase
|
||||
setUser(userId: any, authors: Map<string, {name: string, avatar_url: string}>) {
|
||||
this.store.dispatch(
|
||||
updateGlobalSettings({
|
||||
user: getAuthor(authors, userId)
|
||||
})
|
||||
);
|
||||
}
|
||||
updateAnnotation(
|
||||
annotation: Annotation,
|
||||
commentId: number
|
||||
) {
|
||||
this.attachAnnotationLayout(annotation, commentId);
|
||||
this.store.dispatch(
|
||||
updateComment(
|
||||
commentId,
|
||||
{ annotation: annotation }
|
||||
)
|
||||
);
|
||||
}
|
||||
attachAnnotationLayout(
|
||||
annotation: Annotation,
|
||||
commentId: number
|
||||
) {
|
||||
// Attach an annotation to an existing comment in the layout
|
||||
|
||||
// const layout engine know the annotation so it would position the comment correctly
|
||||
this.layout.setCommentAnnotation(commentId, annotation);
|
||||
}
|
||||
makeComment(annotation: Annotation, contentpath: string, position = '') {
|
||||
const commentId = getNextCommentId();
|
||||
|
||||
this.attachAnnotationLayout(annotation, commentId);
|
||||
|
||||
// Create the comment
|
||||
this.store.dispatch(
|
||||
addComment(
|
||||
newComment(
|
||||
contentpath,
|
||||
position,
|
||||
commentId,
|
||||
annotation,
|
||||
this.store.getState().settings.user,
|
||||
Date.now(),
|
||||
{
|
||||
mode: 'creating',
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Focus and pin the comment
|
||||
this.store.dispatch(setFocusedComment(commentId, { updatePinnedComment: true }));
|
||||
return commentId;
|
||||
}
|
||||
renderApp(
|
||||
element: HTMLElement,
|
||||
outputElement: HTMLElement,
|
||||
userId: any,
|
||||
initialComments: InitialComment[],
|
||||
// eslint-disable-next-line camelcase
|
||||
authors: Map<string, {name: string, avatar_url: string}>,
|
||||
translationStrings: TranslatableStrings | null
|
||||
) {
|
||||
let pinnedComment: number | null = null;
|
||||
this.setUser(userId, authors);
|
||||
|
||||
const strings = translationStrings || defaultStrings;
|
||||
|
||||
// Check if there is "comment" query parameter.
|
||||
// If this is set, the user has clicked on a "View on frontend" link of an
|
||||
// individual comment. We should focus this comment and scroll to it
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let initialFocusedCommentId: number | null = null;
|
||||
const commentParams = urlParams.get('comment');
|
||||
if (commentParams) {
|
||||
initialFocusedCommentId = parseInt(commentParams, 10);
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
const state = this.store.getState();
|
||||
const commentList: Comment[] = Array.from(state.comments.comments.values());
|
||||
|
||||
ReactDOM.render(
|
||||
<CommentFormSetComponent
|
||||
comments={commentList}
|
||||
remoteCommentCount={state.comments.remoteCommentCount}
|
||||
/>,
|
||||
outputElement
|
||||
);
|
||||
|
||||
// Check if the pinned comment has changed
|
||||
if (state.comments.pinnedComment !== pinnedComment) {
|
||||
// Tell layout controller about the pinned comment
|
||||
// so it is moved alongside its annotation
|
||||
this.layout.setPinnedComment(state.comments.pinnedComment);
|
||||
|
||||
pinnedComment = state.comments.pinnedComment;
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
renderCommentsUi(this.store, this.layout, commentList, strings),
|
||||
element,
|
||||
() => {
|
||||
// Render again if layout has changed (eg, a comment was added, deleted or resized)
|
||||
// This will just update the "top" style attributes in the comments to get them to move
|
||||
if (this.layout.refresh()) {
|
||||
ReactDOM.render(
|
||||
renderCommentsUi(this.store, this.layout, commentList, strings),
|
||||
element
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Fetch existing comments
|
||||
for (const comment of initialComments) {
|
||||
const commentId = getNextCommentId();
|
||||
|
||||
// Create comment
|
||||
this.store.dispatch(
|
||||
addComment(
|
||||
newComment(
|
||||
comment.contentpath,
|
||||
comment.position,
|
||||
commentId,
|
||||
null,
|
||||
getAuthor(authors, comment.user),
|
||||
Date.parse(comment.created_at),
|
||||
{
|
||||
remoteId: comment.pk,
|
||||
text: comment.text,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Create replies
|
||||
for (const reply of comment.replies) {
|
||||
this.store.dispatch(
|
||||
addReply(
|
||||
commentId,
|
||||
newCommentReply(
|
||||
getNextReplyId(),
|
||||
getAuthor(authors, reply.user),
|
||||
Date.parse(reply.created_at),
|
||||
{ remoteId: reply.pk, text: reply.text }
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If this is the initial focused comment. Focus and pin it
|
||||
// TODO: Scroll to this comment
|
||||
if (initialFocusedCommentId && comment.pk === initialFocusedCommentId) {
|
||||
this.store.dispatch(setFocusedComment(commentId, { updatePinnedComment: true }));
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
this.store.subscribe(render);
|
||||
|
||||
// Unfocus when document body is clicked
|
||||
document.body.addEventListener('click', (e) => {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
// ignore if click target is a comment or an annotation
|
||||
if (!e.target.closest('#comments, [data-annotation]')) {
|
||||
// Running store.dispatch directly here seems to prevent the event from being handled anywhere else
|
||||
setTimeout(() => {
|
||||
this.store.dispatch(setFocusedComment(null, { updatePinnedComment: true }));
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initCommentApp() {
|
||||
return new CommentApp();
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import type { Comment } from '../state/comments';
|
||||
import type { State } from '../state';
|
||||
|
||||
export const selectComments = (state: State) => state.comments.comments;
|
||||
export const selectFocused = (state: State) => state.comments.focusedComment;
|
||||
|
||||
export function selectCommentsForContentPathFactory(contentpath: string) {
|
||||
return createSelector(selectComments, (comments) =>
|
||||
[...comments.values()].filter(
|
||||
(comment: Comment) =>
|
||||
comment.contentpath === contentpath && !comment.deleted
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function selectCommentFactory(localId: number) {
|
||||
return createSelector(selectComments, (comments) => {
|
||||
const comment = comments.get(localId);
|
||||
if (comment !== undefined && comment.deleted) {
|
||||
return undefined;
|
||||
}
|
||||
return comment;
|
||||
}
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export const selectEnabled = (state: State) => state.settings.commentsEnabled;
|
|
@ -0,0 +1,25 @@
|
|||
import { basicCommentsState } from '../__fixtures__/state';
|
||||
import { INITIAL_STATE } from '../state/settings';
|
||||
|
||||
import { selectCommentsForContentPathFactory } from './index';
|
||||
|
||||
test('Select comments for contentpath', () => {
|
||||
// test that the selectCommentsForContentPathFactory can generate selectors for the two
|
||||
// contentpaths in basicCommentsState
|
||||
const state = {
|
||||
comments: basicCommentsState,
|
||||
settings: INITIAL_STATE,
|
||||
};
|
||||
const testContentPathSelector = selectCommentsForContentPathFactory(
|
||||
'test_contentpath'
|
||||
);
|
||||
const testContentPathSelector2 = selectCommentsForContentPathFactory(
|
||||
'test_contentpath_2'
|
||||
);
|
||||
const selectedComments = testContentPathSelector(state);
|
||||
expect(selectedComments.length).toBe(1);
|
||||
expect(selectedComments[0].contentpath).toBe('test_contentpath');
|
||||
const otherSelectedComments = testContentPathSelector2(state);
|
||||
expect(otherSelectedComments.length).toBe(1);
|
||||
expect(otherSelectedComments[0].contentpath).toBe('test_contentpath_2');
|
||||
});
|
|
@ -0,0 +1,214 @@
|
|||
import { basicCommentsState } from '../__fixtures__/state';
|
||||
import {
|
||||
Comment,
|
||||
CommentReply,
|
||||
CommentReplyUpdate,
|
||||
CommentUpdate,
|
||||
reducer,
|
||||
} from './comments';
|
||||
import { createStore } from 'redux';
|
||||
|
||||
import * as actions from '../actions/comments';
|
||||
|
||||
test('Initial comments state empty', () => {
|
||||
const state = createStore(reducer).getState();
|
||||
expect(state.focusedComment).toBe(null);
|
||||
expect(state.pinnedComment).toBe(null);
|
||||
expect(state.comments.size).toBe(0);
|
||||
expect(state.remoteCommentCount).toBe(0);
|
||||
});
|
||||
|
||||
test('New comment added to state', () => {
|
||||
const newComment: Comment = {
|
||||
contentpath: 'test_contentpath',
|
||||
position: '',
|
||||
localId: 5,
|
||||
annotation: null,
|
||||
remoteId: null,
|
||||
mode: 'default',
|
||||
deleted: false,
|
||||
author: { id: 1, name: 'test user' },
|
||||
date: 0,
|
||||
text: 'new comment',
|
||||
newReply: '',
|
||||
newText: '',
|
||||
remoteReplyCount: 0,
|
||||
replies: new Map(),
|
||||
};
|
||||
const commentAction = actions.addComment(newComment);
|
||||
const newState = reducer(basicCommentsState, commentAction);
|
||||
expect(newState.comments.get(newComment.localId)).toBe(newComment);
|
||||
expect(newState.remoteCommentCount).toBe(
|
||||
basicCommentsState.remoteCommentCount
|
||||
);
|
||||
});
|
||||
|
||||
test('Remote comment added to state', () => {
|
||||
const newComment: Comment = {
|
||||
contentpath: 'test_contentpath',
|
||||
position: '',
|
||||
localId: 5,
|
||||
annotation: null,
|
||||
remoteId: 10,
|
||||
mode: 'default',
|
||||
deleted: false,
|
||||
author: { id: 1, name: 'test user' },
|
||||
date: 0,
|
||||
text: 'new comment',
|
||||
newReply: '',
|
||||
newText: '',
|
||||
remoteReplyCount: 0,
|
||||
replies: new Map(),
|
||||
};
|
||||
const commentAction = actions.addComment(newComment);
|
||||
const newState = reducer(basicCommentsState, commentAction);
|
||||
expect(newState.comments.get(newComment.localId)).toBe(newComment);
|
||||
expect(newState.remoteCommentCount).toBe(
|
||||
basicCommentsState.remoteCommentCount + 1
|
||||
);
|
||||
});
|
||||
|
||||
test('Existing comment updated', () => {
|
||||
const commentUpdate: CommentUpdate = {
|
||||
mode: 'editing',
|
||||
};
|
||||
const updateAction = actions.updateComment(1, commentUpdate);
|
||||
const newState = reducer(basicCommentsState, updateAction);
|
||||
const comment = newState.comments.get(1);
|
||||
expect(comment).toBeDefined();
|
||||
if (comment) {
|
||||
expect(comment.mode).toBe('editing');
|
||||
}
|
||||
});
|
||||
|
||||
test('Local comment deleted', () => {
|
||||
// Test that deleting a comment without a remoteId removes it from the state entirely
|
||||
const deleteAction = actions.deleteComment(4);
|
||||
const newState = reducer(basicCommentsState, deleteAction);
|
||||
expect(newState.comments.has(4)).toBe(false);
|
||||
});
|
||||
|
||||
test('Remote comment deleted', () => {
|
||||
// Test that deleting a comment without a remoteId does not remove it from the state, but marks it as deleted
|
||||
const deleteAction = actions.deleteComment(1);
|
||||
const newState = reducer(basicCommentsState, deleteAction);
|
||||
const comment = newState.comments.get(1);
|
||||
expect(comment).toBeDefined();
|
||||
if (comment) {
|
||||
expect(comment.deleted).toBe(true);
|
||||
}
|
||||
expect(newState.focusedComment).toBe(null);
|
||||
expect(newState.pinnedComment).toBe(null);
|
||||
expect(newState.remoteCommentCount).toBe(
|
||||
basicCommentsState.remoteCommentCount
|
||||
);
|
||||
});
|
||||
|
||||
test('Comment focused', () => {
|
||||
const focusAction = actions.setFocusedComment(4);
|
||||
const newState = reducer(basicCommentsState, focusAction);
|
||||
expect(newState.focusedComment).toBe(4);
|
||||
});
|
||||
|
||||
test('Invalid comment not focused', () => {
|
||||
const focusAction = actions.setFocusedComment(9000, { updatePinnedComment: true });
|
||||
const newState = reducer(basicCommentsState, focusAction);
|
||||
expect(newState.focusedComment).toBe(basicCommentsState.focusedComment);
|
||||
expect(newState.pinnedComment).toBe(basicCommentsState.pinnedComment);
|
||||
});
|
||||
|
||||
test('Reply added', () => {
|
||||
const reply: CommentReply = {
|
||||
localId: 10,
|
||||
remoteId: null,
|
||||
mode: 'default',
|
||||
author: { id: 1, name: 'test user' },
|
||||
date: 0,
|
||||
text: 'a new reply',
|
||||
newText: '',
|
||||
deleted: false,
|
||||
};
|
||||
const addAction = actions.addReply(1, reply);
|
||||
const newState = reducer(basicCommentsState, addAction);
|
||||
const comment = newState.comments.get(1);
|
||||
expect(comment).toBeDefined();
|
||||
if (comment) {
|
||||
const stateReply = comment.replies.get(10);
|
||||
expect(stateReply).toBeDefined();
|
||||
if (stateReply) {
|
||||
expect(stateReply).toBe(reply);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Remote reply added', () => {
|
||||
const reply: CommentReply = {
|
||||
localId: 10,
|
||||
remoteId: 1,
|
||||
mode: 'default',
|
||||
author: { id: 1, name: 'test user' },
|
||||
date: 0,
|
||||
text: 'a new reply',
|
||||
newText: '',
|
||||
deleted: false,
|
||||
};
|
||||
const addAction = actions.addReply(1, reply);
|
||||
const newState = reducer(basicCommentsState, addAction);
|
||||
const originalComment = basicCommentsState.comments.get(1);
|
||||
const comment = newState.comments.get(1);
|
||||
expect(comment).toBeDefined();
|
||||
if (comment) {
|
||||
const stateReply = comment.replies.get(reply.localId);
|
||||
expect(stateReply).toBeDefined();
|
||||
expect(stateReply).toBe(reply);
|
||||
if (originalComment) {
|
||||
expect(comment.remoteReplyCount).toBe(originalComment.remoteReplyCount + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Reply updated', () => {
|
||||
const replyUpdate: CommentReplyUpdate = {
|
||||
mode: 'editing',
|
||||
};
|
||||
const updateAction = actions.updateReply(1, 2, replyUpdate);
|
||||
const newState = reducer(basicCommentsState, updateAction);
|
||||
const comment = newState.comments.get(1);
|
||||
expect(comment).toBeDefined();
|
||||
if (comment) {
|
||||
const reply = comment.replies.get(2);
|
||||
expect(reply).toBeDefined();
|
||||
if (reply) {
|
||||
expect(reply.mode).toBe('editing');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Local reply deleted', () => {
|
||||
// Test that the delete action deletes a reply that hasn't yet been saved to the db from the state entirely
|
||||
const deleteAction = actions.deleteReply(1, 3);
|
||||
const newState = reducer(basicCommentsState, deleteAction);
|
||||
const comment = newState.comments.get(1);
|
||||
expect(comment).toBeDefined();
|
||||
if (comment) {
|
||||
expect(comment.replies.has(3)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('Remote reply deleted', () => {
|
||||
// Test that the delete action deletes a reply that has been saved to the db by marking it as deleted instead
|
||||
const deleteAction = actions.deleteReply(1, 2);
|
||||
const newState = reducer(basicCommentsState, deleteAction);
|
||||
const comment = newState.comments.get(1);
|
||||
const originalComment = basicCommentsState.comments.get(1);
|
||||
expect(comment).toBeDefined();
|
||||
expect(originalComment).toBeDefined();
|
||||
if (comment && originalComment) {
|
||||
expect(comment.remoteReplyCount).toBe(originalComment.remoteReplyCount);
|
||||
const reply = comment.replies.get(2);
|
||||
expect(reply).toBeDefined();
|
||||
if (reply) {
|
||||
expect(reply.deleted).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -0,0 +1,242 @@
|
|||
import type { Annotation } from '../utils/annotation';
|
||||
import * as actions from '../actions/comments';
|
||||
import { update } from './utils';
|
||||
import produce, { enableMapSet, enableES5 } from 'immer';
|
||||
|
||||
enableES5();
|
||||
enableMapSet();
|
||||
|
||||
export interface Author {
|
||||
id: any;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export type CommentReplyMode =
|
||||
| 'default'
|
||||
| 'editing'
|
||||
| 'saving'
|
||||
| 'delete_confirm'
|
||||
| 'deleting'
|
||||
| 'deleted'
|
||||
| 'save_error'
|
||||
| 'delete_error';
|
||||
|
||||
export interface CommentReply {
|
||||
localId: number;
|
||||
remoteId: number | null;
|
||||
mode: CommentReplyMode;
|
||||
author: Author | null;
|
||||
date: number;
|
||||
text: string;
|
||||
newText: string;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface NewReplyOptions {
|
||||
remoteId?: number | null;
|
||||
mode?: CommentReplyMode;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function newCommentReply(
|
||||
localId: number,
|
||||
author: Author | null,
|
||||
date: number,
|
||||
{
|
||||
remoteId = null,
|
||||
mode = 'default',
|
||||
text = '',
|
||||
}: NewReplyOptions
|
||||
): CommentReply {
|
||||
return {
|
||||
localId,
|
||||
remoteId,
|
||||
mode,
|
||||
author,
|
||||
date,
|
||||
text,
|
||||
newText: '',
|
||||
deleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
export type CommentReplyUpdate = Partial<CommentReply>;
|
||||
|
||||
export type CommentMode =
|
||||
| 'default'
|
||||
| 'creating'
|
||||
| 'editing'
|
||||
| 'saving'
|
||||
| 'delete_confirm'
|
||||
| 'deleting'
|
||||
| 'deleted'
|
||||
| 'save_error'
|
||||
| 'delete_error';
|
||||
|
||||
export interface Comment {
|
||||
contentpath: string;
|
||||
localId: number;
|
||||
annotation: Annotation | null;
|
||||
position: string;
|
||||
remoteId: number | null;
|
||||
mode: CommentMode;
|
||||
deleted: boolean;
|
||||
author: Author | null;
|
||||
date: number;
|
||||
text: string;
|
||||
replies: Map<number, CommentReply>;
|
||||
newReply: string;
|
||||
newText: string;
|
||||
remoteReplyCount: number;
|
||||
}
|
||||
|
||||
export interface NewCommentOptions {
|
||||
remoteId?: number | null;
|
||||
mode?: CommentMode;
|
||||
text?: string;
|
||||
replies?: Map<number, CommentReply>;
|
||||
}
|
||||
|
||||
export function newComment(
|
||||
contentpath: string,
|
||||
position: string,
|
||||
localId: number,
|
||||
annotation: Annotation | null,
|
||||
author: Author | null,
|
||||
date: number,
|
||||
{
|
||||
remoteId = null,
|
||||
mode = 'default',
|
||||
text = '',
|
||||
replies = new Map(),
|
||||
}: NewCommentOptions
|
||||
): Comment {
|
||||
return {
|
||||
contentpath,
|
||||
position,
|
||||
localId,
|
||||
annotation,
|
||||
remoteId,
|
||||
mode,
|
||||
author,
|
||||
date,
|
||||
text,
|
||||
replies,
|
||||
newReply: '',
|
||||
newText: '',
|
||||
deleted: false,
|
||||
remoteReplyCount: Array.from(replies.values()).reduce(
|
||||
(n, reply) => (reply.remoteId !== null ? n + 1 : n),
|
||||
0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export type CommentUpdate = Partial<Comment>;
|
||||
|
||||
export interface CommentsState {
|
||||
comments: Map<number, Comment>;
|
||||
focusedComment: number | null;
|
||||
pinnedComment: number | null;
|
||||
// This is redundant, but stored for efficiency as it will change only as the app adds its loaded comments
|
||||
remoteCommentCount: number;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: CommentsState = {
|
||||
comments: new Map(),
|
||||
focusedComment: null,
|
||||
pinnedComment: null,
|
||||
remoteCommentCount: 0,
|
||||
};
|
||||
|
||||
export const reducer = produce((draft: CommentsState, action: actions.Action) => {
|
||||
/* eslint-disable no-param-reassign */
|
||||
switch (action.type) {
|
||||
case actions.ADD_COMMENT: {
|
||||
draft.comments.set(action.comment.localId, action.comment);
|
||||
if (action.comment.remoteId) {
|
||||
draft.remoteCommentCount += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case actions.UPDATE_COMMENT: {
|
||||
const comment = draft.comments.get(action.commentId);
|
||||
if (comment) {
|
||||
update(comment, action.update);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case actions.DELETE_COMMENT: {
|
||||
const comment = draft.comments.get(action.commentId);
|
||||
if (!comment) {
|
||||
break;
|
||||
} else if (!comment.remoteId) {
|
||||
// If the comment doesn't exist in the database, there's no need to keep it around locally
|
||||
draft.comments.delete(action.commentId);
|
||||
} else {
|
||||
comment.deleted = true;
|
||||
}
|
||||
|
||||
// Unset focusedComment if the focused comment is the one being deleted
|
||||
if (draft.focusedComment === action.commentId) {
|
||||
draft.focusedComment = null;
|
||||
}
|
||||
if (draft.pinnedComment === action.commentId) {
|
||||
draft.pinnedComment = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case actions.SET_FOCUSED_COMMENT: {
|
||||
if ((action.commentId === null) || (draft.comments.has(action.commentId))) {
|
||||
draft.focusedComment = action.commentId;
|
||||
if (action.updatePinnedComment) {
|
||||
draft.pinnedComment = action.commentId;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case actions.ADD_REPLY: {
|
||||
const comment = draft.comments.get(action.commentId);
|
||||
if (!comment) {
|
||||
break;
|
||||
}
|
||||
if (action.reply.remoteId) {
|
||||
comment.remoteReplyCount += 1;
|
||||
}
|
||||
comment.replies.set(action.reply.localId, action.reply);
|
||||
break;
|
||||
}
|
||||
case actions.UPDATE_REPLY: {
|
||||
const comment = draft.comments.get(action.commentId);
|
||||
if (!comment) {
|
||||
break;
|
||||
}
|
||||
const reply = comment.replies.get(action.replyId);
|
||||
if (!reply) {
|
||||
break;
|
||||
}
|
||||
update(reply, action.update);
|
||||
break;
|
||||
}
|
||||
case actions.DELETE_REPLY: {
|
||||
const comment = draft.comments.get(action.commentId);
|
||||
if (!comment) {
|
||||
break;
|
||||
}
|
||||
const reply = comment.replies.get(action.replyId);
|
||||
if (!reply) {
|
||||
break;
|
||||
}
|
||||
if (!reply.remoteId) {
|
||||
// The reply doesn't exist in the database, so we don't need to store it locally
|
||||
comment.replies.delete(reply.localId);
|
||||
} else {
|
||||
reply.deleted = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, INITIAL_STATE);
|
|
@ -0,0 +1,14 @@
|
|||
import { combineReducers, Store as reduxStore } from 'redux';
|
||||
|
||||
import { reducer as commentsReducer } from './comments';
|
||||
import { reducer as settingsReducer } from './settings';
|
||||
import type { Action } from '../actions';
|
||||
|
||||
export type State = ReturnType<typeof reducer>;
|
||||
|
||||
export const reducer = combineReducers({
|
||||
comments: commentsReducer,
|
||||
settings: settingsReducer,
|
||||
});
|
||||
|
||||
export type Store = reduxStore<State, Action>;
|
|
@ -0,0 +1,29 @@
|
|||
import * as actions from '../actions/settings';
|
||||
import type { Author } from './comments';
|
||||
import { update } from './utils';
|
||||
import produce from 'immer';
|
||||
|
||||
export interface SettingsState {
|
||||
user: Author | null;
|
||||
commentsEnabled: boolean;
|
||||
showResolvedComments: boolean;
|
||||
}
|
||||
|
||||
export type SettingsStateUpdate = Partial<SettingsState>;
|
||||
|
||||
// Reducer with initial state
|
||||
export const INITIAL_STATE: SettingsState = {
|
||||
user: null,
|
||||
commentsEnabled: true,
|
||||
showResolvedComments: false,
|
||||
};
|
||||
|
||||
export const reducer = produce((draft: SettingsState, action: actions.Action) => {
|
||||
switch (action.type) {
|
||||
case actions.UPDATE_GLOBAL_SETTINGS:
|
||||
update(draft, action.update);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, INITIAL_STATE);
|
|
@ -0,0 +1,3 @@
|
|||
export function update<T>(base: T, updatePartial: Partial<T>): T {
|
||||
return Object.assign(base, updatePartial);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export interface Annotation {
|
||||
getDesiredPosition(focused: boolean): number;
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
import type { Annotation } from './annotation';
|
||||
import { getOrDefault } from './maps';
|
||||
|
||||
const GAP = 20.0; // Gap between comments in pixels
|
||||
const TOP_MARGIN = 100.0; // Spacing from the top to the first comment in pixels
|
||||
const OFFSET = -50; // How many pixels from the annotation position should the comments be placed?
|
||||
|
||||
export class LayoutController {
|
||||
commentElements: Map<number, HTMLElement> = new Map();
|
||||
commentAnnotations: Map<number, Annotation> = new Map();
|
||||
commentDesiredPositions: Map<number, number> = new Map();
|
||||
commentHeights: Map<number, number> = new Map();
|
||||
pinnedComment: number | null = null;
|
||||
commentCalculatedPositions: Map<number, number> = new Map();
|
||||
isDirty = false;
|
||||
|
||||
setCommentElement(commentId: number, element: HTMLElement | null) {
|
||||
if (element !== null) {
|
||||
this.commentElements.set(commentId, element);
|
||||
} else {
|
||||
this.commentElements.delete(commentId);
|
||||
}
|
||||
|
||||
this.isDirty = true;
|
||||
}
|
||||
|
||||
setCommentAnnotation(commentId: number, annotation: Annotation) {
|
||||
this.commentAnnotations.set(commentId, annotation);
|
||||
this.updateDesiredPosition(commentId);
|
||||
this.isDirty = true;
|
||||
}
|
||||
|
||||
setCommentHeight(commentId: number, height: number) {
|
||||
if (this.commentHeights.get(commentId) !== height) {
|
||||
this.commentHeights.set(commentId, height);
|
||||
this.isDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
setPinnedComment(commentId: number | null) {
|
||||
this.pinnedComment = commentId;
|
||||
this.isDirty = true;
|
||||
}
|
||||
|
||||
updateDesiredPosition(commentId: number) {
|
||||
const annotation = this.commentAnnotations.get(commentId);
|
||||
|
||||
if (!annotation) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.commentDesiredPositions.set(
|
||||
commentId,
|
||||
annotation.getDesiredPosition(commentId === this.pinnedComment) + OFFSET
|
||||
);
|
||||
}
|
||||
|
||||
refreshDesiredPositions() {
|
||||
this.commentAnnotations.forEach((_, commentId) =>
|
||||
this.updateDesiredPosition(commentId)
|
||||
);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
const oldDesiredPositions = new Map(this.commentDesiredPositions);
|
||||
this.refreshDesiredPositions();
|
||||
// It's not great to be recalculating all positions so regularly, but Wagtail's FE widgets
|
||||
// aren't very constrained so could change layout in any number of ways. If we have a stable FE
|
||||
// widget framework in the future, this could be used to trigger the position refresh more
|
||||
// intelligently, or alternatively once comments is incorporated into the page form, a
|
||||
// MutationObserver could potentially track most types of changes.
|
||||
if (this.commentDesiredPositions !== oldDesiredPositions) {
|
||||
this.isDirty = true;
|
||||
}
|
||||
|
||||
if (!this.isDirty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
interface Block {
|
||||
position: number;
|
||||
height: number;
|
||||
comments: number[];
|
||||
containsPinnedComment: boolean;
|
||||
pinnedCommentPosition: number;
|
||||
}
|
||||
|
||||
// Build list of blocks (starting with one for each comment)
|
||||
let blocks: Block[] = Array.from(this.commentElements.keys()).map(
|
||||
(commentId) => ({
|
||||
position: getOrDefault(this.commentDesiredPositions, commentId, 0),
|
||||
height: getOrDefault(this.commentHeights, commentId, 0),
|
||||
comments: [commentId],
|
||||
containsPinnedComment:
|
||||
this.pinnedComment !== null && commentId === this.pinnedComment,
|
||||
pinnedCommentPosition: 0,
|
||||
})
|
||||
);
|
||||
|
||||
// Sort blocks
|
||||
blocks.sort(
|
||||
(block, comparisonBlock) => block.position - comparisonBlock.position
|
||||
);
|
||||
|
||||
// Resolve overlapping blocks
|
||||
let overlaps = true;
|
||||
while (overlaps) {
|
||||
overlaps = false;
|
||||
const newBlocks: Block[] = [];
|
||||
let previousBlock: Block | null = null;
|
||||
const pinnedCommentPosition = this.pinnedComment ?
|
||||
this.commentDesiredPositions.get(this.pinnedComment) : undefined;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (previousBlock) {
|
||||
if (
|
||||
previousBlock.position + previousBlock.height + GAP >
|
||||
block.position
|
||||
) {
|
||||
overlaps = true;
|
||||
|
||||
// Merge the blocks
|
||||
previousBlock.comments.push(...block.comments);
|
||||
|
||||
if (block.containsPinnedComment) {
|
||||
previousBlock.containsPinnedComment = true;
|
||||
previousBlock.pinnedCommentPosition =
|
||||
block.pinnedCommentPosition + previousBlock.height;
|
||||
}
|
||||
previousBlock.height += block.height;
|
||||
|
||||
// Make sure comments don't disappear off the top of the page
|
||||
// But only if a comment isn't focused
|
||||
if (
|
||||
!this.pinnedComment &&
|
||||
previousBlock.position < TOP_MARGIN + OFFSET
|
||||
) {
|
||||
previousBlock.position =
|
||||
TOP_MARGIN + previousBlock.height - OFFSET;
|
||||
}
|
||||
|
||||
// If this block contains the focused comment, position it so
|
||||
// the focused comment is in it's desired position
|
||||
if (
|
||||
pinnedCommentPosition &&
|
||||
previousBlock.containsPinnedComment
|
||||
) {
|
||||
previousBlock.position =
|
||||
pinnedCommentPosition -
|
||||
previousBlock.pinnedCommentPosition;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
newBlocks.push(block);
|
||||
previousBlock = block;
|
||||
}
|
||||
|
||||
blocks = newBlocks;
|
||||
}
|
||||
|
||||
// Write positions
|
||||
blocks.forEach((block) => {
|
||||
let currentPosition = block.position;
|
||||
block.comments.forEach((commentId) => {
|
||||
this.commentCalculatedPositions.set(commentId, currentPosition);
|
||||
const height = this.commentHeights.get(commentId);
|
||||
if (height) {
|
||||
currentPosition += height + GAP;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.isDirty = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getCommentPosition(commentId: number) {
|
||||
if (this.commentCalculatedPositions.has(commentId)) {
|
||||
return this.commentCalculatedPositions.get(commentId);
|
||||
}
|
||||
return this.commentDesiredPositions.get(commentId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export function getOrDefault<K, V>(map: Map<K, V>, key: K, defaultValue: V) {
|
||||
const value = map.get(key);
|
||||
if (typeof value === 'undefined') {
|
||||
return defaultValue;
|
||||
}
|
||||
return value;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
let nextCommentId = 1;
|
||||
let nextReplyId = 1;
|
||||
|
||||
export function getNextCommentId() {
|
||||
return nextCommentId++;
|
||||
}
|
||||
|
||||
export function getNextReplyId() {
|
||||
return nextReplyId++;
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Store } from '../state';
|
||||
import {
|
||||
addComment,
|
||||
setFocusedComment,
|
||||
addReply,
|
||||
} from '../actions/comments';
|
||||
import {
|
||||
Author,
|
||||
Comment,
|
||||
NewCommentOptions,
|
||||
newComment,
|
||||
newCommentReply,
|
||||
NewReplyOptions,
|
||||
} from '../state/comments';
|
||||
import { LayoutController } from '../utils/layout';
|
||||
import { getNextCommentId } from './sequences';
|
||||
import { defaultStrings } from '../main';
|
||||
|
||||
import CommentComponent from '../components/Comment/index';
|
||||
|
||||
export function RenderCommentsForStorybook({
|
||||
store,
|
||||
author,
|
||||
}: {
|
||||
store: Store;
|
||||
author?: Author;
|
||||
}) {
|
||||
const [state, setState] = React.useState(store.getState());
|
||||
store.subscribe(() => {
|
||||
setState(store.getState());
|
||||
});
|
||||
|
||||
const layout = new LayoutController();
|
||||
|
||||
const commentsToRender: Comment[] = Array.from(
|
||||
state.comments.comments.values()
|
||||
);
|
||||
|
||||
const commentsRendered = commentsToRender.map((comment) => (
|
||||
<CommentComponent
|
||||
key={comment.localId}
|
||||
store={store}
|
||||
layout={layout}
|
||||
user={
|
||||
author || {
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
avatarUrl: 'https://gravatar.com/avatar/e31ec811942afbf7b9ce0ac5affe426f?s=200&d=robohash&r=x',
|
||||
}
|
||||
}
|
||||
comment={comment}
|
||||
isFocused={comment.localId === state.comments.focusedComment}
|
||||
strings={defaultStrings}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<ol className="comments-list">{commentsRendered}</ol>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddTestCommentOptions extends NewCommentOptions {
|
||||
focused?: boolean;
|
||||
author?: Author;
|
||||
}
|
||||
|
||||
export function addTestComment(
|
||||
store: Store,
|
||||
options: AddTestCommentOptions
|
||||
): number {
|
||||
const commentId = getNextCommentId();
|
||||
|
||||
const addCommentOptions = options;
|
||||
|
||||
const author = options.author || {
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
avatarUrl: 'https://gravatar.com/avatar/e31ec811942afbf7b9ce0ac5affe426f?s=200&d=robohash&r=x',
|
||||
};
|
||||
|
||||
// We must have a remoteId unless the comment is being created
|
||||
if (options.mode !== 'creating' && options.remoteId === undefined) {
|
||||
addCommentOptions.remoteId = commentId;
|
||||
}
|
||||
|
||||
// Comment must be focused if the mode is anything other than default
|
||||
if (options.mode !== 'default' && options.focused === undefined) {
|
||||
addCommentOptions.focused = true;
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
addComment(
|
||||
newComment('test', '', commentId, null, author, Date.now(), addCommentOptions)
|
||||
)
|
||||
);
|
||||
|
||||
if (options.focused) {
|
||||
store.dispatch(setFocusedComment(commentId, { updatePinnedComment: true }));
|
||||
}
|
||||
|
||||
return commentId;
|
||||
}
|
||||
|
||||
interface AddTestReplyOptions extends NewReplyOptions {
|
||||
focused?: boolean;
|
||||
author?: Author;
|
||||
}
|
||||
|
||||
export function addTestReply(
|
||||
store: Store,
|
||||
commentId: number,
|
||||
options: AddTestReplyOptions
|
||||
) {
|
||||
const addReplyOptions = options;
|
||||
const author = options.author || {
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
avatarUrl: 'https://gravatar.com/avatar/e31ec811942afbf7b9ce0ac5affe426f?s=200&d=robohash&r=x',
|
||||
};
|
||||
|
||||
if (!options.remoteId) {
|
||||
addReplyOptions.remoteId = 1;
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
addReply(commentId, newCommentReply(1, author, Date.now(), addReplyOptions))
|
||||
);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import type { CommentApp } from 'wagtail-comment-frontend';
|
||||
import type { Annotation } from 'wagtail-comment-frontend/src/utils/annotation';
|
||||
import type { Comment } from 'wagtail-comment-frontend/src/state/comments';
|
||||
import type { CommentApp } from '../../CommentApp/main';
|
||||
import type { Annotation } from '../../CommentApp/utils/annotation';
|
||||
import type { Comment } from '../../CommentApp/state/comments';
|
||||
import {
|
||||
DraftailEditor,
|
||||
ToolbarButton,
|
||||
|
|
|
@ -7311,6 +7311,11 @@
|
|||
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==",
|
||||
"dev": true
|
||||
},
|
||||
"immer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.1.tgz",
|
||||
"integrity": "sha512-7CCw1DSgr8kKYXTYOI1qMM/f5qxT5vIVMeGLDCDX8CSxsggr1Sjdoha4OhsP0AZ1UvWbyZlILHvLjaynuu02Mg=="
|
||||
},
|
||||
"immutable": {
|
||||
"version": "3.7.6",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz",
|
||||
|
@ -16129,14 +16134,6 @@
|
|||
"xml-name-validator": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"wagtail-comment-frontend": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/wagtail-comment-frontend/-/wagtail-comment-frontend-0.0.6.tgz",
|
||||
"integrity": "sha512-qdkNNK9YtEP/JOZmB2TMCag/Fq4u66OZKRsgs7zGJozbShtT+a+CMtO7lpLjRnmez6+33KCzdkaOJ1dfTXeLsg==",
|
||||
"requires": {
|
||||
"reselect": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"walker": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz",
|
||||
|
|
|
@ -95,6 +95,7 @@
|
|||
"draftjs-filters": "^2.5.0",
|
||||
"element-closest": "^2.0.2",
|
||||
"focus-trap-react": "^3.1.0",
|
||||
"immer": "^9.0.1",
|
||||
"postcss-calc": "^7.0.5",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^16.14.0",
|
||||
|
@ -103,9 +104,9 @@
|
|||
"react-transition-group": "^1.1.3",
|
||||
"redux": "^4.0.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"reselect": "^4.0.0",
|
||||
"telepath-unpack": "^0.0.3",
|
||||
"uuid": "^8.3.2",
|
||||
"wagtail-comment-frontend": "0.0.6",
|
||||
"whatwg-fetch": "^2.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
"noUnusedParameters": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true
|
||||
"allowJs": true,
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"files": [
|
||||
"client/src/index.ts",
|
||||
|
|
Ładowanie…
Reference in New Issue