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:
Simon Backx 2023-06-27 14:51:37 +02:00 committed by GitHub
parent 549e608b27
commit 331533d724
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1148 additions and 907 deletions

16
.github/dev.js vendored
View File

@ -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() {

View File

@ -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'
}
};

View File

@ -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",

View File

@ -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';

View File

@ -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);

View File

@ -1,6 +0,0 @@
// Ref: https://reactjs.org/docs/context.html
import React from 'react';
const AppContext = React.createContext({});
export default AppContext;

View 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);

View File

@ -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 {};
}

View File

@ -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;
}

View File

@ -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',

View File

@ -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() {

View File

@ -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;

View File

@ -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) {

View File

@ -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

View File

@ -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 --

View File

@ -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';

View File

@ -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;

View File

@ -1,4 +1,3 @@
import React from 'react';
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
function Loading() {

View File

@ -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) {

View File

@ -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>
);

View File

@ -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}>

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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>

View File

@ -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();
};

View File

@ -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();
};

View File

@ -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', {

View File

@ -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

View File

@ -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} />

View File

@ -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]);

View File

@ -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', {

View File

@ -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]);

View File

@ -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>

View File

@ -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>
);

View File

@ -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);
};
});

View File

@ -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>
);
};

View File

@ -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

View File

@ -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;

View 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]

View File

@ -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()
}));

View 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
View 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;
}

View File

@ -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;

View File

@ -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
}),
})
);
});

View 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>;

View File

@ -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));

View File

@ -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,

View File

@ -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('');
});
});

View File

@ -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;

View File

@ -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]);

View File

@ -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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -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();

View 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" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "package.json"]
}

View File

@ -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
}
};
});

View 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
View File

@ -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==