mirror of
https://github.com/wassname/talk.git
synced 2026-08-12 12:30:39 +08:00
Merge branch 'master' into dont-agree
This commit is contained in:
@@ -29,5 +29,5 @@
|
||||
"as": "POSTMARK"
|
||||
}],
|
||||
"image": "heroku/nodejs",
|
||||
"success_url": "/admin/setup"
|
||||
"success_url": "/admin/install"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Card} from 'coral-ui';
|
||||
|
||||
const EmptyCard = props => (
|
||||
<Card style={{textAlign: 'center', maxWidth: 400, margin: '0 auto'}}>
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
EmptyCard.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
export default EmptyCard;
|
||||
@@ -1,18 +1,49 @@
|
||||
import React from 'react';
|
||||
import {Navigation, Drawer} from 'react-mdl';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Header.css';
|
||||
import {IndexLink, Link} from 'react-router';
|
||||
import styles from './Drawer.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
|
||||
export default () => (
|
||||
<Drawer>
|
||||
<Navigation>
|
||||
<Link className={styles.navLink} to="/admin">{lang.t('configure.moderate')}</Link>
|
||||
<Link className={styles.navLink} to="/admin/community">{lang.t('configure.community')}</Link>
|
||||
<Link className={styles.navLink} to="/admin/configure">{lang.t('configure.configure')}</Link>
|
||||
</Navigation>
|
||||
export default ({handleLogout, restricted = false}) => (
|
||||
<Drawer className={styles.header}>
|
||||
{ !restricted ?
|
||||
<div>
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink
|
||||
className={styles.navLink}
|
||||
to="/admin/dashboard"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.dashboard')}
|
||||
</IndexLink>
|
||||
<Link
|
||||
className={styles.navLink}
|
||||
to="/admin/moderate"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.moderate')}
|
||||
</Link>
|
||||
<Link className={styles.navLink}
|
||||
to="/admin/streams"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.streams')}
|
||||
</Link>
|
||||
<Link className={styles.navLink}
|
||||
to="/admin/community"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.community')}
|
||||
</Link>
|
||||
<Link
|
||||
className={styles.navLink}
|
||||
to="/admin/configure"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.configure')}
|
||||
</Link>
|
||||
<a onClick={handleLogout}>Sign Out</a>
|
||||
<span>{`v${process.env.VERSION}`}</span>
|
||||
</Navigation>
|
||||
</div> : null }
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
@custom-media --table-viewport (max-width: 1024px);
|
||||
|
||||
@media (--table-viewport) {
|
||||
.logo {
|
||||
margin-left: 58px;
|
||||
h1 {
|
||||
background: #696969;
|
||||
}
|
||||
span {
|
||||
color: #fcfcfc;
|
||||
}
|
||||
}
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
min-height: 58px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header > div {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {Logo} from './Logo';
|
||||
|
||||
export default ({handleLogout, restricted = false}) => (
|
||||
<Header className={styles.header}>
|
||||
<Logo />
|
||||
<Logo className={styles.logo} />
|
||||
{
|
||||
!restricted ?
|
||||
<div>
|
||||
|
||||
@@ -6,8 +6,8 @@ import styles from './Layout.css';
|
||||
|
||||
export const Layout = ({children, ...props}) => (
|
||||
<LayoutMDL fixedDrawer>
|
||||
<Header {...props}/>
|
||||
<Drawer />
|
||||
<Header {...props} />
|
||||
<Drawer {...props} />
|
||||
<div className={styles.layout} >
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@ import React from 'react';
|
||||
import styles from './Logo.css';
|
||||
import {CoralLogo} from 'coral-ui';
|
||||
|
||||
export const Logo = () => (
|
||||
<div className={styles.logo}>
|
||||
export const Logo = ({className = ''}) => (
|
||||
<div className={`${styles.logo} ${className}`}>
|
||||
<h1>
|
||||
<CoralLogo className={styles.base} />
|
||||
<span>Talk</span>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
|
||||
import styles from './Community.css';
|
||||
import Table from './Table';
|
||||
import Loading from './Loading';
|
||||
import NoResults from './NoResults';
|
||||
import {Pager} from 'coral-ui';
|
||||
import EmptyCard from '../../components/EmptyCard';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
@@ -54,13 +54,14 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
{ isFetching && <Loading /> }
|
||||
{ !hasResults && <NoResults /> }
|
||||
{ hasResults &&
|
||||
<Table
|
||||
headers={tableHeaders}
|
||||
data={commenters}
|
||||
onHeaderClickHandler={props.onHeaderClickHandler}
|
||||
/>
|
||||
{
|
||||
hasResults
|
||||
? <Table
|
||||
headers={tableHeaders}
|
||||
data={commenters}
|
||||
onHeaderClickHandler={props.onHeaderClickHandler}
|
||||
/>
|
||||
: <EmptyCard>{lang.t('community.no-results')}</EmptyCard>
|
||||
}
|
||||
<Pager
|
||||
totalPages={props.totalPages}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const NoResults = () => (
|
||||
<div>
|
||||
No users found with that user name or email address
|
||||
</div>
|
||||
);
|
||||
|
||||
export default NoResults;
|
||||
@@ -52,7 +52,6 @@ const updateClosedMessage = (updateSettings) => (event) => {
|
||||
};
|
||||
|
||||
const updateCustomCssUrl = (updateSettings) => (event) => {
|
||||
console.log('updateCustomCssUrl', event.target.value);
|
||||
const customCssUrl = event.target.value;
|
||||
updateSettings({customCssUrl});
|
||||
};
|
||||
|
||||
@@ -54,7 +54,6 @@ class ModerationContainer extends Component {
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
console.log(data);
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
|
||||
import Comment from './components/Comment';
|
||||
import EmptyCard from '../../components/EmptyCard';
|
||||
import {actionsMap} from './helpers/moderationQueueActionsMap';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const ModerationQueue = ({activeTab = 'premod', ...props}) => {
|
||||
const areComments = props.data[activeTab].length;
|
||||
return (
|
||||
<div id="moderationList">
|
||||
<ul>
|
||||
<ul style={{paddingLeft: 0}}>
|
||||
{
|
||||
props.data[activeTab].map((comment, i) => {
|
||||
areComments
|
||||
? props.data[activeTab].map((comment, i) => {
|
||||
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
|
||||
return <Comment
|
||||
key={i}
|
||||
@@ -22,6 +29,7 @@ const ModerationQueue = ({activeTab = 'premod', ...props}) => {
|
||||
currentAsset={props.currentAsset}
|
||||
/>;
|
||||
})
|
||||
: <EmptyCard>{lang.t('modqueue.emptyqueue')}</EmptyCard>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -29,7 +37,12 @@ const ModerationQueue = ({activeTab = 'premod', ...props}) => {
|
||||
};
|
||||
|
||||
ModerationQueue.propTypes = {
|
||||
data: PropTypes.object.isRequired
|
||||
data: PropTypes.object.isRequired,
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
showBanUserDialog: PropTypes.func.isRequired,
|
||||
currentAsset: PropTypes.object,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
};
|
||||
|
||||
export default ModerationQueue;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import timeago from 'timeago.js';
|
||||
import Linkify from 'react-linkify';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
@@ -17,7 +17,7 @@ const lang = new I18n(translations);
|
||||
|
||||
const Comment = ({actions = [], ...props}) => {
|
||||
const links = linkify.getMatches(props.comment.body);
|
||||
const actionSumaries = props.comment.action_summaries;
|
||||
const actionSummaries = props.comment.action_summaries;
|
||||
return (
|
||||
<li tabIndex={props.index}
|
||||
className={`mdl-card mdl-shadow--2dp ${styles.Comment} ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
@@ -42,7 +42,7 @@ const Comment = ({actions = [], ...props}) => {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{props.comment.user.banned === 'banned' ?
|
||||
{props.comment.user.status === 'banned' ?
|
||||
<span className={styles.banned}>
|
||||
<Icon name='error_outline'/>
|
||||
{lang.t('comment.banned_user')}
|
||||
@@ -62,11 +62,31 @@ const Comment = ({actions = [], ...props}) => {
|
||||
</Linkify>
|
||||
</p>
|
||||
</div>
|
||||
{actionSumaries && <FlagBox actionSumaries={actionSumaries} />}
|
||||
{actionSummaries && <FlagBox actionSummaries={actionSummaries} />}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
Comment.propTypes = {
|
||||
acceptComment: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
currentAsset: PropTypes.object,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string.isRequired,
|
||||
action_summaries: PropTypes.array,
|
||||
created_at: PropTypes.string.isRequired,
|
||||
user: PropTypes.shape({
|
||||
status: PropTypes.string
|
||||
}),
|
||||
asset: PropTypes.shape({
|
||||
title: PropTypes.string,
|
||||
id: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
const linkStyles = {
|
||||
backgroundColor: 'rgb(255, 219, 135)',
|
||||
padding: '1px 2px'
|
||||
|
||||
@@ -5,7 +5,7 @@ const FlagBox = props => (
|
||||
<div className={styles.flagBox}>
|
||||
<h3>Flags:</h3>
|
||||
<ul>
|
||||
{props.actionSumaries.map((action, i) =>
|
||||
{props.actionSummaries.map((action, i) =>
|
||||
<li key={i}>{!action.reason ? <i>No reason provided</i> : action.reason} (<strong>{action.count}</strong>)</li>
|
||||
)}
|
||||
</ul>
|
||||
@@ -13,7 +13,7 @@ const FlagBox = props => (
|
||||
);
|
||||
|
||||
FlagBox.propTypes = {
|
||||
actionSumaries: PropTypes.array.isRequired
|
||||
actionSummaries: PropTypes.array.isRequired
|
||||
};
|
||||
|
||||
export default FlagBox;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {Link} from 'react-router';
|
||||
|
||||
import {Pager, Icon} from 'coral-ui';
|
||||
import {DataTable, TableHeader, RadioGroup, Radio} from 'react-mdl';
|
||||
import EmptyCard from 'coral-admin/src/components/EmptyCard';
|
||||
|
||||
const limit = 25;
|
||||
|
||||
@@ -142,22 +143,25 @@ class Streams extends Component {
|
||||
<Radio value='asc'>{lang.t('streams.oldest')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
<DataTable className={styles.streamsTable} rows={assetsIds} onClick={this.goToModeration}>
|
||||
<TableHeader name="title" cellFormatter={this.renderTitle}>{lang.t('streams.article')}</TableHeader>
|
||||
<TableHeader name="publication_date" cellFormatter={this.renderDate}>
|
||||
{lang.t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader name="closedAt" cellFormatter={this.renderStatus} className={styles.status}>
|
||||
{lang.t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
<Pager
|
||||
totalPages={Math.ceil((assets.count || 0) / limit)}
|
||||
page={this.state.page}
|
||||
onNewPageHandler={this.onPageClick}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
assetsIds.length
|
||||
? <div className={styles.mainContent}>
|
||||
<DataTable className={styles.streamsTable} rows={assetsIds} onClick={this.goToModeration}>
|
||||
<TableHeader name="title" cellFormatter={this.renderTitle}>{lang.t('streams.article')}</TableHeader>
|
||||
<TableHeader name="publication_date" cellFormatter={this.renderDate}>
|
||||
{lang.t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader name="closedAt" cellFormatter={this.renderStatus} className={styles.status}>
|
||||
{lang.t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
<Pager
|
||||
totalPages={Math.ceil((assets.count || 0) / limit)}
|
||||
page={this.state.page}
|
||||
onNewPageHandler={this.onPageClick} />
|
||||
</div>
|
||||
: <EmptyCard>{lang.t('streams.empty_result')}</EmptyCard>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import store from './services/store';
|
||||
|
||||
import App from './components/App';
|
||||
|
||||
import 'react-mdl/extra/material.css';
|
||||
import 'react-mdl/extra/material.js';
|
||||
|
||||
render(
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<App />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"admin": "Administrator",
|
||||
"moderator": "Moderator",
|
||||
"role": "Select role...",
|
||||
"no-results": "No users found with that user name or email address.",
|
||||
"no-results": "No users found with that user name or email address. They're hiding!",
|
||||
"status": "Status",
|
||||
"select-status": "Select status...",
|
||||
"active": "Active",
|
||||
@@ -32,6 +32,7 @@
|
||||
"prevcomment": "Go to the previous comment",
|
||||
"singleview": "Toggle single comment edit view",
|
||||
"thismenu": "Open this menu",
|
||||
"emptyqueue": "No more comments to moderate! You're all caught up. Go have some ☕️",
|
||||
"showshortcuts": "Show Shortcuts"
|
||||
},
|
||||
"comment": {
|
||||
@@ -113,6 +114,7 @@
|
||||
"comment_count": "Comments"
|
||||
},
|
||||
"streams": {
|
||||
"empty_result": "No assets match this search. Maybe try widening your search?",
|
||||
"search": "Search",
|
||||
"filter-streams": "Filter Streams",
|
||||
"stream-status": "Stream Status",
|
||||
@@ -152,6 +154,7 @@
|
||||
"flagged": "marcado",
|
||||
"shortcuts": "Atajos de teclado",
|
||||
"close": "Cerrar",
|
||||
"emptyqueue": "No se encontro ningún usuario. Están escondidos.",
|
||||
"showshortcuts": "Mostrar atajos"
|
||||
},
|
||||
"comment": {
|
||||
@@ -220,6 +223,7 @@
|
||||
"comment_count": "Comentarios"
|
||||
},
|
||||
"streams": {
|
||||
"empty_result": "No se encuentro articulo con esta busqueda. Tal vez extender la busqueda?",
|
||||
"search": "",
|
||||
"filter-streams": "",
|
||||
"stream-status": "",
|
||||
|
||||
@@ -180,7 +180,7 @@ class Comment extends React.Component {
|
||||
comment.replies &&
|
||||
<div className='coral-load-more-replies'>
|
||||
<LoadMore
|
||||
id={asset.id}
|
||||
assetId={asset.id}
|
||||
comments={comment.replies}
|
||||
parentId={comment.id}
|
||||
moreComments={comment.replyCount > comment.replies.length}
|
||||
|
||||
@@ -2,6 +2,9 @@ import React, {Component} from 'react';
|
||||
import {compose} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
import {TabBar, Tab, TabContent, Spinner} from 'coral-ui';
|
||||
|
||||
@@ -12,6 +15,7 @@ const {fetchAssetSuccess} = assetActions;
|
||||
import {queryStream} from 'coral-framework/graphql/queries';
|
||||
import {postComment, postFlag, postLike, postDontAgree, deleteAction} from 'coral-framework/graphql/mutations';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {updateCountCache} from 'coral-framework/actions/asset';
|
||||
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
|
||||
|
||||
import Stream from './Stream';
|
||||
@@ -24,10 +28,11 @@ import UserBox from 'coral-sign-in/components/UserBox';
|
||||
import SignInContainer from 'coral-sign-in/containers/SignInContainer';
|
||||
import SuspendedAccount from 'coral-framework/components/SuspendedAccount';
|
||||
import ChangeUsernameContainer from '../../coral-sign-in/containers/ChangeUsernameContainer';
|
||||
import SettingsContainer from 'coral-settings/containers/SettingsContainer';
|
||||
import ProfileContainer from 'coral-settings/containers/ProfileContainer';
|
||||
import RestrictedContent from 'coral-framework/components/RestrictedContent';
|
||||
import ConfigureStreamContainer from 'coral-configure/containers/ConfigureStreamContainer';
|
||||
import LoadMore from './LoadMore';
|
||||
import NewCount from './NewCount';
|
||||
|
||||
class Embed extends Component {
|
||||
|
||||
@@ -82,7 +87,7 @@ class Embed extends Component {
|
||||
|
||||
render () {
|
||||
const {activeTab} = this.state;
|
||||
const {closedAt} = this.props.asset;
|
||||
const {closedAt, countCache = {}} = this.props.asset;
|
||||
const {loading, asset, refetch} = this.props.data;
|
||||
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
|
||||
|
||||
@@ -98,12 +103,17 @@ class Embed extends Component {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
// Find the created_at date of the first comment. If no comments exist, set the date to a week ago.
|
||||
const firstCommentDate = asset.comments[0]
|
||||
? asset.comments[0].created_at
|
||||
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
|
||||
|
||||
return (
|
||||
<div style={expandForLogin}>
|
||||
<div className="commentStream">
|
||||
<TabBar onChange={this.changeTab} activeTab={activeTab}>
|
||||
<Tab><Count count={asset.commentCount}/></Tab>
|
||||
<Tab>Settings</Tab>
|
||||
<Tab>{lang.t('profile')}</Tab>
|
||||
<Tab restricted={!isAdmin}>Configure Stream</Tab>
|
||||
</TabBar>
|
||||
{loggedIn && <UserBox user={user} logout={this.props.logout} changeTab={this.changeTab}/>}
|
||||
@@ -132,6 +142,8 @@ class Embed extends Component {
|
||||
postItem={this.props.postItem}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
countCache={countCache[asset.id]}
|
||||
assetId={asset.id}
|
||||
premod={asset.settings.moderation}
|
||||
isReply={false}
|
||||
@@ -147,6 +159,14 @@ class Embed extends Component {
|
||||
}
|
||||
{!loggedIn && <SignInContainer requireEmailConfirmation={asset.settings.requireEmailConfirmation} offset={signInOffset}/>}
|
||||
{loggedIn && user && <ChangeUsernameContainer loggedIn={loggedIn} offset={signInOffset} user={user} />}
|
||||
<NewCount
|
||||
commentCount={asset.commentCount}
|
||||
countCache={countCache[asset.id]}
|
||||
loadMore={this.props.loadMore}
|
||||
firstCommentDate={firstCommentDate}
|
||||
assetId={asset.id}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
/>
|
||||
<Stream
|
||||
refetch={refetch}
|
||||
addNotification={this.props.addNotification}
|
||||
@@ -156,6 +176,8 @@ class Embed extends Component {
|
||||
postLike={this.props.postLike}
|
||||
postFlag={this.props.postFlag}
|
||||
postDontAgree={this.props.postDontAgree}
|
||||
getCounts={this.props.getCounts}
|
||||
updateCountCache={this.props.updateCountCache}
|
||||
loadMore={this.props.loadMore}
|
||||
deleteAction={this.props.deleteAction}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
@@ -172,7 +194,7 @@ class Embed extends Component {
|
||||
loadMore={this.props.loadMore}/>
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
<SettingsContainer
|
||||
<ProfileContainer
|
||||
loggedIn={loggedIn}
|
||||
userData={this.props.userData}
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
@@ -217,6 +239,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
clearNotification: () => dispatch(clearNotification()),
|
||||
editName: (username) => dispatch(editName(username)),
|
||||
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
|
||||
updateCountCache: (id, count) => dispatch(updateCountCache(id, count)),
|
||||
logout: () => dispatch(logout()),
|
||||
dispatch: d => dispatch(d)
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ const loadMoreComments = (assetId, comments, loadMore, parentId) => {
|
||||
}
|
||||
|
||||
const cursor = parentId
|
||||
? comments[1].created_at
|
||||
? comments[0].created_at
|
||||
: comments[comments.length - 1].created_at;
|
||||
|
||||
loadMore({
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const onLoadMoreClick = ({loadMore, commentCount, firstCommentDate, assetId, updateCountCache}) => (e) => {
|
||||
e.preventDefault();
|
||||
updateCountCache(assetId, commentCount);
|
||||
loadMore({
|
||||
limit: 500,
|
||||
cursor: firstCommentDate,
|
||||
assetId,
|
||||
sort: 'CHRONOLOGICAL'
|
||||
}, true);
|
||||
};
|
||||
|
||||
const NewCount = (props) => {
|
||||
const newComments = props.commentCount - props.countCache;
|
||||
|
||||
return <div className='coral-new-comments'>
|
||||
{
|
||||
props.countCache && newComments > 0 &&
|
||||
<button onClick={onLoadMoreClick(props)} className='coral-load-more'>
|
||||
{newComments === 1
|
||||
? lang.t('newCount', newComments, lang.t('comment'))
|
||||
: lang.t('newCount', newComments, lang.t('comments'))}
|
||||
</button>
|
||||
}
|
||||
</div>;
|
||||
};
|
||||
|
||||
NewCount.propTypes = {
|
||||
commentCount: PropTypes.number.isRequired,
|
||||
countCache: PropTypes.number,
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
assetId: PropTypes.string.isRequired,
|
||||
firstCommentDate: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default NewCount;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from 'coral-framework/constants/comments';
|
||||
|
||||
class Stream extends React.Component {
|
||||
|
||||
@@ -17,10 +18,30 @@ class Stream extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {activeReplyBox: ''};
|
||||
this.state = {activeReplyBox: '', countPoll: null};
|
||||
this.setActiveReplyBox = this.setActiveReplyBox.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {asset, getCounts, updateCountCache} = this.props;
|
||||
|
||||
updateCountCache(asset.id, asset.commentCount);
|
||||
|
||||
// Note: Apollo's built-in polling doesn't work with fetchMore queries, so a
|
||||
// setInterval is being used instead.
|
||||
this.setState({
|
||||
countPoll: setInterval(() => getCounts({
|
||||
asset_id: asset.id,
|
||||
limit: asset.comments.length,
|
||||
sort: 'REVERSE_CHRONOLOGICAL'
|
||||
}), NEW_COMMENT_COUNT_POLL_INTERVAL),
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.state.countPoll);
|
||||
}
|
||||
|
||||
setActiveReplyBox (reactKey) {
|
||||
if (!this.props.currentUser) {
|
||||
const offset = document.getElementById(`c_${reactKey}`).getBoundingClientRect().top - 75;
|
||||
|
||||
@@ -4,10 +4,10 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Lato', sans-serif;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-family: 'Lato', sans-serif;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
margin: 0px;
|
||||
padding: 0px 0px 50px 0px;
|
||||
}
|
||||
@@ -17,16 +17,16 @@ body {
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
margin: 5px;
|
||||
margin: 5px 10px 5px 0px;
|
||||
background: none;
|
||||
padding: 0px;
|
||||
border: none;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-radius: 2px;
|
||||
color: #FFF;
|
||||
background-color: rgb(155, 155, 155);
|
||||
color: #767676;
|
||||
}
|
||||
|
||||
button i {
|
||||
@@ -58,6 +58,17 @@ hr {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Coral sign in button */
|
||||
|
||||
#coralSignInButton {
|
||||
background-color: #2a2a2a;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
#coralSignInButton:hover {
|
||||
background-color: #767676;
|
||||
}
|
||||
|
||||
/* Info Box Styles */
|
||||
.coral-plugin-infobox-info {
|
||||
top: 0;
|
||||
@@ -65,10 +76,9 @@ hr {
|
||||
background: rgb(35,118,216);
|
||||
color: white;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -157,19 +167,31 @@ hr {
|
||||
}
|
||||
|
||||
.coral-plugin-commentcontent-text {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.coral-plugin-author-name-text {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
font-weight: bolder;
|
||||
margin: 10px 8px 10px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.coral-plugin-author-name-bio-flag {
|
||||
float: right;
|
||||
}
|
||||
|
||||
/* Tag Labels */
|
||||
|
||||
.coral-plugin-tag-label {
|
||||
background-color: #4C1066;
|
||||
color: white;
|
||||
display: inline-block;
|
||||
margin: 10px 10px;
|
||||
padding: 5px 5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Reply styles */
|
||||
|
||||
@@ -206,8 +228,9 @@ hr {
|
||||
}
|
||||
|
||||
.coral-plugin-pubdate-text {
|
||||
color: #CCC;
|
||||
color: #696969;
|
||||
display: inline-block;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.coral-plugin-permalinks-container {
|
||||
@@ -336,18 +359,26 @@ button.coral-load-more {
|
||||
text-align: center;
|
||||
color: #FFF;
|
||||
background-color: #2376D8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.coral-load-more:hover {
|
||||
background-color: #4399FF;
|
||||
}
|
||||
|
||||
.coral-load-more-replies {
|
||||
.coral-load-more-replies, .coral-new-comments {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.coral-load-more-replies button.coral-load-more {
|
||||
.coral-new-comments {
|
||||
position: relative;
|
||||
top: 1.8em;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.coral-load-more-replies button.coral-load-more, .coral-new-comments button.coral-load-more{
|
||||
width: initial;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const updateOpenStream = closedBody => (dispatch, getState) => {
|
||||
|
||||
const openStream = () => ({type: actions.OPEN_COMMENTS});
|
||||
const closeStream = () => ({type: actions.CLOSE_COMMENTS});
|
||||
export const updateCountCache = (id, count) => ({type: actions.UPDATE_COUNT_CACHE, id, count});
|
||||
|
||||
export const updateOpenStatus = status => dispatch => {
|
||||
if (status === 'open') {
|
||||
|
||||
@@ -192,9 +192,8 @@ export const requestConfirmEmail = (email, redirectUri) => dispatch => {
|
||||
dispatch(verifyEmailSuccess());
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('failed to send email verification', err);
|
||||
|
||||
// email might have already been verifyed
|
||||
dispatch(verifyEmailFailure());
|
||||
dispatch(verifyEmailFailure(err));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,3 +8,4 @@ export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
|
||||
|
||||
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
|
||||
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
|
||||
export const UPDATE_COUNT_CACHE = 'UPDATE_COUNT_CACHE';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const ADDTL_COMMENTS_ON_LOAD_MORE = 10;
|
||||
export const NEW_COMMENT_COUNT_POLL_INTERVAL = 20000;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
import STREAM_QUERY from './streamQuery.graphql';
|
||||
import LOAD_MORE from './loadMore.graphql';
|
||||
import GET_COUNTS from './getCounts.graphql';
|
||||
import MY_COMMENT_HISTORY from './myCommentHistory.graphql';
|
||||
|
||||
function getQueryVariable(variable) {
|
||||
@@ -17,6 +18,72 @@ function getQueryVariable(variable) {
|
||||
return 'http://localhost/default/stream';
|
||||
}
|
||||
|
||||
export const getCounts = (data) => ({asset_id, limit, sort}) => {
|
||||
return data.fetchMore({
|
||||
query: GET_COUNTS,
|
||||
variables: {
|
||||
asset_id,
|
||||
limit,
|
||||
sort
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{data}}) => {
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
asset: {
|
||||
...oldData.asset,
|
||||
commentCount: data.asset.commentCount
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const loadMore = (data) => ({limit, cursor, parent_id, asset_id, sort}, newComments) => {
|
||||
return data.fetchMore({
|
||||
query: LOAD_MORE,
|
||||
variables: {
|
||||
limit,
|
||||
cursor,
|
||||
parent_id,
|
||||
asset_id,
|
||||
sort
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{data:{new_top_level_comments}}}) => {
|
||||
|
||||
let updatedAsset;
|
||||
|
||||
if (parent_id) {
|
||||
|
||||
// If loading more replies
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
asset: {
|
||||
...oldData.asset,
|
||||
comments: oldData.asset.comments.map((comment) =>
|
||||
comment.id === parent_id
|
||||
? {...comment, replies: [...comment.replies, ...new_top_level_comments]}
|
||||
: comment)
|
||||
}
|
||||
};
|
||||
} else {
|
||||
|
||||
// If loading more top-level comments
|
||||
updatedAsset = {
|
||||
...oldData,
|
||||
asset: {
|
||||
...oldData.asset,
|
||||
comments: newComments ? [...new_top_level_comments.reverse(), ...oldData.asset.comments]
|
||||
: [...oldData.asset.comments, ...new_top_level_comments]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return updatedAsset;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const queryStream = graphql(STREAM_QUERY, {
|
||||
options: () => ({
|
||||
variables: {
|
||||
@@ -25,40 +92,8 @@ export const queryStream = graphql(STREAM_QUERY, {
|
||||
}),
|
||||
props: ({data}) => ({
|
||||
data,
|
||||
loadMore: ({limit, cursor, parent_id, asset_id, sort}) => {
|
||||
return data.fetchMore({
|
||||
query: LOAD_MORE,
|
||||
variables: {
|
||||
limit,
|
||||
cursor,
|
||||
parent_id,
|
||||
asset_id,
|
||||
sort
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{data:{new_top_level_comments}}}) =>
|
||||
|
||||
// If loading more replies
|
||||
parent_id ? {
|
||||
...oldData,
|
||||
asset: {
|
||||
...oldData.asset,
|
||||
comments: oldData.asset.comments.map((comment) =>
|
||||
comment.id === parent_id
|
||||
? {...comment, replies: [...comment.replies, ...new_top_level_comments]}
|
||||
: comment)
|
||||
}
|
||||
}
|
||||
|
||||
// If loading more top-level comments
|
||||
: {
|
||||
...oldData,
|
||||
asset: {
|
||||
...oldData.asset,
|
||||
comments: [...oldData.asset.comments, ...new_top_level_comments]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
loadMore: loadMore(data),
|
||||
getCounts: getCounts(data),
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const handleResp = res => {
|
||||
}
|
||||
|
||||
if (err.error && err.error.translation_key) {
|
||||
message = err.error.translation_key;
|
||||
error.translation_key = err.error.translation_key;
|
||||
}
|
||||
|
||||
error.message = message;
|
||||
|
||||
@@ -19,6 +19,9 @@ export default function asset (state = initialState, action) {
|
||||
case actions.UPDATE_ASSET_SETTINGS_SUCCESS:
|
||||
return state
|
||||
.setIn(['settings'], action.settings);
|
||||
case actions.UPDATE_COUNT_CACHE:
|
||||
return state
|
||||
.setIn(['countCache', action.id], action.count);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"en": {
|
||||
"profile": "Profile",
|
||||
"successUpdateSettings": "The changes you have made have been applied to the comment stream on this article",
|
||||
"successNameUpdate": "Your username has been updated",
|
||||
"contentNotAvailable": "This content is not available",
|
||||
@@ -11,6 +12,9 @@
|
||||
"button": "Submit",
|
||||
"error": "Usernames can contain letters, numbers and _ only"
|
||||
},
|
||||
"newCount": "View {0} more {1}",
|
||||
"comment": "comment",
|
||||
"comments": "comments",
|
||||
"error": {
|
||||
"emailNotVerified": "Email address {0} not verified.",
|
||||
"email": "Not a valid E-Mail",
|
||||
@@ -33,12 +37,16 @@
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"profile": "Perfil",
|
||||
"successUpdateSettings": "La configuración de este articulo fue actualizada",
|
||||
"successBioUpdate": "Tu bio fue actualizada",
|
||||
"contentNotAvailable": "El contenido no se encuentra disponible",
|
||||
"bannedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes dar Like, Marcar o escribir commentarios. Por favor, contacta moderator@fakeurl for more information",
|
||||
"editNameMsg": "",
|
||||
"loadMore": "Ver más",
|
||||
"newCount": "Ver {0} {1} más",
|
||||
"comment": "commentario",
|
||||
"comments": "commentarios",
|
||||
"error": {
|
||||
"emailNotVerified": "Dirección de correo electrónico {0} no verificada.",
|
||||
"email": "No es un email válido",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, {Component} from 'react';
|
||||
const packagename = 'coral-plugin-author-name';
|
||||
import styles from './styles.css';
|
||||
|
||||
export default class AuthorName extends Component {
|
||||
|
||||
@@ -24,8 +23,7 @@ export default class AuthorName extends Component {
|
||||
const {author} = this.props;
|
||||
return (
|
||||
<div
|
||||
className={`${packagename}-text`}
|
||||
className={`${styles.authorName}`}>
|
||||
className={`${packagename}-text`}>
|
||||
{author && author.name}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,9 @@ class CommentBox extends Component {
|
||||
commentPostedHandler,
|
||||
postItem,
|
||||
assetId,
|
||||
updateCountCache,
|
||||
isReply,
|
||||
countCache,
|
||||
parentId,
|
||||
addNotification,
|
||||
authorId
|
||||
@@ -44,16 +47,17 @@ class CommentBox extends Component {
|
||||
if (this.props.charCount && this.state.body.length > this.props.charCount) {
|
||||
return;
|
||||
}
|
||||
!isReply && updateCountCache(assetId, countCache + 1);
|
||||
postItem(comment, 'comments')
|
||||
.then(({data}) => {
|
||||
const postedComment = data.createComment.comment;
|
||||
|
||||
if (postedComment.status === 'REJECTED') {
|
||||
addNotification('error', lang.t('comment-post-banned-word'));
|
||||
!isReply && updateCountCache(assetId, countCache);
|
||||
} else if (postedComment.status === 'PREMOD') {
|
||||
addNotification('success', lang.t('comment-post-notif-premod'));
|
||||
} else {
|
||||
addNotification('success', 'Your comment has been posted.');
|
||||
!isReply && updateCountCache(assetId, countCache);
|
||||
}
|
||||
|
||||
if (commentPostedHandler) {
|
||||
@@ -105,7 +109,6 @@ class CommentBox extends Component {
|
||||
cStyle='darkGrey'
|
||||
className={`${name}-cancel-button`}
|
||||
onClick={() => {
|
||||
console.log('cancel button in comment box');
|
||||
cancelButtonClicked('');
|
||||
}}>
|
||||
{lang.t('cancel')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
const name = 'coral-plugin-content';
|
||||
const name = 'coral-plugin-commentcontent';
|
||||
|
||||
const Content = ({body, styles}) => {
|
||||
const textbreaks = body.split('\n');
|
||||
|
||||
@@ -49,8 +49,8 @@ class PermalinkButton extends React.Component {
|
||||
return (
|
||||
<div className={`${name}-container`}>
|
||||
<button onClick={this.toggle} className={`${name}-button`}>
|
||||
<i className={`${name}-icon material-icons`} aria-hidden={true}>link</i>
|
||||
{lang.t('permalink.permalink')}
|
||||
<i className={`${name}-icon material-icons`} aria-hidden={true}>link</i>
|
||||
</button>
|
||||
<div className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}>
|
||||
<input
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import React, {Component} from 'react';
|
||||
import {graphql} from 'react-apollo';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export class RileysAwesomeCommentBox extends Component {
|
||||
|
||||
postComment() {
|
||||
console.log(this.props);
|
||||
console.log('postComment', this.props.asset_id);
|
||||
this.props.mutate({
|
||||
variables: {
|
||||
asset_id: this.props.asset_id,
|
||||
body: this.textarea.value,
|
||||
parent_id: null
|
||||
}
|
||||
}).then(({data}) => {
|
||||
console.log('it workt');
|
||||
console.log(data);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>
|
||||
<textarea ref={textarea => this.textarea = textarea}></textarea>
|
||||
<button onClick={this.postComment.bind(this)}>POST</button>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const postComment = gql`
|
||||
fragment commentView on Comment {
|
||||
id
|
||||
body
|
||||
user {
|
||||
name: username
|
||||
}
|
||||
actions {
|
||||
type: action_type
|
||||
count
|
||||
current: current_user {
|
||||
id
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
|
||||
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
|
||||
...commentView
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const RileysAwesomeCommentBoxWithData = graphql(
|
||||
postComment
|
||||
)(RileysAwesomeCommentBox);
|
||||
|
||||
export default RileysAwesomeCommentBoxWithData;
|
||||
@@ -1,100 +0,0 @@
|
||||
import React, {Component} from 'react';
|
||||
import {graphql} from 'react-apollo';
|
||||
import gql from 'graphql-tag';
|
||||
import {fetchSignIn} from 'coral-framework/actions/auth';
|
||||
import RileysAwesomeCommentBox from 'coral-plugin-stream/RileysAwesomeCommentBox';
|
||||
|
||||
const assetID = '6187a94b-0b6d-4a96-ac6b-62b529cd8410';
|
||||
|
||||
// MyComponent is a "presentational" or apollo-unaware component,
|
||||
// It could be a simple React class:
|
||||
class Stream extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
logMeIn() {
|
||||
fetchSignIn({email: 'your@example.com', password: 'dfasidfaisdufoiausdfoiuaspdoifas'})(() => {});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {data} = this.props;
|
||||
return <div>
|
||||
<button onClick={this.logMeIn.bind(this)}>Login or whatever</button>
|
||||
{
|
||||
data.loading
|
||||
? 'loading!'
|
||||
: <div>
|
||||
<RileysAwesomeCommentBox asset_id={data.asset.id} />
|
||||
<p>Asset ID: {data.asset.id}</p>
|
||||
<ul>
|
||||
{
|
||||
data.asset.comments.map(comment => {
|
||||
return <li key={comment.id}>
|
||||
{comment.body} [{comment.id}]
|
||||
<ul>
|
||||
{
|
||||
comment.replies.map(reply => {
|
||||
return <li key={reply.id}>{reply.body}</li>;
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</li>;
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize GraphQL queries or mutations with the gql tag
|
||||
const StreamQuery = gql`fragment commentView on Comment {
|
||||
id
|
||||
body
|
||||
user {
|
||||
name: username
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
actions {
|
||||
type: action_type
|
||||
count
|
||||
current: current_user {
|
||||
id
|
||||
created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query AssetQuery($asset_id: ID!) {
|
||||
asset(id: $asset_id) {
|
||||
id
|
||||
title
|
||||
url
|
||||
commentCount
|
||||
comments {
|
||||
...commentView
|
||||
replies {
|
||||
...commentView
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
// We then can use `graphql` to pass the query results returned by MyQuery
|
||||
// to MyComponent as a prop (and update them as the results change)
|
||||
const StreamWithData = graphql(
|
||||
StreamQuery, {
|
||||
options: {
|
||||
variables: {
|
||||
asset_id: assetID
|
||||
}
|
||||
}
|
||||
}
|
||||
)(Stream);
|
||||
|
||||
export default StreamWithData;
|
||||
@@ -1,8 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './styles.css';
|
||||
|
||||
const TagLabel = ({isStaff}) => <div className={`${styles.staff}`}>
|
||||
const TagLabel = ({isStaff}) => <div className='coral-plugin-tag-label'>
|
||||
{isStaff ? 'Staff' : ''}
|
||||
</div>;
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.staff {
|
||||
background-color: #4C1066;
|
||||
color: white;
|
||||
display: inline-block;
|
||||
margin: 10px 10px;
|
||||
padding: 5px 5px;
|
||||
}
|
||||
@@ -11,5 +11,4 @@
|
||||
cursor: pointer;
|
||||
margin: 0px;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: solid 1px black;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import React from 'react';
|
||||
import styles from './NotLoggedIn.css';
|
||||
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
|
||||
import translations from '../translations';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({showSignInDialog}) => (
|
||||
<div className={styles.message}>
|
||||
<SignInContainer noButton={true}/>
|
||||
<div>
|
||||
<a onClick={() => {
|
||||
console.log('Signin click');
|
||||
showSignInDialog();
|
||||
}}>Sign In</a> to access Settings
|
||||
}}>{lang.t('signIn')}</a> {lang.t('toAccess')}
|
||||
</div>
|
||||
<div>
|
||||
From the Settings Page you can
|
||||
<ul>
|
||||
<li>See your comment history</li>
|
||||
</ul>
|
||||
{lang.t('fromSettingsPage')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './ProfileHeader.css';
|
||||
|
||||
const ProfileHeader = ({username}) => (
|
||||
<div className={styles.header}>
|
||||
<h1>{username}</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
ProfileHeader.propTypes = {username: PropTypes.string.isRequired};
|
||||
|
||||
export default ProfileHeader;
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import styles from './SettingsHeader.css';
|
||||
|
||||
export default ({userData}) => (
|
||||
<div className={styles.header}>
|
||||
<h1>{userData.username}</h1>
|
||||
|
||||
{
|
||||
|
||||
// Hiding display of users ID unless there's a use case for it.
|
||||
// <h2>{userData.profiles.map(profile => profile.id)}</h2>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
+4
-4
@@ -8,13 +8,13 @@ import {myCommentHistory} from 'coral-framework/graphql/queries';
|
||||
import {link} from 'coral-framework/services/PymConnection';
|
||||
import NotLoggedIn from '../components/NotLoggedIn';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import ProfileHeader from '../components/ProfileHeader';
|
||||
import CommentHistory from 'coral-plugin-history/CommentHistory';
|
||||
|
||||
import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
class SettingsContainer extends Component {
|
||||
class ProfileContainer extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -44,7 +44,7 @@ class SettingsContainer extends Component {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader {...this.props} />
|
||||
<ProfileHeader username={this.props.userData.username} />
|
||||
{
|
||||
|
||||
// Hiding bio until moderation can get figured out
|
||||
@@ -88,4 +88,4 @@ const mapDispatchToProps = () => ({
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
myCommentHistory
|
||||
)(SettingsContainer);
|
||||
)(ProfileContainer);
|
||||
@@ -1,14 +1,22 @@
|
||||
{
|
||||
"en":{
|
||||
"userNoComment": "This user has not yet left a comment.",
|
||||
"profile": "Profile",
|
||||
"userNoComment": "You've never left a comment. Join the conversation!",
|
||||
"allComments": "All Comments",
|
||||
"profileSettings": "Profile Settings",
|
||||
"myCommentHistory": "My comment History"
|
||||
"myCommentHistory": "My comment History",
|
||||
"signIn": "Sign in",
|
||||
"toAccess": " to access Profile",
|
||||
"fromSettingsPage": "From the Profile Page you can see your comment history."
|
||||
},
|
||||
"es":{
|
||||
"userNoComment": "Aún no ha escrito ningún comentario.",
|
||||
"profile": "Perfil",
|
||||
"userNoComment": "No has dejado áun ningún comentario. ¡Unete a la conversación!",
|
||||
"allComments": "Todos los comentarios",
|
||||
"profileSettings": "Configuración del perfil",
|
||||
"myCommentHistory": "Mi historial de comentarios"
|
||||
"myCommentHistory": "Mi historial de comentarios",
|
||||
"signIn": "Entrar",
|
||||
"toAccess": "para acceder a al perfil",
|
||||
"fromSettingsPage": "Desde la peagina de configuración puede ver su historia de comentarios."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,18 @@ import TextField from 'coral-ui/components/TextField';
|
||||
import Alert from './Alert';
|
||||
import Button from 'coral-ui/components/Button';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import FakeComment from './FakeComment';
|
||||
|
||||
import styles from './styles.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const CreateUsernameDialog = ({open, handleClose, offset, formData, handleSubmitUsername, handleChange, ...props}) => (
|
||||
const CreateUsernameDialog = ({open, handleClose, offset, formData, handleSubmitUsername, handleChange, ...props}) => {
|
||||
return (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
className={styles.dialogusername}
|
||||
id="createUsernameDialog"
|
||||
open={open}
|
||||
style={{
|
||||
@@ -25,24 +29,32 @@ const CreateUsernameDialog = ({open, handleClose, offset, formData, handleSubmit
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="username">{lang.t('createdisplay.yourusername')}</label>
|
||||
<p className={styles.yourusername}>{lang.t('createdisplay.yourusername')}</p>
|
||||
<FakeComment
|
||||
className={styles.fakeComment}
|
||||
username={formData.username}
|
||||
created_at={Date.now()}
|
||||
body={lang.t('createdisplay.fakecommentbody')}
|
||||
/>
|
||||
<p className={styles.ifyoudont}>{lang.t('createdisplay.ifyoudontchangeyourname')}</p>
|
||||
{ props.auth.error && <Alert>{props.auth.error}</Alert> }
|
||||
<form id="saveUsername" onSubmit={handleSubmitUsername}>
|
||||
<TextField
|
||||
id="username"
|
||||
type="string"
|
||||
label={lang.t('createdisplay.username')}
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{ props.errors.username && <span className={styles.hint}> {lang.t('createdisplay.specialCharacters')} </span> }
|
||||
<div className={styles.action}>
|
||||
{ props.errors.username && <span className={styles.hint}> {lang.t('createdisplay.specialCharacters')} </span> }
|
||||
<div className={styles.saveusername}>
|
||||
<TextField
|
||||
id="username"
|
||||
type="string"
|
||||
label={lang.t('createdisplay.username')}
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button id="save" type="submit" className={styles.saveButton}>{lang.t('createdisplay.save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUsernameDialog;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import styles from 'coral-embed-stream/src/Comment.css';
|
||||
|
||||
import AuthorName from 'coral-plugin-author-name/AuthorName';
|
||||
import Content from 'coral-plugin-commentcontent/CommentContent';
|
||||
import PubDate from 'coral-plugin-pubdate/PubDate';
|
||||
import {ReplyButton} from 'coral-plugin-replies';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
class FakeComment extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render () {
|
||||
const {username, created_at, body} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`comment ${styles.Comment}`}
|
||||
style={{marginLeft: 0 * 30}}>
|
||||
<hr aria-hidden={true} />
|
||||
<AuthorName
|
||||
author={{'name': username}}/>
|
||||
<PubDate created_at={created_at} />
|
||||
<Content body={body} />
|
||||
<div className="commentActionsLeft">
|
||||
<div className={`${'coral-plugin-likes'}-container`}>
|
||||
<button className={`${'coral-plugin-likes'}-button`}>
|
||||
<span className={`${'coral-plugin-likes'}-button-text`}>{lang.t('like')}</span>
|
||||
<i className={`${'coral-plugin-likes'}-icon material-icons`}
|
||||
aria-hidden={true}>thumb_up</i>
|
||||
</button>
|
||||
</div>
|
||||
<ReplyButton
|
||||
onClick={() => {}}
|
||||
parentCommentId={'commentID'}
|
||||
currentUserId={{}}
|
||||
banned={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="commentActionsRight">
|
||||
<div className="coral-plugin-permalinks-container">
|
||||
<button className="coral-plugin-permalinks-button">
|
||||
<i className="coral-plugin-permalinks-icon material-icons" aria-hidden={true}>link</i>
|
||||
{lang.t('permalink.permalink')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`${'coral-plugin-flags'}-container`}>
|
||||
<button className={`${'coral-plugin-flags'}-button`}>
|
||||
<span className={`${'coral-plugin-flags'}-button-text`}>{lang.t('report')}</span>
|
||||
<i className={`${'coral-plugin-flags'}-icon material-icons`}
|
||||
aria-hidden={true}>flag</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default FakeComment;
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
.signInButton {
|
||||
margin-top: 10px;
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.close {
|
||||
@@ -117,7 +118,7 @@ input.error{
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 15px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.passwordRequestSuccess {
|
||||
@@ -140,6 +141,40 @@ input.error{
|
||||
display: block;
|
||||
}
|
||||
|
||||
.confirmSubmit {
|
||||
/* Change username Dialog*/
|
||||
|
||||
.dialogusername {
|
||||
border: none;
|
||||
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
|
||||
width: 400px;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
.yourusername {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.example {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ifyoudont {
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.saveusername {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.savebutton {
|
||||
display: inline;
|
||||
background-color: rgb(105,105,105);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.fakeComment {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class ChangeUsernameContainer extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.initialState.formData.username = props.user.username;
|
||||
this.state = this.initialState;
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmitUsername = this.handleSubmitUsername.bind(this);
|
||||
@@ -103,7 +104,7 @@ class ChangeUsernameContainer extends Component {
|
||||
return (
|
||||
<div>
|
||||
<CreateUsernameDialog
|
||||
open={auth.showCreateUsernameDialog && auth.fromSignUp}
|
||||
open={auth.showCreateUsernameDialog && auth.user.canEditName}
|
||||
offset={offset}
|
||||
handleClose={this.handleClose}
|
||||
loggedIn={loggedIn}
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
facebookSignIn: 'Sign in with Facebook',
|
||||
facebookSignUp: 'Sign up with Facebook',
|
||||
logout: 'Logout',
|
||||
signIn: 'Sign In',
|
||||
signIn: 'Sign in to join the conversation',
|
||||
or: 'Or',
|
||||
email: 'E-mail Address',
|
||||
password: 'Password',
|
||||
@@ -30,15 +30,24 @@ export default {
|
||||
checkTheForm: 'Invalid Form. Please, check the fields'
|
||||
},
|
||||
'createdisplay': {
|
||||
writeyourusername: 'Write your username',
|
||||
yourusername: 'Your username is publicly visible on all comments you post. A username is needed before you can post your first comment.',
|
||||
writeyourusername: 'Edit your username',
|
||||
yourusername: 'Your username appears on every comment you post.',
|
||||
ifyoudontchangeyourname: 'If you don\'t change your username at this step, your Facebook display name will appear alongside of all your comments.',
|
||||
username: 'Username',
|
||||
continue: 'Continue with the same Facebook username',
|
||||
save: 'Save',
|
||||
fakecommentdate: '1 minute ago',
|
||||
fakecommentbody: 'This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section.',
|
||||
requiredField: 'Required field',
|
||||
errorCreate: 'Error when changing username',
|
||||
checkTheForm: 'Invalid Form. Please, check the fields',
|
||||
specialCharacters: 'Usernames can contain letters, numbers and _ only'
|
||||
}
|
||||
},
|
||||
'permalink': {
|
||||
permalink: 'Link'
|
||||
},
|
||||
'report': 'Report',
|
||||
'like': 'Like',
|
||||
},
|
||||
es: {
|
||||
'signIn': {
|
||||
@@ -51,7 +60,7 @@ export default {
|
||||
facebookSignIn: 'Entrar con Facebook',
|
||||
facebookSignUp: 'Regístrate con Facebook',
|
||||
logout: 'Salir',
|
||||
signIn: 'Entrar',
|
||||
signIn: 'Entrar para Unirte a la Conversación',
|
||||
or: 'o',
|
||||
email: 'E-mail',
|
||||
password: 'Contraseña',
|
||||
@@ -71,14 +80,23 @@ export default {
|
||||
checkTheForm: 'Formulario Inválido. Por favor, completa los campos'
|
||||
},
|
||||
'createdisplay': {
|
||||
writeyourusername: 'Escribe tu nombre',
|
||||
yourusername: 'Tu nombre es visible publicamente en todos los comentarios que publiques. Es necesario tener un nombre de usuario antes de poder publicar tu primer comentario.',
|
||||
username: 'Nombre a mostrar',
|
||||
writeyourusername: 'Edita tu nombre',
|
||||
yourusername: 'Tu nombre aparece en cada comentario que publiques.',
|
||||
ifyoudontchangeyourname: 'Si no modificas tu nombre de usuario en este paso, tu nombre de Facebook aparecera al lado de cada comentario que publiques.',
|
||||
username: 'Nombre',
|
||||
continue: 'Continuar con nombre de Facebook',
|
||||
save: 'Guardar',
|
||||
fakecommentdate: 'hace un minuto',
|
||||
fakecommentbody: 'Este es un comentario de ejemplo. Las lectoras pueden compartir sus ideas y opiniones con los periodistas en la sección de comentarios.',
|
||||
requiredField: 'Campo necesario',
|
||||
errorCreate: 'Hubo un error al cambiar el nombre de usuario',
|
||||
checkTheForm: 'Formulario Invalido. Por favor, verifica los campos',
|
||||
specialCharacters: 'Sólo pueden contener letras, números y _'
|
||||
},
|
||||
'permalink': {
|
||||
permalink: 'Enlace'
|
||||
},
|
||||
'report': 'Informe',
|
||||
'like': 'Me gusta',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
li.base--active {
|
||||
background: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
li.material--active {
|
||||
|
||||
@@ -150,10 +150,8 @@ const createPublicComment = (context, commentInput) => {
|
||||
item_id: comment.id,
|
||||
item_type: 'COMMENTS',
|
||||
action_type: 'FLAG',
|
||||
metadata: {
|
||||
field: 'body',
|
||||
details: 'Matched suspect word filters.'
|
||||
}
|
||||
group_id: 'Matched suspect word filter',
|
||||
metadata: {}
|
||||
})
|
||||
.then(() => comment);
|
||||
}
|
||||
|
||||
@@ -486,7 +486,7 @@ type RootQuery {
|
||||
# Comments returned based on a query.
|
||||
comments(query: CommentsQuery!): [Comment]
|
||||
|
||||
# Returne the count of comments satisfied by the query. Note that this edge is
|
||||
# Return the count of comments satisfied by the query. Note that this edge is
|
||||
# expensive as it is not batched. Requires the `ADMIN` role.
|
||||
commentCount(query: CommentCountQuery!): Int
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ const express = require('express');
|
||||
const passport = require('../../../services/passport');
|
||||
const authorization = require('../../../middleware/authorization');
|
||||
const errors = require('../../../errors');
|
||||
const UsersService = require('../../../services/users');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -61,6 +60,7 @@ const HandleAuthCallback = (req, res, next) => (err, user) => {
|
||||
/**
|
||||
* Returns the response to the login attempt via a popup callback with some JS.
|
||||
*/
|
||||
|
||||
const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
|
||||
if (err) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
|
||||
@@ -70,20 +70,15 @@ const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
|
||||
return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null});
|
||||
}
|
||||
|
||||
// Authorize the user to edit their username.
|
||||
UsersService.toggleNameEdit(user.id, true)
|
||||
.then(() => {
|
||||
|
||||
// Perform the login of the user!
|
||||
req.logIn(user, (err) => {
|
||||
if (err) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
|
||||
}
|
||||
// Perform the login of the user!
|
||||
req.logIn(user, (err) => {
|
||||
if (err) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(err), data: null});
|
||||
}
|
||||
|
||||
// We logged in the user! Let's send back the user data.
|
||||
res.render('auth-callback', {err: null, data: JSON.stringify(user)});
|
||||
});
|
||||
});
|
||||
// We logged in the user! Let's send back the user data.
|
||||
res.render('auth-callback', {err: null, data: JSON.stringify(user)});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+4
-1
@@ -124,6 +124,8 @@ module.exports = class UsersService {
|
||||
return user;
|
||||
}
|
||||
|
||||
// User does not exist and need to be created.
|
||||
|
||||
let username = UsersService.castUsername(displayName);
|
||||
|
||||
// The user was not found, lets create them!
|
||||
@@ -131,7 +133,8 @@ module.exports = class UsersService {
|
||||
username,
|
||||
lowercaseUsername: username.toLowerCase(),
|
||||
roles: [],
|
||||
profiles: [{id, provider}]
|
||||
profiles: [{id, provider}],
|
||||
canEditName: true
|
||||
});
|
||||
|
||||
return user.save();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Map} from 'immutable';
|
||||
import {expect} from 'chai';
|
||||
import assetReducer from '../../../../client/coral-framework/reducers/asset';
|
||||
import * as actions from '../../../../client/coral-framework/constants/asset';
|
||||
|
||||
describe ('coral-embed-stream assetReducer', () => {
|
||||
describe('UPDATE_COUNT_CACHE', () => {
|
||||
it('should update the count cache', () => {
|
||||
const action = {
|
||||
type: actions.UPDATE_COUNT_CACHE,
|
||||
id: '123',
|
||||
count: 456
|
||||
};
|
||||
const store = new Map({});
|
||||
const result = assetReducer(store, action);
|
||||
expect(result.getIn(['countCache', '123'])).to.equal(456);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {Map} from 'immutable';
|
||||
import {expect} from 'chai';
|
||||
import notificationReducer from '../../../../client/coral-framework/reducers/notification';
|
||||
import * as actions from '../../../../client/coral-framework/actions/notification';
|
||||
|
||||
describe ('notificationsReducer', () => {
|
||||
describe('ADD_NOTIFICATION', () => {
|
||||
it('should add a notification', () => {
|
||||
const action = {
|
||||
type: actions.ADD_NOTIFICATION,
|
||||
text: 'Test notification',
|
||||
notifType: 'test'
|
||||
};
|
||||
const store = new Map({});
|
||||
const result = notificationReducer(store, action);
|
||||
expect(result.get('text')).to.equal(action.text);
|
||||
expect(result.get('type')).to.equal(action.notifType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLEAR_NOTIFICATION', () => {
|
||||
it('should clear a notification', () => {
|
||||
const action = {
|
||||
type: actions.CLEAR_NOTIFICATION
|
||||
};
|
||||
const store = new Map({
|
||||
text: 'Test notification',
|
||||
type: 'test'
|
||||
});
|
||||
const result = notificationReducer(store, action);
|
||||
expect(result.get('text')).to.equal('');
|
||||
expect(result.get('type')).to.equal('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta property="csrf" content="<%= csrfToken %>">
|
||||
<link rel="stylesheet" type="text/css" href="/client/embed/stream/default.css">
|
||||
<link href="https://fonts.googleapis.com/css?family=Lato|Open+Sans" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css?family=Lato:400,700" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<% if (locals.customCssUrl) { %>
|
||||
<link href="<%= customCssUrl %>" rel="stylesheet" type="text/css">
|
||||
|
||||
Reference in New Issue
Block a user