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
|
||||
// Note the port is different because of this extra layer. Use the following Caddyfile:
|
||||
// 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';
|
||||
@ -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('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({
|
||||
name: 'comments',
|
||||
command: 'yarn dev',
|
||||
@ -166,8 +178,6 @@ if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) {
|
||||
prefixColor: '#E55137',
|
||||
env: {}
|
||||
});
|
||||
|
||||
COMMAND_GHOST.env['comments__url'] = 'http://localhost:7174/comments-ui.min.js';
|
||||
}
|
||||
|
||||
async function handleStripe() {
|
||||
|
@ -36,6 +36,9 @@ module.exports = {
|
||||
'tailwindcss/migration-from-tailwind-2': ['warn', {config: 'tailwind.config.cjs'}],
|
||||
'tailwindcss/no-arbitrary-value': '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/"
|
||||
},
|
||||
"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",
|
||||
"build": "vite build",
|
||||
"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:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=1000 yarn test:e2e --headed",
|
||||
"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",
|
||||
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; fi",
|
||||
"postship": "git push ${GHOST_UPSTREAM:-origin} --follow-tags && npm publish",
|
||||
@ -61,15 +61,18 @@
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@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",
|
||||
"@vitest/coverage-v8": "0.32.2",
|
||||
"autoprefixer": "10.4.14",
|
||||
"bson-objectid": "2.0.4",
|
||||
"concurrently": "8.2.0",
|
||||
"eslint": "8.43.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-plugin-ghost": "2.12.0",
|
||||
"eslint-plugin-tailwindcss": "^3.6.0",
|
||||
"eslint": "8.38.0",
|
||||
"eslint-plugin-ghost": "3.2.0",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-react-refresh": "0.3.4",
|
||||
"eslint-plugin-tailwindcss": "3.11.0",
|
||||
"jsdom": "22.1.0",
|
||||
"postcss": "8.4.24",
|
||||
"tailwindcss": "3.3.2",
|
||||
|
@ -1,9 +1,9 @@
|
||||
import AppContext from './AppContext';
|
||||
import ContentBox from './components/ContentBox';
|
||||
import PopupBox from './components/PopupBox';
|
||||
import React from 'react';
|
||||
import setupGhostApi from './utils/api';
|
||||
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
|
||||
import {AppContext} from './AppContext';
|
||||
import {CommentsFrame} from './components/Frame';
|
||||
import {createPopupNotification} from './utils/helpers';
|
||||
import {hasMode} from './utils/check-mode';
|
@ -2,7 +2,7 @@ import App from './App';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {ROOT_DIV_ID} from './utils/constants';
|
||||
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 = {}} = {}) {
|
||||
const postId = 'my-post';
|
||||
@ -80,7 +80,7 @@ function renderApp({member = null, documentStyles = {}, props = {}} = {}) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.scrollTo = jest.fn();
|
||||
window.scrollTo = vi.fn();
|
||||
Range.prototype.getClientRects = function getClientRects() {
|
||||
return [
|
||||
{
|
||||
@ -98,7 +98,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Auth frame', () => {
|
||||
@ -148,7 +148,7 @@ describe('Dark mode', () => {
|
||||
describe('Comments', () => {
|
||||
it('renders comments', async () => {
|
||||
const {api, iframeDocument} = renderApp();
|
||||
jest.spyOn(api.comments, 'browse').mockImplementation(() => {
|
||||
vi.spyOn(api.comments, 'browse').mockImplementation(() => {
|
||||
return {
|
||||
comments: [
|
||||
buildComment({html: '<p>This is a comment body</p>'})
|
||||
@ -174,7 +174,7 @@ describe('Comments', () => {
|
||||
const limit = 5;
|
||||
|
||||
const {api, iframeDocument} = renderApp();
|
||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
if (page === 2) {
|
||||
return {
|
||||
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 {api, iframeDocument} = renderApp();
|
||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
return {
|
||||
comments: new Array(limit).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>', member: null})),
|
||||
meta: {
|
||||
@ -239,7 +239,7 @@ describe('Comments', () => {
|
||||
const limit = 5;
|
||||
|
||||
const {api, iframeDocument} = renderApp();
|
||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
if (page === 2) {
|
||||
throw new Error('Not requested');
|
||||
}
|
||||
@ -281,7 +281,7 @@ describe('Likes', () => {
|
||||
member
|
||||
});
|
||||
|
||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
if (page === 2) {
|
||||
throw new Error('Not requested');
|
||||
}
|
||||
@ -299,8 +299,8 @@ describe('Likes', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const likeSpy = jest.spyOn(api.comments, 'like');
|
||||
const unlikeSpy = jest.spyOn(api.comments, 'unlike');
|
||||
const likeSpy = vi.spyOn(api.comments, 'like');
|
||||
const unlikeSpy = vi.spyOn(api.comments, 'unlike');
|
||||
|
||||
const comment = await within(iframeDocument).findByTestId('comment-component');
|
||||
|
||||
@ -346,7 +346,7 @@ describe('Replies', () => {
|
||||
member
|
||||
});
|
||||
|
||||
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
|
||||
if (page === 2) {
|
||||
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');
|
||||
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;
|
||||
if (state.pagination && state.pagination.page) {
|
||||
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});
|
||||
|
||||
// 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});
|
||||
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;
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
// 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});
|
||||
|
||||
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});
|
||||
|
||||
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});
|
||||
|
||||
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({
|
||||
comment: {
|
||||
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({
|
||||
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 patchData = {};
|
||||
|
||||
const patchData: {name?: string, expertise?: string} = {};
|
||||
|
||||
const originalName = state?.member?.name;
|
||||
|
||||
if (name && originalName !== name) {
|
||||
@ -320,7 +324,7 @@ async function updateMember({data, state, api}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function openPopup({data}) {
|
||||
function openPopup({data}: {data: Page}) {
|
||||
return {
|
||||
popup: data
|
||||
};
|
||||
@ -332,27 +336,29 @@ function closePopup() {
|
||||
};
|
||||
}
|
||||
|
||||
function increaseSecundaryFormCount({state}) {
|
||||
function increaseSecundaryFormCount({state}: {state: AppContextType}) {
|
||||
return {
|
||||
secundaryFormCount: state.secundaryFormCount + 1
|
||||
};
|
||||
}
|
||||
|
||||
function decreaseSecundaryFormCount({state}) {
|
||||
function decreaseSecundaryFormCount({state}: {state: AppContextType}) {
|
||||
return {
|
||||
secundaryFormCount: state.secundaryFormCount - 1
|
||||
};
|
||||
}
|
||||
|
||||
// Sync actions make use of setState((currentState) => newState), to avoid 'race' conditions
|
||||
const SyncActions = {
|
||||
export const SyncActions = {
|
||||
openPopup,
|
||||
closePopup,
|
||||
increaseSecundaryFormCount,
|
||||
decreaseSecundaryFormCount
|
||||
};
|
||||
|
||||
const Actions = {
|
||||
export type SyncActionType = keyof typeof SyncActions;
|
||||
|
||||
export const Actions = {
|
||||
// Put your actions here
|
||||
addComment,
|
||||
editComment,
|
||||
@ -368,25 +374,27 @@ const Actions = {
|
||||
updateMember
|
||||
};
|
||||
|
||||
export function isSyncAction(action) {
|
||||
return !!SyncActions[action];
|
||||
export type ActionType = keyof typeof Actions;
|
||||
|
||||
export function isSyncAction(action: string): action is SyncActionType {
|
||||
return !!(SyncActions as any)[action];
|
||||
}
|
||||
|
||||
/** 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];
|
||||
if (handler) {
|
||||
return await handler({data, state, api, adminApi}) || {};
|
||||
return await handler({data, state, api, adminApi} as any) || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** 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];
|
||||
if (handler) {
|
||||
// Do not await here
|
||||
return handler({data, state, api, adminApi}) || {};
|
||||
return handler({data, state, api, adminApi} as any) || {};
|
||||
}
|
||||
return {};
|
||||
}
|
@ -1,11 +1,14 @@
|
||||
import AppContext from '../AppContext';
|
||||
import Content from './content/Content';
|
||||
import Loading from './content/Loading';
|
||||
import React, {useContext} from 'react';
|
||||
import React from 'react';
|
||||
import {ROOT_DIV_ID} from '../utils/constants';
|
||||
import {useAppContext} from '../AppContext';
|
||||
|
||||
const ContentBox = ({done}) => {
|
||||
const luminance = (r, g, b) => {
|
||||
type Props = {
|
||||
done: boolean
|
||||
};
|
||||
const ContentBox: React.FC<Props> = ({done}) => {
|
||||
const luminance = (r: number, g: number, b: number) => {
|
||||
var a = [r, g, b].map(function (v) {
|
||||
v /= 255;
|
||||
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;
|
||||
};
|
||||
|
||||
const contrast = (rgb1, rgb2) => {
|
||||
const contrast = (rgb1: [number, number, number], rgb2: [number, number, number]) => {
|
||||
var lum1 = luminance(rgb1[0], rgb1[1], rgb1[2]);
|
||||
var lum2 = luminance(rgb2[0], rgb2[1], rgb2[2]);
|
||||
var brightest = Math.max(lum1, lum2);
|
||||
var darkest = Math.min(lum1, lum2);
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
};
|
||||
const {accentColor, colorScheme} = useContext(AppContext);
|
||||
const {accentColor, colorScheme} = useAppContext();
|
||||
|
||||
const darkMode = () => {
|
||||
if (colorScheme === 'light') {
|
||||
@ -28,12 +31,16 @@ const ContentBox = ({done}) => {
|
||||
} else if (colorScheme === 'dark') {
|
||||
return true;
|
||||
} 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 red = colorsOnly[0];
|
||||
const green = colorsOnly[1];
|
||||
const blue = colorsOnly[2];
|
||||
const red = parseInt(colorsOnly[0]);
|
||||
const green = parseInt(colorsOnly[1]);
|
||||
const blue = parseInt(colorsOnly[2]);
|
||||
|
||||
return contrast([255, 255, 255], [red, green, blue]) < 5;
|
||||
}
|
@ -2,10 +2,20 @@ import IFrame from './IFrame';
|
||||
import React, {useCallback, useState} from 'react';
|
||||
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.
|
||||
*/
|
||||
const TailwindFrame = ({children, onResize, style, title}) => {
|
||||
const TailwindFrame: React.FC<TailwindFrameProps> = ({children, onResize, style, title}) => {
|
||||
const head = (
|
||||
<>
|
||||
<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
|
||||
*/
|
||||
const ResizableFrame = ({children, style, title}) => {
|
||||
const ResizableFrame: React.FC<ResizableFrameProps> = ({children, style, title}) => {
|
||||
const [iframeStyle, setIframeStyle] = useState(style);
|
||||
const onResize = useCallback((iframeRoot) => {
|
||||
setIframeStyle((current) => {
|
||||
@ -42,7 +57,7 @@ const ResizableFrame = ({children, style, title}) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const CommentsFrame = ({children}) => {
|
||||
export const CommentsFrame: React.FC<FrameProps> = ({children}) => {
|
||||
const style = {
|
||||
width: '100%',
|
||||
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 = {
|
||||
zIndex: '3999999',
|
||||
position: 'fixed',
|
@ -1,13 +1,21 @@
|
||||
import React, {Component} from 'react';
|
||||
import {Component} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
export default class IFrame extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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.node = null;
|
||||
}
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
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
|
||||
// To get around this, we pass down the keydown events to the main window
|
||||
// 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
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', e)
|
||||
@ -44,8 +52,8 @@ export default class IFrame extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
setNode(node) {
|
||||
this.node = node;
|
||||
setNode(node: any) {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
render() {
|
@ -1,10 +1,11 @@
|
||||
import AppContext from '../AppContext';
|
||||
import GenericPopup from './popups/GenericPopup';
|
||||
import Pages from '../pages';
|
||||
import {useContext, useEffect, useState} from 'react';
|
||||
import {Pages} from '../pages';
|
||||
import {useAppContext} from '../AppContext';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
export default function PopupBox() {
|
||||
const {popup} = useContext(AppContext);
|
||||
type Props = {};
|
||||
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
|
||||
// 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 (
|
||||
<>
|
||||
<GenericPopup callback={popupProps.callback} show={show} title={type}>
|
||||
<PageComponent {...popupProps}/>
|
||||
<PageComponent {...popupProps as any}/>
|
||||
</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 {Comment, useAppContext} from '../../AppContext';
|
||||
import {getInitials} from '../../utils/helpers';
|
||||
|
||||
function getDimensionClasses() {
|
||||
@ -18,13 +17,16 @@ export const BlankAvatar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Avatar = ({comment}) => {
|
||||
const {member, avatarSaturation} = useContext(AppContext);
|
||||
type AvatarProps = {
|
||||
comment?: Comment;
|
||||
};
|
||||
export const Avatar: React.FC<AvatarProps> = ({comment}) => {
|
||||
const {member, avatarSaturation} = useAppContext();
|
||||
const dimensionClasses = getDimensionClasses();
|
||||
|
||||
const memberName = member?.name ?? comment?.member?.name;
|
||||
|
||||
const getHashOfString = (str) => {
|
||||
const getHashOfString = (str: string) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
@ -33,18 +35,18 @@ export const Avatar = ({comment}) => {
|
||||
return hash;
|
||||
};
|
||||
|
||||
const normalizeHash = (hash, min, max) => {
|
||||
const normalizeHash = (hash: number, min: number, max: number) => {
|
||||
return Math.floor((hash % (max - min)) + min);
|
||||
};
|
||||
|
||||
const generateHSL = () => {
|
||||
const generateHSL = (): [number, number, number] => {
|
||||
let commentMember = (comment ? comment.member : member);
|
||||
|
||||
if (!commentMember || !commentMember.name) {
|
||||
return [0,0,10];
|
||||
}
|
||||
|
||||
const saturation = isNaN(avatarSaturation) ? 50 : avatarSaturation;
|
||||
const saturation = avatarSaturation === undefined || isNaN(avatarSaturation) ? 50 : avatarSaturation;
|
||||
|
||||
const hRange = [0, 360];
|
||||
const lRangeTop = Math.round(saturation / (100 / 30)) + 30;
|
||||
@ -54,11 +56,11 @@ export const Avatar = ({comment}) => {
|
||||
const hash = getHashOfString(commentMember.name);
|
||||
const h = normalizeHash(hash, hRange[0], hRange[1]);
|
||||
const l = normalizeHash(hash, lRange[0], lRange[1]);
|
||||
|
||||
|
||||
return [h, saturation, l];
|
||||
};
|
||||
|
||||
const HSLtoString = (hsl) => {
|
||||
const HSLtoString = (hsl: [number, number, number]) => {
|
||||
return `hsl(${hsl[0]}, ${hsl[1]}%, ${hsl[2]}%)`;
|
||||
};
|
||||
|
||||
@ -66,7 +68,7 @@ export const Avatar = ({comment}) => {
|
||||
if (comment && !comment.member) {
|
||||
return getInitials('Deleted member');
|
||||
}
|
||||
|
||||
|
||||
let commentMember = (comment ? comment.member : member);
|
||||
|
||||
if (!commentMember || !commentMember.name) {
|
@ -1,8 +1,11 @@
|
||||
import AppContext from '../../AppContext';
|
||||
import {useContext} from 'react';
|
||||
import {useAppContext} from '../../AppContext';
|
||||
|
||||
const CTABox = ({isFirst, isPaid}) => {
|
||||
const {accentColor, publication, member} = useContext(AppContext);
|
||||
type Props = {
|
||||
isFirst: boolean,
|
||||
isPaid: boolean
|
||||
};
|
||||
const CTABox: React.FC<Props> = ({isFirst, isPaid}) => {
|
||||
const {accentColor, publication, member} = useAppContext();
|
||||
|
||||
const buttonStyle = {
|
||||
backgroundColor: accentColor
|
@ -1,17 +1,22 @@
|
||||
import AppContext from '../../AppContext';
|
||||
import EditForm from './forms/EditForm';
|
||||
import LikeButton from './buttons/LikeButton';
|
||||
import MoreButton from './buttons/MoreButton';
|
||||
import React, {useContext, useState} from 'react';
|
||||
import Replies from './Replies';
|
||||
import ReplyButton from './buttons/ReplyButton';
|
||||
import ReplyForm from './forms/ReplyForm';
|
||||
import {Avatar, BlankAvatar} from './Avatar';
|
||||
import {Comment, useAppContext} from '../../AppContext';
|
||||
import {Transition} from '@headlessui/react';
|
||||
import {formatExplicitTime, isCommentPublished} from '../../utils/helpers';
|
||||
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 (
|
||||
<Transition
|
||||
enter="transition-opacity duration-300 ease-out"
|
||||
@ -26,9 +31,10 @@ function AnimatedComment({comment, parent}) {
|
||||
<EditableComment comment={comment} parent={parent} />
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function EditableComment({comment, parent}) {
|
||||
type EditableCommentProps = AnimatedCommentProps;
|
||||
const EditableComment: React.FC<EditableCommentProps> = ({comment, parent}) => {
|
||||
const [isInEditMode, setIsInEditMode] = useState(false);
|
||||
|
||||
const closeEditMode = () => {
|
||||
@ -44,22 +50,25 @@ function EditableComment({comment, parent}) {
|
||||
<EditForm close={closeEditMode} comment={comment} parent={parent} />
|
||||
);
|
||||
} 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);
|
||||
|
||||
if (isPublished) {
|
||||
return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />);
|
||||
}
|
||||
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 {dispatchAction} = useContext(AppContext);
|
||||
const {dispatchAction} = useAppContext();
|
||||
|
||||
const toggleReplyMode = async () => {
|
||||
if (!isInReplyMode) {
|
||||
@ -86,10 +95,14 @@ function PublishedComment({comment, parent, openEditMode}) {
|
||||
<ReplyFormBox closeReplyMode={closeReplyMode} comment={comment} isInReplyMode={isInReplyMode} />
|
||||
</CommentLayout>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function UnpublishedComment({comment, openEditMode}) {
|
||||
const {admin} = useContext(AppContext);
|
||||
type UnpublishedCommentProps = {
|
||||
comment: Comment;
|
||||
openEditMode: () => void;
|
||||
}
|
||||
const UnpublishedComment: React.FC<UnpublishedCommentProps> = ({comment, openEditMode}) => {
|
||||
const {admin} = useAppContext();
|
||||
|
||||
let notPublishedMessage;
|
||||
if (admin && comment.status === 'hidden') {
|
||||
@ -114,12 +127,12 @@ function UnpublishedComment({comment, openEditMode}) {
|
||||
<RepliesContainer comment={comment} />
|
||||
</CommentLayout>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper components
|
||||
|
||||
function MemberExpertise({comment}) {
|
||||
const {member} = useContext(AppContext);
|
||||
const MemberExpertise: React.FC<{comment: Comment}> = ({comment}) => {
|
||||
const {member} = useAppContext();
|
||||
const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise;
|
||||
|
||||
if (!memberExpertise) {
|
||||
@ -129,9 +142,9 @@ function MemberExpertise({comment}) {
|
||||
return (
|
||||
<span>{memberExpertise}<span className="mx-[0.3em]">·</span></span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function EditedInfo({comment}) {
|
||||
const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => {
|
||||
if (!comment.edited_at) {
|
||||
return null;
|
||||
}
|
||||
@ -140,9 +153,9 @@ function EditedInfo({comment}) {
|
||||
<span className="mx-[0.3em]">·</span>Edited
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function RepliesContainer({comment}) {
|
||||
const RepliesContainer: React.FC<{comment: Comment}> = ({comment}) => {
|
||||
const hasReplies = comment.replies && comment.replies.length > 0;
|
||||
|
||||
if (!hasReplies) {
|
||||
@ -154,9 +167,14 @@ function RepliesContainer({comment}) {
|
||||
<Replies comment={comment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function ReplyFormBox({comment, isInReplyMode, closeReplyMode}) {
|
||||
type ReplyFormBoxProps = {
|
||||
comment: Comment;
|
||||
isInReplyMode: boolean;
|
||||
closeReplyMode: () => void;
|
||||
};
|
||||
const ReplyFormBox: React.FC<ReplyFormBoxProps> = ({comment, isInReplyMode, closeReplyMode}) => {
|
||||
if (!isInReplyMode) {
|
||||
return null;
|
||||
}
|
||||
@ -166,23 +184,23 @@ function ReplyFormBox({comment, isInReplyMode, closeReplyMode}) {
|
||||
<ReplyForm close={closeReplyMode} parent={comment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// -- Published comment components --
|
||||
//
|
||||
|
||||
// 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');
|
||||
return (
|
||||
<h4 className="text-[rgb(23,23,23] font-sans text-[17px] font-bold tracking-tight dark:text-[rgba(255,255,255,0.85)]">
|
||||
{name}
|
||||
</h4>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function CommentHeader({comment}) {
|
||||
const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => {
|
||||
const createdAtRelative = useRelativeTime(comment.created_at);
|
||||
|
||||
return (
|
||||
@ -199,21 +217,28 @@ function CommentHeader({comment}) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function CommentBody({html}) {
|
||||
const CommentBody: React.FC<{html: string}> = ({html}) => {
|
||||
const dangerouslySetInnerHTML = {__html: html};
|
||||
return (
|
||||
<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"/>
|
||||
</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
|
||||
// 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 isPaidMember = member && !!member.paid;
|
||||
@ -222,25 +247,30 @@ function CommentMenu({comment, toggleReplyMode, isInReplyMode, openEditMode, par
|
||||
return (
|
||||
<div className="flex items-center gap-5">
|
||||
{<LikeButton comment={comment} />}
|
||||
{(canReply && <ReplyButton comment={comment} isReplying={isInReplyMode} toggleReply={toggleReplyMode} />)}
|
||||
{(canReply && <ReplyButton isReplying={isInReplyMode} toggleReply={toggleReplyMode} />)}
|
||||
{<MoreButton comment={comment} toggleEdit={openEditMode} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// -- Layout --
|
||||
//
|
||||
|
||||
function RepliesLine({hasReplies}) {
|
||||
const RepliesLine: React.FC<{hasReplies: boolean}> = ({hasReplies}) => {
|
||||
if (!hasReplies) {
|
||||
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)]" />);
|
||||
}
|
||||
};
|
||||
|
||||
function CommentLayout({children, avatar, hasReplies}) {
|
||||
type CommentLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
avatar: React.ReactNode;
|
||||
hasReplies: boolean;
|
||||
}
|
||||
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies}) => {
|
||||
return (
|
||||
<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">
|
||||
@ -254,7 +284,7 @@ function CommentLayout({children, avatar, hasReplies}) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// -- Default --
|
@ -1,14 +1,14 @@
|
||||
import AppContext from '../../AppContext';
|
||||
import CTABox from './CTABox';
|
||||
import Comment from './Comment';
|
||||
import ContentTitle from './ContentTitle';
|
||||
import MainForm from './forms/MainForm';
|
||||
import Pagination from './Pagination';
|
||||
import React, {useContext, useEffect} from 'react';
|
||||
import {ROOT_DIV_ID} from '../../utils/constants';
|
||||
import {useAppContext} from '../../AppContext';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
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 paidOnly = commentsEnabled === 'paid';
|
@ -1,6 +1,10 @@
|
||||
import {formatNumber} from '../../utils/helpers';
|
||||
|
||||
const Count = ({showCount, count}) => {
|
||||
type CountProps = {
|
||||
showCount: boolean,
|
||||
count: number
|
||||
};
|
||||
const Count: React.FC<CountProps> = ({showCount, count}) => {
|
||||
if (!showCount) {
|
||||
return null;
|
||||
}
|
||||
@ -16,17 +20,22 @@ const Count = ({showCount, count}) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Title = ({title}) => {
|
||||
const Title: React.FC<{title: string | null}> = ({title}) => {
|
||||
if (title === null) {
|
||||
return (
|
||||
<><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
|
||||
if (!title && !showCount && title !== null) {
|
||||
return null;
|
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
|
||||
|
||||
function Loading() {
|
@ -1,12 +1,11 @@
|
||||
import AppContext from '../../AppContext';
|
||||
import React, {useContext} from 'react';
|
||||
import {formatNumber} from '../../utils/helpers';
|
||||
import {useAppContext} from '../../AppContext';
|
||||
|
||||
const Pagination = () => {
|
||||
const {pagination, dispatchAction} = useContext(AppContext);
|
||||
const {pagination, dispatchAction} = useAppContext();
|
||||
|
||||
const loadMore = () => {
|
||||
dispatchAction('loadMoreComments');
|
||||
dispatchAction('loadMoreComments', {});
|
||||
};
|
||||
|
||||
if (!pagination) {
|
@ -1,10 +1,12 @@
|
||||
import AppContext from '../../AppContext';
|
||||
import Comment from './Comment';
|
||||
import CommentComponent from './Comment';
|
||||
import RepliesPagination from './RepliesPagination';
|
||||
import {useContext} from 'react';
|
||||
import {Comment, useAppContext} from '../../AppContext';
|
||||
|
||||
const Replies = ({comment}) => {
|
||||
const {dispatchAction} = useContext(AppContext);
|
||||
type Props = {
|
||||
comment: Comment
|
||||
};
|
||||
const Replies: React.FC<Props> = ({comment}) => {
|
||||
const {dispatchAction} = useAppContext();
|
||||
|
||||
const repliesLeft = comment.count.replies - comment.replies.length;
|
||||
|
||||
@ -14,7 +16,7 @@ const Replies = ({comment}) => {
|
||||
|
||||
return (
|
||||
<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}/>}
|
||||
</div>
|
||||
);
|
@ -1,7 +1,11 @@
|
||||
import React from 'react';
|
||||
import {formatNumber} from '../../utils/helpers';
|
||||
|
||||
const RepliesPagination = ({loadMore, count}) => {
|
||||
type Props = {
|
||||
loadMore: () => void;
|
||||
count: number;
|
||||
};
|
||||
const RepliesPagination: React.FC<Props> = ({loadMore, count}) => {
|
||||
return (
|
||||
<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}>
|
@ -1,9 +1,12 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
import {Comment, useAppContext} from '../../../AppContext';
|
||||
import {ReactComponent as LikeIcon} from '../../../images/icons/like.svg';
|
||||
import {useContext, useState} from 'react';
|
||||
import {useState} from 'react';
|
||||
|
||||
function LikeButton({comment}) {
|
||||
const {dispatchAction, member, commentsEnabled} = useContext(AppContext);
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
};
|
||||
const LikeButton: React.FC<Props> = ({comment}) => {
|
||||
const {dispatchAction, member, commentsEnabled} = useAppContext();
|
||||
const [animationClass, setAnimation] = useState('');
|
||||
|
||||
const paidOnly = commentsEnabled === 'paid';
|
||||
@ -40,6 +43,6 @@ function LikeButton({comment}) {
|
||||
{comment.count.likes}
|
||||
</CustomTag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default LikeButton;
|
@ -1,11 +1,16 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
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 {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 {member, admin} = useContext(AppContext);
|
||||
const {member, admin} = useAppContext();
|
||||
|
||||
const toggleContextMenu = () => {
|
||||
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 {useAppContext} from '../../../AppContext';
|
||||
|
||||
function ReplyButton({disabled, isReplying, toggleReply}) {
|
||||
const {member} = useContext(AppContext);
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
isReplying: boolean;
|
||||
toggleReply: () => void;
|
||||
};
|
||||
const ReplyButton: React.FC<Props> = ({disabled, isReplying, toggleReply}) => {
|
||||
const {member} = useAppContext();
|
||||
|
||||
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}>
|
||||
<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;
|
||||
}
|
||||
};
|
||||
|
||||
export default ReplyButton;
|
@ -1,8 +1,11 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
import React, {useContext} from 'react';
|
||||
import {Comment, useAppContext} from '../../../AppContext';
|
||||
|
||||
const AdminContextMenu = ({comment, close}) => {
|
||||
const {dispatchAction} = useContext(AppContext);
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
close: () => void;
|
||||
};
|
||||
const AdminContextMenu: React.FC<Props> = ({comment, close}) => {
|
||||
const {dispatchAction} = useAppContext();
|
||||
|
||||
const hideComment = () => {
|
||||
dispatchAction('hideComment', comment);
|
||||
@ -19,11 +22,11 @@ const AdminContextMenu = ({comment, close}) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{
|
||||
isHidden ?
|
||||
isHidden ?
|
||||
<button className="w-full text-left text-[14px]" type="button" onClick={showComment}>
|
||||
<span>Show </span><span className="hidden sm:inline">comment</span>
|
||||
</button>
|
||||
:
|
||||
</button>
|
||||
:
|
||||
<button className="w-full text-left text-[14px]" type="button" onClick={hideComment}>
|
||||
<span>Hide </span><span className="hidden sm:inline">comment</span>
|
||||
</button>
|
@ -1,10 +1,15 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
import React, {useContext} from 'react';
|
||||
import React from 'react';
|
||||
import {Comment, useAppContext} from '../../../AppContext';
|
||||
|
||||
const AuthorContextMenu = ({comment, close, toggleEdit}) => {
|
||||
const {dispatchAction} = useContext(AppContext);
|
||||
type Props = {
|
||||
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);
|
||||
close();
|
||||
};
|
@ -1,14 +1,19 @@
|
||||
import AdminContextMenu from './AdminContextMenu';
|
||||
import AppContext from '../../../AppContext';
|
||||
import AuthorContextMenu from './AuthorContextMenu';
|
||||
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}) => {
|
||||
const {member, admin} = useContext(AppContext);
|
||||
type Props = {
|
||||
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 isAdmin = !!admin;
|
||||
const element = useRef();
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
@ -24,15 +29,15 @@ const CommentContextMenu = ({comment, close, toggleEdit}) => {
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', listener, {passive: true});
|
||||
window.removeEventListener('click', listener, {passive: true} as any);
|
||||
if (el && el !== window) {
|
||||
el.removeEventListener('click', listener, {passive: true});
|
||||
el.removeEventListener('click', listener, {passive: true} as any);
|
||||
}
|
||||
};
|
||||
}, [close]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event) => {
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
@ -42,12 +47,12 @@ const CommentContextMenu = ({comment, close, toggleEdit}) => {
|
||||
window.addEventListener('keydown', listener, {passive: true});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', listener, {passive: true});
|
||||
window.removeEventListener('keydown', listener, {passive: true} as any);
|
||||
};
|
||||
}, [close]);
|
||||
|
||||
// Prevent closing the context menu when clicking inside of it
|
||||
const stopPropagation = (event) => {
|
||||
const stopPropagation = (event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
@ -1,8 +1,12 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
import React, {useContext} from 'react';
|
||||
import React from 'react';
|
||||
import {useAppContext} from '../../../AppContext';
|
||||
|
||||
const NotAuthorContextMenu = ({comment, close}) => {
|
||||
const {dispatchAction} = useContext(AppContext);
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
close: () => void;
|
||||
};
|
||||
const NotAuthorContextMenu: React.FC<Props> = ({comment, close}) => {
|
||||
const {dispatchAction} = useAppContext();
|
||||
|
||||
const openModal = () => {
|
||||
dispatchAction('openPopup', {
|
@ -1,15 +1,21 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
import SecundaryForm from './SecundaryForm';
|
||||
import {default as React, useCallback, useContext, useEffect} from 'react';
|
||||
import {Comment, useAppContext} from '../../../AppContext';
|
||||
import {getEditorConfig} from '../../../utils/editor';
|
||||
import {useCallback, useEffect} from 'react';
|
||||
import {useEditor} from '@tiptap/react';
|
||||
|
||||
const EditForm = ({comment, parent, close}) => {
|
||||
const {dispatchAction} = useContext(AppContext);
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
parent?: Comment;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const EditForm: React.FC<Props> = ({comment, parent, close}) => {
|
||||
const {dispatchAction} = useAppContext();
|
||||
|
||||
const config = {
|
||||
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
|
||||
autofocus: false,
|
||||
content: comment.html
|
@ -1,13 +1,27 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
|
||||
import React from 'react';
|
||||
import {Avatar} from '../Avatar';
|
||||
import {Comment, useAppContext} from '../../../AppContext';
|
||||
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 {Transition} from '@headlessui/react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
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;
|
||||
|
||||
if (progress === 'sending') {
|
||||
@ -23,7 +37,7 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
|
||||
}, [editor]);
|
||||
|
||||
const submitForm = useCallback(async () => {
|
||||
if (editor.isEmpty) {
|
||||
if (!editor || editor.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -52,7 +66,7 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
|
||||
useEffect(() => {
|
||||
// Add some basic keyboard shortcuts
|
||||
// ESC to blur the editor
|
||||
const keyDownListener = (event) => {
|
||||
const keyDownListener = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
// CMD on MacOS or CTRL
|
||||
|
||||
@ -83,7 +97,7 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
|
||||
window.addEventListener('keydown', keyDownListener, {passive: true});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', keyDownListener, {passive: true});
|
||||
window.removeEventListener('keydown', keyDownListener, {passive: true} as any);
|
||||
};
|
||||
}, [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 (
|
||||
<Transition
|
||||
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}) => {
|
||||
const {member, dispatchAction} = useContext(AppContext);
|
||||
type FormProps = {
|
||||
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 [progress, setProgress] = useState('default');
|
||||
const [progress, setProgress] = useState<Progress>('default');
|
||||
const formEl = useRef(null);
|
||||
|
||||
const memberName = member?.name ?? comment?.member?.name;
|
||||
@ -161,7 +194,7 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
const preventIfFocused = (event) => {
|
||||
const preventIfFocused = (event: React.SyntheticEvent) => {
|
||||
if (editor?.isFocused) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@ -174,8 +207,7 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
|
||||
dispatchAction('openPopup', {
|
||||
type: 'addDetailsPopup',
|
||||
expertiseAutofocus: options.expertiseAutofocus ?? false,
|
||||
// WIP
|
||||
callback: function (succeeded) {
|
||||
callback: function (succeeded: boolean) {
|
||||
if (!editor || !formEl.current) {
|
||||
return;
|
||||
}
|
||||
@ -201,6 +233,10 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
|
||||
}, [openEditDetails]);
|
||||
|
||||
const focusEditor = useCallback(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.isFocused) {
|
||||
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} />
|
||||
</div>
|
||||
<div className='absolute left-0 top-1 flex h-12 w-full items-center justify-start'>
|
||||
<div className="mr-3 grow-0">
|
||||
<Avatar className="pointer-events-none" comment={comment} />
|
||||
<div className="pointer-events-none mr-3 grow-0">
|
||||
<Avatar comment={comment} />
|
||||
</div>
|
||||
<div className="grow-1 w-full">
|
||||
<FormHeader editExpertise={editExpertise} editName={editName} expertise={memberExpertise} name={memberName} show={isOpen} />
|
@ -1,12 +1,15 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
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 {scrollToElement} from '../../../utils/helpers';
|
||||
import {useAppContext} from '../../../AppContext';
|
||||
import {useEditor} from '@tiptap/react';
|
||||
|
||||
const MainForm = ({commentsCount}) => {
|
||||
const {postId, dispatchAction} = useContext(AppContext);
|
||||
type Props = {
|
||||
commentsCount: number
|
||||
};
|
||||
const MainForm: React.FC<Props> = ({commentsCount}) => {
|
||||
const {postId, dispatchAction} = useAppContext();
|
||||
|
||||
const config = {
|
||||
placeholder: (commentsCount === 0 ? 'Start the conversation' : 'Join the discussion'),
|
||||
@ -36,7 +39,7 @@ const MainForm = ({commentsCount}) => {
|
||||
|
||||
// Add some basic keyboard shortcuts
|
||||
// ESC to blur the editor
|
||||
const keyDownListener = (event) => {
|
||||
const keyDownListener = (event: KeyboardEvent) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
@ -47,15 +50,15 @@ const MainForm = ({commentsCount}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let focusedElement = document.activeElement;
|
||||
let focusedElement = document.activeElement as HTMLElement | null;
|
||||
while (focusedElement && focusedElement.tagName === 'IFRAME') {
|
||||
if (!focusedElement.contentDocument) {
|
||||
if (!(focusedElement as HTMLIFrameElement).contentDocument) {
|
||||
// CORS issue
|
||||
// disable the C shortcut when we have a focused external iframe
|
||||
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');
|
||||
|
||||
@ -74,7 +77,7 @@ const MainForm = ({commentsCount}) => {
|
||||
window.addEventListener('keydown', keyDownListener, {passive: true});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', keyDownListener, {passive: true});
|
||||
window.removeEventListener('keydown', keyDownListener, {passive: true} as any);
|
||||
};
|
||||
}, [editor]);
|
||||
|
@ -1,14 +1,18 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
import SecundaryForm from './SecundaryForm';
|
||||
import {default as React, useCallback, useContext} from 'react';
|
||||
import {Comment, useAppContext} from '../../../AppContext';
|
||||
import {getEditorConfig} from '../../../utils/editor';
|
||||
import {scrollToElement} from '../../../utils/helpers';
|
||||
import {useCallback} from 'react';
|
||||
import {useEditor} from '@tiptap/react';
|
||||
import {useRefCallback} from '../../../utils/hooks';
|
||||
|
||||
const ReplyForm = ({parent, close}) => {
|
||||
const {postId, dispatchAction} = useContext(AppContext);
|
||||
const [, setForm] = useRefCallback(scrollToElement);
|
||||
type Props = {
|
||||
parent: Comment;
|
||||
close: () => void;
|
||||
}
|
||||
const ReplyForm: React.FC<Props> = ({parent, close}) => {
|
||||
const {postId, dispatchAction} = useAppContext();
|
||||
const [, setForm] = useRefCallback<HTMLDivElement>(scrollToElement);
|
||||
|
||||
const config = {
|
||||
placeholder: 'Reply to comment',
|
||||
@ -18,7 +22,7 @@ const ReplyForm = ({parent, close}) => {
|
||||
const editor = useEditor({
|
||||
...getEditorConfig(config)
|
||||
});
|
||||
|
||||
|
||||
const submit = useCallback(async ({html}) => {
|
||||
// Send comment to server
|
||||
await dispatchAction('addReply', {
|
@ -1,18 +1,27 @@
|
||||
import AppContext from '../../../AppContext';
|
||||
import Form from './Form';
|
||||
import React, {useContext, useEffect} from 'react';
|
||||
import Form, {SubmitSize} from './Form';
|
||||
import {Editor} from '@tiptap/react';
|
||||
import {isMobile} from '../../../utils/helpers';
|
||||
import {useAppContext} from '../../../AppContext';
|
||||
import {useEffect} from 'react';
|
||||
import {useSecondUpdate} from '../../../utils/hooks';
|
||||
|
||||
const SecundaryForm = ({editor, submit, close, closeIfNotChanged, submitText, submitSize}) => {
|
||||
const {dispatchAction, secundaryFormCount} = useContext(AppContext);
|
||||
type Props = {
|
||||
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
|
||||
useEffect(() => {
|
||||
dispatchAction('increaseSecundaryFormCount');
|
||||
dispatchAction('increaseSecundaryFormCount', {});
|
||||
|
||||
return () => {
|
||||
dispatchAction('decreaseSecundaryFormCount');
|
||||
dispatchAction('decreaseSecundaryFormCount', {});
|
||||
};
|
||||
}, [dispatchAction]);
|
||||
|
@ -1,13 +1,17 @@
|
||||
import AppContext from '../../AppContext';
|
||||
import CloseButton from './CloseButton';
|
||||
import React, {useContext, useEffect, useRef, useState} from 'react';
|
||||
import {Transition} from '@headlessui/react';
|
||||
import {isMobile} from '../../utils/helpers';
|
||||
import {useAppContext} from '../../AppContext';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
const AddDetailsPopup = (props) => {
|
||||
const inputNameRef = useRef(null);
|
||||
const inputExpertiseRef = useRef(null);
|
||||
const {dispatchAction, member, accentColor} = useContext(AppContext);
|
||||
type Props = {
|
||||
callback: (succeeded: boolean) => void,
|
||||
expertiseAutofocus?: boolean
|
||||
};
|
||||
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 [expertise, setExpertise] = useState(member.expertise ?? '');
|
||||
@ -21,12 +25,12 @@ const AddDetailsPopup = (props) => {
|
||||
|
||||
const [error, setError] = useState({name: '', expertise: ''});
|
||||
|
||||
const stopPropagation = (event) => {
|
||||
const stopPropagation = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const close = (succeeded) => {
|
||||
dispatchAction('closePopup');
|
||||
const close = (succeeded: boolean) => {
|
||||
dispatchAction('closePopup', {});
|
||||
props.callback(succeeded);
|
||||
};
|
||||
|
||||
@ -38,7 +42,7 @@ const AddDetailsPopup = (props) => {
|
||||
});
|
||||
close(true);
|
||||
} else {
|
||||
setError({name: 'Enter your name'});
|
||||
setError({name: 'Enter your name', expertise: ''});
|
||||
setName('');
|
||||
inputNameRef.current?.focus();
|
||||
}
|
||||
@ -61,8 +65,8 @@ const AddDetailsPopup = (props) => {
|
||||
}
|
||||
}, [inputNameRef, inputExpertiseRef, props.expertiseAutofocus]);
|
||||
|
||||
const renderExampleProfiles = (index) => {
|
||||
const renderEl = (profile) => {
|
||||
const renderExampleProfiles = () => {
|
||||
const renderEl = (profile: {name: string, avatar: string, expertise: string}) => {
|
||||
return (
|
||||
<Transition
|
||||
key={profile.name}
|
||||
@ -138,18 +142,19 @@ const AddDetailsPopup = (props) => {
|
||||
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'}`}
|
||||
id="comments-name"
|
||||
maxLength="64"
|
||||
maxLength={64}
|
||||
name="name"
|
||||
placeholder="Jamie Larson"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setName(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setName(e.target.value);
|
||||
submit();
|
||||
setName(e.currentTarget.value);
|
||||
// eslint-disable-next-line no-console
|
||||
submit().catch(console.error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -167,14 +172,15 @@ const AddDetailsPopup = (props) => {
|
||||
type="text"
|
||||
value={expertise}
|
||||
onChange={(e) => {
|
||||
let expertiseText = e.target.value;
|
||||
let expertiseText = e.currentTarget.value;
|
||||
setExpertiseCharsLeft(maxExpertiseChars - expertiseText.length);
|
||||
setExpertise(expertiseText);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setExpertise(e.target.value);
|
||||
submit();
|
||||
setExpertise(e.currentTarget.value);
|
||||
// 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`}
|
||||
style={{backgroundColor: accentColor ?? '#000000'}}
|
||||
type="button"
|
||||
onClick={submit}
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line no-console
|
||||
submit().catch(console.error);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
@ -1,9 +1,12 @@
|
||||
import React from 'react';
|
||||
import {ReactComponent as CloseIcon} from '../../images/icons/close.svg';
|
||||
|
||||
const CloseButton = (props) => {
|
||||
type Props = {
|
||||
close: () => void;
|
||||
}
|
||||
const CloseButton: React.FC<Props> = ({close}) => {
|
||||
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]" />
|
||||
</button>
|
||||
);
|
@ -1,21 +1,27 @@
|
||||
import AppContext from '../../AppContext';
|
||||
import React, {useContext, useEffect} from 'react';
|
||||
import {PopupFrame} from '../Frame';
|
||||
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
|
||||
const {dispatchAction} = useContext(AppContext);
|
||||
const {dispatchAction} = useAppContext();
|
||||
|
||||
const close = (event) => {
|
||||
dispatchAction('closePopup');
|
||||
const close = () => {
|
||||
dispatchAction('closePopup', {});
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event) => {
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
@ -23,7 +29,7 @@ const GenericPopup = ({show, children, title, callback}) => {
|
||||
window.addEventListener('keydown', listener, {passive: true});
|
||||
|
||||
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 React, {useContext, useState} from 'react';
|
||||
import {Comment} from '../../AppContext';
|
||||
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
|
||||
import {ReactComponent as SuccessIcon} from '../../images/icons/success.svg';
|
||||
import {useAppContext} from '../../AppContext';
|
||||
import {useState} from 'react';
|
||||
|
||||
const ReportPopup = (props) => {
|
||||
const {dispatchAction} = useContext(AppContext);
|
||||
const ReportPopup = ({comment}: {comment: Comment}) => {
|
||||
const {dispatchAction} = useAppContext();
|
||||
const [progress, setProgress] = useState('default');
|
||||
|
||||
let buttonColor = 'bg-red-600';
|
||||
@ -27,15 +28,15 @@ const ReportPopup = (props) => {
|
||||
buttonIcon = <SuccessIcon className="mr-2 h-[16px] w-[16px]" />;
|
||||
}
|
||||
|
||||
const stopPropagation = (event) => {
|
||||
const stopPropagation = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const close = (event) => {
|
||||
dispatchAction('closePopup');
|
||||
const close = () => {
|
||||
dispatchAction('closePopup', {});
|
||||
};
|
||||
|
||||
const submit = (event) => {
|
||||
const submit = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
setProgress('sending');
|
||||
@ -43,7 +44,7 @@ const ReportPopup = (props) => {
|
||||
// purposely faking the timing of the report being sent for user feedback purposes
|
||||
setTimeout(() => {
|
||||
setProgress('sent');
|
||||
dispatchAction('reportComment', props.comment);
|
||||
dispatchAction('reportComment', comment);
|
||||
|
||||
setTimeout(() => {
|
||||
close();
|
||||
@ -66,7 +67,7 @@ const ReportPopup = (props) => {
|
||||
</button>
|
||||
<button className="font-sans text-sm font-medium text-neutral-500 dark:text-neutral-400" type="button" onClick={close}>Cancel</button>
|
||||
</div>
|
||||
<CloseButton close={() => close(false)} />
|
||||
<CloseButton close={close} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -3,34 +3,43 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {ROOT_DIV_ID} from './utils/constants';
|
||||
|
||||
function addRootDiv() {
|
||||
let scriptTag = document.currentScript;
|
||||
function getScriptTag(): HTMLElement {
|
||||
let scriptTag = document.currentScript as HTMLElement | null;
|
||||
|
||||
if (!scriptTag && import.meta.env.DEV) {
|
||||
// In development mode, use any script tag (because in ESM mode, document.currentScript is not set)
|
||||
scriptTag = document.querySelector('script[data-ghost-comments]');
|
||||
}
|
||||
|
||||
// We need to inject the comment box at the same place as the script tag
|
||||
if (scriptTag) {
|
||||
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.');
|
||||
if (!scriptTag) {
|
||||
throw new Error('[Comments-UI] Cannot find current script tag');
|
||||
}
|
||||
|
||||
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}
|
||||
*/
|
||||
const scriptTag = document.querySelector('script[data-ghost-comments]');
|
||||
let dataset = scriptTag?.dataset;
|
||||
|
||||
if (!scriptTag && process.env.NODE_ENV === 'development') {
|
||||
@ -46,7 +55,7 @@ function getSiteData() {
|
||||
const adminUrl = dataset.admin;
|
||||
const postId = dataset.postId;
|
||||
const colorScheme = dataset.colorScheme;
|
||||
const avatarSaturation = dataset.avatarSaturation;
|
||||
const avatarSaturation = dataset.avatarSaturation ? parseInt(dataset.avatarSaturation) : undefined;
|
||||
const accentColor = dataset.accentColor;
|
||||
const commentsEnabled = dataset.commentsEnabled;
|
||||
const title = dataset.title === 'null' ? null : dataset.title;
|
||||
@ -64,24 +73,22 @@ function handleTokenUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
function setup({siteUrl}) {
|
||||
addRootDiv();
|
||||
handleTokenUrl();
|
||||
}
|
||||
|
||||
function init() {
|
||||
const scriptTag = getScriptTag();
|
||||
const root = getRootDiv(scriptTag);
|
||||
|
||||
// const customSiteUrl = getSiteUrl();
|
||||
const {siteUrl: customSiteUrl, ...siteData} = getSiteData();
|
||||
const {siteUrl: customSiteUrl, ...siteData} = getSiteData(scriptTag);
|
||||
const siteUrl = customSiteUrl || window.location.origin;
|
||||
|
||||
try {
|
||||
setup({siteUrl});
|
||||
handleTokenUrl();
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
{<App customSiteUrl={customSiteUrl} siteUrl={siteUrl} {...siteData} />}
|
||||
</React.StrictMode>,
|
||||
document.getElementById(ROOT_DIV_ID)
|
||||
root
|
||||
);
|
||||
} catch (e) {
|
||||
// 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';
|
||||
|
||||
test('should call counts endpoint', () => {
|
||||
jest.spyOn(window, 'fetch');
|
||||
window.fetch.mockResolvedValueOnce({
|
||||
const spy = vi.spyOn(window, 'fetch');
|
||||
spy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({success: true})
|
||||
});
|
||||
} as any);
|
||||
|
||||
const api = setupGhostApi({});
|
||||
const api = setupGhostApi({siteUrl: 'http://localhost:3000', apiUrl: '', apiKey: ''});
|
||||
|
||||
api.comments.count({postId: null});
|
||||
|
||||
expect(window.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/members/api/comments/counts/',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: undefined
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should call counts endpoint with postId query param', () => {
|
||||
jest.spyOn(window, 'fetch');
|
||||
window.fetch.mockResolvedValueOnce({
|
||||
const spy = vi.spyOn(window, 'fetch');
|
||||
spy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({success: true})
|
||||
});
|
||||
} as any);
|
||||
|
||||
const api = setupGhostApi({});
|
||||
const api = setupGhostApi({siteUrl: 'http://localhost:3000', apiUrl: '', apiKey: ''});
|
||||
|
||||
api.comments.count({postId: '123'});
|
||||
|
||||
expect(window.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(window.fetch).toHaveBeenCalledWith(
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
'http://localhost:3000/members/api/comments/counts/?ids=123',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
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
|
||||
};
|
||||
|
||||
export const hasMode = (modes = [], options = {}) => {
|
||||
export const hasMode = (modes: ('dev' | 'test')[] = [], options: {customSiteUrl?: string} = {}) => {
|
||||
return modes.some((mode) => {
|
||||
const modeFn = modeFns[mode];
|
||||
return !!(modeFn && modeFn(options));
|
@ -5,8 +5,9 @@ import Link from '@tiptap/extension-link';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
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 {
|
||||
extensions: [
|
||||
Document,
|
@ -10,10 +10,10 @@ describe('formatNumber', function () {
|
||||
});
|
||||
|
||||
it('handles undefined', function () {
|
||||
expect(helpers.formatNumber()).toEqual('');
|
||||
expect((helpers.formatNumber as any)()).toEqual('');
|
||||
});
|
||||
|
||||
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;
|
||||
if (state && state.popupNotification) {
|
||||
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) {
|
||||
return '';
|
||||
}
|
||||
@ -24,7 +35,7 @@ export function formatNumber(number) {
|
||||
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 now = new Date();
|
||||
|
||||
@ -82,7 +93,7 @@ export function formatRelativeTime(dateString) {
|
||||
return `${Math.floor(diff)} weeks ago`;
|
||||
}
|
||||
|
||||
export function formatExplicitTime(dateString) {
|
||||
export function formatExplicitTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export function getInitials(name) {
|
||||
export function getInitials(name: string): string {
|
||||
if (!name) {
|
||||
return '';
|
||||
}
|
||||
@ -117,22 +128,22 @@ export function isMobile() {
|
||||
return (Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) < 480);
|
||||
}
|
||||
|
||||
export function isCommentPublished(comment) {
|
||||
export function isCommentPublished(comment: Comment) {
|
||||
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
|
||||
*/
|
||||
export const getScrollToPosition = (element) => {
|
||||
export const getScrollToPosition = (element: HTMLElement) => {
|
||||
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
|
||||
// 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)
|
||||
while (currentWindow !== window) {
|
||||
while (currentWindow && currentWindow !== window) {
|
||||
const currentParentWindow = currentWindow.parent;
|
||||
for (let idx = 0; idx < currentParentWindow.frames.length; idx++) {
|
||||
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
|
||||
*/
|
||||
export const scrollToElement = (element) => {
|
||||
export const scrollToElement = (element: HTMLElement) => {
|
||||
// Is the form already in view?
|
||||
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 {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
|
||||
import {useAppContext} from '../AppContext';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function useRefCallback(setup, clear) {
|
||||
const ref = useRef(null);
|
||||
export function useRefCallback<T>(setup: (element: T) => void, clear?: (element: T) => void) {
|
||||
const ref = useRef<T | null>(null);
|
||||
const setRef = useCallback((node) => {
|
||||
if (ref.current && clear) {
|
||||
// Make sure to cleanup any events/references added to the last instance
|
||||
clear(ref.current);
|
||||
}
|
||||
|
||||
|
||||
if (node && setup) {
|
||||
// Check if a node is actually passed. Otherwise node would be null.
|
||||
// You can now do what you need to, addEventListeners, measure, etc.
|
||||
setup(node);
|
||||
}
|
||||
|
||||
|
||||
// Save a reference to the node
|
||||
ref.current = node;
|
||||
}, [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)
|
||||
* @param {Same} fn
|
||||
* @param {*} inputs
|
||||
* @param {Same} fn
|
||||
* @param {*} inputs
|
||||
*/
|
||||
export function useSecondUpdate(fn, inputs) {
|
||||
export function useSecondUpdate(fn: () => void, inputs: React.DependencyList) {
|
||||
const didMountRef = useRef(0);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (didMountRef.current >= 2) {
|
||||
if (didMountRef.current >= 2) {
|
||||
return fn();
|
||||
}
|
||||
didMountRef.current += 1;
|
||||
@ -44,15 +44,15 @@ export function useSecondUpdate(fn, inputs) {
|
||||
}, inputs);
|
||||
}
|
||||
|
||||
export function usePopupOpen(type) {
|
||||
const {popup} = useContext(AppContext);
|
||||
export function usePopupOpen(type: string) {
|
||||
const {popup} = useAppContext();
|
||||
return popup?.type === type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 formatRelativeTime(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
|
||||
this.comments.sort((a, b) => {
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
|
||||
integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
|
||||
@ -16537,20 +16537,6 @@ eslint-plugin-babel@5.3.1:
|
||||
dependencies:
|
||||
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:
|
||||
version "11.8.0"
|
||||
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/regexpp" "^4.5.0"
|
||||
|
||||
eslint-plugin-es@^3.0.0:
|
||||
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:
|
||||
eslint-plugin-filenames@allouis/eslint-plugin-filenames#15dc354f4e3d155fc2d6ae082dbfc26377539a18:
|
||||
version "1.3.2"
|
||||
resolved "https://codeload.github.com/allouis/eslint-plugin-filenames/tar.gz/15dc354f4e3d155fc2d6ae082dbfc26377539a18"
|
||||
dependencies:
|
||||
@ -16602,19 +16580,6 @@ eslint-plugin-flowtype@^8.0.3:
|
||||
lodash "^4.17.21"
|
||||
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:
|
||||
version "3.2.0"
|
||||
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"
|
||||
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:
|
||||
version "4.6.0"
|
||||
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"
|
||||
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:
|
||||
version "5.10.2"
|
||||
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:
|
||||
"@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:
|
||||
version "42.0.0"
|
||||
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"
|
||||
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:
|
||||
version "7.32.0"
|
||||
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"
|
||||
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:
|
||||
version "3.2.25"
|
||||
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"
|
||||
functions-have-names "^1.2.2"
|
||||
|
||||
regexpp@^3.0.0, regexpp@^3.1.0:
|
||||
regexpp@^3.1.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
|
||||
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:
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
||||
|
Loading…
Reference in New Issue
Block a user