Migrated Comments-UI to TypeScript (#17129)
refs https://github.com/TryGhost/Team/issues/3504 This migrates comments-ui to TypeScript. Only `App.js` is left to migrate, but since this isn't using hooks yet, it will need a bigger rewrite so this will need to happen in a separate PR.
This commit is contained in:
parent
549e608b27
commit
331533d724
16
.github/dev.js
vendored
16
.github/dev.js
vendored
@ -149,7 +149,7 @@ if (DASH_DASH_ARGS.includes('lexical')) {
|
|||||||
// To make this work, you'll need a CADDY server running in front
|
// To make this work, you'll need a CADDY server running in front
|
||||||
// Note the port is different because of this extra layer. Use the following Caddyfile:
|
// Note the port is different because of this extra layer. Use the following Caddyfile:
|
||||||
// https://localhost:4174 {
|
// https://localhost:4174 {
|
||||||
// reverse_proxy 127.0.0.1:4173
|
// reverse_proxy http://127.0.0.1:4173
|
||||||
// }
|
// }
|
||||||
|
|
||||||
COMMAND_GHOST.env['editor__url'] = 'https://localhost:4174/koenig-lexical.umd.js';
|
COMMAND_GHOST.env['editor__url'] = 'https://localhost:4174/koenig-lexical.umd.js';
|
||||||
@ -159,6 +159,18 @@ if (DASH_DASH_ARGS.includes('lexical')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) {
|
if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) {
|
||||||
|
if (DASH_DASH_ARGS.includes('https')) {
|
||||||
|
// Safari needs HTTPS for it to work
|
||||||
|
// To make this work, you'll need a CADDY server running in front
|
||||||
|
// Note the port is different because of this extra layer. Use the following Caddyfile:
|
||||||
|
// https://localhost:7174 {
|
||||||
|
// reverse_proxy http://127.0.0.1:7173
|
||||||
|
// }
|
||||||
|
COMMAND_GHOST.env['comments__url'] = 'https://localhost:7174/comments-ui.min.js';
|
||||||
|
} else {
|
||||||
|
COMMAND_GHOST.env['comments__url'] = 'http://localhost:7173/comments-ui.min.js';
|
||||||
|
}
|
||||||
|
|
||||||
commands.push({
|
commands.push({
|
||||||
name: 'comments',
|
name: 'comments',
|
||||||
command: 'yarn dev',
|
command: 'yarn dev',
|
||||||
@ -166,8 +178,6 @@ if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) {
|
|||||||
prefixColor: '#E55137',
|
prefixColor: '#E55137',
|
||||||
env: {}
|
env: {}
|
||||||
});
|
});
|
||||||
|
|
||||||
COMMAND_GHOST.env['comments__url'] = 'http://localhost:7174/comments-ui.min.js';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStripe() {
|
async function handleStripe() {
|
||||||
|
@ -36,6 +36,9 @@ module.exports = {
|
|||||||
'tailwindcss/migration-from-tailwind-2': ['warn', {config: 'tailwind.config.cjs'}],
|
'tailwindcss/migration-from-tailwind-2': ['warn', {config: 'tailwind.config.cjs'}],
|
||||||
'tailwindcss/no-arbitrary-value': 'off',
|
'tailwindcss/no-arbitrary-value': 'off',
|
||||||
'tailwindcss/no-custom-classname': 'off',
|
'tailwindcss/no-custom-classname': 'off',
|
||||||
'tailwindcss/no-contradicting-classname': ['error', {config: 'tailwind.config.cjs'}]
|
'tailwindcss/no-contradicting-classname': ['error', {config: 'tailwind.config.cjs'}],
|
||||||
|
|
||||||
|
// This rule doesn't work correctly with TypeScript, and TypeScript has its own better version
|
||||||
|
'no-undef': 'off'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
"registry": "https://registry.npmjs.org/"
|
"registry": "https://registry.npmjs.org/"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"yarn preview -l silent\" \"yarn build:watch\"",
|
"dev": "concurrently \"yarn preview --host -l silent\" \"yarn build:watch\"",
|
||||||
"dev:test": "vite build && vite preview --port 7175",
|
"dev:test": "vite build && vite preview --port 7175",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:watch": "vite build --watch",
|
"build:watch": "vite build --watch",
|
||||||
@ -24,7 +24,7 @@
|
|||||||
"test:e2e": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test",
|
"test:e2e": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test",
|
||||||
"test:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=1000 yarn test:e2e --headed",
|
"test:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=1000 yarn test:e2e --headed",
|
||||||
"test:e2e:full": "ALL_BROWSERS=1 yarn test:e2e",
|
"test:e2e:full": "ALL_BROWSERS=1 yarn test:e2e",
|
||||||
"lint": "eslint src --ext .js --cache",
|
"lint": "eslint src --ext .js,.ts,.jsx,.tsx --cache",
|
||||||
"preship": "yarn lint",
|
"preship": "yarn lint",
|
||||||
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; fi",
|
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; fi",
|
||||||
"postship": "git push ${GHOST_UPSTREAM:-origin} --follow-tags && npm publish",
|
"postship": "git push ${GHOST_UPSTREAM:-origin} --follow-tags && npm publish",
|
||||||
@ -61,15 +61,18 @@
|
|||||||
"@testing-library/jest-dom": "5.16.5",
|
"@testing-library/jest-dom": "5.16.5",
|
||||||
"@testing-library/react": "12.1.5",
|
"@testing-library/react": "12.1.5",
|
||||||
"@testing-library/user-event": "14.4.3",
|
"@testing-library/user-event": "14.4.3",
|
||||||
|
"@typescript-eslint/eslint-plugin": "5.57.1",
|
||||||
|
"@typescript-eslint/parser": "5.57.1",
|
||||||
"@vitejs/plugin-react": "4.0.1",
|
"@vitejs/plugin-react": "4.0.1",
|
||||||
"@vitest/coverage-v8": "0.32.2",
|
"@vitest/coverage-v8": "0.32.2",
|
||||||
"autoprefixer": "10.4.14",
|
"autoprefixer": "10.4.14",
|
||||||
"bson-objectid": "2.0.4",
|
"bson-objectid": "2.0.4",
|
||||||
"concurrently": "8.2.0",
|
"concurrently": "8.2.0",
|
||||||
"eslint": "8.43.0",
|
"eslint": "8.38.0",
|
||||||
"eslint-config-react-app": "7.0.1",
|
"eslint-plugin-ghost": "3.2.0",
|
||||||
"eslint-plugin-ghost": "2.12.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"eslint-plugin-tailwindcss": "^3.6.0",
|
"eslint-plugin-react-refresh": "0.3.4",
|
||||||
|
"eslint-plugin-tailwindcss": "3.11.0",
|
||||||
"jsdom": "22.1.0",
|
"jsdom": "22.1.0",
|
||||||
"postcss": "8.4.24",
|
"postcss": "8.4.24",
|
||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import AppContext from './AppContext';
|
|
||||||
import ContentBox from './components/ContentBox';
|
import ContentBox from './components/ContentBox';
|
||||||
import PopupBox from './components/PopupBox';
|
import PopupBox from './components/PopupBox';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import setupGhostApi from './utils/api';
|
import setupGhostApi from './utils/api';
|
||||||
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
|
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
|
||||||
|
import {AppContext} from './AppContext';
|
||||||
import {CommentsFrame} from './components/Frame';
|
import {CommentsFrame} from './components/Frame';
|
||||||
import {createPopupNotification} from './utils/helpers';
|
import {createPopupNotification} from './utils/helpers';
|
||||||
import {hasMode} from './utils/check-mode';
|
import {hasMode} from './utils/check-mode';
|
@ -2,7 +2,7 @@ import App from './App';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import {ROOT_DIV_ID} from './utils/constants';
|
import {ROOT_DIV_ID} from './utils/constants';
|
||||||
import {act, fireEvent, render, waitFor, within} from '@testing-library/react';
|
import {act, fireEvent, render, waitFor, within} from '@testing-library/react';
|
||||||
import {buildComment, buildMember} from './utils/test-utils';
|
import {buildComment, buildMember} from '../test/utils/fixtures';
|
||||||
|
|
||||||
function renderApp({member = null, documentStyles = {}, props = {}} = {}) {
|
function renderApp({member = null, documentStyles = {}, props = {}} = {}) {
|
||||||
const postId = 'my-post';
|
const postId = 'my-post';
|
||||||
@ -80,7 +80,7 @@ function renderApp({member = null, documentStyles = {}, props = {}} = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.scrollTo = jest.fn();
|
window.scrollTo = vi.fn();
|
||||||
Range.prototype.getClientRects = function getClientRects() {
|
Range.prototype.getClientRects = function getClientRects() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -98,7 +98,7 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Auth frame', () => {
|
describe('Auth frame', () => {
|
||||||
@ -148,7 +148,7 @@ describe('Dark mode', () => {
|
|||||||
describe('Comments', () => {
|
describe('Comments', () => {
|
||||||
it('renders comments', async () => {
|
it('renders comments', async () => {
|
||||||
const {api, iframeDocument} = renderApp();
|
const {api, iframeDocument} = renderApp();
|
||||||
jest.spyOn(api.comments, 'browse').mockImplementation(() => {
|
vi.spyOn(api.comments, 'browse').mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
comments: [
|
comments: [
|
||||||
buildComment({html: '<p>This is a comment body</p>'})
|
buildComment({html: '<p>This is a comment body</p>'})
|
||||||
@ -174,7 +174,7 @@ describe('Comments', () => {
|
|||||||
const limit = 5;
|
const limit = 5;
|
||||||
|
|
||||||
const {api, iframeDocument} = renderApp();
|
const {api, iframeDocument} = renderApp();
|
||||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||||
if (page === 2) {
|
if (page === 2) {
|
||||||
return {
|
return {
|
||||||
comments: new Array(1).fill({}).map(() => buildComment({html: '<p>This is a paginated comment</p>'})),
|
comments: new Array(1).fill({}).map(() => buildComment({html: '<p>This is a paginated comment</p>'})),
|
||||||
@ -216,7 +216,7 @@ describe('Comments', () => {
|
|||||||
const limit = 5;
|
const limit = 5;
|
||||||
|
|
||||||
const {api, iframeDocument} = renderApp();
|
const {api, iframeDocument} = renderApp();
|
||||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||||
return {
|
return {
|
||||||
comments: new Array(limit).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>', member: null})),
|
comments: new Array(limit).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>', member: null})),
|
||||||
meta: {
|
meta: {
|
||||||
@ -239,7 +239,7 @@ describe('Comments', () => {
|
|||||||
const limit = 5;
|
const limit = 5;
|
||||||
|
|
||||||
const {api, iframeDocument} = renderApp();
|
const {api, iframeDocument} = renderApp();
|
||||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||||
if (page === 2) {
|
if (page === 2) {
|
||||||
throw new Error('Not requested');
|
throw new Error('Not requested');
|
||||||
}
|
}
|
||||||
@ -281,7 +281,7 @@ describe('Likes', () => {
|
|||||||
member
|
member
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||||
if (page === 2) {
|
if (page === 2) {
|
||||||
throw new Error('Not requested');
|
throw new Error('Not requested');
|
||||||
}
|
}
|
||||||
@ -299,8 +299,8 @@ describe('Likes', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const likeSpy = jest.spyOn(api.comments, 'like');
|
const likeSpy = vi.spyOn(api.comments, 'like');
|
||||||
const unlikeSpy = jest.spyOn(api.comments, 'unlike');
|
const unlikeSpy = vi.spyOn(api.comments, 'unlike');
|
||||||
|
|
||||||
const comment = await within(iframeDocument).findByTestId('comment-component');
|
const comment = await within(iframeDocument).findByTestId('comment-component');
|
||||||
|
|
||||||
@ -346,7 +346,7 @@ describe('Replies', () => {
|
|||||||
member
|
member
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||||
if (page === 2) {
|
if (page === 2) {
|
||||||
throw new Error('Not requested');
|
throw new Error('Not requested');
|
||||||
}
|
}
|
||||||
@ -364,7 +364,7 @@ describe('Replies', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const repliesSpy = jest.spyOn(api.comments, 'replies');
|
const repliesSpy = vi.spyOn(api.comments, 'replies');
|
||||||
|
|
||||||
const comments = await within(iframeDocument).findAllByTestId('comment-component');
|
const comments = await within(iframeDocument).findAllByTestId('comment-component');
|
||||||
expect(comments).toHaveLength(limit);
|
expect(comments).toHaveLength(limit);
|
@ -1,6 +0,0 @@
|
|||||||
// Ref: https://reactjs.org/docs/context.html
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
const AppContext = React.createContext({});
|
|
||||||
|
|
||||||
export default AppContext;
|
|
80
apps/comments-ui/src/AppContext.ts
Normal file
80
apps/comments-ui/src/AppContext.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// Ref: https://reactjs.org/docs/context.html
|
||||||
|
import React, {useContext} from 'react';
|
||||||
|
import {ActionType, Actions, SyncActionType, SyncActions} from './actions';
|
||||||
|
import {Page} from './pages';
|
||||||
|
|
||||||
|
export type PopupNotification = {
|
||||||
|
type: string,
|
||||||
|
status: string,
|
||||||
|
autoHide: boolean,
|
||||||
|
closeable: boolean,
|
||||||
|
duration: number,
|
||||||
|
meta: any,
|
||||||
|
message: string,
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Member = {
|
||||||
|
id: string,
|
||||||
|
uuid: string,
|
||||||
|
name: string,
|
||||||
|
avatar: string,
|
||||||
|
expertise: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Comment = {
|
||||||
|
id: string,
|
||||||
|
post_id: string,
|
||||||
|
replies: Comment[],
|
||||||
|
status: string,
|
||||||
|
liked: boolean,
|
||||||
|
count: {
|
||||||
|
replies: number,
|
||||||
|
likes: number,
|
||||||
|
},
|
||||||
|
member: Member,
|
||||||
|
edited_at: string,
|
||||||
|
created_at: string,
|
||||||
|
html: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddComment = {
|
||||||
|
post_id: string,
|
||||||
|
status: string,
|
||||||
|
html: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppContextType = {
|
||||||
|
action: string,
|
||||||
|
popupNotification: PopupNotification | null,
|
||||||
|
customSiteUrl: string | undefined,
|
||||||
|
member: null | any,
|
||||||
|
admin: null | any,
|
||||||
|
comments: Comment[],
|
||||||
|
pagination: {
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
pages: number,
|
||||||
|
total: number
|
||||||
|
} | null,
|
||||||
|
commentCount: number,
|
||||||
|
postId: string,
|
||||||
|
title: string,
|
||||||
|
showCount: boolean,
|
||||||
|
colorScheme: string | undefined,
|
||||||
|
avatarSaturation: number | undefined,
|
||||||
|
accentColor: string | undefined,
|
||||||
|
commentsEnabled: string | undefined,
|
||||||
|
publication: string,
|
||||||
|
secundaryFormCount: number,
|
||||||
|
popup: Page | null,
|
||||||
|
|
||||||
|
// This part makes sure we can add automatic data and return types to the actions when using context.dispatchAction('actionName', data)
|
||||||
|
dispatchAction: <T extends ActionType | SyncActionType>(action: T, data: Parameters<(typeof Actions & typeof SyncActions)[T]>[0] extends {data: any} ? Parameters<(typeof Actions & typeof SyncActions)[T]>[0]['data'] : {}) => T extends ActionType ? Promise<void> : void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppContext = React.createContext<AppContextType>({} as any);
|
||||||
|
|
||||||
|
export const AppContextProvider = AppContext.Provider;
|
||||||
|
|
||||||
|
export const useAppContext = () => useContext(AppContext);
|
@ -1,4 +1,8 @@
|
|||||||
async function loadMoreComments({state, api}) {
|
import {AddComment, AppContextType, Comment} from './AppContext';
|
||||||
|
import {GhostApi} from './utils/api';
|
||||||
|
import {Page} from './pages';
|
||||||
|
|
||||||
|
async function loadMoreComments({state, api}: {state: AppContextType, api: GhostApi}): Promise<Partial<AppContextType>> {
|
||||||
let page = 1;
|
let page = 1;
|
||||||
if (state.pagination && state.pagination.page) {
|
if (state.pagination && state.pagination.page) {
|
||||||
page = state.pagination.page + 1;
|
page = state.pagination.page + 1;
|
||||||
@ -12,7 +16,7 @@ async function loadMoreComments({state, api}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMoreReplies({state, api, data: {comment, limit}}) {
|
async function loadMoreReplies({state, api, data: {comment, limit}}: {state: AppContextType, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise<Partial<AppContextType>> {
|
||||||
const data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
|
const 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
|
// Note: we store the comments from new to old, and show them in reverse order
|
||||||
@ -29,7 +33,7 @@ async function loadMoreReplies({state, api, data: {comment, limit}}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addComment({state, api, data: comment}) {
|
async function addComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: AddComment}) {
|
||||||
const data = await api.comments.add({comment});
|
const data = await api.comments.add({comment});
|
||||||
comment = data.comments[0];
|
comment = data.comments[0];
|
||||||
|
|
||||||
@ -39,7 +43,7 @@ async function addComment({state, api, data: comment}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addReply({state, api, data: {reply, parent}}) {
|
async function addReply({state, api, data: {reply, parent}}: {state: AppContextType, api: GhostApi, data: {reply: any, parent: any}}) {
|
||||||
let comment = reply;
|
let comment = reply;
|
||||||
comment.parent_id = parent.id;
|
comment.parent_id = parent.id;
|
||||||
|
|
||||||
@ -69,7 +73,7 @@ async function addReply({state, api, data: {reply, parent}}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hideComment({state, adminApi, data: comment}) {
|
async function hideComment({state, adminApi, data: comment}: {state: AppContextType, adminApi: any, data: {id: string}}) {
|
||||||
await adminApi.hideComment(comment.id);
|
await adminApi.hideComment(comment.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -102,7 +106,7 @@ async function hideComment({state, adminApi, data: comment}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showComment({state, api, adminApi, data: comment}) {
|
async function showComment({state, api, adminApi, data: comment}: {state: AppContextType, api: GhostApi, adminApi: any, data: {id: string}}) {
|
||||||
await adminApi.showComment(comment.id);
|
await adminApi.showComment(comment.id);
|
||||||
|
|
||||||
// We need to refetch the comment, to make sure we have an up to date HTML content
|
// We need to refetch the comment, to make sure we have an up to date HTML content
|
||||||
@ -133,7 +137,7 @@ async function showComment({state, api, adminApi, data: comment}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function likeComment({state, api, data: comment}) {
|
async function likeComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
|
||||||
await api.comments.like({comment});
|
await api.comments.like({comment});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -173,13 +177,13 @@ async function likeComment({state, api, data: comment}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reportComment({state, api, data: comment}) {
|
async function reportComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
|
||||||
await api.comments.report({comment});
|
await api.comments.report({comment});
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unlikeComment({state, api, data: comment}) {
|
async function unlikeComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
|
||||||
await api.comments.unlike({comment});
|
await api.comments.unlike({comment});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -218,7 +222,7 @@ async function unlikeComment({state, api, data: comment}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComment({state, api, data: comment}) {
|
async function deleteComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
|
||||||
await api.comments.edit({
|
await api.comments.edit({
|
||||||
comment: {
|
comment: {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
@ -256,7 +260,7 @@ async function deleteComment({state, api, data: comment}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editComment({state, api, data: {comment, parent}}) {
|
async function editComment({state, api, data: {comment, parent}}: {state: AppContextType, api: GhostApi, data: {comment: Partial<Comment> & {id: string}, parent?: Comment}}) {
|
||||||
const data = await api.comments.edit({
|
const data = await api.comments.edit({
|
||||||
comment
|
comment
|
||||||
});
|
});
|
||||||
@ -284,10 +288,10 @@ async function editComment({state, api, data: {comment, parent}}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateMember({data, state, api}) {
|
async function updateMember({data, state, api}: {data: {name: string, expertise: string}, state: AppContextType, api: GhostApi}) {
|
||||||
const {name, expertise} = data;
|
const {name, expertise} = data;
|
||||||
const patchData = {};
|
const patchData: {name?: string, expertise?: string} = {};
|
||||||
|
|
||||||
const originalName = state?.member?.name;
|
const originalName = state?.member?.name;
|
||||||
|
|
||||||
if (name && originalName !== name) {
|
if (name && originalName !== name) {
|
||||||
@ -320,7 +324,7 @@ async function updateMember({data, state, api}) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPopup({data}) {
|
function openPopup({data}: {data: Page}) {
|
||||||
return {
|
return {
|
||||||
popup: data
|
popup: data
|
||||||
};
|
};
|
||||||
@ -332,27 +336,29 @@ function closePopup() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function increaseSecundaryFormCount({state}) {
|
function increaseSecundaryFormCount({state}: {state: AppContextType}) {
|
||||||
return {
|
return {
|
||||||
secundaryFormCount: state.secundaryFormCount + 1
|
secundaryFormCount: state.secundaryFormCount + 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function decreaseSecundaryFormCount({state}) {
|
function decreaseSecundaryFormCount({state}: {state: AppContextType}) {
|
||||||
return {
|
return {
|
||||||
secundaryFormCount: state.secundaryFormCount - 1
|
secundaryFormCount: state.secundaryFormCount - 1
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync actions make use of setState((currentState) => newState), to avoid 'race' conditions
|
// Sync actions make use of setState((currentState) => newState), to avoid 'race' conditions
|
||||||
const SyncActions = {
|
export const SyncActions = {
|
||||||
openPopup,
|
openPopup,
|
||||||
closePopup,
|
closePopup,
|
||||||
increaseSecundaryFormCount,
|
increaseSecundaryFormCount,
|
||||||
decreaseSecundaryFormCount
|
decreaseSecundaryFormCount
|
||||||
};
|
};
|
||||||
|
|
||||||
const Actions = {
|
export type SyncActionType = keyof typeof SyncActions;
|
||||||
|
|
||||||
|
export const Actions = {
|
||||||
// Put your actions here
|
// Put your actions here
|
||||||
addComment,
|
addComment,
|
||||||
editComment,
|
editComment,
|
||||||
@ -368,25 +374,27 @@ const Actions = {
|
|||||||
updateMember
|
updateMember
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isSyncAction(action) {
|
export type ActionType = keyof typeof Actions;
|
||||||
return !!SyncActions[action];
|
|
||||||
|
export function isSyncAction(action: string): action is SyncActionType {
|
||||||
|
return !!(SyncActions as any)[action];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle actions in the App, returns updated state */
|
/** Handle actions in the App, returns updated state */
|
||||||
export async function ActionHandler({action, data, state, api, adminApi}) {
|
export async function ActionHandler({action, data, state, api, adminApi}: {action: ActionType, data: any, state: AppContextType, api: GhostApi, adminApi: any}) {
|
||||||
const handler = Actions[action];
|
const handler = Actions[action];
|
||||||
if (handler) {
|
if (handler) {
|
||||||
return await handler({data, state, api, adminApi}) || {};
|
return await handler({data, state, api, adminApi} as any) || {};
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle actions in the App, returns updated state */
|
/** Handle actions in the App, returns updated state */
|
||||||
export function SyncActionHandler({action, data, state, api, adminApi}) {
|
export function SyncActionHandler({action, data, state, api, adminApi}: {action: SyncActionType, data: any, state: AppContextType, api: GhostApi, adminApi: any}) {
|
||||||
const handler = SyncActions[action];
|
const handler = SyncActions[action];
|
||||||
if (handler) {
|
if (handler) {
|
||||||
// Do not await here
|
// Do not await here
|
||||||
return handler({data, state, api, adminApi}) || {};
|
return handler({data, state, api, adminApi} as any) || {};
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
@ -1,11 +1,14 @@
|
|||||||
import AppContext from '../AppContext';
|
|
||||||
import Content from './content/Content';
|
import Content from './content/Content';
|
||||||
import Loading from './content/Loading';
|
import Loading from './content/Loading';
|
||||||
import React, {useContext} from 'react';
|
import React from 'react';
|
||||||
import {ROOT_DIV_ID} from '../utils/constants';
|
import {ROOT_DIV_ID} from '../utils/constants';
|
||||||
|
import {useAppContext} from '../AppContext';
|
||||||
|
|
||||||
const ContentBox = ({done}) => {
|
type Props = {
|
||||||
const luminance = (r, g, b) => {
|
done: boolean
|
||||||
|
};
|
||||||
|
const ContentBox: React.FC<Props> = ({done}) => {
|
||||||
|
const luminance = (r: number, g: number, b: number) => {
|
||||||
var a = [r, g, b].map(function (v) {
|
var a = [r, g, b].map(function (v) {
|
||||||
v /= 255;
|
v /= 255;
|
||||||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||||
@ -13,14 +16,14 @@ const ContentBox = ({done}) => {
|
|||||||
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
|
||||||
};
|
};
|
||||||
|
|
||||||
const contrast = (rgb1, rgb2) => {
|
const contrast = (rgb1: [number, number, number], rgb2: [number, number, number]) => {
|
||||||
var lum1 = luminance(rgb1[0], rgb1[1], rgb1[2]);
|
var lum1 = luminance(rgb1[0], rgb1[1], rgb1[2]);
|
||||||
var lum2 = luminance(rgb2[0], rgb2[1], rgb2[2]);
|
var lum2 = luminance(rgb2[0], rgb2[1], rgb2[2]);
|
||||||
var brightest = Math.max(lum1, lum2);
|
var brightest = Math.max(lum1, lum2);
|
||||||
var darkest = Math.min(lum1, lum2);
|
var darkest = Math.min(lum1, lum2);
|
||||||
return (brightest + 0.05) / (darkest + 0.05);
|
return (brightest + 0.05) / (darkest + 0.05);
|
||||||
};
|
};
|
||||||
const {accentColor, colorScheme} = useContext(AppContext);
|
const {accentColor, colorScheme} = useAppContext();
|
||||||
|
|
||||||
const darkMode = () => {
|
const darkMode = () => {
|
||||||
if (colorScheme === 'light') {
|
if (colorScheme === 'light') {
|
||||||
@ -28,12 +31,16 @@ const ContentBox = ({done}) => {
|
|||||||
} else if (colorScheme === 'dark') {
|
} else if (colorScheme === 'dark') {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
const containerColor = getComputedStyle(document.getElementById(ROOT_DIV_ID).parentNode).getPropertyValue('color');
|
const el = document.getElementById(ROOT_DIV_ID);
|
||||||
|
if (!el || !el.parentElement) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const containerColor = getComputedStyle(el.parentElement).getPropertyValue('color');
|
||||||
|
|
||||||
const colorsOnly = containerColor.substring(containerColor.indexOf('(') + 1, containerColor.lastIndexOf(')')).split(/,\s*/);
|
const colorsOnly = containerColor.substring(containerColor.indexOf('(') + 1, containerColor.lastIndexOf(')')).split(/,\s*/);
|
||||||
const red = colorsOnly[0];
|
const red = parseInt(colorsOnly[0]);
|
||||||
const green = colorsOnly[1];
|
const green = parseInt(colorsOnly[1]);
|
||||||
const blue = colorsOnly[2];
|
const blue = parseInt(colorsOnly[2]);
|
||||||
|
|
||||||
return contrast([255, 255, 255], [red, green, blue]) < 5;
|
return contrast([255, 255, 255], [red, green, blue]) < 5;
|
||||||
}
|
}
|
@ -2,10 +2,20 @@ import IFrame from './IFrame';
|
|||||||
import React, {useCallback, useState} from 'react';
|
import React, {useCallback, useState} from 'react';
|
||||||
import styles from '../styles/iframe.css?inline';
|
import styles from '../styles/iframe.css?inline';
|
||||||
|
|
||||||
|
type FrameProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
};
|
||||||
|
|
||||||
|
type TailwindFrameProps = FrameProps & {
|
||||||
|
style: React.CSSProperties,
|
||||||
|
title: string,
|
||||||
|
onResize: (iframeRoot: HTMLElement) => void
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads all the CSS styles inside an iFrame. Only shows the visible content as soon as the CSS file with the tailwind classes has loaded.
|
* Loads all the CSS styles inside an iFrame. Only shows the visible content as soon as the CSS file with the tailwind classes has loaded.
|
||||||
*/
|
*/
|
||||||
const TailwindFrame = ({children, onResize, style, title}) => {
|
const TailwindFrame: React.FC<TailwindFrameProps> = ({children, onResize, style, title}) => {
|
||||||
const head = (
|
const head = (
|
||||||
<>
|
<>
|
||||||
<style dangerouslySetInnerHTML={{__html: styles}} />
|
<style dangerouslySetInnerHTML={{__html: styles}} />
|
||||||
@ -21,10 +31,15 @@ const TailwindFrame = ({children, onResize, style, title}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResizableFrameProps = FrameProps & {
|
||||||
|
style: React.CSSProperties,
|
||||||
|
title: string
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This iframe has the same height as it contents and mimics a shadow DOM component
|
* This iframe has the same height as it contents and mimics a shadow DOM component
|
||||||
*/
|
*/
|
||||||
const ResizableFrame = ({children, style, title}) => {
|
const ResizableFrame: React.FC<ResizableFrameProps> = ({children, style, title}) => {
|
||||||
const [iframeStyle, setIframeStyle] = useState(style);
|
const [iframeStyle, setIframeStyle] = useState(style);
|
||||||
const onResize = useCallback((iframeRoot) => {
|
const onResize = useCallback((iframeRoot) => {
|
||||||
setIframeStyle((current) => {
|
setIframeStyle((current) => {
|
||||||
@ -42,7 +57,7 @@ const ResizableFrame = ({children, style, title}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CommentsFrame = ({children}) => {
|
export const CommentsFrame: React.FC<FrameProps> = ({children}) => {
|
||||||
const style = {
|
const style = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '400px'
|
height: '400px'
|
||||||
@ -54,7 +69,11 @@ export const CommentsFrame = ({children}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PopupFrame = ({children, title}) => {
|
type PopupFrameProps = FrameProps & {
|
||||||
|
title: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PopupFrame: React.FC<PopupFrameProps> = ({children, title}) => {
|
||||||
const style = {
|
const style = {
|
||||||
zIndex: '3999999',
|
zIndex: '3999999',
|
||||||
position: 'fixed',
|
position: 'fixed',
|
@ -1,13 +1,21 @@
|
|||||||
import React, {Component} from 'react';
|
import {Component} from 'react';
|
||||||
import {createPortal} from 'react-dom';
|
import {createPortal} from 'react-dom';
|
||||||
|
|
||||||
export default class IFrame extends Component {
|
/**
|
||||||
constructor() {
|
* This is still a class component because it causes issues with the behaviour (DOM recreation and layout glitches) if we switch to a functional component. Feel free to refactor.
|
||||||
super();
|
*/
|
||||||
|
export default class IFrame extends Component<any> {
|
||||||
|
node: any;
|
||||||
|
iframeHtml: any;
|
||||||
|
iframeHead: any;
|
||||||
|
iframeRoot: any;
|
||||||
|
|
||||||
|
constructor(props: {onResize?: (el: HTMLElement) => void, children: any}) {
|
||||||
|
super(props);
|
||||||
this.setNode = this.setNode.bind(this);
|
this.setNode = this.setNode.bind(this);
|
||||||
this.node = null;
|
this.node = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.node.addEventListener('load', this.handleLoad);
|
this.node.addEventListener('load', this.handleLoad);
|
||||||
}
|
}
|
||||||
@ -35,7 +43,7 @@ export default class IFrame extends Component {
|
|||||||
// because when we want to listen for keydown events, those are only send in the window of iframe that is focused
|
// because when we want to listen for keydown events, those are only send in the window of iframe that is focused
|
||||||
// To get around this, we pass down the keydown events to the main window
|
// To get around this, we pass down the keydown events to the main window
|
||||||
// No need to detach, because the iframe would get removed
|
// No need to detach, because the iframe would get removed
|
||||||
this.node.contentWindow.addEventListener('keydown', (e) => {
|
this.node.contentWindow.addEventListener('keydown', (e: KeyboardEvent | undefined) => {
|
||||||
// dispatch a new event
|
// dispatch a new event
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', e)
|
new KeyboardEvent('keydown', e)
|
||||||
@ -44,8 +52,8 @@ export default class IFrame extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setNode(node) {
|
setNode(node: any) {
|
||||||
this.node = node;
|
this.node = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
@ -1,10 +1,11 @@
|
|||||||
import AppContext from '../AppContext';
|
|
||||||
import GenericPopup from './popups/GenericPopup';
|
import GenericPopup from './popups/GenericPopup';
|
||||||
import Pages from '../pages';
|
import {Pages} from '../pages';
|
||||||
import {useContext, useEffect, useState} from 'react';
|
import {useAppContext} from '../AppContext';
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
|
||||||
export default function PopupBox() {
|
type Props = {};
|
||||||
const {popup} = useContext(AppContext);
|
const PopupBox: React.FC<Props> = () => {
|
||||||
|
const {popup} = useAppContext();
|
||||||
|
|
||||||
// To make sure we can properly animate a popup that goes away, we keep a state of the last visible popup
|
// To make sure we can properly animate a popup that goes away, we keep a state of the last visible popup
|
||||||
// This way, when the popup context is set to null, we still can show the popup while we transition it away
|
// This way, when the popup context is set to null, we still can show the popup while we transition it away
|
||||||
@ -47,8 +48,10 @@ export default function PopupBox() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GenericPopup callback={popupProps.callback} show={show} title={type}>
|
<GenericPopup callback={popupProps.callback} show={show} title={type}>
|
||||||
<PageComponent {...popupProps}/>
|
<PageComponent {...popupProps as any}/>
|
||||||
</GenericPopup>
|
</GenericPopup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default PopupBox;
|
@ -1,6 +1,5 @@
|
|||||||
import AppContext from '../../AppContext';
|
|
||||||
import React, {useContext} from 'react';
|
|
||||||
import {ReactComponent as AvatarIcon} from '../../images/icons/avatar.svg';
|
import {ReactComponent as AvatarIcon} from '../../images/icons/avatar.svg';
|
||||||
|
import {Comment, useAppContext} from '../../AppContext';
|
||||||
import {getInitials} from '../../utils/helpers';
|
import {getInitials} from '../../utils/helpers';
|
||||||
|
|
||||||
function getDimensionClasses() {
|
function getDimensionClasses() {
|
||||||
@ -18,13 +17,16 @@ export const BlankAvatar = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Avatar = ({comment}) => {
|
type AvatarProps = {
|
||||||
const {member, avatarSaturation} = useContext(AppContext);
|
comment?: Comment;
|
||||||
|
};
|
||||||
|
export const Avatar: React.FC<AvatarProps> = ({comment}) => {
|
||||||
|
const {member, avatarSaturation} = useAppContext();
|
||||||
const dimensionClasses = getDimensionClasses();
|
const dimensionClasses = getDimensionClasses();
|
||||||
|
|
||||||
const memberName = member?.name ?? comment?.member?.name;
|
const memberName = member?.name ?? comment?.member?.name;
|
||||||
|
|
||||||
const getHashOfString = (str) => {
|
const getHashOfString = (str: string) => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
@ -33,18 +35,18 @@ export const Avatar = ({comment}) => {
|
|||||||
return hash;
|
return hash;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeHash = (hash, min, max) => {
|
const normalizeHash = (hash: number, min: number, max: number) => {
|
||||||
return Math.floor((hash % (max - min)) + min);
|
return Math.floor((hash % (max - min)) + min);
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateHSL = () => {
|
const generateHSL = (): [number, number, number] => {
|
||||||
let commentMember = (comment ? comment.member : member);
|
let commentMember = (comment ? comment.member : member);
|
||||||
|
|
||||||
if (!commentMember || !commentMember.name) {
|
if (!commentMember || !commentMember.name) {
|
||||||
return [0,0,10];
|
return [0,0,10];
|
||||||
}
|
}
|
||||||
|
|
||||||
const saturation = isNaN(avatarSaturation) ? 50 : avatarSaturation;
|
const saturation = avatarSaturation === undefined || isNaN(avatarSaturation) ? 50 : avatarSaturation;
|
||||||
|
|
||||||
const hRange = [0, 360];
|
const hRange = [0, 360];
|
||||||
const lRangeTop = Math.round(saturation / (100 / 30)) + 30;
|
const lRangeTop = Math.round(saturation / (100 / 30)) + 30;
|
||||||
@ -54,11 +56,11 @@ export const Avatar = ({comment}) => {
|
|||||||
const hash = getHashOfString(commentMember.name);
|
const hash = getHashOfString(commentMember.name);
|
||||||
const h = normalizeHash(hash, hRange[0], hRange[1]);
|
const h = normalizeHash(hash, hRange[0], hRange[1]);
|
||||||
const l = normalizeHash(hash, lRange[0], lRange[1]);
|
const l = normalizeHash(hash, lRange[0], lRange[1]);
|
||||||
|
|
||||||
return [h, saturation, l];
|
return [h, saturation, l];
|
||||||
};
|
};
|
||||||
|
|
||||||
const HSLtoString = (hsl) => {
|
const HSLtoString = (hsl: [number, number, number]) => {
|
||||||
return `hsl(${hsl[0]}, ${hsl[1]}%, ${hsl[2]}%)`;
|
return `hsl(${hsl[0]}, ${hsl[1]}%, ${hsl[2]}%)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,7 +68,7 @@ export const Avatar = ({comment}) => {
|
|||||||
if (comment && !comment.member) {
|
if (comment && !comment.member) {
|
||||||
return getInitials('Deleted member');
|
return getInitials('Deleted member');
|
||||||
}
|
}
|
||||||
|
|
||||||
let commentMember = (comment ? comment.member : member);
|
let commentMember = (comment ? comment.member : member);
|
||||||
|
|
||||||
if (!commentMember || !commentMember.name) {
|
if (!commentMember || !commentMember.name) {
|
@ -1,8 +1,11 @@
|
|||||||
import AppContext from '../../AppContext';
|
import {useAppContext} from '../../AppContext';
|
||||||
import {useContext} from 'react';
|
|
||||||
|
|
||||||
const CTABox = ({isFirst, isPaid}) => {
|
type Props = {
|
||||||
const {accentColor, publication, member} = useContext(AppContext);
|
isFirst: boolean,
|
||||||
|
isPaid: boolean
|
||||||
|
};
|
||||||
|
const CTABox: React.FC<Props> = ({isFirst, isPaid}) => {
|
||||||
|
const {accentColor, publication, member} = useAppContext();
|
||||||
|
|
||||||
const buttonStyle = {
|
const buttonStyle = {
|
||||||
backgroundColor: accentColor
|
backgroundColor: accentColor
|
@ -1,17 +1,22 @@
|
|||||||
import AppContext from '../../AppContext';
|
|
||||||
import EditForm from './forms/EditForm';
|
import EditForm from './forms/EditForm';
|
||||||
import LikeButton from './buttons/LikeButton';
|
import LikeButton from './buttons/LikeButton';
|
||||||
import MoreButton from './buttons/MoreButton';
|
import MoreButton from './buttons/MoreButton';
|
||||||
import React, {useContext, useState} from 'react';
|
|
||||||
import Replies from './Replies';
|
import Replies from './Replies';
|
||||||
import ReplyButton from './buttons/ReplyButton';
|
import ReplyButton from './buttons/ReplyButton';
|
||||||
import ReplyForm from './forms/ReplyForm';
|
import ReplyForm from './forms/ReplyForm';
|
||||||
import {Avatar, BlankAvatar} from './Avatar';
|
import {Avatar, BlankAvatar} from './Avatar';
|
||||||
|
import {Comment, useAppContext} from '../../AppContext';
|
||||||
import {Transition} from '@headlessui/react';
|
import {Transition} from '@headlessui/react';
|
||||||
import {formatExplicitTime, isCommentPublished} from '../../utils/helpers';
|
import {formatExplicitTime, isCommentPublished} from '../../utils/helpers';
|
||||||
import {useRelativeTime} from '../../utils/hooks';
|
import {useRelativeTime} from '../../utils/hooks';
|
||||||
|
import {useState} from 'react';
|
||||||
|
|
||||||
function AnimatedComment({comment, parent}) {
|
type AnimatedCommentProps = {
|
||||||
|
comment: Comment;
|
||||||
|
parent?: Comment;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent}) => {
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
enter="transition-opacity duration-300 ease-out"
|
enter="transition-opacity duration-300 ease-out"
|
||||||
@ -26,9 +31,10 @@ function AnimatedComment({comment, parent}) {
|
|||||||
<EditableComment comment={comment} parent={parent} />
|
<EditableComment comment={comment} parent={parent} />
|
||||||
</Transition>
|
</Transition>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function EditableComment({comment, parent}) {
|
type EditableCommentProps = AnimatedCommentProps;
|
||||||
|
const EditableComment: React.FC<EditableCommentProps> = ({comment, parent}) => {
|
||||||
const [isInEditMode, setIsInEditMode] = useState(false);
|
const [isInEditMode, setIsInEditMode] = useState(false);
|
||||||
|
|
||||||
const closeEditMode = () => {
|
const closeEditMode = () => {
|
||||||
@ -44,22 +50,25 @@ function EditableComment({comment, parent}) {
|
|||||||
<EditForm close={closeEditMode} comment={comment} parent={parent} />
|
<EditForm close={closeEditMode} comment={comment} parent={parent} />
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (<Comment comment={comment} openEditMode={openEditMode} parent={parent} />);
|
return (<CommentComponent comment={comment} openEditMode={openEditMode} parent={parent} />);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function Comment({comment, parent, openEditMode}) {
|
type CommentProps = AnimatedCommentProps & {
|
||||||
|
openEditMode: () => void;
|
||||||
|
};
|
||||||
|
const CommentComponent: React.FC<CommentProps> = ({comment, parent, openEditMode}) => {
|
||||||
const isPublished = isCommentPublished(comment);
|
const isPublished = isCommentPublished(comment);
|
||||||
|
|
||||||
if (isPublished) {
|
if (isPublished) {
|
||||||
return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />);
|
return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />);
|
||||||
}
|
}
|
||||||
return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />);
|
return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />);
|
||||||
}
|
};
|
||||||
|
|
||||||
function PublishedComment({comment, parent, openEditMode}) {
|
const PublishedComment: React.FC<CommentProps> = ({comment, parent, openEditMode}) => {
|
||||||
const [isInReplyMode, setIsInReplyMode] = useState(false);
|
const [isInReplyMode, setIsInReplyMode] = useState(false);
|
||||||
const {dispatchAction} = useContext(AppContext);
|
const {dispatchAction} = useAppContext();
|
||||||
|
|
||||||
const toggleReplyMode = async () => {
|
const toggleReplyMode = async () => {
|
||||||
if (!isInReplyMode) {
|
if (!isInReplyMode) {
|
||||||
@ -86,10 +95,14 @@ function PublishedComment({comment, parent, openEditMode}) {
|
|||||||
<ReplyFormBox closeReplyMode={closeReplyMode} comment={comment} isInReplyMode={isInReplyMode} />
|
<ReplyFormBox closeReplyMode={closeReplyMode} comment={comment} isInReplyMode={isInReplyMode} />
|
||||||
</CommentLayout>
|
</CommentLayout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function UnpublishedComment({comment, openEditMode}) {
|
type UnpublishedCommentProps = {
|
||||||
const {admin} = useContext(AppContext);
|
comment: Comment;
|
||||||
|
openEditMode: () => void;
|
||||||
|
}
|
||||||
|
const UnpublishedComment: React.FC<UnpublishedCommentProps> = ({comment, openEditMode}) => {
|
||||||
|
const {admin} = useAppContext();
|
||||||
|
|
||||||
let notPublishedMessage;
|
let notPublishedMessage;
|
||||||
if (admin && comment.status === 'hidden') {
|
if (admin && comment.status === 'hidden') {
|
||||||
@ -114,12 +127,12 @@ function UnpublishedComment({comment, openEditMode}) {
|
|||||||
<RepliesContainer comment={comment} />
|
<RepliesContainer comment={comment} />
|
||||||
</CommentLayout>
|
</CommentLayout>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
// Helper components
|
// Helper components
|
||||||
|
|
||||||
function MemberExpertise({comment}) {
|
const MemberExpertise: React.FC<{comment: Comment}> = ({comment}) => {
|
||||||
const {member} = useContext(AppContext);
|
const {member} = useAppContext();
|
||||||
const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise;
|
const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise;
|
||||||
|
|
||||||
if (!memberExpertise) {
|
if (!memberExpertise) {
|
||||||
@ -129,9 +142,9 @@ function MemberExpertise({comment}) {
|
|||||||
return (
|
return (
|
||||||
<span>{memberExpertise}<span className="mx-[0.3em]">·</span></span>
|
<span>{memberExpertise}<span className="mx-[0.3em]">·</span></span>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function EditedInfo({comment}) {
|
const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => {
|
||||||
if (!comment.edited_at) {
|
if (!comment.edited_at) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -140,9 +153,9 @@ function EditedInfo({comment}) {
|
|||||||
<span className="mx-[0.3em]">·</span>Edited
|
<span className="mx-[0.3em]">·</span>Edited
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function RepliesContainer({comment}) {
|
const RepliesContainer: React.FC<{comment: Comment}> = ({comment}) => {
|
||||||
const hasReplies = comment.replies && comment.replies.length > 0;
|
const hasReplies = comment.replies && comment.replies.length > 0;
|
||||||
|
|
||||||
if (!hasReplies) {
|
if (!hasReplies) {
|
||||||
@ -154,9 +167,14 @@ function RepliesContainer({comment}) {
|
|||||||
<Replies comment={comment} />
|
<Replies comment={comment} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function ReplyFormBox({comment, isInReplyMode, closeReplyMode}) {
|
type ReplyFormBoxProps = {
|
||||||
|
comment: Comment;
|
||||||
|
isInReplyMode: boolean;
|
||||||
|
closeReplyMode: () => void;
|
||||||
|
};
|
||||||
|
const ReplyFormBox: React.FC<ReplyFormBoxProps> = ({comment, isInReplyMode, closeReplyMode}) => {
|
||||||
if (!isInReplyMode) {
|
if (!isInReplyMode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -166,23 +184,23 @@ function ReplyFormBox({comment, isInReplyMode, closeReplyMode}) {
|
|||||||
<ReplyForm close={closeReplyMode} parent={comment} />
|
<ReplyForm close={closeReplyMode} parent={comment} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
//
|
//
|
||||||
// -- Published comment components --
|
// -- Published comment components --
|
||||||
//
|
//
|
||||||
|
|
||||||
// TODO: move name detection to helper
|
// TODO: move name detection to helper
|
||||||
function AuthorName({comment}) {
|
const AuthorName: React.FC<{comment: Comment}> = ({comment}) => {
|
||||||
const name = !comment.member ? 'Deleted member' : (comment.member.name ? comment.member.name : 'Anonymous');
|
const name = !comment.member ? 'Deleted member' : (comment.member.name ? comment.member.name : 'Anonymous');
|
||||||
return (
|
return (
|
||||||
<h4 className="text-[rgb(23,23,23] font-sans text-[17px] font-bold tracking-tight dark:text-[rgba(255,255,255,0.85)]">
|
<h4 className="text-[rgb(23,23,23] font-sans text-[17px] font-bold tracking-tight dark:text-[rgba(255,255,255,0.85)]">
|
||||||
{name}
|
{name}
|
||||||
</h4>
|
</h4>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function CommentHeader({comment}) {
|
const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => {
|
||||||
const createdAtRelative = useRelativeTime(comment.created_at);
|
const createdAtRelative = useRelativeTime(comment.created_at);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -199,21 +217,28 @@ function CommentHeader({comment}) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function CommentBody({html}) {
|
const CommentBody: React.FC<{html: string}> = ({html}) => {
|
||||||
const dangerouslySetInnerHTML = {__html: html};
|
const dangerouslySetInnerHTML = {__html: html};
|
||||||
return (
|
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">
|
||||||
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content font-sans text-[16px] leading-normal text-neutral-900 dark:text-[rgba(255,255,255,0.85)]" data-testid="comment-content"/>
|
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content font-sans text-[16px] leading-normal text-neutral-900 dark:text-[rgba(255,255,255,0.85)]" data-testid="comment-content"/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function CommentMenu({comment, toggleReplyMode, isInReplyMode, openEditMode, parent}) {
|
type CommentMenuProps = {
|
||||||
|
comment: Comment;
|
||||||
|
toggleReplyMode: () => void;
|
||||||
|
isInReplyMode: boolean;
|
||||||
|
openEditMode: () => void;
|
||||||
|
parent?: Comment;
|
||||||
|
};
|
||||||
|
const CommentMenu: React.FC<CommentMenuProps> = ({comment, toggleReplyMode, isInReplyMode, openEditMode, parent}) => {
|
||||||
// If this comment is from the current member, always override member
|
// 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
|
// with the member from the context, so we update the expertise in existing comments when we change it
|
||||||
const {member, commentsEnabled} = useContext(AppContext);
|
const {member, commentsEnabled} = useAppContext();
|
||||||
|
|
||||||
const paidOnly = commentsEnabled === 'paid';
|
const paidOnly = commentsEnabled === 'paid';
|
||||||
const isPaidMember = member && !!member.paid;
|
const isPaidMember = member && !!member.paid;
|
||||||
@ -222,25 +247,30 @@ function CommentMenu({comment, toggleReplyMode, isInReplyMode, openEditMode, par
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
{<LikeButton comment={comment} />}
|
{<LikeButton comment={comment} />}
|
||||||
{(canReply && <ReplyButton comment={comment} isReplying={isInReplyMode} toggleReply={toggleReplyMode} />)}
|
{(canReply && <ReplyButton isReplying={isInReplyMode} toggleReply={toggleReplyMode} />)}
|
||||||
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
|
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
//
|
//
|
||||||
// -- Layout --
|
// -- Layout --
|
||||||
//
|
//
|
||||||
|
|
||||||
function RepliesLine({hasReplies}) {
|
const RepliesLine: React.FC<{hasReplies: boolean}> = ({hasReplies}) => {
|
||||||
if (!hasReplies) {
|
if (!hasReplies) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (<div className="mb-2 h-full w-[3px] grow rounded bg-gradient-to-b from-[rgba(0,0,0,0.05)] via-[rgba(0,0,0,0.05)] to-transparent dark:from-[rgba(255,255,255,0.08)] dark:via-[rgba(255,255,255,0.08)]" />);
|
return (<div className="mb-2 h-full w-[3px] grow rounded bg-gradient-to-b from-[rgba(0,0,0,0.05)] via-[rgba(0,0,0,0.05)] to-transparent dark:from-[rgba(255,255,255,0.08)] dark:via-[rgba(255,255,255,0.08)]" />);
|
||||||
}
|
};
|
||||||
|
|
||||||
function CommentLayout({children, avatar, hasReplies}) {
|
type CommentLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
avatar: React.ReactNode;
|
||||||
|
hasReplies: boolean;
|
||||||
|
}
|
||||||
|
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies}) => {
|
||||||
return (
|
return (
|
||||||
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-10'}`} data-testid="comment-component">
|
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-10'}`} data-testid="comment-component">
|
||||||
<div className="mr-3 flex flex-col items-center justify-start">
|
<div className="mr-3 flex flex-col items-center justify-start">
|
||||||
@ -254,7 +284,7 @@ function CommentLayout({children, avatar, hasReplies}) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
//
|
//
|
||||||
// -- Default --
|
// -- Default --
|
@ -1,14 +1,14 @@
|
|||||||
import AppContext from '../../AppContext';
|
|
||||||
import CTABox from './CTABox';
|
import CTABox from './CTABox';
|
||||||
import Comment from './Comment';
|
import Comment from './Comment';
|
||||||
import ContentTitle from './ContentTitle';
|
import ContentTitle from './ContentTitle';
|
||||||
import MainForm from './forms/MainForm';
|
import MainForm from './forms/MainForm';
|
||||||
import Pagination from './Pagination';
|
import Pagination from './Pagination';
|
||||||
import React, {useContext, useEffect} from 'react';
|
|
||||||
import {ROOT_DIV_ID} from '../../utils/constants';
|
import {ROOT_DIV_ID} from '../../utils/constants';
|
||||||
|
import {useAppContext} from '../../AppContext';
|
||||||
|
import {useEffect} from 'react';
|
||||||
|
|
||||||
const Content = () => {
|
const Content = () => {
|
||||||
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, secundaryFormCount} = useContext(AppContext);
|
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, secundaryFormCount} = useAppContext();
|
||||||
const commentsElements = comments.slice().reverse().map(comment => <Comment key={comment.id} comment={comment} />);
|
const commentsElements = comments.slice().reverse().map(comment => <Comment key={comment.id} comment={comment} />);
|
||||||
|
|
||||||
const paidOnly = commentsEnabled === 'paid';
|
const paidOnly = commentsEnabled === 'paid';
|
@ -1,6 +1,10 @@
|
|||||||
import {formatNumber} from '../../utils/helpers';
|
import {formatNumber} from '../../utils/helpers';
|
||||||
|
|
||||||
const Count = ({showCount, count}) => {
|
type CountProps = {
|
||||||
|
showCount: boolean,
|
||||||
|
count: number
|
||||||
|
};
|
||||||
|
const Count: React.FC<CountProps> = ({showCount, count}) => {
|
||||||
if (!showCount) {
|
if (!showCount) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -16,17 +20,22 @@ const Count = ({showCount, count}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Title = ({title}) => {
|
const Title: React.FC<{title: string | null}> = ({title}) => {
|
||||||
if (title === null) {
|
if (title === null) {
|
||||||
return (
|
return (
|
||||||
<><span className="hidden sm:inline">Member </span><span className="capitalize sm:normal-case">discussion</span></>
|
<><span className="hidden sm:inline">Member </span><span className="capitalize sm:normal-case">discussion</span></>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return title;
|
return <>{title}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContentTitle = ({title, showCount, count}) => {
|
type ContentTitleProps = {
|
||||||
|
title: string | null,
|
||||||
|
showCount: boolean,
|
||||||
|
count: number
|
||||||
|
};
|
||||||
|
const ContentTitle: React.FC<ContentTitleProps> = ({title, showCount, count}) => {
|
||||||
// We have to check for null for title because null means default, wheras empty string means empty
|
// We have to check for null for title because null means default, wheras empty string means empty
|
||||||
if (!title && !showCount && title !== null) {
|
if (!title && !showCount && title !== null) {
|
||||||
return null;
|
return null;
|
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
|
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
@ -1,12 +1,11 @@
|
|||||||
import AppContext from '../../AppContext';
|
|
||||||
import React, {useContext} from 'react';
|
|
||||||
import {formatNumber} from '../../utils/helpers';
|
import {formatNumber} from '../../utils/helpers';
|
||||||
|
import {useAppContext} from '../../AppContext';
|
||||||
|
|
||||||
const Pagination = () => {
|
const Pagination = () => {
|
||||||
const {pagination, dispatchAction} = useContext(AppContext);
|
const {pagination, dispatchAction} = useAppContext();
|
||||||
|
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
dispatchAction('loadMoreComments');
|
dispatchAction('loadMoreComments', {});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!pagination) {
|
if (!pagination) {
|
@ -1,10 +1,12 @@
|
|||||||
import AppContext from '../../AppContext';
|
import CommentComponent from './Comment';
|
||||||
import Comment from './Comment';
|
|
||||||
import RepliesPagination from './RepliesPagination';
|
import RepliesPagination from './RepliesPagination';
|
||||||
import {useContext} from 'react';
|
import {Comment, useAppContext} from '../../AppContext';
|
||||||
|
|
||||||
const Replies = ({comment}) => {
|
type Props = {
|
||||||
const {dispatchAction} = useContext(AppContext);
|
comment: Comment
|
||||||
|
};
|
||||||
|
const Replies: React.FC<Props> = ({comment}) => {
|
||||||
|
const {dispatchAction} = useAppContext();
|
||||||
|
|
||||||
const repliesLeft = comment.count.replies - comment.replies.length;
|
const repliesLeft = comment.count.replies - comment.replies.length;
|
||||||
|
|
||||||
@ -14,7 +16,7 @@ const Replies = ({comment}) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{comment.replies.map((reply => <Comment key={reply.id} comment={reply} isReply={true} parent={comment} />))}
|
{comment.replies.map((reply => <CommentComponent key={reply.id} comment={reply} parent={comment} />))}
|
||||||
{repliesLeft > 0 && <RepliesPagination count={repliesLeft} loadMore={loadMore}/>}
|
{repliesLeft > 0 && <RepliesPagination count={repliesLeft} loadMore={loadMore}/>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
@ -1,7 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {formatNumber} from '../../utils/helpers';
|
import {formatNumber} from '../../utils/helpers';
|
||||||
|
|
||||||
const RepliesPagination = ({loadMore, count}) => {
|
type Props = {
|
||||||
|
loadMore: () => void;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
const RepliesPagination: React.FC<Props> = ({loadMore, count}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-start">
|
<div className="flex w-full items-center justify-start">
|
||||||
<button className="text-md group mb-10 ml-[48px] flex w-auto items-center px-0 pb-2 pt-0 text-left font-sans font-semibold text-neutral-700 dark:text-white sm:mb-12 " data-testid="reply-pagination-button" type="button" onClick={loadMore}>
|
<button className="text-md group mb-10 ml-[48px] flex w-auto items-center px-0 pb-2 pt-0 text-left font-sans font-semibold text-neutral-700 dark:text-white sm:mb-12 " data-testid="reply-pagination-button" type="button" onClick={loadMore}>
|
@ -1,9 +1,12 @@
|
|||||||
import AppContext from '../../../AppContext';
|
import {Comment, useAppContext} from '../../../AppContext';
|
||||||
import {ReactComponent as LikeIcon} from '../../../images/icons/like.svg';
|
import {ReactComponent as LikeIcon} from '../../../images/icons/like.svg';
|
||||||
import {useContext, useState} from 'react';
|
import {useState} from 'react';
|
||||||
|
|
||||||
function LikeButton({comment}) {
|
type Props = {
|
||||||
const {dispatchAction, member, commentsEnabled} = useContext(AppContext);
|
comment: Comment;
|
||||||
|
};
|
||||||
|
const LikeButton: React.FC<Props> = ({comment}) => {
|
||||||
|
const {dispatchAction, member, commentsEnabled} = useAppContext();
|
||||||
const [animationClass, setAnimation] = useState('');
|
const [animationClass, setAnimation] = useState('');
|
||||||
|
|
||||||
const paidOnly = commentsEnabled === 'paid';
|
const paidOnly = commentsEnabled === 'paid';
|
||||||
@ -40,6 +43,6 @@ function LikeButton({comment}) {
|
|||||||
{comment.count.likes}
|
{comment.count.likes}
|
||||||
</CustomTag>
|
</CustomTag>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default LikeButton;
|
export default LikeButton;
|
@ -1,11 +1,16 @@
|
|||||||
import AppContext from '../../../AppContext';
|
|
||||||
import CommentContextMenu from '../context-menus/CommentContextMenu';
|
import CommentContextMenu from '../context-menus/CommentContextMenu';
|
||||||
import React, {useContext, useState} from 'react';
|
import {Comment, useAppContext} from '../../../AppContext';
|
||||||
import {ReactComponent as MoreIcon} from '../../../images/icons/more.svg';
|
import {ReactComponent as MoreIcon} from '../../../images/icons/more.svg';
|
||||||
|
import {useState} from 'react';
|
||||||
|
|
||||||
const MoreButton = ({comment, toggleEdit}) => {
|
type Props = {
|
||||||
|
comment: Comment;
|
||||||
|
toggleEdit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MoreButton: React.FC<Props> = ({comment, toggleEdit}) => {
|
||||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||||
const {member, admin} = useContext(AppContext);
|
const {member, admin} = useAppContext();
|
||||||
|
|
||||||
const toggleContextMenu = () => {
|
const toggleContextMenu = () => {
|
||||||
setIsContextMenuOpen(current => !current);
|
setIsContextMenuOpen(current => !current);
|
@ -1,14 +1,18 @@
|
|||||||
import AppContext from '../../../AppContext';
|
|
||||||
import React, {useContext} from 'react';
|
|
||||||
import {ReactComponent as ReplyIcon} from '../../../images/icons/reply.svg';
|
import {ReactComponent as ReplyIcon} from '../../../images/icons/reply.svg';
|
||||||
|
import {useAppContext} from '../../../AppContext';
|
||||||
|
|
||||||
function ReplyButton({disabled, isReplying, toggleReply}) {
|
type Props = {
|
||||||
const {member} = useContext(AppContext);
|
disabled?: boolean;
|
||||||
|
isReplying: boolean;
|
||||||
|
toggleReply: () => void;
|
||||||
|
};
|
||||||
|
const ReplyButton: React.FC<Props> = ({disabled, isReplying, toggleReply}) => {
|
||||||
|
const {member} = useAppContext();
|
||||||
|
|
||||||
return member ?
|
return member ?
|
||||||
(<button className={`duration-50 group flex items-center font-sans text-sm outline-0 transition-all ease-linear ${isReplying ? 'text-[rgba(0,0,0,0.9)] dark:text-[rgba(255,255,255,0.9)]' : 'text-[rgba(0,0,0,0.5)] hover:text-[rgba(0,0,0,0.75)] dark:text-[rgba(255,255,255,0.5)] dark:hover:text-[rgba(255,255,255,0.25)]'}`} data-testid="reply-button" disabled={!!disabled} type="button" onClick={toggleReply}>
|
(<button className={`duration-50 group flex items-center font-sans text-sm outline-0 transition-all ease-linear ${isReplying ? 'text-[rgba(0,0,0,0.9)] dark:text-[rgba(255,255,255,0.9)]' : 'text-[rgba(0,0,0,0.5)] hover:text-[rgba(0,0,0,0.75)] dark:text-[rgba(255,255,255,0.5)] dark:hover:text-[rgba(255,255,255,0.25)]'}`} data-testid="reply-button" disabled={!!disabled} type="button" onClick={toggleReply}>
|
||||||
<ReplyIcon className={`mr-[6px] ${isReplying ? 'fill-[rgba(0,0,0,0.9)] stroke-[rgba(0,0,0,0.9)] dark:fill-[rgba(255,255,255,0.9)] dark:stroke-[rgba(255,255,255,0.9)]' : 'stroke-[rgba(0,0,0,0.5)] group-hover:stroke-[rgba(0,0,0,0.75)] dark:stroke-[rgba(255,255,255,0.5)] dark:group-hover:stroke-[rgba(255,255,255,0.25)]'} duration-50 transition ease-linear`} />Reply
|
<ReplyIcon className={`mr-[6px] ${isReplying ? 'fill-[rgba(0,0,0,0.9)] stroke-[rgba(0,0,0,0.9)] dark:fill-[rgba(255,255,255,0.9)] dark:stroke-[rgba(255,255,255,0.9)]' : 'stroke-[rgba(0,0,0,0.5)] group-hover:stroke-[rgba(0,0,0,0.75)] dark:stroke-[rgba(255,255,255,0.5)] dark:group-hover:stroke-[rgba(255,255,255,0.25)]'} duration-50 transition ease-linear`} />Reply
|
||||||
</button>) : null;
|
</button>) : null;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ReplyButton;
|
export default ReplyButton;
|
@ -1,8 +1,11 @@
|
|||||||
import AppContext from '../../../AppContext';
|
import {Comment, useAppContext} from '../../../AppContext';
|
||||||
import React, {useContext} from 'react';
|
|
||||||
|
|
||||||
const AdminContextMenu = ({comment, close}) => {
|
type Props = {
|
||||||
const {dispatchAction} = useContext(AppContext);
|
comment: Comment;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
const AdminContextMenu: React.FC<Props> = ({comment, close}) => {
|
||||||
|
const {dispatchAction} = useAppContext();
|
||||||
|
|
||||||
const hideComment = () => {
|
const hideComment = () => {
|
||||||
dispatchAction('hideComment', comment);
|
dispatchAction('hideComment', comment);
|
||||||
@ -19,11 +22,11 @@ const AdminContextMenu = ({comment, close}) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{
|
{
|
||||||
isHidden ?
|
isHidden ?
|
||||||
<button className="w-full text-left text-[14px]" type="button" onClick={showComment}>
|
<button className="w-full text-left text-[14px]" type="button" onClick={showComment}>
|
||||||
<span>Show </span><span className="hidden sm:inline">comment</span>
|
<span>Show </span><span className="hidden sm:inline">comment</span>
|
||||||
</button>
|
</button>
|
||||||
:
|
:
|
||||||
<button className="w-full text-left text-[14px]" type="button" onClick={hideComment}>
|
<button className="w-full text-left text-[14px]" type="button" onClick={hideComment}>
|
||||||
<span>Hide </span><span className="hidden sm:inline">comment</span>
|
<span>Hide </span><span className="hidden sm:inline">comment</span>
|
||||||
</button>
|
</button>
|
@ -1,10 +1,15 @@
|
|||||||
import AppContext from '../../../AppContext';
|
import React from 'react';
|
||||||
import React, {useContext} from 'react';
|
import {Comment, useAppContext} from '../../../AppContext';
|
||||||
|
|
||||||
const AuthorContextMenu = ({comment, close, toggleEdit}) => {
|
type Props = {
|
||||||
const {dispatchAction} = useContext(AppContext);
|
comment: Comment;
|
||||||
|
close: () => void;
|
||||||
|
toggleEdit: () => void;
|
||||||
|
};
|
||||||
|
const AuthorContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
|
||||||
|
const {dispatchAction} = useAppContext();
|
||||||
|
|
||||||
const deleteComment = (event) => {
|
const deleteComment = () => {
|
||||||
dispatchAction('deleteComment', comment);
|
dispatchAction('deleteComment', comment);
|
||||||
close();
|
close();
|
||||||
};
|
};
|
@ -1,14 +1,19 @@
|
|||||||
import AdminContextMenu from './AdminContextMenu';
|
import AdminContextMenu from './AdminContextMenu';
|
||||||
import AppContext from '../../../AppContext';
|
|
||||||
import AuthorContextMenu from './AuthorContextMenu';
|
import AuthorContextMenu from './AuthorContextMenu';
|
||||||
import NotAuthorContextMenu from './NotAuthorContextMenu';
|
import NotAuthorContextMenu from './NotAuthorContextMenu';
|
||||||
import React, {useContext, useEffect, useRef} from 'react';
|
import {Comment, useAppContext} from '../../../AppContext';
|
||||||
|
import {useEffect, useRef} from 'react';
|
||||||
|
|
||||||
const CommentContextMenu = ({comment, close, toggleEdit}) => {
|
type Props = {
|
||||||
const {member, admin} = useContext(AppContext);
|
comment: Comment;
|
||||||
|
close: () => void;
|
||||||
|
toggleEdit: () => void;
|
||||||
|
};
|
||||||
|
const CommentContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
|
||||||
|
const {member, admin} = useAppContext();
|
||||||
const isAuthor = member && comment.member?.uuid === member?.uuid;
|
const isAuthor = member && comment.member?.uuid === member?.uuid;
|
||||||
const isAdmin = !!admin;
|
const isAdmin = !!admin;
|
||||||
const element = useRef();
|
const element = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = () => {
|
const listener = () => {
|
||||||
@ -24,15 +29,15 @@ const CommentContextMenu = ({comment, close, toggleEdit}) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('click', listener, {passive: true});
|
window.removeEventListener('click', listener, {passive: true} as any);
|
||||||
if (el && el !== window) {
|
if (el && el !== window) {
|
||||||
el.removeEventListener('click', listener, {passive: true});
|
el.removeEventListener('click', listener, {passive: true} as any);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [close]);
|
}, [close]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (event) => {
|
const listener = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
@ -42,12 +47,12 @@ const CommentContextMenu = ({comment, close, toggleEdit}) => {
|
|||||||
window.addEventListener('keydown', listener, {passive: true});
|
window.addEventListener('keydown', listener, {passive: true});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', listener, {passive: true});
|
window.removeEventListener('keydown', listener, {passive: true} as any);
|
||||||
};
|
};
|
||||||
}, [close]);
|
}, [close]);
|
||||||
|
|
||||||
// Prevent closing the context menu when clicking inside of it
|
// Prevent closing the context menu when clicking inside of it
|
||||||
const stopPropagation = (event) => {
|
const stopPropagation = (event: React.SyntheticEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
@ -1,8 +1,12 @@
|
|||||||
import AppContext from '../../../AppContext';
|
import React from 'react';
|
||||||
import React, {useContext} from 'react';
|
import {useAppContext} from '../../../AppContext';
|
||||||
|
|
||||||
const NotAuthorContextMenu = ({comment, close}) => {
|
type Props = {
|
||||||
const {dispatchAction} = useContext(AppContext);
|
comment: Comment;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
const NotAuthorContextMenu: React.FC<Props> = ({comment, close}) => {
|
||||||
|
const {dispatchAction} = useAppContext();
|
||||||
|
|
||||||
const openModal = () => {
|
const openModal = () => {
|
||||||
dispatchAction('openPopup', {
|
dispatchAction('openPopup', {
|
@ -1,15 +1,21 @@
|
|||||||
import AppContext from '../../../AppContext';
|
|
||||||
import SecundaryForm from './SecundaryForm';
|
import SecundaryForm from './SecundaryForm';
|
||||||
import {default as React, useCallback, useContext, useEffect} from 'react';
|
import {Comment, useAppContext} from '../../../AppContext';
|
||||||
import {getEditorConfig} from '../../../utils/editor';
|
import {getEditorConfig} from '../../../utils/editor';
|
||||||
|
import {useCallback, useEffect} from 'react';
|
||||||
import {useEditor} from '@tiptap/react';
|
import {useEditor} from '@tiptap/react';
|
||||||
|
|
||||||
const EditForm = ({comment, parent, close}) => {
|
type Props = {
|
||||||
const {dispatchAction} = useContext(AppContext);
|
comment: Comment;
|
||||||
|
parent?: Comment;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditForm: React.FC<Props> = ({comment, parent, close}) => {
|
||||||
|
const {dispatchAction} = useAppContext();
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
placeholder: 'Edit this comment',
|
placeholder: 'Edit this comment',
|
||||||
// warning: we cannot use autofocus on the edit field, because that sets
|
// warning: we cannot use autofocus on the edit field, because that sets
|
||||||
// the cursor position at the beginning of the text field instead of the end
|
// the cursor position at the beginning of the text field instead of the end
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
content: comment.html
|
content: comment.html
|
@ -1,13 +1,27 @@
|
|||||||
import AppContext from '../../../AppContext';
|
import React from 'react';
|
||||||
import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
|
|
||||||
import {Avatar} from '../Avatar';
|
import {Avatar} from '../Avatar';
|
||||||
|
import {Comment, useAppContext} from '../../../AppContext';
|
||||||
import {ReactComponent as EditIcon} from '../../../images/icons/edit.svg';
|
import {ReactComponent as EditIcon} from '../../../images/icons/edit.svg';
|
||||||
import {EditorContent} from '@tiptap/react';
|
import {Editor, EditorContent} from '@tiptap/react';
|
||||||
import {ReactComponent as SpinnerIcon} from '../../../images/icons/spinner.svg';
|
import {ReactComponent as SpinnerIcon} from '../../../images/icons/spinner.svg';
|
||||||
import {Transition} from '@headlessui/react';
|
import {Transition} from '@headlessui/react';
|
||||||
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
import {usePopupOpen} from '../../../utils/hooks';
|
import {usePopupOpen} from '../../../utils/hooks';
|
||||||
|
|
||||||
const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, editor, submitText, submitSize}) => {
|
type Progress = 'default' | 'sending' | 'sent' | 'error'
|
||||||
|
export type SubmitSize = 'small' | 'medium' | 'large';
|
||||||
|
type FormEditorProps = {
|
||||||
|
submit: (data: {html: string}) => Promise<void>;
|
||||||
|
progress: Progress;
|
||||||
|
setProgress: (progress: Progress) => void;
|
||||||
|
close?: () => void;
|
||||||
|
reduced?: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
editor: Editor | null;
|
||||||
|
submitText: JSX.Element | null;
|
||||||
|
submitSize: SubmitSize;
|
||||||
|
};
|
||||||
|
const FormEditor: React.FC<FormEditorProps> = ({submit, progress, setProgress, close, reduced, isOpen, editor, submitText, submitSize}) => {
|
||||||
let buttonIcon = null;
|
let buttonIcon = null;
|
||||||
|
|
||||||
if (progress === 'sending') {
|
if (progress === 'sending') {
|
||||||
@ -23,7 +37,7 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
|
|||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const submitForm = useCallback(async () => {
|
const submitForm = useCallback(async () => {
|
||||||
if (editor.isEmpty) {
|
if (!editor || editor.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +66,7 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Add some basic keyboard shortcuts
|
// Add some basic keyboard shortcuts
|
||||||
// ESC to blur the editor
|
// ESC to blur the editor
|
||||||
const keyDownListener = (event) => {
|
const keyDownListener = (event: KeyboardEvent) => {
|
||||||
if (event.metaKey || event.ctrlKey) {
|
if (event.metaKey || event.ctrlKey) {
|
||||||
// CMD on MacOS or CTRL
|
// CMD on MacOS or CTRL
|
||||||
|
|
||||||
@ -83,7 +97,7 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
|
|||||||
window.addEventListener('keydown', keyDownListener, {passive: true});
|
window.addEventListener('keydown', keyDownListener, {passive: true});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', keyDownListener, {passive: true});
|
window.removeEventListener('keydown', keyDownListener, {passive: true} as any);
|
||||||
};
|
};
|
||||||
}, [editor, close, submitForm]);
|
}, [editor, close, submitForm]);
|
||||||
|
|
||||||
@ -115,7 +129,15 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FormHeader = ({show, name, expertise, editName, editExpertise}) => {
|
type FormHeaderProps = {
|
||||||
|
show: boolean;
|
||||||
|
name: string | null;
|
||||||
|
expertise: string | null;
|
||||||
|
editName: () => void;
|
||||||
|
editExpertise: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormHeader: React.FC<FormHeaderProps> = ({show, name, expertise, editName, editExpertise}) => {
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
enter="transition duration-500 delay-100 ease-in-out"
|
enter="transition duration-500 delay-100 ease-in-out"
|
||||||
@ -147,10 +169,21 @@ const FormHeader = ({show, name, expertise, editName, editExpertise}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Form = ({comment, submit, submitText, submitSize, close, editor, reduced, isOpen}) => {
|
type FormProps = {
|
||||||
const {member, dispatchAction} = useContext(AppContext);
|
comment?: Comment;
|
||||||
|
submit: (data: {html: string}) => Promise<void>;
|
||||||
|
submitText: JSX.Element;
|
||||||
|
submitSize: SubmitSize;
|
||||||
|
close?: () => void;
|
||||||
|
editor: Editor | null;
|
||||||
|
reduced: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, close, editor, reduced, isOpen}) => {
|
||||||
|
const {member, dispatchAction} = useAppContext();
|
||||||
const isAskingDetails = usePopupOpen('addDetailsPopup');
|
const isAskingDetails = usePopupOpen('addDetailsPopup');
|
||||||
const [progress, setProgress] = useState('default');
|
const [progress, setProgress] = useState<Progress>('default');
|
||||||
const formEl = useRef(null);
|
const formEl = useRef(null);
|
||||||
|
|
||||||
const memberName = member?.name ?? comment?.member?.name;
|
const memberName = member?.name ?? comment?.member?.name;
|
||||||
@ -161,7 +194,7 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
|
|||||||
isOpen = true;
|
isOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preventIfFocused = (event) => {
|
const preventIfFocused = (event: React.SyntheticEvent) => {
|
||||||
if (editor?.isFocused) {
|
if (editor?.isFocused) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@ -174,8 +207,7 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
|
|||||||
dispatchAction('openPopup', {
|
dispatchAction('openPopup', {
|
||||||
type: 'addDetailsPopup',
|
type: 'addDetailsPopup',
|
||||||
expertiseAutofocus: options.expertiseAutofocus ?? false,
|
expertiseAutofocus: options.expertiseAutofocus ?? false,
|
||||||
// WIP
|
callback: function (succeeded: boolean) {
|
||||||
callback: function (succeeded) {
|
|
||||||
if (!editor || !formEl.current) {
|
if (!editor || !formEl.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -201,6 +233,10 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
|
|||||||
}, [openEditDetails]);
|
}, [openEditDetails]);
|
||||||
|
|
||||||
const focusEditor = useCallback(() => {
|
const focusEditor = useCallback(() => {
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (editor.isFocused) {
|
if (editor.isFocused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -230,8 +266,8 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
|
|||||||
<FormEditor close={close} editor={editor} isOpen={isOpen} progress={progress} reduced={reduced} setProgress={setProgress} submit={submit} submitSize={submitSize} submitText={submitText} />
|
<FormEditor close={close} editor={editor} isOpen={isOpen} progress={progress} reduced={reduced} setProgress={setProgress} submit={submit} submitSize={submitSize} submitText={submitText} />
|
||||||
</div>
|
</div>
|
||||||
<div className='absolute left-0 top-1 flex h-12 w-full items-center justify-start'>
|
<div className='absolute left-0 top-1 flex h-12 w-full items-center justify-start'>
|
||||||
<div className="mr-3 grow-0">
|
<div className="pointer-events-none mr-3 grow-0">
|
||||||
<Avatar className="pointer-events-none" comment={comment} />
|
<Avatar comment={comment} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grow-1 w-full">
|
<div className="grow-1 w-full">
|
||||||
<FormHeader editExpertise={editExpertise} editName={editName} expertise={memberExpertise} name={memberName} show={isOpen} />
|
<FormHeader editExpertise={editExpertise} editName={editName} expertise={memberExpertise} name={memberName} show={isOpen} />
|
@ -1,12 +1,15 @@
|
|||||||
import AppContext from '../../../AppContext';
|
|
||||||
import Form from './Form';
|
import Form from './Form';
|
||||||
import React, {useCallback, useContext, useEffect, useRef} from 'react';
|
import React, {useCallback, useEffect, useRef} from 'react';
|
||||||
import {getEditorConfig} from '../../../utils/editor';
|
import {getEditorConfig} from '../../../utils/editor';
|
||||||
import {scrollToElement} from '../../../utils/helpers';
|
import {scrollToElement} from '../../../utils/helpers';
|
||||||
|
import {useAppContext} from '../../../AppContext';
|
||||||
import {useEditor} from '@tiptap/react';
|
import {useEditor} from '@tiptap/react';
|
||||||
|
|
||||||
const MainForm = ({commentsCount}) => {
|
type Props = {
|
||||||
const {postId, dispatchAction} = useContext(AppContext);
|
commentsCount: number
|
||||||
|
};
|
||||||
|
const MainForm: React.FC<Props> = ({commentsCount}) => {
|
||||||
|
const {postId, dispatchAction} = useAppContext();
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
placeholder: (commentsCount === 0 ? 'Start the conversation' : 'Join the discussion'),
|
placeholder: (commentsCount === 0 ? 'Start the conversation' : 'Join the discussion'),
|
||||||
@ -36,7 +39,7 @@ const MainForm = ({commentsCount}) => {
|
|||||||
|
|
||||||
// Add some basic keyboard shortcuts
|
// Add some basic keyboard shortcuts
|
||||||
// ESC to blur the editor
|
// ESC to blur the editor
|
||||||
const keyDownListener = (event) => {
|
const keyDownListener = (event: KeyboardEvent) => {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -47,15 +50,15 @@ const MainForm = ({commentsCount}) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let focusedElement = document.activeElement;
|
let focusedElement = document.activeElement as HTMLElement | null;
|
||||||
while (focusedElement && focusedElement.tagName === 'IFRAME') {
|
while (focusedElement && focusedElement.tagName === 'IFRAME') {
|
||||||
if (!focusedElement.contentDocument) {
|
if (!(focusedElement as HTMLIFrameElement).contentDocument) {
|
||||||
// CORS issue
|
// CORS issue
|
||||||
// disable the C shortcut when we have a focused external iframe
|
// disable the C shortcut when we have a focused external iframe
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
focusedElement = focusedElement.contentDocument.activeElement;
|
focusedElement = ((focusedElement as HTMLIFrameElement).contentDocument?.activeElement ?? null) as HTMLElement | null;
|
||||||
}
|
}
|
||||||
const hasInputFocused = focusedElement && (focusedElement.tagName === 'INPUT' || focusedElement.tagName === 'TEXTAREA' || focusedElement.tagName === 'IFRAME' || focusedElement.contentEditable === 'true');
|
const hasInputFocused = focusedElement && (focusedElement.tagName === 'INPUT' || focusedElement.tagName === 'TEXTAREA' || focusedElement.tagName === 'IFRAME' || focusedElement.contentEditable === 'true');
|
||||||
|
|
||||||
@ -74,7 +77,7 @@ const MainForm = ({commentsCount}) => {
|
|||||||
window.addEventListener('keydown', keyDownListener, {passive: true});
|
window.addEventListener('keydown', keyDownListener, {passive: true});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', keyDownListener, {passive: true});
|
window.removeEventListener('keydown', keyDownListener, {passive: true} as any);
|
||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
@ -1,14 +1,18 @@
|
|||||||
import AppContext from '../../../AppContext';
|
|
||||||
import SecundaryForm from './SecundaryForm';
|
import SecundaryForm from './SecundaryForm';
|
||||||
import {default as React, useCallback, useContext} from 'react';
|
import {Comment, useAppContext} from '../../../AppContext';
|
||||||
import {getEditorConfig} from '../../../utils/editor';
|
import {getEditorConfig} from '../../../utils/editor';
|
||||||
import {scrollToElement} from '../../../utils/helpers';
|
import {scrollToElement} from '../../../utils/helpers';
|
||||||
|
import {useCallback} from 'react';
|
||||||
import {useEditor} from '@tiptap/react';
|
import {useEditor} from '@tiptap/react';
|
||||||
import {useRefCallback} from '../../../utils/hooks';
|
import {useRefCallback} from '../../../utils/hooks';
|
||||||
|
|
||||||
const ReplyForm = ({parent, close}) => {
|
type Props = {
|
||||||
const {postId, dispatchAction} = useContext(AppContext);
|
parent: Comment;
|
||||||
const [, setForm] = useRefCallback(scrollToElement);
|
close: () => void;
|
||||||
|
}
|
||||||
|
const ReplyForm: React.FC<Props> = ({parent, close}) => {
|
||||||
|
const {postId, dispatchAction} = useAppContext();
|
||||||
|
const [, setForm] = useRefCallback<HTMLDivElement>(scrollToElement);
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
placeholder: 'Reply to comment',
|
placeholder: 'Reply to comment',
|
||||||
@ -18,7 +22,7 @@ const ReplyForm = ({parent, close}) => {
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
...getEditorConfig(config)
|
...getEditorConfig(config)
|
||||||
});
|
});
|
||||||
|
|
||||||
const submit = useCallback(async ({html}) => {
|
const submit = useCallback(async ({html}) => {
|
||||||
// Send comment to server
|
// Send comment to server
|
||||||
await dispatchAction('addReply', {
|
await dispatchAction('addReply', {
|
@ -1,18 +1,27 @@
|
|||||||
import AppContext from '../../../AppContext';
|
import Form, {SubmitSize} from './Form';
|
||||||
import Form from './Form';
|
import {Editor} from '@tiptap/react';
|
||||||
import React, {useContext, useEffect} from 'react';
|
|
||||||
import {isMobile} from '../../../utils/helpers';
|
import {isMobile} from '../../../utils/helpers';
|
||||||
|
import {useAppContext} from '../../../AppContext';
|
||||||
|
import {useEffect} from 'react';
|
||||||
import {useSecondUpdate} from '../../../utils/hooks';
|
import {useSecondUpdate} from '../../../utils/hooks';
|
||||||
|
|
||||||
const SecundaryForm = ({editor, submit, close, closeIfNotChanged, submitText, submitSize}) => {
|
type Props = {
|
||||||
const {dispatchAction, secundaryFormCount} = useContext(AppContext);
|
editor: Editor;
|
||||||
|
submit: (data: {html: string}) => Promise<void>;
|
||||||
|
close: () => void;
|
||||||
|
closeIfNotChanged: () => void;
|
||||||
|
submitText: JSX.Element;
|
||||||
|
submitSize: SubmitSize
|
||||||
|
};
|
||||||
|
const SecundaryForm: React.FC<Props> = ({editor, submit, close, closeIfNotChanged, submitText, submitSize}) => {
|
||||||
|
const {dispatchAction, secundaryFormCount} = useAppContext();
|
||||||
|
|
||||||
// Keep track of the amount of open forms
|
// Keep track of the amount of open forms
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatchAction('increaseSecundaryFormCount');
|
dispatchAction('increaseSecundaryFormCount', {});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
dispatchAction('decreaseSecundaryFormCount');
|
dispatchAction('decreaseSecundaryFormCount', {});
|
||||||
};
|
};
|
||||||
}, [dispatchAction]);
|
}, [dispatchAction]);
|
||||||
|
|
@ -1,13 +1,17 @@
|
|||||||
import AppContext from '../../AppContext';
|
|
||||||
import CloseButton from './CloseButton';
|
import CloseButton from './CloseButton';
|
||||||
import React, {useContext, useEffect, useRef, useState} from 'react';
|
|
||||||
import {Transition} from '@headlessui/react';
|
import {Transition} from '@headlessui/react';
|
||||||
import {isMobile} from '../../utils/helpers';
|
import {isMobile} from '../../utils/helpers';
|
||||||
|
import {useAppContext} from '../../AppContext';
|
||||||
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
|
||||||
const AddDetailsPopup = (props) => {
|
type Props = {
|
||||||
const inputNameRef = useRef(null);
|
callback: (succeeded: boolean) => void,
|
||||||
const inputExpertiseRef = useRef(null);
|
expertiseAutofocus?: boolean
|
||||||
const {dispatchAction, member, accentColor} = useContext(AppContext);
|
};
|
||||||
|
const AddDetailsPopup = (props: Props) => {
|
||||||
|
const inputNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const inputExpertiseRef = useRef<HTMLInputElement>(null);
|
||||||
|
const {dispatchAction, member, accentColor} = useAppContext();
|
||||||
|
|
||||||
const [name, setName] = useState(member.name ?? '');
|
const [name, setName] = useState(member.name ?? '');
|
||||||
const [expertise, setExpertise] = useState(member.expertise ?? '');
|
const [expertise, setExpertise] = useState(member.expertise ?? '');
|
||||||
@ -21,12 +25,12 @@ const AddDetailsPopup = (props) => {
|
|||||||
|
|
||||||
const [error, setError] = useState({name: '', expertise: ''});
|
const [error, setError] = useState({name: '', expertise: ''});
|
||||||
|
|
||||||
const stopPropagation = (event) => {
|
const stopPropagation = (event: Event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = (succeeded) => {
|
const close = (succeeded: boolean) => {
|
||||||
dispatchAction('closePopup');
|
dispatchAction('closePopup', {});
|
||||||
props.callback(succeeded);
|
props.callback(succeeded);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -38,7 +42,7 @@ const AddDetailsPopup = (props) => {
|
|||||||
});
|
});
|
||||||
close(true);
|
close(true);
|
||||||
} else {
|
} else {
|
||||||
setError({name: 'Enter your name'});
|
setError({name: 'Enter your name', expertise: ''});
|
||||||
setName('');
|
setName('');
|
||||||
inputNameRef.current?.focus();
|
inputNameRef.current?.focus();
|
||||||
}
|
}
|
||||||
@ -61,8 +65,8 @@ const AddDetailsPopup = (props) => {
|
|||||||
}
|
}
|
||||||
}, [inputNameRef, inputExpertiseRef, props.expertiseAutofocus]);
|
}, [inputNameRef, inputExpertiseRef, props.expertiseAutofocus]);
|
||||||
|
|
||||||
const renderExampleProfiles = (index) => {
|
const renderExampleProfiles = () => {
|
||||||
const renderEl = (profile) => {
|
const renderEl = (profile: {name: string, avatar: string, expertise: string}) => {
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
key={profile.name}
|
key={profile.name}
|
||||||
@ -138,18 +142,19 @@ const AddDetailsPopup = (props) => {
|
|||||||
ref={inputNameRef}
|
ref={inputNameRef}
|
||||||
className={`flex h-[42px] w-full items-center rounded border border-neutral-200 px-3 font-sans text-[16px] outline-0 transition-[border-color] duration-200 focus:border-neutral-300 ${error.name && 'border-red-500 focus:border-red-500'}`}
|
className={`flex h-[42px] w-full items-center rounded border border-neutral-200 px-3 font-sans text-[16px] outline-0 transition-[border-color] duration-200 focus:border-neutral-300 ${error.name && 'border-red-500 focus:border-red-500'}`}
|
||||||
id="comments-name"
|
id="comments-name"
|
||||||
maxLength="64"
|
maxLength={64}
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Jamie Larson"
|
placeholder="Jamie Larson"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setName(e.target.value);
|
setName(e.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
setName(e.target.value);
|
setName(e.currentTarget.value);
|
||||||
submit();
|
// eslint-disable-next-line no-console
|
||||||
|
submit().catch(console.error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -167,14 +172,15 @@ const AddDetailsPopup = (props) => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={expertise}
|
value={expertise}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
let expertiseText = e.target.value;
|
let expertiseText = e.currentTarget.value;
|
||||||
setExpertiseCharsLeft(maxExpertiseChars - expertiseText.length);
|
setExpertiseCharsLeft(maxExpertiseChars - expertiseText.length);
|
||||||
setExpertise(expertiseText);
|
setExpertise(expertiseText);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
setExpertise(e.target.value);
|
setExpertise(e.currentTarget.value);
|
||||||
submit();
|
// eslint-disable-next-line no-console
|
||||||
|
submit().catch(console.error);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -182,7 +188,10 @@ const AddDetailsPopup = (props) => {
|
|||||||
className={`mt-10 flex h-[42px] w-full items-center justify-center rounded-md px-8 font-sans text-[15px] font-semibold text-white opacity-100 transition-opacity duration-200 ease-linear hover:opacity-90`}
|
className={`mt-10 flex h-[42px] w-full items-center justify-center rounded-md px-8 font-sans text-[15px] font-semibold text-white opacity-100 transition-opacity duration-200 ease-linear hover:opacity-90`}
|
||||||
style={{backgroundColor: accentColor ?? '#000000'}}
|
style={{backgroundColor: accentColor ?? '#000000'}}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={() => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
submit().catch(console.error);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
@ -1,9 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {ReactComponent as CloseIcon} from '../../images/icons/close.svg';
|
import {ReactComponent as CloseIcon} from '../../images/icons/close.svg';
|
||||||
|
|
||||||
const CloseButton = (props) => {
|
type Props = {
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
const CloseButton: React.FC<Props> = ({close}) => {
|
||||||
return (
|
return (
|
||||||
<button className="absolute right-6 top-6 opacity-20 transition-opacity duration-100 ease-out hover:opacity-40 sm:right-8 sm:top-10" type="button" onClick={props.close}>
|
<button className="absolute right-6 top-6 opacity-20 transition-opacity duration-100 ease-out hover:opacity-40 sm:right-8 sm:top-10" type="button" onClick={close}>
|
||||||
<CloseIcon className="h-[20px] w-[20px]" />
|
<CloseIcon className="h-[20px] w-[20px]" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
@ -1,21 +1,27 @@
|
|||||||
import AppContext from '../../AppContext';
|
|
||||||
import React, {useContext, useEffect} from 'react';
|
|
||||||
import {PopupFrame} from '../Frame';
|
import {PopupFrame} from '../Frame';
|
||||||
import {Transition} from '@headlessui/react';
|
import {Transition} from '@headlessui/react';
|
||||||
|
import {useAppContext} from '../../AppContext';
|
||||||
|
import {useEffect} from 'react';
|
||||||
|
|
||||||
const GenericPopup = ({show, children, title, callback}) => {
|
type Props = {
|
||||||
|
show: boolean;
|
||||||
|
title: string;
|
||||||
|
callback?: (result: boolean) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
const GenericPopup: React.FC<Props> = ({show, children, title, callback}) => {
|
||||||
// The modal will cover the whole screen, so while it is hidden, we need to disable pointer events
|
// The modal will cover the whole screen, so while it is hidden, we need to disable pointer events
|
||||||
const {dispatchAction} = useContext(AppContext);
|
const {dispatchAction} = useAppContext();
|
||||||
|
|
||||||
const close = (event) => {
|
const close = () => {
|
||||||
dispatchAction('closePopup');
|
dispatchAction('closePopup', {});
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback(false);
|
callback(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = (event) => {
|
const listener = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
@ -23,7 +29,7 @@ const GenericPopup = ({show, children, title, callback}) => {
|
|||||||
window.addEventListener('keydown', listener, {passive: true});
|
window.addEventListener('keydown', listener, {passive: true});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', listener, {passive: true});
|
window.removeEventListener('keydown', listener, {passive: true} as any);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -1,11 +1,12 @@
|
|||||||
import AppContext from '../../AppContext';
|
|
||||||
import CloseButton from './CloseButton';
|
import CloseButton from './CloseButton';
|
||||||
import React, {useContext, useState} from 'react';
|
import {Comment} from '../../AppContext';
|
||||||
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
|
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
|
||||||
import {ReactComponent as SuccessIcon} from '../../images/icons/success.svg';
|
import {ReactComponent as SuccessIcon} from '../../images/icons/success.svg';
|
||||||
|
import {useAppContext} from '../../AppContext';
|
||||||
|
import {useState} from 'react';
|
||||||
|
|
||||||
const ReportPopup = (props) => {
|
const ReportPopup = ({comment}: {comment: Comment}) => {
|
||||||
const {dispatchAction} = useContext(AppContext);
|
const {dispatchAction} = useAppContext();
|
||||||
const [progress, setProgress] = useState('default');
|
const [progress, setProgress] = useState('default');
|
||||||
|
|
||||||
let buttonColor = 'bg-red-600';
|
let buttonColor = 'bg-red-600';
|
||||||
@ -27,15 +28,15 @@ const ReportPopup = (props) => {
|
|||||||
buttonIcon = <SuccessIcon className="mr-2 h-[16px] w-[16px]" />;
|
buttonIcon = <SuccessIcon className="mr-2 h-[16px] w-[16px]" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopPropagation = (event) => {
|
const stopPropagation = (event: React.MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = (event) => {
|
const close = () => {
|
||||||
dispatchAction('closePopup');
|
dispatchAction('closePopup', {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = (event) => {
|
const submit = (event: React.MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
setProgress('sending');
|
setProgress('sending');
|
||||||
@ -43,7 +44,7 @@ const ReportPopup = (props) => {
|
|||||||
// purposely faking the timing of the report being sent for user feedback purposes
|
// purposely faking the timing of the report being sent for user feedback purposes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setProgress('sent');
|
setProgress('sent');
|
||||||
dispatchAction('reportComment', props.comment);
|
dispatchAction('reportComment', comment);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
close();
|
close();
|
||||||
@ -66,7 +67,7 @@ const ReportPopup = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
<button className="font-sans text-sm font-medium text-neutral-500 dark:text-neutral-400" type="button" onClick={close}>Cancel</button>
|
<button className="font-sans text-sm font-medium text-neutral-500 dark:text-neutral-400" type="button" onClick={close}>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<CloseButton close={() => close(false)} />
|
<CloseButton close={close} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
@ -3,34 +3,43 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import {ROOT_DIV_ID} from './utils/constants';
|
import {ROOT_DIV_ID} from './utils/constants';
|
||||||
|
|
||||||
function addRootDiv() {
|
function getScriptTag(): HTMLElement {
|
||||||
let scriptTag = document.currentScript;
|
let scriptTag = document.currentScript as HTMLElement | null;
|
||||||
|
|
||||||
if (!scriptTag && import.meta.env.DEV) {
|
if (!scriptTag && import.meta.env.DEV) {
|
||||||
// In development mode, use any script tag (because in ESM mode, document.currentScript is not set)
|
// In development mode, use any script tag (because in ESM mode, document.currentScript is not set)
|
||||||
scriptTag = document.querySelector('script[data-ghost-comments]');
|
scriptTag = document.querySelector('script[data-ghost-comments]');
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to inject the comment box at the same place as the script tag
|
if (!scriptTag) {
|
||||||
if (scriptTag) {
|
throw new Error('[Comments-UI] Cannot find current script tag');
|
||||||
const elem = document.createElement('div');
|
|
||||||
elem.id = ROOT_DIV_ID;
|
|
||||||
scriptTag.parentElement.insertBefore(elem, scriptTag);
|
|
||||||
} else if (import.meta.env.DEV) {
|
|
||||||
const elem = document.createElement('div');
|
|
||||||
elem.id = ROOT_DIV_ID;
|
|
||||||
document.body.appendChild(elem);
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn('[Comments] Comment box location was not found: could not load comments box.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return scriptTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSiteData() {
|
/**
|
||||||
|
* Returns a div to mount the React application into, creating it if necessary
|
||||||
|
*/
|
||||||
|
function getRootDiv(scriptTag: HTMLElement) {
|
||||||
|
if (scriptTag.previousElementSibling && scriptTag.previousElementSibling.id === ROOT_DIV_ID) {
|
||||||
|
return scriptTag.previousElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scriptTag.parentElement) {
|
||||||
|
throw new Error('[Comments-UI] Script tag does not have a parent element');
|
||||||
|
}
|
||||||
|
|
||||||
|
const elem = document.createElement('div');
|
||||||
|
elem.id = ROOT_DIV_ID;
|
||||||
|
scriptTag.parentElement.insertBefore(elem, scriptTag);
|
||||||
|
return elem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSiteData(scriptTag: HTMLElement) {
|
||||||
/**
|
/**
|
||||||
* @type {HTMLElement}
|
* @type {HTMLElement}
|
||||||
*/
|
*/
|
||||||
const scriptTag = document.querySelector('script[data-ghost-comments]');
|
|
||||||
let dataset = scriptTag?.dataset;
|
let dataset = scriptTag?.dataset;
|
||||||
|
|
||||||
if (!scriptTag && process.env.NODE_ENV === 'development') {
|
if (!scriptTag && process.env.NODE_ENV === 'development') {
|
||||||
@ -46,7 +55,7 @@ function getSiteData() {
|
|||||||
const adminUrl = dataset.admin;
|
const adminUrl = dataset.admin;
|
||||||
const postId = dataset.postId;
|
const postId = dataset.postId;
|
||||||
const colorScheme = dataset.colorScheme;
|
const colorScheme = dataset.colorScheme;
|
||||||
const avatarSaturation = dataset.avatarSaturation;
|
const avatarSaturation = dataset.avatarSaturation ? parseInt(dataset.avatarSaturation) : undefined;
|
||||||
const accentColor = dataset.accentColor;
|
const accentColor = dataset.accentColor;
|
||||||
const commentsEnabled = dataset.commentsEnabled;
|
const commentsEnabled = dataset.commentsEnabled;
|
||||||
const title = dataset.title === 'null' ? null : dataset.title;
|
const title = dataset.title === 'null' ? null : dataset.title;
|
||||||
@ -64,24 +73,22 @@ function handleTokenUrl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup({siteUrl}) {
|
|
||||||
addRootDiv();
|
|
||||||
handleTokenUrl();
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
const scriptTag = getScriptTag();
|
||||||
|
const root = getRootDiv(scriptTag);
|
||||||
|
|
||||||
// const customSiteUrl = getSiteUrl();
|
// const customSiteUrl = getSiteUrl();
|
||||||
const {siteUrl: customSiteUrl, ...siteData} = getSiteData();
|
const {siteUrl: customSiteUrl, ...siteData} = getSiteData(scriptTag);
|
||||||
const siteUrl = customSiteUrl || window.location.origin;
|
const siteUrl = customSiteUrl || window.location.origin;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setup({siteUrl});
|
handleTokenUrl();
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
{<App customSiteUrl={customSiteUrl} siteUrl={siteUrl} {...siteData} />}
|
{<App customSiteUrl={customSiteUrl} siteUrl={siteUrl} {...siteData} />}
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
document.getElementById(ROOT_DIV_ID)
|
root
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
@ -1,11 +0,0 @@
|
|||||||
import AddDetailsPopup from './components/popups/AddDetailsPopup';
|
|
||||||
import ReportPopup from './components/popups/ReportPopup';
|
|
||||||
|
|
||||||
/** List of all available pages in Comments-UI, mapped to their UI component
|
|
||||||
* Any new page added to comments-ui needs to be mapped here
|
|
||||||
*/
|
|
||||||
const Pages = {
|
|
||||||
addDetailsPopup: AddDetailsPopup,
|
|
||||||
reportPopup: ReportPopup
|
|
||||||
};
|
|
||||||
export default Pages;
|
|
25
apps/comments-ui/src/pages.ts
Normal file
25
apps/comments-ui/src/pages.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import AddDetailsPopup from './components/popups/AddDetailsPopup';
|
||||||
|
import React from 'react';
|
||||||
|
import ReportPopup from './components/popups/ReportPopup';
|
||||||
|
|
||||||
|
/** List of all available pages in Comments-UI, mapped to their UI component
|
||||||
|
* Any new page added to comments-ui needs to be mapped here
|
||||||
|
*/
|
||||||
|
export const Pages = {
|
||||||
|
addDetailsPopup: AddDetailsPopup,
|
||||||
|
reportPopup: ReportPopup
|
||||||
|
};
|
||||||
|
export type PageName = keyof typeof Pages;
|
||||||
|
|
||||||
|
type PageTypes = {
|
||||||
|
[name in PageName]: {
|
||||||
|
type: name,
|
||||||
|
/**
|
||||||
|
* Called when closing the popup
|
||||||
|
* @param succeeded False if normal cancel/close buttons are used
|
||||||
|
*/
|
||||||
|
callback?: (succeeded: boolean) => void,
|
||||||
|
} & React.ComponentProps<typeof Pages[name]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Page = PageTypes[keyof PageTypes]
|
@ -1,15 +0,0 @@
|
|||||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
|
||||||
// allows you to do things like:
|
|
||||||
// expect(element).toHaveTextContent(/react/i)
|
|
||||||
// learn more: https://github.com/testing-library/jest-dom
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
|
||||||
// TODO: remove this once we're switched `jest` to `vi` in code
|
|
||||||
// eslint-disable-next-line no-undef
|
|
||||||
globalThis.jest = vi;
|
|
||||||
|
|
||||||
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
|
||||||
observe: jest.fn(),
|
|
||||||
unobserve: jest.fn(),
|
|
||||||
disconnect: jest.fn()
|
|
||||||
}));
|
|
11
apps/comments-ui/src/setupTests.ts
Normal file
11
apps/comments-ui/src/setupTests.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn()
|
||||||
|
}));
|
6
apps/comments-ui/src/typings.d.ts
vendored
Normal file
6
apps/comments-ui/src/typings.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
declare module '*.svg' {
|
||||||
|
import React = require('react');
|
||||||
|
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
@ -1,279 +0,0 @@
|
|||||||
function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
|
|
||||||
const apiPath = 'members/api';
|
|
||||||
|
|
||||||
function endpointFor({type, resource, params = ''}) {
|
|
||||||
if (type === 'members') {
|
|
||||||
return `${siteUrl.replace(/\/$/, '')}/${apiPath}/${resource}/${params}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function contentEndpointFor({resource, params = ''}) {
|
|
||||||
if (apiUrl && apiKey) {
|
|
||||||
return `${apiUrl.replace(/\/$/, '')}/${resource}/?key=${apiKey}&limit=all${params}`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRequest({url, method = 'GET', headers = {}, credentials = undefined, body = undefined}) {
|
|
||||||
const options = {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
credentials,
|
|
||||||
body
|
|
||||||
};
|
|
||||||
return fetch(url, options);
|
|
||||||
}
|
|
||||||
const api = {};
|
|
||||||
|
|
||||||
api.site = {
|
|
||||||
settings() {
|
|
||||||
const url = contentEndpointFor({resource: 'settings'});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}).then(function (res) {
|
|
||||||
if (res.ok) {
|
|
||||||
return res.json();
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to fetch site data');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
api.member = {
|
|
||||||
identity() {
|
|
||||||
const url = endpointFor({type: 'members', resource: 'session'});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function (res) {
|
|
||||||
if (!res.ok || res.status === 204) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return res.text();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
sessionData() {
|
|
||||||
const url = endpointFor({type: 'members', resource: 'member'});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function (res) {
|
|
||||||
if (!res.ok || res.status === 204) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
update({name, expertise}) {
|
|
||||||
const url = endpointFor({type: 'members', resource: 'member'});
|
|
||||||
const body = {
|
|
||||||
name,
|
|
||||||
expertise
|
|
||||||
};
|
|
||||||
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
}).then(function (res) {
|
|
||||||
if (!res.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
// To fix pagination when we create new comments (or people post comments after you loaded the page, we need to only load comments creatd AFTER the page load)
|
|
||||||
let firstCommentsLoadedAt = null;
|
|
||||||
|
|
||||||
api.comments = {
|
|
||||||
async count({postId}) {
|
|
||||||
const params = postId ? `?ids=${postId}` : '';
|
|
||||||
const url = endpointFor({type: 'members', resource: `comments/counts`, params});
|
|
||||||
const response = await makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
|
|
||||||
return json[postId];
|
|
||||||
},
|
|
||||||
browse({page, postId}) {
|
|
||||||
firstCommentsLoadedAt = firstCommentsLoadedAt ?? new Date().toISOString();
|
|
||||||
|
|
||||||
const filter = encodeURIComponent(`post_id:${postId}+created_at:<=${firstCommentsLoadedAt}`);
|
|
||||||
const order = encodeURIComponent('created_at DESC, id DESC');
|
|
||||||
|
|
||||||
const url = endpointFor({type: 'members', resource: 'comments', params: `?limit=5&order=${order}&filter=${filter}&page=${page}`});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function (res) {
|
|
||||||
if (res.ok) {
|
|
||||||
return res.json();
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to fetch comments');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async replies({page, commentId, afterReplyId, limit}) {
|
|
||||||
const filter = encodeURIComponent(`id:>${afterReplyId}`);
|
|
||||||
const order = encodeURIComponent('created_at ASC, id ASC');
|
|
||||||
|
|
||||||
const url = endpointFor({type: 'members', resource: `comments/${commentId}/replies`, params: `?limit=${limit ?? 5}&order=${order}&filter=${filter}`});
|
|
||||||
const res = await makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
return res.json();
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to fetch replies');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
add({comment}) {
|
|
||||||
const body = {
|
|
||||||
comments: [comment]
|
|
||||||
};
|
|
||||||
const url = endpointFor({type: 'members', resource: 'comments'});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
}).then(function (res) {
|
|
||||||
if (res.ok) {
|
|
||||||
return res.json();
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to add comment');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
edit({comment}) {
|
|
||||||
const body = {
|
|
||||||
comments: [comment]
|
|
||||||
};
|
|
||||||
const url = endpointFor({type: 'members', resource: `comments/${comment.id}`});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
}).then(function (res) {
|
|
||||||
if (res.ok) {
|
|
||||||
return res.json();
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to edit comment');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
read(commentId) {
|
|
||||||
const url = endpointFor({type: 'members', resource: `comments/${commentId}`});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function (res) {
|
|
||||||
if (res.ok) {
|
|
||||||
return res.json();
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to read comment');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
like({comment}) {
|
|
||||||
const url = endpointFor({type: 'members', resource: `comments/${comment.id}/like`});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}).then(function (res) {
|
|
||||||
if (res.ok) {
|
|
||||||
return 'Success';
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to like comment');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
unlike({comment}) {
|
|
||||||
const body = {
|
|
||||||
comments: [comment]
|
|
||||||
};
|
|
||||||
const url = endpointFor({type: 'members', resource: `comments/${comment.id}/like`});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
}).then(function (res) {
|
|
||||||
if (res.ok) {
|
|
||||||
return 'Success';
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to unlike comment');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
report({comment}) {
|
|
||||||
const url = endpointFor({type: 'members', resource: `comments/${comment.id}/report`});
|
|
||||||
return makeRequest({
|
|
||||||
url,
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}).then(function (res) {
|
|
||||||
if (res.ok) {
|
|
||||||
return 'Success';
|
|
||||||
} else {
|
|
||||||
throw new Error('Failed to report comment');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
api.init = async () => {
|
|
||||||
let [member] = await Promise.all([
|
|
||||||
api.member.sessionData()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {member};
|
|
||||||
};
|
|
||||||
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default setupGhostApi;
|
|
@ -1,47 +1,48 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
import setupGhostApi from './api';
|
import setupGhostApi from './api';
|
||||||
|
|
||||||
test('should call counts endpoint', () => {
|
test('should call counts endpoint', () => {
|
||||||
jest.spyOn(window, 'fetch');
|
const spy = vi.spyOn(window, 'fetch');
|
||||||
window.fetch.mockResolvedValueOnce({
|
spy.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({success: true})
|
json: async () => ({success: true})
|
||||||
});
|
} as any);
|
||||||
|
|
||||||
const api = setupGhostApi({});
|
const api = setupGhostApi({siteUrl: 'http://localhost:3000', apiUrl: '', apiKey: ''});
|
||||||
|
|
||||||
api.comments.count({postId: null});
|
api.comments.count({postId: null});
|
||||||
|
|
||||||
expect(window.fetch).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(spy).toHaveBeenCalledWith(
|
||||||
'http://localhost:3000/members/api/comments/counts/',
|
'http://localhost:3000/members/api/comments/counts/',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: undefined
|
body: undefined
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call counts endpoint with postId query param', () => {
|
test('should call counts endpoint with postId query param', () => {
|
||||||
jest.spyOn(window, 'fetch');
|
const spy = vi.spyOn(window, 'fetch');
|
||||||
window.fetch.mockResolvedValueOnce({
|
spy.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({success: true})
|
json: async () => ({success: true})
|
||||||
});
|
} as any);
|
||||||
|
|
||||||
const api = setupGhostApi({});
|
const api = setupGhostApi({siteUrl: 'http://localhost:3000', apiUrl: '', apiKey: ''});
|
||||||
|
|
||||||
api.comments.count({postId: '123'});
|
api.comments.count({postId: '123'});
|
||||||
|
|
||||||
expect(window.fetch).toHaveBeenCalledTimes(1);
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
expect(window.fetch).toHaveBeenCalledWith(
|
expect(spy).toHaveBeenCalledWith(
|
||||||
'http://localhost:3000/members/api/comments/counts/?ids=123',
|
'http://localhost:3000/members/api/comments/counts/?ids=123',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
body: undefined
|
body: undefined
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
287
apps/comments-ui/src/utils/api.ts
Normal file
287
apps/comments-ui/src/utils/api.ts
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
import {AddComment, Comment} from '../AppContext';
|
||||||
|
|
||||||
|
function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {siteUrl: string, apiUrl: string, apiKey: string}) {
|
||||||
|
const apiPath = 'members/api';
|
||||||
|
|
||||||
|
function endpointFor({type, resource, params = ''}: {type: string, resource: string, params?: string}) {
|
||||||
|
if (type === 'members') {
|
||||||
|
return `${siteUrl.replace(/\/$/, '')}/${apiPath}/${resource}/${params}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentEndpointFor({resource, params = ''}: {resource: string, params?: string}) {
|
||||||
|
if (apiUrl && apiKey) {
|
||||||
|
return `${apiUrl.replace(/\/$/, '')}/${resource}/?key=${apiKey}&limit=all${params}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest({url, method = 'GET', headers = {}, credentials = undefined, body = undefined}: {url: string, method?: string, headers?: any, credentials?: any, body?: any}) {
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
credentials,
|
||||||
|
body
|
||||||
|
};
|
||||||
|
return fetch(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// To fix pagination when we create new comments (or people post comments after you loaded the page, we need to only load comments creatd AFTER the page load)
|
||||||
|
let firstCommentsLoadedAt: null | string = null;
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
site: {
|
||||||
|
settings() {
|
||||||
|
const url = contentEndpointFor({resource: 'settings'});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to fetch site data');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
identity() {
|
||||||
|
const url = endpointFor({type: 'members', resource: 'session'});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok || res.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
sessionData() {
|
||||||
|
const url = endpointFor({type: 'members', resource: 'member'});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok || res.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
update({name, expertise}: {name?: string, expertise?: string}) {
|
||||||
|
const url = endpointFor({type: 'members', resource: 'member'});
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
expertise
|
||||||
|
};
|
||||||
|
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
async count({postId}: {postId: string | null}) {
|
||||||
|
const params = postId ? `?ids=${postId}` : '';
|
||||||
|
const url = endpointFor({type: 'members', resource: `comments/counts`, params});
|
||||||
|
const response = await makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
if (postId) {
|
||||||
|
return json[postId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
},
|
||||||
|
browse({page, postId}: {page: number, postId: string}) {
|
||||||
|
firstCommentsLoadedAt = firstCommentsLoadedAt ?? new Date().toISOString();
|
||||||
|
|
||||||
|
const filter = encodeURIComponent(`post_id:${postId}+created_at:<=${firstCommentsLoadedAt}`);
|
||||||
|
const order = encodeURIComponent('created_at DESC, id DESC');
|
||||||
|
|
||||||
|
const url = endpointFor({type: 'members', resource: 'comments', params: `?limit=5&order=${order}&filter=${filter}&page=${page}`});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to fetch comments');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async replies({commentId, afterReplyId, limit}: {commentId: string; afterReplyId: string; limit?: number | 'all'}) {
|
||||||
|
const filter = encodeURIComponent(`id:>${afterReplyId}`);
|
||||||
|
const order = encodeURIComponent('created_at ASC, id ASC');
|
||||||
|
|
||||||
|
const url = endpointFor({type: 'members', resource: `comments/${commentId}/replies`, params: `?limit=${limit ?? 5}&order=${order}&filter=${filter}`});
|
||||||
|
const res = await makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to fetch replies');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
add({comment}: {comment: AddComment}) {
|
||||||
|
const body = {
|
||||||
|
comments: [comment]
|
||||||
|
};
|
||||||
|
const url = endpointFor({type: 'members', resource: 'comments'});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to add comment');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
edit({comment}: {comment: Partial<Comment> & {id: string}}) {
|
||||||
|
const body = {
|
||||||
|
comments: [comment]
|
||||||
|
};
|
||||||
|
const url = endpointFor({type: 'members', resource: `comments/${comment.id}`});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to edit comment');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
read(commentId: string) {
|
||||||
|
const url = endpointFor({type: 'members', resource: `comments/${commentId}`});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json();
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to read comment');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
like({comment}: {comment: {id: string}}) {
|
||||||
|
const url = endpointFor({type: 'members', resource: `comments/${comment.id}/like`});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
return 'Success';
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to like comment');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
unlike({comment}: {comment: {id: string}}) {
|
||||||
|
const body = {
|
||||||
|
comments: [comment]
|
||||||
|
};
|
||||||
|
const url = endpointFor({type: 'members', resource: `comments/${comment.id}/like`});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
return 'Success';
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to unlike comment');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
report({comment}: {comment: {id: string}}) {
|
||||||
|
const url = endpointFor({type: 'members', resource: `comments/${comment.id}/report`});
|
||||||
|
return makeRequest({
|
||||||
|
url,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
return 'Success';
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to report comment');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init: (() => {}) as () => Promise<{ member: any; }>
|
||||||
|
};
|
||||||
|
|
||||||
|
api.init = async () => {
|
||||||
|
let [member] = await Promise.all([
|
||||||
|
api.member.sessionData()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {member};
|
||||||
|
};
|
||||||
|
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default setupGhostApi;
|
||||||
|
export type GhostApi = ReturnType<typeof setupGhostApi>;
|
@ -14,7 +14,7 @@ const modeFns = {
|
|||||||
test: isTestMode
|
test: isTestMode
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hasMode = (modes = [], options = {}) => {
|
export const hasMode = (modes: ('dev' | 'test')[] = [], options: {customSiteUrl?: string} = {}) => {
|
||||||
return modes.some((mode) => {
|
return modes.some((mode) => {
|
||||||
const modeFn = modeFns[mode];
|
const modeFn = modeFns[mode];
|
||||||
return !!(modeFn && modeFn(options));
|
return !!(modeFn && modeFn(options));
|
@ -5,8 +5,9 @@ import Link from '@tiptap/extension-link';
|
|||||||
import Paragraph from '@tiptap/extension-paragraph';
|
import Paragraph from '@tiptap/extension-paragraph';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import Text from '@tiptap/extension-text';
|
import Text from '@tiptap/extension-text';
|
||||||
|
import {EditorOptions} from '@tiptap/core';
|
||||||
|
|
||||||
export function getEditorConfig({placeholder, autofocus = false, content = ''}) {
|
export function getEditorConfig({placeholder, autofocus = false, content = ''}: {placeholder: string; autofocus?: boolean; content?: string}): Partial<EditorOptions> {
|
||||||
return {
|
return {
|
||||||
extensions: [
|
extensions: [
|
||||||
Document,
|
Document,
|
@ -10,10 +10,10 @@ describe('formatNumber', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles undefined', function () {
|
it('handles undefined', function () {
|
||||||
expect(helpers.formatNumber()).toEqual('');
|
expect((helpers.formatNumber as any)()).toEqual('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles null', function () {
|
it('handles null', function () {
|
||||||
expect(helpers.formatNumber(null)).toEqual('');
|
expect((helpers.formatNumber as any)(null)).toEqual('');
|
||||||
});
|
});
|
||||||
});
|
});
|
@ -1,4 +1,15 @@
|
|||||||
export const createPopupNotification = ({type, status, autoHide, duration = 2600, closeable, state, message, meta = {}}) => {
|
import {Comment, PopupNotification} from '../AppContext';
|
||||||
|
|
||||||
|
export const createPopupNotification = ({type, status, autoHide, duration = 2600, closeable, state, message, meta = {}}: {
|
||||||
|
type: string,
|
||||||
|
status: string,
|
||||||
|
autoHide: boolean,
|
||||||
|
duration?: number,
|
||||||
|
closeable: boolean,
|
||||||
|
state: any,
|
||||||
|
message: string,
|
||||||
|
meta?: any
|
||||||
|
}): PopupNotification => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
if (state && state.popupNotification) {
|
if (state && state.popupNotification) {
|
||||||
count = (state.popupNotification.count || 0) + 1;
|
count = (state.popupNotification.count || 0) + 1;
|
||||||
@ -15,7 +26,7 @@ export const createPopupNotification = ({type, status, autoHide, duration = 2600
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatNumber(number) {
|
export function formatNumber(number: number): string {
|
||||||
if (number !== 0 && !number) {
|
if (number !== 0 && !number) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@ -24,7 +35,7 @@ export function formatNumber(number) {
|
|||||||
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRelativeTime(dateString) {
|
export function formatRelativeTime(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@ -82,7 +93,7 @@ export function formatRelativeTime(dateString) {
|
|||||||
return `${Math.floor(diff)} weeks ago`;
|
return `${Math.floor(diff)} weeks ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatExplicitTime(dateString) {
|
export function formatExplicitTime(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
|
|
||||||
let day = date.toLocaleDateString('en-us', {day: '2-digit'}); // eg. 01
|
let day = date.toLocaleDateString('en-us', {day: '2-digit'}); // eg. 01
|
||||||
@ -94,7 +105,7 @@ export function formatExplicitTime(dateString) {
|
|||||||
return `${day} ${month} ${year} ${hour}:${minute}`;
|
return `${day} ${month} ${year} ${hour}:${minute}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitials(name) {
|
export function getInitials(name: string): string {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@ -117,22 +128,22 @@ export function isMobile() {
|
|||||||
return (Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) < 480);
|
return (Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) < 480);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCommentPublished(comment) {
|
export function isCommentPublished(comment: Comment) {
|
||||||
return comment.status === 'published';
|
return comment.status === 'published';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the y scroll position (top) of the main window of a given element that is in one or multiple stacked iframes
|
* Returns the y scroll position (top) of the main window of a given element that is in one or multiple stacked iframes
|
||||||
*/
|
*/
|
||||||
export const getScrollToPosition = (element) => {
|
export const getScrollToPosition = (element: HTMLElement) => {
|
||||||
let yOffset = 0;
|
let yOffset = 0;
|
||||||
|
|
||||||
// Because we are working in an iframe, we need to resolve the position inside this iframe to the position in the top window
|
// Because we are working in an iframe, we need to resolve the position inside this iframe to the position in the top window
|
||||||
// Get the window of the element, not the window (which is the top window)
|
// Get the window of the element, not the window (which is the top window)
|
||||||
let currentWindow = element.ownerDocument.defaultView;
|
let currentWindow: Window | null = element.ownerDocument.defaultView;
|
||||||
|
|
||||||
// Loop all iframe parents (if we have multiple)
|
// Loop all iframe parents (if we have multiple)
|
||||||
while (currentWindow !== window) {
|
while (currentWindow && currentWindow !== window) {
|
||||||
const currentParentWindow = currentWindow.parent;
|
const currentParentWindow = currentWindow.parent;
|
||||||
for (let idx = 0; idx < currentParentWindow.frames.length; idx++) {
|
for (let idx = 0; idx < currentParentWindow.frames.length; idx++) {
|
||||||
if (currentParentWindow.frames[idx] === currentWindow) {
|
if (currentParentWindow.frames[idx] === currentWindow) {
|
||||||
@ -155,7 +166,7 @@ export const getScrollToPosition = (element) => {
|
|||||||
/**
|
/**
|
||||||
* Scroll to an element that is in an iframe, only if it is outside the current viewport
|
* Scroll to an element that is in an iframe, only if it is outside the current viewport
|
||||||
*/
|
*/
|
||||||
export const scrollToElement = (element) => {
|
export const scrollToElement = (element: HTMLElement) => {
|
||||||
// Is the form already in view?
|
// Is the form already in view?
|
||||||
const elementHeight = element.offsetHeight;
|
const elementHeight = element.offsetHeight;
|
||||||
|
|
@ -1,25 +1,25 @@
|
|||||||
import AppContext from '../AppContext';
|
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
|
||||||
import {formatRelativeTime} from './helpers';
|
import {formatRelativeTime} from './helpers';
|
||||||
import {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
|
import {useAppContext} from '../AppContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a callback when a ref is set and unset.
|
* Execute a callback when a ref is set and unset.
|
||||||
* Warning: make sure setup and clear are both functions that do not change on every rerender. So use useCallback if required on them.
|
* Warning: make sure setup and clear are both functions that do not change on every rerender. So use useCallback if required on them.
|
||||||
*/
|
*/
|
||||||
export function useRefCallback(setup, clear) {
|
export function useRefCallback<T>(setup: (element: T) => void, clear?: (element: T) => void) {
|
||||||
const ref = useRef(null);
|
const ref = useRef<T | null>(null);
|
||||||
const setRef = useCallback((node) => {
|
const setRef = useCallback((node) => {
|
||||||
if (ref.current && clear) {
|
if (ref.current && clear) {
|
||||||
// Make sure to cleanup any events/references added to the last instance
|
// Make sure to cleanup any events/references added to the last instance
|
||||||
clear(ref.current);
|
clear(ref.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node && setup) {
|
if (node && setup) {
|
||||||
// Check if a node is actually passed. Otherwise node would be null.
|
// Check if a node is actually passed. Otherwise node would be null.
|
||||||
// You can now do what you need to, addEventListeners, measure, etc.
|
// You can now do what you need to, addEventListeners, measure, etc.
|
||||||
setup(node);
|
setup(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save a reference to the node
|
// Save a reference to the node
|
||||||
ref.current = node;
|
ref.current = node;
|
||||||
}, [setup, clear]);
|
}, [setup, clear]);
|
||||||
@ -28,14 +28,14 @@ export function useRefCallback(setup, clear) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sames as useEffect, but ignores the first mounted call and the first update (so first 2 calls ignored)
|
* Sames as useEffect, but ignores the first mounted call and the first update (so first 2 calls ignored)
|
||||||
* @param {Same} fn
|
* @param {Same} fn
|
||||||
* @param {*} inputs
|
* @param {*} inputs
|
||||||
*/
|
*/
|
||||||
export function useSecondUpdate(fn, inputs) {
|
export function useSecondUpdate(fn: () => void, inputs: React.DependencyList) {
|
||||||
const didMountRef = useRef(0);
|
const didMountRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (didMountRef.current >= 2) {
|
if (didMountRef.current >= 2) {
|
||||||
return fn();
|
return fn();
|
||||||
}
|
}
|
||||||
didMountRef.current += 1;
|
didMountRef.current += 1;
|
||||||
@ -44,15 +44,15 @@ export function useSecondUpdate(fn, inputs) {
|
|||||||
}, inputs);
|
}, inputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePopupOpen(type) {
|
export function usePopupOpen(type: string) {
|
||||||
const {popup} = useContext(AppContext);
|
const {popup} = useAppContext();
|
||||||
return popup?.type === type;
|
return popup?.type === type;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Avoids a rerender of the relative time unless the date changed, and not the current timestamp changed
|
* Avoids a rerender of the relative time unless the date changed, and not the current timestamp changed
|
||||||
*/
|
*/
|
||||||
export function useRelativeTime(dateString) {
|
export function useRelativeTime(dateString: string) {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return formatRelativeTime(dateString);
|
return formatRelativeTime(dateString);
|
||||||
}, [dateString]);
|
}, [dateString]);
|
@ -1,50 +0,0 @@
|
|||||||
const ObjectId = require('bson-objectid').default;
|
|
||||||
let memberCounter = 0;
|
|
||||||
|
|
||||||
export function buildMember(override) {
|
|
||||||
memberCounter += 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
avatar_image: 'https://www.gravatar.com/avatar/7a68f69cc9c9e9b45d97ecad6f24184a?s=250&r=g&d=blank',
|
|
||||||
expertise: 'Head of Testing',
|
|
||||||
id: ObjectId(),
|
|
||||||
name: 'Test Member ' + memberCounter,
|
|
||||||
uuid: ObjectId(),
|
|
||||||
paid: false,
|
|
||||||
...override
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildComment(override) {
|
|
||||||
return {
|
|
||||||
id: ObjectId(),
|
|
||||||
html: '<p>Empty</p>',
|
|
||||||
replies: [],
|
|
||||||
count: {
|
|
||||||
replies: 0,
|
|
||||||
likes: 0
|
|
||||||
},
|
|
||||||
liked: false,
|
|
||||||
created_at: '2022-08-11T09:26:34.000Z',
|
|
||||||
edited_at: null,
|
|
||||||
member: buildMember(),
|
|
||||||
status: 'published',
|
|
||||||
...override
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildReply(override) {
|
|
||||||
return {
|
|
||||||
id: ObjectId(),
|
|
||||||
html: '<p>Empty</p>',
|
|
||||||
count: {
|
|
||||||
likes: 0
|
|
||||||
},
|
|
||||||
liked: false,
|
|
||||||
created_at: '2022-08-11T09:26:34.000Z',
|
|
||||||
edited_at: null,
|
|
||||||
member: buildMember(),
|
|
||||||
status: 'published',
|
|
||||||
...override
|
|
||||||
};
|
|
||||||
}
|
|
1
apps/comments-ui/src/vite-env.d.ts
vendored
Normal file
1
apps/comments-ui/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
@ -55,7 +55,7 @@ export class MockedApi {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
browseComments({limit = 5, order, filter, page}: {limit?: number, order?: string, filter?: string, page: number}) {
|
browseComments({limit = 5, filter, page}: {limit?: number, filter?: string, page: number}) {
|
||||||
// Sort comments on created at + id
|
// Sort comments on created at + id
|
||||||
this.comments.sort((a, b) => {
|
this.comments.sort((a, b) => {
|
||||||
const aDate = new Date(a.created_at).getTime();
|
const aDate = new Date(a.created_at).getTime();
|
||||||
|
32
apps/comments-ui/tsconfig.json
Normal file
32
apps/comments-ui/tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
|
||||||
|
/* Temporary */
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Vitest */
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
10
apps/comments-ui/tsconfig.node.json
Normal file
10
apps/comments-ui/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "package.json"]
|
||||||
|
}
|
@ -1,82 +0,0 @@
|
|||||||
import fs from 'fs/promises';
|
|
||||||
import {resolve} from 'path';
|
|
||||||
|
|
||||||
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
|
|
||||||
import reactPlugin from '@vitejs/plugin-react';
|
|
||||||
import svgrPlugin from 'vite-plugin-svgr';
|
|
||||||
import {defineConfig} from 'vitest/config';
|
|
||||||
|
|
||||||
import pkg from './package.json';
|
|
||||||
|
|
||||||
export default defineConfig((config) => {
|
|
||||||
const outputFileName = pkg.name[0] === '@' ? pkg.name.slice(pkg.name.indexOf('/') + 1) : pkg.name;
|
|
||||||
|
|
||||||
return {
|
|
||||||
clearScreen: false,
|
|
||||||
define: {
|
|
||||||
'process.env.NODE_ENV': JSON.stringify(config.mode),
|
|
||||||
REACT_APP_VERSION: JSON.stringify(process.env.npm_package_version)
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
port: 7174
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 5368
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
cssInjectedByJsPlugin(),
|
|
||||||
reactPlugin(),
|
|
||||||
svgrPlugin()
|
|
||||||
],
|
|
||||||
esbuild: {
|
|
||||||
loader: 'jsx',
|
|
||||||
include: /src\/.*\.jsx?$/,
|
|
||||||
exclude: []
|
|
||||||
},
|
|
||||||
optimizeDeps: {
|
|
||||||
esbuildOptions: {
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
name: 'load-js-files-as-jsx',
|
|
||||||
setup(build) {
|
|
||||||
build.onLoad({filter: /src\/.*\.js$/}, async args => ({
|
|
||||||
loader: 'jsx',
|
|
||||||
contents: await fs.readFile(args.path, 'utf8')
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: resolve(__dirname, 'umd'),
|
|
||||||
emptyOutDir: true,
|
|
||||||
minify: true,
|
|
||||||
sourcemap: true,
|
|
||||||
cssCodeSplit: false,
|
|
||||||
lib: {
|
|
||||||
entry: resolve(__dirname, 'src/index.js'),
|
|
||||||
formats: ['umd'],
|
|
||||||
name: pkg.name,
|
|
||||||
fileName: format => `${outputFileName}.min.js`
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*commonjsOptions: {
|
|
||||||
include: [/ghost/, /node_modules/],
|
|
||||||
dynamicRequireRoot: '../../',
|
|
||||||
dynamicRequireTargets: SUPPORTED_LOCALES.map(locale => `../../ghost/i18n/locales/${locale}/portal.json`)
|
|
||||||
}*/
|
|
||||||
},
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
setupFiles: './src/setupTests.js',
|
|
||||||
include: ['src/**/*.test.js'],
|
|
||||||
testTimeout: 10000
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
72
apps/comments-ui/vite.config.ts
Normal file
72
apps/comments-ui/vite.config.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import commonjs from 'vite-plugin-commonjs';
|
||||||
|
import pkg from './package.json';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import svgr from 'vite-plugin-svgr';
|
||||||
|
import {SUPPORTED_LOCALES} from '@tryghost/i18n';
|
||||||
|
import {defineConfig} from 'vitest/config';
|
||||||
|
import {resolve} from 'path';
|
||||||
|
|
||||||
|
const outputFileName = pkg.name[0] === '@' ? pkg.name.slice(pkg.name.indexOf('/') + 1) : pkg.name;
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default (function viteConfig() {
|
||||||
|
return defineConfig({
|
||||||
|
plugins: [
|
||||||
|
svgr(),
|
||||||
|
react(),
|
||||||
|
commonjs({
|
||||||
|
dynamic: {
|
||||||
|
loose: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
|
||||||
|
'process.env.VITEST_SEGFAULT_RETRY': 3
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 7173
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5368
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: resolve(__dirname, 'umd'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: true,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.tsx'),
|
||||||
|
formats: ['umd'],
|
||||||
|
name: pkg.name,
|
||||||
|
fileName(format) {
|
||||||
|
if (format === 'umd') {
|
||||||
|
return `${outputFileName}.min.js`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${outputFileName}.js`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {}
|
||||||
|
},
|
||||||
|
commonjsOptions: {
|
||||||
|
include: [/ghost/, /node_modules/],
|
||||||
|
dynamicRequireRoot: '../../',
|
||||||
|
dynamicRequireTargets: SUPPORTED_LOCALES.map(locale => `../../ghost/i18n/locales/${locale}/comments.json`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true, // required for @testing-library/jest-dom extensions
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/setupTests.ts',
|
||||||
|
include: ['src/**/*.test.jsx', 'src/**/*.test.js', 'src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||||
|
testTimeout: process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 10000,
|
||||||
|
...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674
|
||||||
|
minThreads: 1,
|
||||||
|
maxThreads: 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
173
yarn.lock
173
yarn.lock
@ -13521,7 +13521,7 @@ css-tree@1.0.0-alpha.37:
|
|||||||
mdn-data "2.0.4"
|
mdn-data "2.0.4"
|
||||||
source-map "^0.6.1"
|
source-map "^0.6.1"
|
||||||
|
|
||||||
css-tree@^1.0.0-alpha.39, css-tree@^1.1.2, css-tree@^1.1.3:
|
css-tree@^1.1.2, css-tree@^1.1.3:
|
||||||
version "1.1.3"
|
version "1.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
|
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
|
||||||
integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
|
integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
|
||||||
@ -16537,20 +16537,6 @@ eslint-plugin-babel@5.3.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
eslint-rule-composer "^0.3.0"
|
eslint-rule-composer "^0.3.0"
|
||||||
|
|
||||||
eslint-plugin-ember@10.5.8:
|
|
||||||
version "10.5.8"
|
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-ember/-/eslint-plugin-ember-10.5.8.tgz#87e004a5ebed88f94008364554daf57df2c9c718"
|
|
||||||
integrity sha512-d21mJ+F+htgi6HhrjwbOfllJojF4ZWGruW13HkBoGS2SaHqKUyvIH/8j3EjSxlsGFiNfhTEUWkNaUSLJxgbtWg==
|
|
||||||
dependencies:
|
|
||||||
"@ember-data/rfc395-data" "^0.0.4"
|
|
||||||
css-tree "^1.0.0-alpha.39"
|
|
||||||
ember-rfc176-data "^0.3.15"
|
|
||||||
eslint-utils "^3.0.0"
|
|
||||||
estraverse "^5.2.0"
|
|
||||||
lodash.kebabcase "^4.1.1"
|
|
||||||
requireindex "^1.2.0"
|
|
||||||
snake-case "^3.0.3"
|
|
||||||
|
|
||||||
eslint-plugin-ember@11.8.0:
|
eslint-plugin-ember@11.8.0:
|
||||||
version "11.8.0"
|
version "11.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-ember/-/eslint-plugin-ember-11.8.0.tgz#3c984500513a01d930c13dcac0b4661a64ea457b"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-ember/-/eslint-plugin-ember-11.8.0.tgz#3c984500513a01d930c13dcac0b4661a64ea457b"
|
||||||
@ -16577,15 +16563,7 @@ eslint-plugin-es-x@^6.1.0:
|
|||||||
"@eslint-community/eslint-utils" "^4.1.2"
|
"@eslint-community/eslint-utils" "^4.1.2"
|
||||||
"@eslint-community/regexpp" "^4.5.0"
|
"@eslint-community/regexpp" "^4.5.0"
|
||||||
|
|
||||||
eslint-plugin-es@^3.0.0:
|
eslint-plugin-filenames@allouis/eslint-plugin-filenames#15dc354f4e3d155fc2d6ae082dbfc26377539a18:
|
||||||
version "3.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz#75a7cdfdccddc0589934aeeb384175f221c57893"
|
|
||||||
integrity sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==
|
|
||||||
dependencies:
|
|
||||||
eslint-utils "^2.0.0"
|
|
||||||
regexpp "^3.0.0"
|
|
||||||
|
|
||||||
eslint-plugin-filenames@1.3.2, eslint-plugin-filenames@allouis/eslint-plugin-filenames#15dc354f4e3d155fc2d6ae082dbfc26377539a18:
|
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
resolved "https://codeload.github.com/allouis/eslint-plugin-filenames/tar.gz/15dc354f4e3d155fc2d6ae082dbfc26377539a18"
|
resolved "https://codeload.github.com/allouis/eslint-plugin-filenames/tar.gz/15dc354f4e3d155fc2d6ae082dbfc26377539a18"
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -16602,19 +16580,6 @@ eslint-plugin-flowtype@^8.0.3:
|
|||||||
lodash "^4.17.21"
|
lodash "^4.17.21"
|
||||||
string-natural-compare "^3.0.1"
|
string-natural-compare "^3.0.1"
|
||||||
|
|
||||||
eslint-plugin-ghost@2.12.0:
|
|
||||||
version "2.12.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-ghost/-/eslint-plugin-ghost-2.12.0.tgz#108c86be40eae12f8839131e30fa2cb57195a279"
|
|
||||||
integrity sha512-YyAmG2RVlYkf66/R9IG/UoCl0nvo3HKVleqzs4Ba/YcKK6gnmYgfRrpOt1k9a3klXEQkxSY1BKesEKTCS60KQw==
|
|
||||||
dependencies:
|
|
||||||
"@kapouer/eslint-plugin-no-return-in-loop" "1.0.0"
|
|
||||||
eslint-plugin-ember "10.5.8"
|
|
||||||
eslint-plugin-filenames "1.3.2"
|
|
||||||
eslint-plugin-mocha "7.0.1"
|
|
||||||
eslint-plugin-node "11.1.0"
|
|
||||||
eslint-plugin-sort-imports-es6-autofix "0.6.0"
|
|
||||||
eslint-plugin-unicorn "40.1.0"
|
|
||||||
|
|
||||||
eslint-plugin-ghost@3.2.0:
|
eslint-plugin-ghost@3.2.0:
|
||||||
version "3.2.0"
|
version "3.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-ghost/-/eslint-plugin-ghost-3.2.0.tgz#c71a8809cbd51d7eb53203556db67432be065433"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-ghost/-/eslint-plugin-ghost-3.2.0.tgz#c71a8809cbd51d7eb53203556db67432be065433"
|
||||||
@ -16708,18 +16673,6 @@ eslint-plugin-n@^16.0.0:
|
|||||||
resolve "^1.22.2"
|
resolve "^1.22.2"
|
||||||
semver "^7.5.0"
|
semver "^7.5.0"
|
||||||
|
|
||||||
eslint-plugin-node@11.1.0:
|
|
||||||
version "11.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d"
|
|
||||||
integrity sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==
|
|
||||||
dependencies:
|
|
||||||
eslint-plugin-es "^3.0.0"
|
|
||||||
eslint-utils "^2.0.0"
|
|
||||||
ignore "^5.1.1"
|
|
||||||
minimatch "^3.0.4"
|
|
||||||
resolve "^1.10.1"
|
|
||||||
semver "^6.1.0"
|
|
||||||
|
|
||||||
eslint-plugin-react-hooks@4.6.0, eslint-plugin-react-hooks@^4.3.0:
|
eslint-plugin-react-hooks@4.6.0, eslint-plugin-react-hooks@^4.3.0:
|
||||||
version "4.6.0"
|
version "4.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3"
|
||||||
@ -16764,14 +16717,6 @@ eslint-plugin-tailwindcss@3.11.0:
|
|||||||
fast-glob "^3.2.5"
|
fast-glob "^3.2.5"
|
||||||
postcss "^8.4.4"
|
postcss "^8.4.4"
|
||||||
|
|
||||||
eslint-plugin-tailwindcss@^3.6.0:
|
|
||||||
version "3.12.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-3.12.1.tgz#ab7fc554b97872208460aac6921f391683c992f1"
|
|
||||||
integrity sha512-LyIRV0rx6prTpJZsSCXSNJ34Yry3Nj9OJwvzh1xTsiG6+UCnAPW1Bx41s7vZzUDKMlwFgpUN9Me+NK12T4DHYg==
|
|
||||||
dependencies:
|
|
||||||
fast-glob "^3.2.5"
|
|
||||||
postcss "^8.4.4"
|
|
||||||
|
|
||||||
eslint-plugin-testing-library@^5.0.1:
|
eslint-plugin-testing-library@^5.0.1:
|
||||||
version "5.10.2"
|
version "5.10.2"
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.10.2.tgz#12f231ad9b52b6aef45c801fd00aa129a932e0c2"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.10.2.tgz#12f231ad9b52b6aef45c801fd00aa129a932e0c2"
|
||||||
@ -16779,26 +16724,6 @@ eslint-plugin-testing-library@^5.0.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@typescript-eslint/utils" "^5.43.0"
|
"@typescript-eslint/utils" "^5.43.0"
|
||||||
|
|
||||||
eslint-plugin-unicorn@40.1.0:
|
|
||||||
version "40.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-40.1.0.tgz#48975360e39d23df726e4b33e8dd5d650e184832"
|
|
||||||
integrity sha512-y5doK2DF9Sr5AqKEHbHxjFllJ167nKDRU01HDcWyv4Tnmaoe9iNxMrBnaybZvWZUaE3OC5Unu0lNIevYamloig==
|
|
||||||
dependencies:
|
|
||||||
"@babel/helper-validator-identifier" "^7.15.7"
|
|
||||||
ci-info "^3.3.0"
|
|
||||||
clean-regexp "^1.0.0"
|
|
||||||
eslint-utils "^3.0.0"
|
|
||||||
esquery "^1.4.0"
|
|
||||||
indent-string "^4.0.0"
|
|
||||||
is-builtin-module "^3.1.0"
|
|
||||||
lodash "^4.17.21"
|
|
||||||
pluralize "^8.0.0"
|
|
||||||
read-pkg-up "^7.0.1"
|
|
||||||
regexp-tree "^0.1.24"
|
|
||||||
safe-regex "^2.1.1"
|
|
||||||
semver "^7.3.5"
|
|
||||||
strip-indent "^3.0.0"
|
|
||||||
|
|
||||||
eslint-plugin-unicorn@42.0.0:
|
eslint-plugin-unicorn@42.0.0:
|
||||||
version "42.0.0"
|
version "42.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-42.0.0.tgz#47d60c00c263ad743403b052db689e39acbacff1"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-42.0.0.tgz#47d60c00c263ad743403b052db689e39acbacff1"
|
||||||
@ -16980,51 +16905,6 @@ eslint@8.38.0:
|
|||||||
strip-json-comments "^3.1.0"
|
strip-json-comments "^3.1.0"
|
||||||
text-table "^0.2.0"
|
text-table "^0.2.0"
|
||||||
|
|
||||||
eslint@8.43.0, eslint@^8.3.0:
|
|
||||||
version "8.43.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.43.0.tgz#3e8c6066a57097adfd9d390b8fc93075f257a094"
|
|
||||||
integrity sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==
|
|
||||||
dependencies:
|
|
||||||
"@eslint-community/eslint-utils" "^4.2.0"
|
|
||||||
"@eslint-community/regexpp" "^4.4.0"
|
|
||||||
"@eslint/eslintrc" "^2.0.3"
|
|
||||||
"@eslint/js" "8.43.0"
|
|
||||||
"@humanwhocodes/config-array" "^0.11.10"
|
|
||||||
"@humanwhocodes/module-importer" "^1.0.1"
|
|
||||||
"@nodelib/fs.walk" "^1.2.8"
|
|
||||||
ajv "^6.10.0"
|
|
||||||
chalk "^4.0.0"
|
|
||||||
cross-spawn "^7.0.2"
|
|
||||||
debug "^4.3.2"
|
|
||||||
doctrine "^3.0.0"
|
|
||||||
escape-string-regexp "^4.0.0"
|
|
||||||
eslint-scope "^7.2.0"
|
|
||||||
eslint-visitor-keys "^3.4.1"
|
|
||||||
espree "^9.5.2"
|
|
||||||
esquery "^1.4.2"
|
|
||||||
esutils "^2.0.2"
|
|
||||||
fast-deep-equal "^3.1.3"
|
|
||||||
file-entry-cache "^6.0.1"
|
|
||||||
find-up "^5.0.0"
|
|
||||||
glob-parent "^6.0.2"
|
|
||||||
globals "^13.19.0"
|
|
||||||
graphemer "^1.4.0"
|
|
||||||
ignore "^5.2.0"
|
|
||||||
import-fresh "^3.0.0"
|
|
||||||
imurmurhash "^0.1.4"
|
|
||||||
is-glob "^4.0.0"
|
|
||||||
is-path-inside "^3.0.3"
|
|
||||||
js-yaml "^4.1.0"
|
|
||||||
json-stable-stringify-without-jsonify "^1.0.1"
|
|
||||||
levn "^0.4.1"
|
|
||||||
lodash.merge "^4.6.2"
|
|
||||||
minimatch "^3.1.2"
|
|
||||||
natural-compare "^1.4.0"
|
|
||||||
optionator "^0.9.1"
|
|
||||||
strip-ansi "^6.0.1"
|
|
||||||
strip-json-comments "^3.1.0"
|
|
||||||
text-table "^0.2.0"
|
|
||||||
|
|
||||||
eslint@^7.32.0:
|
eslint@^7.32.0:
|
||||||
version "7.32.0"
|
version "7.32.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d"
|
resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d"
|
||||||
@ -17071,6 +16951,51 @@ eslint@^7.32.0:
|
|||||||
text-table "^0.2.0"
|
text-table "^0.2.0"
|
||||||
v8-compile-cache "^2.0.3"
|
v8-compile-cache "^2.0.3"
|
||||||
|
|
||||||
|
eslint@^8.3.0:
|
||||||
|
version "8.43.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.43.0.tgz#3e8c6066a57097adfd9d390b8fc93075f257a094"
|
||||||
|
integrity sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==
|
||||||
|
dependencies:
|
||||||
|
"@eslint-community/eslint-utils" "^4.2.0"
|
||||||
|
"@eslint-community/regexpp" "^4.4.0"
|
||||||
|
"@eslint/eslintrc" "^2.0.3"
|
||||||
|
"@eslint/js" "8.43.0"
|
||||||
|
"@humanwhocodes/config-array" "^0.11.10"
|
||||||
|
"@humanwhocodes/module-importer" "^1.0.1"
|
||||||
|
"@nodelib/fs.walk" "^1.2.8"
|
||||||
|
ajv "^6.10.0"
|
||||||
|
chalk "^4.0.0"
|
||||||
|
cross-spawn "^7.0.2"
|
||||||
|
debug "^4.3.2"
|
||||||
|
doctrine "^3.0.0"
|
||||||
|
escape-string-regexp "^4.0.0"
|
||||||
|
eslint-scope "^7.2.0"
|
||||||
|
eslint-visitor-keys "^3.4.1"
|
||||||
|
espree "^9.5.2"
|
||||||
|
esquery "^1.4.2"
|
||||||
|
esutils "^2.0.2"
|
||||||
|
fast-deep-equal "^3.1.3"
|
||||||
|
file-entry-cache "^6.0.1"
|
||||||
|
find-up "^5.0.0"
|
||||||
|
glob-parent "^6.0.2"
|
||||||
|
globals "^13.19.0"
|
||||||
|
graphemer "^1.4.0"
|
||||||
|
ignore "^5.2.0"
|
||||||
|
import-fresh "^3.0.0"
|
||||||
|
imurmurhash "^0.1.4"
|
||||||
|
is-glob "^4.0.0"
|
||||||
|
is-path-inside "^3.0.3"
|
||||||
|
js-yaml "^4.1.0"
|
||||||
|
json-stable-stringify-without-jsonify "^1.0.1"
|
||||||
|
levn "^0.4.1"
|
||||||
|
lodash.merge "^4.6.2"
|
||||||
|
minimatch "^3.1.2"
|
||||||
|
natural-compare "^1.4.0"
|
||||||
|
optionator "^0.9.1"
|
||||||
|
strip-ansi "^6.0.1"
|
||||||
|
strip-json-comments "^3.1.0"
|
||||||
|
text-table "^0.2.0"
|
||||||
|
|
||||||
esm@^3.2.25, esm@^3.2.4:
|
esm@^3.2.25, esm@^3.2.4:
|
||||||
version "3.2.25"
|
version "3.2.25"
|
||||||
resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10"
|
resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10"
|
||||||
@ -28941,7 +28866,7 @@ regexp.prototype.flags@^1.4.3:
|
|||||||
define-properties "^1.1.3"
|
define-properties "^1.1.3"
|
||||||
functions-have-names "^1.2.2"
|
functions-have-names "^1.2.2"
|
||||||
|
|
||||||
regexpp@^3.0.0, regexpp@^3.1.0:
|
regexpp@^3.1.0:
|
||||||
version "3.2.0"
|
version "3.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
|
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
|
||||||
integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
|
integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
|
||||||
@ -29876,7 +29801,7 @@ semver@7.5.3, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver
|
|||||||
dependencies:
|
dependencies:
|
||||||
lru-cache "^6.0.0"
|
lru-cache "^6.0.0"
|
||||||
|
|
||||||
semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
|
semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
|
||||||
version "6.3.0"
|
version "6.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||||
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
||||||
|
Loading…
Reference in New Issue
Block a user