mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-22 11:16:01 +03:00
Wired up Admin Comment Endpoints to UI (#21661)
ref PLG-227 - Updated logic that allows Admin Users on comments to interact with some endpoints from a specific admin-only route. - It pulls 2 admin specific routes: - 1. an admin specific 'browse' route that includes hidden comments that would otherwise be hidden from regular users and members. - 2. A specific replies route, that would also include hidden comments - This was needed in order to get accurate pagination. - Additionally, it wires up the routes via `message-handler` that deal with the potential cors issues. - Includes style updates --------- Co-authored-by: Sanne de Vries <sannedv@protonmail.com> Co-authored-by: Kevin Ansfield <kevin@lookingsideways.co.uk>
This commit is contained in:
parent
703ed9dfbc
commit
cf6884d098
@ -7,9 +7,9 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import i18nLib from '@tryghost/i18n';
|
||||
import setupGhostApi from './utils/api';
|
||||
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
|
||||
import {AdminApi, setupAdminAPI} from './utils/adminApi';
|
||||
import {AppContext, DispatchActionType, EditableAppContext, LabsContextType} from './AppContext';
|
||||
import {CommentsFrame} from './components/Frame';
|
||||
import {setupAdminAPI} from './utils/adminApi';
|
||||
import {useOptions} from './utils/options';
|
||||
|
||||
type AppProps = {
|
||||
@ -28,7 +28,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
|
||||
openCommentForms: [],
|
||||
popup: null,
|
||||
labs: {},
|
||||
order: 'count__likes desc, created_at desc'
|
||||
order: 'count__likes desc, created_at desc',
|
||||
adminApi: null
|
||||
});
|
||||
|
||||
const iframeRef = React.createRef<HTMLIFrameElement>();
|
||||
@ -41,7 +42,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
|
||||
});
|
||||
}, [options]);
|
||||
|
||||
const [adminApi, setAdminApi] = useState<AdminApi|null>(null);
|
||||
// const [adminApi, setAdminApi] = useState<AdminApi|null>(null);
|
||||
|
||||
const setState = useCallback((newState: Partial<EditableAppContext> | ((state: EditableAppContext) => Partial<EditableAppContext>)) => {
|
||||
setFullState((state) => {
|
||||
@ -61,7 +62,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
|
||||
// because updates to state may be asynchronous
|
||||
// so calling dispatchAction('counterUp') multiple times, may yield unexpected results if we don't use a callback function
|
||||
setState((state) => {
|
||||
return SyncActionHandler({action, data, state, api, adminApi: adminApi!, options});
|
||||
return SyncActionHandler({action, data, state, api, adminApi: state.adminApi!, options});
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -70,14 +71,14 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
|
||||
// without creating infinite rerenders because dispatchAction needs to change on every state change
|
||||
// So state shouldn't be a dependency of dispatchAction
|
||||
setState((state) => {
|
||||
ActionHandler({action, data, state, api, adminApi: adminApi!, options}).then((updatedState) => {
|
||||
ActionHandler({action, data, state, api, adminApi: state.adminApi!, options}).then((updatedState) => {
|
||||
setState({...updatedState});
|
||||
}).catch(console.error); // eslint-disable-line no-console
|
||||
|
||||
// No immediate changes
|
||||
return {};
|
||||
});
|
||||
}, [api, adminApi, options]); // Do not add state or context as a dependency here -> infinite render loop
|
||||
}, [api, options]); // Do not add state or context as a dependency here -> infinite render loop
|
||||
|
||||
const i18n = useMemo(() => {
|
||||
return i18nLib(options.locale, 'comments');
|
||||
@ -92,7 +93,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
|
||||
};
|
||||
|
||||
const initAdminAuth = async () => {
|
||||
if (adminApi || !options.adminUrl) {
|
||||
if (state.adminApi || !options.adminUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -100,11 +101,21 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
|
||||
const adminApi = setupAdminAPI({
|
||||
adminUrl: options.adminUrl
|
||||
});
|
||||
setAdminApi(adminApi);
|
||||
|
||||
let admin = null;
|
||||
try {
|
||||
admin = await adminApi.getUser();
|
||||
if (admin && state.labs.commentImprovements) {
|
||||
// this is a bit of a hack, but we need to fetch the comments fully populated if the user is an admin
|
||||
const adminComments = await adminApi.browse({page: 1, postId: options.postId, order: state.order});
|
||||
setState({
|
||||
...state,
|
||||
adminApi: adminApi,
|
||||
comments: adminComments.comments,
|
||||
pagination: adminComments.meta.pagination,
|
||||
commentCount: adminComments.meta.pagination.total
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Loading of admin failed. Could be not signed in, or a different error (not important)
|
||||
// eslint-disable-next-line no-console
|
||||
@ -112,6 +123,7 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
|
||||
}
|
||||
|
||||
setState({
|
||||
adminApi: adminApi,
|
||||
admin
|
||||
});
|
||||
} catch (e) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
// Ref: https://reactjs.org/docs/context.html
|
||||
import React, {useContext} from 'react';
|
||||
import {ActionType, Actions, SyncActionType, SyncActions} from './actions';
|
||||
import {AdminApi} from './utils/adminApi';
|
||||
import {Page} from './pages';
|
||||
|
||||
export type Member = {
|
||||
@ -79,7 +80,8 @@ export type EditableAppContext = {
|
||||
openCommentForms: OpenCommentForm[],
|
||||
popup: Page | null,
|
||||
labs: LabsContextType,
|
||||
order: string
|
||||
order: string,
|
||||
adminApi: AdminApi | null
|
||||
}
|
||||
|
||||
export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string;
|
||||
|
@ -8,7 +8,12 @@ async function loadMoreComments({state, api, options, order}: {state: EditableAp
|
||||
if (state.pagination && state.pagination.page) {
|
||||
page = state.pagination.page + 1;
|
||||
}
|
||||
const data = await api.comments.browse({page, postId: options.postId, order: order || state.order});
|
||||
let data;
|
||||
if (state.admin && state.adminApi && state.labs.commentImprovements) {
|
||||
data = await state.adminApi.browse({page, postId: options.postId, order: order || state.order});
|
||||
} else {
|
||||
data = await api.comments.browse({page, postId: options.postId, order: order || state.order});
|
||||
}
|
||||
|
||||
// Note: we store the comments from new to old, and show them in reverse order
|
||||
return {
|
||||
@ -17,8 +22,13 @@ async function loadMoreComments({state, api, options, order}: {state: EditableAp
|
||||
};
|
||||
}
|
||||
|
||||
async function setOrder({data: {order}, options, api}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi}) {
|
||||
const data = await api.comments.browse({page: 1, postId: options.postId, order: order});
|
||||
async function setOrder({state, data: {order}, options, api}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi}) {
|
||||
let data;
|
||||
|
||||
if (state.admin && state.adminApi && state.labs.commentImprovements) {
|
||||
data = await state.adminApi.browse({page: 1, postId: options.postId, order});
|
||||
}
|
||||
data = await api.comments.browse({page: 1, postId: options.postId, order: order});
|
||||
|
||||
return {
|
||||
comments: [...data.comments],
|
||||
@ -27,8 +37,13 @@ async function setOrder({data: {order}, options, api}: {state: EditableAppContex
|
||||
};
|
||||
}
|
||||
|
||||
async function loadMoreReplies({state, api, data: {comment, limit}}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise<Partial<EditableAppContext>> {
|
||||
const data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
|
||||
async function loadMoreReplies({state, api, data: {comment, limit}, isReply}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}, isReply: boolean}): Promise<Partial<EditableAppContext>> {
|
||||
let data;
|
||||
if (state.admin && state.adminApi && state.labs.commentImprovements && !isReply) { // we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true
|
||||
data = await state.adminApi.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
|
||||
} else {
|
||||
data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
|
||||
}
|
||||
|
||||
// Note: we store the comments from new to old, and show them in reverse order
|
||||
return {
|
||||
@ -84,9 +99,10 @@ async function addReply({state, api, data: {reply, parent}}: {state: EditableApp
|
||||
};
|
||||
}
|
||||
|
||||
async function hideComment({state, adminApi, data: comment}: {state: EditableAppContext, adminApi: any, data: {id: string}}) {
|
||||
await adminApi.hideComment(comment.id);
|
||||
|
||||
async function hideComment({state, data: comment}: {state: EditableAppContext, adminApi: any, data: {id: string}}) {
|
||||
if (state.adminApi) {
|
||||
await state.adminApi.hideComment(comment.id);
|
||||
}
|
||||
return {
|
||||
comments: state.comments.map((c) => {
|
||||
const replies = c.replies.map((r) => {
|
||||
@ -117,9 +133,10 @@ async function hideComment({state, adminApi, data: comment}: {state: EditableApp
|
||||
};
|
||||
}
|
||||
|
||||
async function showComment({state, api, adminApi, data: comment}: {state: EditableAppContext, api: GhostApi, adminApi: any, data: {id: string}}) {
|
||||
await adminApi.showComment(comment.id);
|
||||
|
||||
async function showComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, adminApi: any, data: {id: string}}) {
|
||||
if (state.adminApi) {
|
||||
await state.adminApi.showComment(comment.id);
|
||||
}
|
||||
// We need to refetch the comment, to make sure we have an up to date HTML content
|
||||
// + all relations are loaded as the current member (not the admin)
|
||||
const data = await api.comments.read(comment.id);
|
||||
@ -355,7 +372,8 @@ async function openCommentForm({data: newForm, api, state}: {data: OpenCommentFo
|
||||
const topLevelCommentId = newForm.parent_id || newForm.id;
|
||||
if (newForm.type === 'reply' && !state.openCommentForms.some(f => f.id === topLevelCommentId || f.parent_id === topLevelCommentId)) {
|
||||
const comment = state.comments.find(c => c.id === topLevelCommentId);
|
||||
const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}});
|
||||
// we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true
|
||||
const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}, isReply: true});
|
||||
otherStateChanges = {...otherStateChanges, ...newCommentsState};
|
||||
}
|
||||
|
||||
|
@ -19,8 +19,10 @@ export const BlankAvatar = () => {
|
||||
|
||||
type AvatarProps = {
|
||||
comment?: Comment;
|
||||
isHidden?: boolean;
|
||||
};
|
||||
export const Avatar: React.FC<AvatarProps> = ({comment}) => {
|
||||
// #TODO greyscale the avatar image when it's hidden
|
||||
const {member, avatarSaturation, t} = useAppContext();
|
||||
const dimensionClasses = getDimensionClasses();
|
||||
|
||||
|
@ -7,7 +7,7 @@ import ReplyForm from './forms/ReplyForm';
|
||||
import {Avatar, BlankAvatar} from './Avatar';
|
||||
import {Comment, OpenCommentForm, useAppContext, useLabs} from '../../AppContext';
|
||||
import {Transition} from '@headlessui/react';
|
||||
import {formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment, isCommentPublished} from '../../utils/helpers';
|
||||
import {formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment} from '../../utils/helpers';
|
||||
import {useCallback} from 'react';
|
||||
import {useRelativeTime} from '../../utils/hooks';
|
||||
|
||||
@ -50,10 +50,34 @@ const EditableComment: React.FC<EditableCommentProps> = ({comment, parent}) => {
|
||||
};
|
||||
|
||||
type CommentProps = AnimatedCommentProps;
|
||||
export const CommentComponent: React.FC<CommentProps> = ({comment, parent}) => {
|
||||
const {dispatchAction} = useAppContext();
|
||||
const useCommentVisibility = (comment: Comment, admin: boolean, labs: {commentImprovements?: boolean}) => {
|
||||
const hasReplies = comment.replies && comment.replies.length > 0;
|
||||
const isDeleted = comment.status === 'deleted';
|
||||
const isHidden = comment.status === 'hidden';
|
||||
|
||||
const isPublished = isCommentPublished(comment);
|
||||
if (labs?.commentImprovements) {
|
||||
return {
|
||||
// Show deleted message only when comment has replies (regardless of admin status)
|
||||
showDeletedMessage: isDeleted && hasReplies,
|
||||
// Show hidden message for non-admins when comment has replies
|
||||
showHiddenMessage: hasReplies && isHidden && !admin,
|
||||
// Show comment content if not deleted AND (is published OR admin viewing hidden)
|
||||
showCommentContent: !isDeleted && (admin || comment.status === 'published')
|
||||
};
|
||||
}
|
||||
|
||||
// Original behavior when labs is false
|
||||
return {
|
||||
showDeletedMessage: false,
|
||||
showHiddenMessage: false,
|
||||
showCommentContent: comment.status === 'published'
|
||||
};
|
||||
};
|
||||
|
||||
export const CommentComponent: React.FC<CommentProps> = ({comment, parent}) => {
|
||||
const {dispatchAction, admin} = useAppContext();
|
||||
const labs = useLabs();
|
||||
const {showDeletedMessage, showHiddenMessage, showCommentContent} = useCommentVisibility(comment, admin, labs);
|
||||
|
||||
const openEditMode = useCallback(() => {
|
||||
const newForm: OpenCommentForm = {
|
||||
@ -66,18 +90,27 @@ export const CommentComponent: React.FC<CommentProps> = ({comment, parent}) => {
|
||||
dispatchAction('openCommentForm', newForm);
|
||||
}, [comment.id, dispatchAction]);
|
||||
|
||||
if (isPublished) {
|
||||
return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />);
|
||||
} else {
|
||||
return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />);
|
||||
if (showDeletedMessage) {
|
||||
return <UnpublishedComment comment={comment} openEditMode={openEditMode} />;
|
||||
} else if (showCommentContent && !showHiddenMessage) {
|
||||
return <PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />;
|
||||
} else if (!labs.commentImprovements && comment.status !== 'published' || showHiddenMessage) {
|
||||
return <UnpublishedComment comment={comment} openEditMode={openEditMode} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type PublishedCommentProps = CommentProps & {
|
||||
openEditMode: () => void;
|
||||
}
|
||||
const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, openEditMode}) => {
|
||||
const {dispatchAction, openCommentForms} = useAppContext();
|
||||
const {dispatchAction, openCommentForms, admin} = useAppContext();
|
||||
const labs = useLabs();
|
||||
|
||||
// Determine if the comment should be displayed with reduced opacity
|
||||
const isHidden = labs.commentImprovements && admin && comment.status === 'hidden';
|
||||
const hiddenClass = isHidden ? 'opacity-30' : '';
|
||||
|
||||
// currently a reply-to-reply form is displayed inside the top-level PublishedComment component
|
||||
// so we need to check for a match of either the comment id or the parent id
|
||||
@ -114,11 +147,16 @@ const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, ope
|
||||
const avatar = (<Avatar comment={comment} />);
|
||||
|
||||
return (
|
||||
<CommentLayout avatar={avatar} hasReplies={hasReplies}>
|
||||
<CommentHeader comment={comment} />
|
||||
<CommentBody html={comment.html} />
|
||||
<CommentMenu comment={comment} highlightReplyButton={highlightReplyButton} openEditMode={openEditMode} openReplyForm={openReplyForm} parent={parent} />
|
||||
|
||||
<CommentLayout avatar={avatar} className={hiddenClass} hasReplies={hasReplies}>
|
||||
<CommentHeader className={hiddenClass} comment={comment} />
|
||||
<CommentBody className={hiddenClass} html={comment.html} />
|
||||
<CommentMenu
|
||||
comment={comment}
|
||||
highlightReplyButton={highlightReplyButton}
|
||||
openEditMode={openEditMode}
|
||||
openReplyForm={openReplyForm}
|
||||
parent={parent}
|
||||
/>
|
||||
<RepliesContainer comment={comment} />
|
||||
{displayReplyForm && <ReplyFormBox comment={comment} openForm={openForm} />}
|
||||
</CommentLayout>
|
||||
@ -130,28 +168,34 @@ type UnpublishedCommentProps = {
|
||||
openEditMode: () => void;
|
||||
}
|
||||
const UnpublishedComment: React.FC<UnpublishedCommentProps> = ({comment, openEditMode}) => {
|
||||
const {t} = useAppContext();
|
||||
let notPublishedMessage:string = '';
|
||||
const {t, labs, admin} = useAppContext();
|
||||
|
||||
const avatar = (<BlankAvatar />);
|
||||
const avatar = (labs.commentImprovements && admin && comment.status !== 'deleted') ?
|
||||
<Avatar comment={comment} isHidden={true} /> :
|
||||
<BlankAvatar />;
|
||||
const hasReplies = comment.replies && comment.replies.length > 0;
|
||||
|
||||
if (comment.status === 'hidden') {
|
||||
notPublishedMessage = t('This comment has been hidden.');
|
||||
} else if (comment.status === 'deleted') {
|
||||
notPublishedMessage = t('This comment has been removed.');
|
||||
}
|
||||
const notPublishedMessage = comment.status === 'hidden' ?
|
||||
t('This comment has been hidden.') :
|
||||
comment.status === 'deleted' ?
|
||||
t('This comment has been removed.') :
|
||||
'';
|
||||
|
||||
// Only show MoreButton for hidden (not deleted) comments when admin
|
||||
const showMoreButton = admin && comment.status === 'hidden';
|
||||
|
||||
return (
|
||||
<CommentLayout avatar={avatar} hasReplies={hasReplies}>
|
||||
<div className="mt-[-3px] flex items-start">
|
||||
<div className="flex h-10 flex-row items-center gap-4 pb-[8px] pr-4">
|
||||
<p className="text-md mt-[4px] font-sans italic leading-normal text-black/20 sm:text-lg dark:text-white/35">
|
||||
<p className="text-md mt-[4px] font-sans leading-normal text-neutral-900/40 sm:text-lg dark:text-white/60">
|
||||
{notPublishedMessage}
|
||||
</p>
|
||||
<div className="mt-[4px]">
|
||||
<MoreButton comment={comment} toggleEdit={openEditMode} />
|
||||
</div>
|
||||
{showMoreButton && (
|
||||
<div className="mt-[4px]">
|
||||
<MoreButton comment={comment} toggleEdit={openEditMode} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<RepliesContainer comment={comment} />
|
||||
@ -185,8 +229,7 @@ const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => {
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const RepliesContainer: React.FC<RepliesProps> = ({comment}) => {
|
||||
const RepliesContainer: React.FC<RepliesProps & {className?: string}> = ({comment, className = ''}) => {
|
||||
const hasReplies = comment.replies && comment.replies.length > 0;
|
||||
|
||||
if (!hasReplies) {
|
||||
@ -194,7 +237,7 @@ const RepliesContainer: React.FC<RepliesProps> = ({comment}) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 ml-[-1.4rem] mt-7 sm:mb-0 sm:mt-8">
|
||||
<div className={`mb-4 ml-[-1.4rem] mt-7 sm:mb-0 sm:mt-8 ${className}`}>
|
||||
<Replies comment={comment} />
|
||||
</div>
|
||||
);
|
||||
@ -226,7 +269,12 @@ const AuthorName: React.FC<{comment: Comment}> = ({comment}) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => {
|
||||
type CommentHeaderProps = {
|
||||
comment: Comment;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''}) => {
|
||||
const {t} = useAppContext();
|
||||
const labs = useLabs();
|
||||
const createdAtRelative = useRelativeTime(comment.created_at);
|
||||
@ -249,7 +297,7 @@ const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`mt-0.5 flex flex-wrap items-start sm:flex-row ${memberExpertise ? 'flex-col' : 'flex-row'} ${isReplyToReply ? 'mb-0.5' : 'mb-2'}`}>
|
||||
<div className={`mt-0.5 flex flex-wrap items-start sm:flex-row ${memberExpertise ? 'flex-col' : 'flex-row'} ${isReplyToReply ? 'mb-0.5' : 'mb-2'} ${className}`}>
|
||||
<AuthorName comment={comment} />
|
||||
<div className="flex items-baseline pr-4 font-sans text-base leading-snug text-neutral-900/50 sm:text-sm dark:text-white/60">
|
||||
<span>
|
||||
@ -268,10 +316,15 @@ const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CommentBody: React.FC<{html: string}> = ({html}) => {
|
||||
type CommentBodyProps = {
|
||||
html: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CommentBody: React.FC<CommentBodyProps> = ({html, className = ''}) => {
|
||||
const dangerouslySetInnerHTML = {__html: html};
|
||||
return (
|
||||
<div className="mt mb-2 flex flex-row items-center gap-4 pr-4">
|
||||
<div className={`mt mb-2 flex flex-row items-center gap-4 pr-4 ${className}`}>
|
||||
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content text-md text-pretty font-sans leading-normal text-neutral-900 [overflow-wrap:anywhere] sm:text-lg dark:text-white/85" data-testid="comment-content"/>
|
||||
</div>
|
||||
);
|
||||
@ -283,26 +336,35 @@ type CommentMenuProps = {
|
||||
highlightReplyButton: boolean;
|
||||
openEditMode: () => void;
|
||||
parent?: Comment;
|
||||
className?: string;
|
||||
};
|
||||
const CommentMenu: React.FC<CommentMenuProps> = ({comment, openReplyForm, highlightReplyButton, openEditMode, parent}) => {
|
||||
// If this comment is from the current member, always override member
|
||||
// with the member from the context, so we update the expertise in existing comments when we change it
|
||||
const {member, commentsEnabled} = useAppContext();
|
||||
const CommentMenu: React.FC<CommentMenuProps> = ({comment, openReplyForm, highlightReplyButton, openEditMode, parent, className = ''}) => {
|
||||
const {member, commentsEnabled, t, admin} = useAppContext();
|
||||
const labs = useLabs();
|
||||
|
||||
const paidOnly = commentsEnabled === 'paid';
|
||||
const isPaidMember = member && !!member.paid;
|
||||
const canReply = member && (isPaidMember || !paidOnly) && (labs.commentImprovements ? true : !parent);
|
||||
const isHiddenForAdmin = labs.commentImprovements && admin && comment.status === 'hidden';
|
||||
|
||||
if (isHiddenForAdmin) {
|
||||
return (
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
<span className="font-sans text-base leading-snug text-red-600 sm:text-sm">{t('Hidden for members')}</span>
|
||||
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
labs.commentImprovements ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
{<LikeButton comment={comment} />}
|
||||
{<ReplyButton isReplying={highlightReplyButton} openReplyForm={openReplyForm} />}
|
||||
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
{<LikeButton comment={comment} />}
|
||||
{(canReply && <ReplyButton isReplying={highlightReplyButton} openReplyForm={openReplyForm} />)}
|
||||
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
|
||||
@ -327,12 +389,13 @@ type CommentLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
avatar: React.ReactNode;
|
||||
hasReplies: boolean;
|
||||
className?: string;
|
||||
}
|
||||
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies}) => {
|
||||
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies, className = ''}) => {
|
||||
return (
|
||||
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-7'}`} data-testid="comment-component">
|
||||
<div className="mr-2 flex flex-col items-center justify-start sm:mr-3">
|
||||
<div className="flex-0 mb-3 sm:mb-4">
|
||||
<div className={`flex-0 mb-3 sm:mb-4 ${className}`}>
|
||||
{avatar}
|
||||
</div>
|
||||
<RepliesLine hasReplies={hasReplies} />
|
||||
|
@ -6,6 +6,7 @@ type Props = {
|
||||
isReplying: boolean;
|
||||
openReplyForm: () => void;
|
||||
};
|
||||
|
||||
const ReplyButton: React.FC<Props> = ({disabled, isReplying, openReplyForm}) => {
|
||||
const {member, t, dispatchAction, commentsEnabled} = useAppContext();
|
||||
const labs = useLabs();
|
||||
@ -29,11 +30,11 @@ const ReplyButton: React.FC<Props> = ({disabled, isReplying, openReplyForm}) =>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`duration-50 group flex items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${isReplying ? 'text-black/90 dark:text-white/90' : 'text-black/50 hover:text-black/75 dark:text-white/60 dark:hover:text-white/75'}`}
|
||||
data-testid="reply-button"
|
||||
disabled={!!disabled}
|
||||
type="button"
|
||||
<button
|
||||
className={`duration-50 group flex items-center font-sans text-base outline-0 transition-all ease-linear sm:text-sm ${isReplying ? 'text-black/90 dark:text-white/90' : 'text-black/50 hover:text-black/75 dark:text-white/60 dark:hover:text-white/75'}`}
|
||||
data-testid="reply-button"
|
||||
disabled={!!disabled}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ReplyIcon className={`mr-[6px] ${isReplying ? 'fill-black dark:fill-white' : 'stroke-black/50 group-hover:stroke-black/75 dark:stroke-white/60 dark:group-hover:stroke-white/75'} duration-50 transition ease-linear`} />
|
||||
|
@ -4,6 +4,8 @@ export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
|
||||
const handlers: Record<string, (error: Error|undefined, result: any) => void> = {};
|
||||
const adminOrigin = new URL(adminUrl).origin;
|
||||
|
||||
let firstCommentCreatedAt: null | string = null;
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.origin !== adminOrigin) {
|
||||
// Other message that is not intended for us
|
||||
@ -59,6 +61,51 @@ export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
|
||||
},
|
||||
async showComment(id: string) {
|
||||
return await callApi('showComment', {id});
|
||||
},
|
||||
|
||||
async browse({page, postId, order}: {page: number, postId: string, order?: string}) {
|
||||
let filter = null;
|
||||
if (firstCommentCreatedAt && !order) {
|
||||
filter = `created_at:<=${firstCommentCreatedAt}`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('limit', '20');
|
||||
if (filter) {
|
||||
params.set('filter', filter);
|
||||
}
|
||||
params.set('page', page.toString());
|
||||
if (order) {
|
||||
params.set('order', order);
|
||||
}
|
||||
|
||||
const response = await callApi('browseComments', {postId, params: params.toString()});
|
||||
if (!firstCommentCreatedAt) {
|
||||
const firstComment = response.comments[0];
|
||||
if (firstComment) {
|
||||
firstCommentCreatedAt = firstComment.created_at;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async replies({commentId, afterReplyId, limit}: {commentId: string; afterReplyId: string; limit?: number | 'all'}) {
|
||||
const filter = `id:>'${afterReplyId}'`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (limit) {
|
||||
params.set('limit', limit.toString());
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
params.set('filter', filter);
|
||||
}
|
||||
|
||||
const response = await callApi('getReplies', {commentId, params: params.toString()});
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -118,7 +118,7 @@ test.describe('Auth Frame', async () => {
|
||||
|
||||
// Check comment2 is replaced with a hidden message
|
||||
const secondComment = comments.nth(1);
|
||||
await expect(secondComment).toHaveText('This comment has been hidden.');
|
||||
await expect(secondComment).toContainText('This comment has been hidden.');
|
||||
await expect(secondComment).not.toContainText('This is comment 2');
|
||||
|
||||
// Check can show it again
|
||||
@ -175,7 +175,7 @@ test.describe('Auth Frame', async () => {
|
||||
|
||||
// Check comment2 is replaced with a hidden message
|
||||
const secondComment = comments.nth(1);
|
||||
await expect(secondComment).toHaveText('This comment has been hidden.');
|
||||
await expect(secondComment).toContainText('This comment has been hidden.');
|
||||
await expect(secondComment).not.toContainText('This is comment 2');
|
||||
|
||||
// Check can show it again
|
||||
@ -183,5 +183,62 @@ test.describe('Auth Frame', async () => {
|
||||
await moreButtons.nth(1).getByText('Show comment').click();
|
||||
await expect(secondComment).toContainText('This is comment 2');
|
||||
});
|
||||
|
||||
test('Hidden comment is displayed for admins - needs flags enabled', async ({page}) => {
|
||||
const mockedApi = new MockedApi({});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 1</p>'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 2</p>'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 3</p>'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 4</p>'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 5</p>'
|
||||
});
|
||||
|
||||
await mockAdminAuthFrame({
|
||||
admin,
|
||||
page
|
||||
});
|
||||
|
||||
const {frame} = await initialize({
|
||||
mockedApi,
|
||||
page,
|
||||
publication: 'Publisher Weekly',
|
||||
admin,
|
||||
labs: {
|
||||
commentImprovements: true
|
||||
}
|
||||
});
|
||||
|
||||
const iframeElement = await page.locator('iframe[data-frame="admin-auth"]');
|
||||
await expect(iframeElement).toHaveCount(1);
|
||||
|
||||
// Check if more actions button is visible on each comment
|
||||
const comments = await frame.getByTestId('comment-component');
|
||||
await expect(comments).toHaveCount(5);
|
||||
|
||||
const moreButtons = await frame.getByTestId('more-button');
|
||||
await expect(moreButtons).toHaveCount(5);
|
||||
|
||||
// Click the 2nd button
|
||||
await moreButtons.nth(1).click();
|
||||
await moreButtons.nth(1).getByText('Hide comment').click();
|
||||
|
||||
const secondComment = comments.nth(1);
|
||||
// expect "hidden for members" message
|
||||
await expect(secondComment).toContainText('Hidden for members');
|
||||
|
||||
// Check can show it again
|
||||
await moreButtons.nth(1).click();
|
||||
await moreButtons.nth(1).getByText('Show comment').click();
|
||||
await expect(secondComment).toContainText('This is comment 2');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {FrameLocator, expect, test} from '@playwright/test';
|
||||
import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e';
|
||||
import {buildReply} from '../utils/fixtures';
|
||||
|
||||
test.describe('Autoclose forms', async () => {
|
||||
let mockedApi: MockedApi;
|
||||
@ -15,7 +16,7 @@ test.describe('Autoclose forms', async () => {
|
||||
html: '<p>Reply 1.1</p>'
|
||||
}, {
|
||||
html: '<p>Reply 1.2</p>'
|
||||
}]
|
||||
}].map(buildReply)
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>Comment 2</p>'
|
||||
@ -36,7 +37,7 @@ test.describe('Autoclose forms', async () => {
|
||||
|
||||
test('autocloses open reply forms when opening another', async ({}) => {
|
||||
const comment1 = await frame.getByTestId('comment-component').nth(0);
|
||||
await comment1.getByTestId('reply-button').click();
|
||||
await comment1.getByTestId('reply-button').nth(1).click();
|
||||
|
||||
await expect(frame.getByTestId('form')).toHaveCount(2);
|
||||
|
||||
@ -48,7 +49,7 @@ test.describe('Autoclose forms', async () => {
|
||||
|
||||
test('does not autoclose open reply form with unsaved changes', async ({}) => {
|
||||
const comment1 = await frame.getByTestId('comment-component').nth(0);
|
||||
await comment1.getByTestId('reply-button').click();
|
||||
await comment1.getByTestId('reply-button').nth(1).click();
|
||||
|
||||
await expect(frame.getByTestId('form')).toHaveCount(2);
|
||||
|
||||
|
144
apps/comments-ui/test/e2e/content.test.ts
Normal file
144
apps/comments-ui/test/e2e/content.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import {MockedApi, initialize} from '../utils/e2e';
|
||||
import {expect, test} from '@playwright/test';
|
||||
|
||||
test.describe('Deleted and Hidden Content', async () => {
|
||||
// This is actually handled by the API since it shouldn not longer return hidden or deleted comments for non-admins, but we still test the behaviour here.
|
||||
test('hides hidden and deleted comments for non admins', async ({page}) => {
|
||||
const mockedApi = new MockedApi({});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 1</p>'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 2</p>',
|
||||
status: 'hidden'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 3</p>'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 4</p>',
|
||||
status: 'deleted'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 5</p>'
|
||||
});
|
||||
|
||||
const {frame} = await initialize({
|
||||
mockedApi,
|
||||
page,
|
||||
publication: 'Publisher Weekly',
|
||||
labs: {
|
||||
commentImprovements: true
|
||||
}
|
||||
});
|
||||
|
||||
const iframeElement = await page.locator('iframe[data-frame="admin-auth"]');
|
||||
await expect(iframeElement).toHaveCount(1);
|
||||
|
||||
// Check if more actions button is visible on each comment
|
||||
const comments = await frame.getByTestId('comment-component');
|
||||
// 3 comments are visible
|
||||
await expect(comments).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('hide and deleted comment shows with hidden/deleted text when it has replies', async ({page}) => {
|
||||
const mockedApi = new MockedApi({});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 1</p>'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 2</p>',
|
||||
status: 'hidden',
|
||||
replies: [
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 1</p>'
|
||||
}),
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 2</p>'
|
||||
}),
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 3</p>'
|
||||
}),
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 4</p>'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 3</p>'
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 4</p>',
|
||||
status: 'deleted',
|
||||
replies: [
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 1</p>'
|
||||
}),
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 2</p>'
|
||||
}),
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 3</p>'
|
||||
}),
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 4</p>'
|
||||
})
|
||||
]
|
||||
});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 5</p>'
|
||||
});
|
||||
|
||||
const {frame} = await initialize({
|
||||
mockedApi,
|
||||
page,
|
||||
publication: 'Publisher Weekly',
|
||||
labs: {
|
||||
commentImprovements: true
|
||||
}
|
||||
});
|
||||
|
||||
await expect (frame.getByText('This is comment 2')).not.toBeVisible();
|
||||
await expect (frame.getByText('This comment has been hidden')).toBeVisible();
|
||||
|
||||
await expect (frame.getByText('This is comment 4')).not.toBeVisible();
|
||||
await expect (frame.getByText('This comment has been removed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('hides replies thats hidden and deleted', async ({page}) => {
|
||||
const mockedApi = new MockedApi({});
|
||||
mockedApi.addComment({
|
||||
html: '<p>This is comment 2</p>',
|
||||
status: 'hidden',
|
||||
replies: [
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 1</p>'
|
||||
}),
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 2</p>',
|
||||
status: 'deleted'
|
||||
}),
|
||||
mockedApi.buildReply({
|
||||
html: '<p>This is reply 3</p>',
|
||||
status: 'hidden'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
const {frame} = await initialize({
|
||||
mockedApi,
|
||||
page,
|
||||
publication: 'Publisher Weekly',
|
||||
labs: {
|
||||
commentImprovements: true
|
||||
}
|
||||
});
|
||||
|
||||
await expect (frame.getByText('This is reply 1')).toBeVisible();
|
||||
await expect (frame.getByText('This is reply 2')).not.toBeVisible();
|
||||
// parent comment is hidden but shows text
|
||||
await expect (frame.getByText('This comment has been hidden')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
@ -25,8 +25,22 @@ window.addEventListener('message', async function (event) {
|
||||
|
||||
if (data.action === 'browseComments') {
|
||||
try {
|
||||
const {postId, params} = data;
|
||||
const res = await fetch(
|
||||
adminUrl + '/comments/?limit=50&order=created_at%20desc'
|
||||
adminUrl + `/comments/post/${postId}/?${new URLSearchParams(params).toString()}`
|
||||
);
|
||||
const json = await res.json();
|
||||
respond(null, json);
|
||||
} catch (err) {
|
||||
respond(err, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.action === 'getReplies') {
|
||||
try {
|
||||
const {commentId, params} = data;
|
||||
const res = await fetch(
|
||||
adminUrl + `/comments/${commentId}/replies/?${new URLSearchParams(params).toString()}`
|
||||
);
|
||||
const json = await res.json();
|
||||
respond(null, json);
|
||||
|
@ -289,7 +289,6 @@ const Comment = ghostBookshelf.Model.extend({
|
||||
.as('count__replies');
|
||||
});
|
||||
}
|
||||
|
||||
if (options.isAdmin && labs.isSet('commentImprovements')) {
|
||||
modelOrCollection.query('columns', 'comments.*', (qb) => {
|
||||
qb.count('replies.id')
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Stigter @ Acme Inc",
|
||||
"Full-time parent": "Voltydse ouer",
|
||||
"Head of Marketing at Acme, Inc": "Hoof van Bemarking by Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Versteek",
|
||||
"Hide comment": "Versteek kommentaar",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "مؤسس لدى شركة أكمي",
|
||||
"Full-time parent": "أب بدوام كامل",
|
||||
"Head of Marketing at Acme, Inc": "رئيس وحدة التسويق لدى شركة أكمى",
|
||||
"Hidden for members": "",
|
||||
"Hide": "اخفاء",
|
||||
"Hide comment": "اخف التعليق",
|
||||
"Jamie Larson": "فلان الفلانى",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Основател на Компания ООД",
|
||||
"Full-time parent": "Родител на пълно работно време",
|
||||
"Head of Marketing at Acme, Inc": "Директор маркетинг в Компания ООД",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Скриване",
|
||||
"Hide comment": "Скриване на коментара",
|
||||
"Jamie Larson": "Иван Иванов",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "প্রতিষ্ঠাতা @ বাংলাদেশ ট্রেড হাব",
|
||||
"Full-time parent": "পূর্ণকালীন অভিভাবক",
|
||||
"Head of Marketing at Acme, Inc": "মার্কেটিং প্রধান @ বাংলাদেশ ট্রেড হাব",
|
||||
"Hidden for members": "",
|
||||
"Hide": "লুকান",
|
||||
"Hide comment": "মন্তব্য লুকান",
|
||||
"Jamie Larson": "শাহ নেওয়াজ",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Acme Inc osnivač",
|
||||
"Full-time parent": "Full time roditelj",
|
||||
"Head of Marketing at Acme, Inc": "Šef marketinga u kompaniji Acme d.o.o",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Sakrij",
|
||||
"Hide comment": "Sakrij komentar",
|
||||
"Jamie Larson": "Vanja Larsić",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Fundador @ Acme Inc",
|
||||
"Full-time parent": "Pare a temps complert",
|
||||
"Head of Marketing at Acme, Inc": "Cap de màrqueting a Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Amaga",
|
||||
"Hide comment": "Amaga el comentari",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Zakladatel @ Acme Inc",
|
||||
"Full-time parent": "Rodič na plný úvazek",
|
||||
"Head of Marketing at Acme, Inc": "Vedoucí marketingu v Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Skrýt",
|
||||
"Hide comment": "Skrýt komentář",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Grundlægger @ Acme Inc",
|
||||
"Full-time parent": "Forældre på fuld tid",
|
||||
"Head of Marketing at Acme, Inc": "Chef for marking hos Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Skjul",
|
||||
"Hide comment": "Skjul kommentar",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Gründer bei Acme Inc",
|
||||
"Full-time parent": "Vollzeit-Elternteil",
|
||||
"Head of Marketing at Acme, Inc": "Leiter Marketing bei Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Verbergen",
|
||||
"Hide comment": "Kommentar verbergen",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Ιδρυτής @ Acme Inc",
|
||||
"Full-time parent": "Γονέας πλήρους απασχόλησης",
|
||||
"Head of Marketing at Acme, Inc": "Επικεφαλής Μάρκετινγκ στην Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Απόκρυψη",
|
||||
"Hide comment": "Απόκρυψη σχολίου",
|
||||
"Jamie Larson": "Τζέιμι Λάρσον",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Fundador @ Acme Inc",
|
||||
"Full-time parent": "Padres de tiempo completo",
|
||||
"Head of Marketing at Acme, Inc": "Jefe de Marketing en Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Ocultar",
|
||||
"Hide comment": "Ocultar comentario",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Asutaja @ Acme Inc",
|
||||
"Full-time parent": "Täiskohaga lapsevanem",
|
||||
"Head of Marketing at Acme, Inc": "Turundusjuht Acme, Inc-s",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Peida",
|
||||
"Hide comment": "Peida kommentaar",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "مدیر عامل یک شرکت خیالی",
|
||||
"Full-time parent": " خانه\u200cدار تمام وقت",
|
||||
"Head of Marketing at Acme, Inc": "سرپرست بخش بازاریابی یک شرکت خیالی",
|
||||
"Hidden for members": "",
|
||||
"Hide": "مخفی کردن",
|
||||
"Hide comment": "مخفی کردن دیدگاه",
|
||||
"Jamie Larson": "جیمی لارسن",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "Kokoaikainen vanhempi",
|
||||
"Head of Marketing at Acme, Inc": "Markkinointijohtaja",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Piilota",
|
||||
"Hide comment": "Piilota kommentti",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Fondateur @ Acme Inc",
|
||||
"Full-time parent": "Parent à plein temps",
|
||||
"Head of Marketing at Acme, Inc": "Responsable du marketing chez Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Masquer",
|
||||
"Hide comment": "Masquer le commentaire",
|
||||
"Jamie Larson": "Jean Martin",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Neach-stèidheachaidh @ Acme Inc",
|
||||
"Full-time parent": "Pàrant làn-ùine",
|
||||
"Head of Marketing at Acme, Inc": "Àrd-cheann Margaidheachd aig Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Cuir am falach",
|
||||
"Hide comment": "Cuir am beachd am falach",
|
||||
"Jamie Larson": "Seamaidh Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "מייסד @ Acme Inc",
|
||||
"Full-time parent": "הורה במשרה מלאה",
|
||||
"Head of Marketing at Acme, Inc": "ראש אגף שיווק ב- Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "הסתר",
|
||||
"Hide comment": "הסתר תגובה",
|
||||
"Jamie Larson": "ג׳יימי לרסון",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "संस्थापक @ Acme Inc",
|
||||
"Full-time parent": "पूर्णकालिक माता-पिता",
|
||||
"Head of Marketing at Acme, Inc": "Acme, Inc में विपणन प्रमुख",
|
||||
"Hidden for members": "",
|
||||
"Hide": "छिपाएं",
|
||||
"Hide comment": "टिप्पणी छिपाएं",
|
||||
"Jamie Larson": "राहुल शर्मा",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Osnivač Acme Inc",
|
||||
"Full-time parent": "Full-time Roditelj",
|
||||
"Head of Marketing at Acme, Inc": "Voditelj marketinga Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Sakrij",
|
||||
"Hide comment": "Sakrij komentar",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Acme Kft. alapító",
|
||||
"Full-time parent": "Főállású szülő",
|
||||
"Head of Marketing at Acme, Inc": "Marketing vezető —\u00a0Acme Kft.",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Elrejtés",
|
||||
"Hide comment": "Hozzászólás elrejtése",
|
||||
"Jamie Larson": "Kiss Sára",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Founder @ Acme Inc",
|
||||
"Full-time parent": "Orang tua penuh waktu",
|
||||
"Head of Marketing at Acme, Inc": "Direktur Pemasaran di Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Sembunyikan",
|
||||
"Hide comment": "Sembunyikan komentar",
|
||||
"Jamie Larson": "Sutan T. Alisjahbana",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Megadirettore Galattico",
|
||||
"Full-time parent": "Genitore a tempo pieno",
|
||||
"Head of Marketing at Acme, Inc": "Ragioniere presso Megaditta",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Nascondi",
|
||||
"Hide comment": "Nascondi commento",
|
||||
"Jamie Larson": "Andrea Rossi",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "創業者 @ Acme Inc",
|
||||
"Full-time parent": "専業主婦・主夫",
|
||||
"Head of Marketing at Acme, Inc": "マーケティング責任者 @ Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "非表示にする",
|
||||
"Hide comment": "コメントを非表示にする",
|
||||
"Jamie Larson": "ジェイミー・ラーソン",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Acme Inc 창업자",
|
||||
"Full-time parent": "전업 부모",
|
||||
"Head of Marketing at Acme, Inc": "Acme Inc 마케팅 책임자",
|
||||
"Hidden for members": "",
|
||||
"Hide": "숨기기",
|
||||
"Hide comment": "댓글 숨기기",
|
||||
"Jamie Larson": "제이미 라슨",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "@ Acme Inc құрылтайшысы",
|
||||
"Full-time parent": "Толық күн жұмыс істейтін ата-ана",
|
||||
"Head of Marketing at Acme, Inc": "@ Acme маркетинг жетекшісі ",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Жасыру",
|
||||
"Hide comment": "Пікірді жасыру",
|
||||
"Jamie Larson": "Пәленше Түгеншиев",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Istorikas",
|
||||
"Full-time parent": "Vaiką prižiūrintis tėvas",
|
||||
"Head of Marketing at Acme, Inc": "Tyrinėtojas",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Paslėpti",
|
||||
"Hide comment": "Paslėpti komentarą",
|
||||
"Jamie Larson": "Vardenis Pavardenis",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Основач @ Примерна Компанија",
|
||||
"Full-time parent": "Родител",
|
||||
"Head of Marketing at Acme, Inc": "Раководител на Маркетинг во Примерна Компанија",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Скријте",
|
||||
"Hide comment": "Скријте го коментарот",
|
||||
"Jamie Larson": "Петар Петровски",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Oprichter, Acme BV",
|
||||
"Full-time parent": "Full-time ouder",
|
||||
"Head of Marketing at Acme, Inc": "Acme BV, Hoofd Marketing",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Verbergen",
|
||||
"Hide comment": "Verberg reactie",
|
||||
"Jamie Larson": "Jan Jansen",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Grunnlegger @ Acme Inc",
|
||||
"Full-time parent": "Forelder på heltid",
|
||||
"Head of Marketing at Acme, Inc": "Markedsføringsleder hos Acme Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Skjul",
|
||||
"Hide comment": "Skjul kommentar",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Founder @ Acme Inc",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "Head of Marketing at Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Ukryj",
|
||||
"Hide comment": "Ukryj komentarz",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Fundador @ Acme Inc",
|
||||
"Full-time parent": "Pai/Mãe em tempo integral",
|
||||
"Head of Marketing at Acme, Inc": "Chefe de Marketing na Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Esconder",
|
||||
"Hide comment": "Esconder comentário",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Fundador @ Amce Inc",
|
||||
"Full-time parent": "Pai a tempo inteiro",
|
||||
"Head of Marketing at Acme, Inc": "Responsável do Marketing @ Amce, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Esconder",
|
||||
"Hide comment": "Esconder comentário",
|
||||
"Jamie Larson": "Jane Doe",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Fondator @ Acme Inc",
|
||||
"Full-time parent": "Părinte cu normă întreagă",
|
||||
"Head of Marketing at Acme, Inc": "Șef de Marketing la Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Ascunde",
|
||||
"Hide comment": "Ascunde comentariul",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Основатель @ Корпорация «Акме»",
|
||||
"Full-time parent": "Родитель, работающий полный рабочий день",
|
||||
"Head of Marketing at Acme, Inc": "Руководитель отдела маркетинга в Корпорации «Акме»",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Скрыть",
|
||||
"Hide comment": "Скрыть комментарий",
|
||||
"Jamie Larson": "Павел Бид",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Acme Inc හි නිර්මාතෘ",
|
||||
"Full-time parent": "පූර්ණ කාලීන භාරකරුව\u200bන්",
|
||||
"Head of Marketing at Acme, Inc": "Acme, Inc හි අලෙවි ප්\u200dරධානී",
|
||||
"Hidden for members": "",
|
||||
"Hide": "සඟවන්න",
|
||||
"Hide comment": "අදහස සඟවන්න",
|
||||
"Jamie Larson": "ජේමි ලාර්සන්",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Zakladateľ @ Acme Inc",
|
||||
"Full-time parent": "Rodič na plný úväzok",
|
||||
"Head of Marketing at Acme, Inc": "Vedúci marketingu v spoločnosti Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Skryť",
|
||||
"Hide comment": "Skryť komentár",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Оснивач @ Acme Inc",
|
||||
"Full-time parent": "Родитељ пуним радним временом",
|
||||
"Head of Marketing at Acme, Inc": "Шеф маркетинга у Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Сакриј",
|
||||
"Hide comment": "Сакриј коментар",
|
||||
"Jamie Larson": "Џејми Ларсон",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Grundare av Företaget AB",
|
||||
"Full-time parent": "Hemmaförälder",
|
||||
"Head of Marketing at Acme, Inc": "Marknadsföringschef på Företaget AB",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Dölj",
|
||||
"Hide comment": "Dölj kommentar",
|
||||
"Jamie Larson": "Anders Andersson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Mwanzilishi @ Acme Inc",
|
||||
"Full-time parent": "Mzazi wa muda wote",
|
||||
"Head of Marketing at Acme, Inc": "Mkuu wa Masoko katika Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Ficha",
|
||||
"Hide comment": "Ficha maoni",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "நிறுவனர் @ Acme Inc",
|
||||
"Full-time parent": "முழுநேர பெற்றோர்",
|
||||
"Head of Marketing at Acme, Inc": "Acme Inc நிறுவனத்தின் சந்தைப்படுத்தல் தலைவர்",
|
||||
"Hidden for members": "",
|
||||
"Hide": "மறைக்கவும்",
|
||||
"Hide comment": "கருத்தை மறை",
|
||||
"Jamie Larson": "ஜேமி லார்சன்",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "ผู้ก่อตั้ง @ Acme Inc",
|
||||
"Full-time parent": "ผู้ปกครองเต็มเวลา",
|
||||
"Head of Marketing at Acme, Inc": "หัวหน้าฝ่ายการตลาด ณ Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "ซ่อน",
|
||||
"Hide comment": "ซ่อนข้อความ",
|
||||
"Jamie Larson": "กนกพัฒน์ สัณห์ฤทัย",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Kurucu @ Firma Ünvan",
|
||||
"Full-time parent": "Tam zamanlı ebeveyn",
|
||||
"Head of Marketing at Acme, Inc": "Firma, Ünvan'da Pazarlama Müdürü",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Gizle",
|
||||
"Hide comment": "Yorumu gizle",
|
||||
"Jamie Larson": "Ad Soyad",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Засновник @ Acme Inc",
|
||||
"Full-time parent": "Виховую дітей",
|
||||
"Head of Marketing at Acme, Inc": "Голова продажів в Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Сховати",
|
||||
"Hide comment": "Сховати коментар",
|
||||
"Jamie Larson": "Ваше імʼя",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "ناظم @ Acme Inc",
|
||||
"Full-time parent": "پورے وقت کا والد یا والدہ",
|
||||
"Head of Marketing at Acme, Inc": "Acme، Inc کے مارکیٹنگ کا سربراہ",
|
||||
"Hidden for members": "",
|
||||
"Hide": "چھپائیں",
|
||||
"Hide comment": "تبادلہ چھپائیں",
|
||||
"Jamie Larson": "جیمی لارسن",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "",
|
||||
"Full-time parent": "",
|
||||
"Head of Marketing at Acme, Inc": "",
|
||||
"Hidden for members": "",
|
||||
"Hide": "",
|
||||
"Hide comment": "",
|
||||
"Jamie Larson": "",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Nhà sáng lập @ Acme Inc",
|
||||
"Full-time parent": "Phụ huynh toàn thời gian",
|
||||
"Head of Marketing at Acme, Inc": "Trưởng phòng Marketing tại Acme, Inc",
|
||||
"Hidden for members": "",
|
||||
"Hide": "Ẩn",
|
||||
"Hide comment": "Ẩn bình luận",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Acme Inc的創辦人",
|
||||
"Full-time parent": "全職父母",
|
||||
"Head of Marketing at Acme, Inc": "Acme, Inc市場部負責人",
|
||||
"Hidden for members": "",
|
||||
"Hide": "隱藏",
|
||||
"Hide comment": "隱藏留言",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"Founder @ Acme Inc": "Acme Inc的创建者",
|
||||
"Full-time parent": "全职父母",
|
||||
"Head of Marketing at Acme, Inc": "Acme, Inc市场部负责人",
|
||||
"Hidden for members": "",
|
||||
"Hide": "隐藏",
|
||||
"Hide comment": "隐藏评论",
|
||||
"Jamie Larson": "Jamie Larson",
|
||||
|
Loading…
Reference in New Issue
Block a user