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 // To make this work, you'll need a CADDY server running in front
// Note the port is different because of this extra layer. Use the following Caddyfile: // Note the port is different because of this extra layer. Use the following Caddyfile:
// https://localhost:4174 { // https://localhost:4174 {
// reverse_proxy 127.0.0.1:4173 // reverse_proxy http://127.0.0.1:4173
// } // }
COMMAND_GHOST.env['editor__url'] = 'https://localhost:4174/koenig-lexical.umd.js'; COMMAND_GHOST.env['editor__url'] = 'https://localhost:4174/koenig-lexical.umd.js';
@ -159,6 +159,18 @@ if (DASH_DASH_ARGS.includes('lexical')) {
} }
if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) { if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) {
if (DASH_DASH_ARGS.includes('https')) {
// Safari needs HTTPS for it to work
// To make this work, you'll need a CADDY server running in front
// Note the port is different because of this extra layer. Use the following Caddyfile:
// https://localhost:7174 {
// reverse_proxy http://127.0.0.1:7173
// }
COMMAND_GHOST.env['comments__url'] = 'https://localhost:7174/comments-ui.min.js';
} else {
COMMAND_GHOST.env['comments__url'] = 'http://localhost:7173/comments-ui.min.js';
}
commands.push({ commands.push({
name: 'comments', name: 'comments',
command: 'yarn dev', command: 'yarn dev',
@ -166,8 +178,6 @@ if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) {
prefixColor: '#E55137', prefixColor: '#E55137',
env: {} env: {}
}); });
COMMAND_GHOST.env['comments__url'] = 'http://localhost:7174/comments-ui.min.js';
} }
async function handleStripe() { async function handleStripe() {

View File

@ -36,6 +36,9 @@ module.exports = {
'tailwindcss/migration-from-tailwind-2': ['warn', {config: 'tailwind.config.cjs'}], 'tailwindcss/migration-from-tailwind-2': ['warn', {config: 'tailwind.config.cjs'}],
'tailwindcss/no-arbitrary-value': 'off', 'tailwindcss/no-arbitrary-value': 'off',
'tailwindcss/no-custom-classname': 'off', 'tailwindcss/no-custom-classname': 'off',
'tailwindcss/no-contradicting-classname': ['error', {config: 'tailwind.config.cjs'}] 'tailwindcss/no-contradicting-classname': ['error', {config: 'tailwind.config.cjs'}],
// This rule doesn't work correctly with TypeScript, and TypeScript has its own better version
'no-undef': 'off'
} }
}; };

View File

@ -15,7 +15,7 @@
"registry": "https://registry.npmjs.org/" "registry": "https://registry.npmjs.org/"
}, },
"scripts": { "scripts": {
"dev": "concurrently \"yarn preview -l silent\" \"yarn build:watch\"", "dev": "concurrently \"yarn preview --host -l silent\" \"yarn build:watch\"",
"dev:test": "vite build && vite preview --port 7175", "dev:test": "vite build && vite preview --port 7175",
"build": "vite build", "build": "vite build",
"build:watch": "vite build --watch", "build:watch": "vite build --watch",
@ -24,7 +24,7 @@
"test:e2e": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test", "test:e2e": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test",
"test:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=1000 yarn test:e2e --headed", "test:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=1000 yarn test:e2e --headed",
"test:e2e:full": "ALL_BROWSERS=1 yarn test:e2e", "test:e2e:full": "ALL_BROWSERS=1 yarn test:e2e",
"lint": "eslint src --ext .js --cache", "lint": "eslint src --ext .js,.ts,.jsx,.tsx --cache",
"preship": "yarn lint", "preship": "yarn lint",
"ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; fi", "ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; fi",
"postship": "git push ${GHOST_UPSTREAM:-origin} --follow-tags && npm publish", "postship": "git push ${GHOST_UPSTREAM:-origin} --follow-tags && npm publish",
@ -61,15 +61,18 @@
"@testing-library/jest-dom": "5.16.5", "@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5", "@testing-library/react": "12.1.5",
"@testing-library/user-event": "14.4.3", "@testing-library/user-event": "14.4.3",
"@typescript-eslint/eslint-plugin": "5.57.1",
"@typescript-eslint/parser": "5.57.1",
"@vitejs/plugin-react": "4.0.1", "@vitejs/plugin-react": "4.0.1",
"@vitest/coverage-v8": "0.32.2", "@vitest/coverage-v8": "0.32.2",
"autoprefixer": "10.4.14", "autoprefixer": "10.4.14",
"bson-objectid": "2.0.4", "bson-objectid": "2.0.4",
"concurrently": "8.2.0", "concurrently": "8.2.0",
"eslint": "8.43.0", "eslint": "8.38.0",
"eslint-config-react-app": "7.0.1", "eslint-plugin-ghost": "3.2.0",
"eslint-plugin-ghost": "2.12.0", "eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-tailwindcss": "^3.6.0", "eslint-plugin-react-refresh": "0.3.4",
"eslint-plugin-tailwindcss": "3.11.0",
"jsdom": "22.1.0", "jsdom": "22.1.0",
"postcss": "8.4.24", "postcss": "8.4.24",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",

View File

@ -1,9 +1,9 @@
import AppContext from './AppContext';
import ContentBox from './components/ContentBox'; import ContentBox from './components/ContentBox';
import PopupBox from './components/PopupBox'; import PopupBox from './components/PopupBox';
import React from 'react'; import React from 'react';
import setupGhostApi from './utils/api'; import setupGhostApi from './utils/api';
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions'; import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
import {AppContext} from './AppContext';
import {CommentsFrame} from './components/Frame'; import {CommentsFrame} from './components/Frame';
import {createPopupNotification} from './utils/helpers'; import {createPopupNotification} from './utils/helpers';
import {hasMode} from './utils/check-mode'; import {hasMode} from './utils/check-mode';

View File

@ -2,7 +2,7 @@ import App from './App';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import {ROOT_DIV_ID} from './utils/constants'; import {ROOT_DIV_ID} from './utils/constants';
import {act, fireEvent, render, waitFor, within} from '@testing-library/react'; import {act, fireEvent, render, waitFor, within} from '@testing-library/react';
import {buildComment, buildMember} from './utils/test-utils'; import {buildComment, buildMember} from '../test/utils/fixtures';
function renderApp({member = null, documentStyles = {}, props = {}} = {}) { function renderApp({member = null, documentStyles = {}, props = {}} = {}) {
const postId = 'my-post'; const postId = 'my-post';
@ -80,7 +80,7 @@ function renderApp({member = null, documentStyles = {}, props = {}} = {}) {
} }
beforeEach(() => { beforeEach(() => {
window.scrollTo = jest.fn(); window.scrollTo = vi.fn();
Range.prototype.getClientRects = function getClientRects() { Range.prototype.getClientRects = function getClientRects() {
return [ return [
{ {
@ -98,7 +98,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe('Auth frame', () => { describe('Auth frame', () => {
@ -148,7 +148,7 @@ describe('Dark mode', () => {
describe('Comments', () => { describe('Comments', () => {
it('renders comments', async () => { it('renders comments', async () => {
const {api, iframeDocument} = renderApp(); const {api, iframeDocument} = renderApp();
jest.spyOn(api.comments, 'browse').mockImplementation(() => { vi.spyOn(api.comments, 'browse').mockImplementation(() => {
return { return {
comments: [ comments: [
buildComment({html: '<p>This is a comment body</p>'}) buildComment({html: '<p>This is a comment body</p>'})
@ -174,7 +174,7 @@ describe('Comments', () => {
const limit = 5; const limit = 5;
const {api, iframeDocument} = renderApp(); const {api, iframeDocument} = renderApp();
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => { vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
if (page === 2) { if (page === 2) {
return { return {
comments: new Array(1).fill({}).map(() => buildComment({html: '<p>This is a paginated comment</p>'})), comments: new Array(1).fill({}).map(() => buildComment({html: '<p>This is a paginated comment</p>'})),
@ -216,7 +216,7 @@ describe('Comments', () => {
const limit = 5; const limit = 5;
const {api, iframeDocument} = renderApp(); const {api, iframeDocument} = renderApp();
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => { vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
return { return {
comments: new Array(limit).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>', member: null})), comments: new Array(limit).fill({}).map(() => buildComment({html: '<p>This is a comment body</p>', member: null})),
meta: { meta: {
@ -239,7 +239,7 @@ describe('Comments', () => {
const limit = 5; const limit = 5;
const {api, iframeDocument} = renderApp(); const {api, iframeDocument} = renderApp();
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => { vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
if (page === 2) { if (page === 2) {
throw new Error('Not requested'); throw new Error('Not requested');
} }
@ -281,7 +281,7 @@ describe('Likes', () => {
member member
}); });
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => { vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
if (page === 2) { if (page === 2) {
throw new Error('Not requested'); throw new Error('Not requested');
} }
@ -299,8 +299,8 @@ describe('Likes', () => {
}; };
}); });
const likeSpy = jest.spyOn(api.comments, 'like'); const likeSpy = vi.spyOn(api.comments, 'like');
const unlikeSpy = jest.spyOn(api.comments, 'unlike'); const unlikeSpy = vi.spyOn(api.comments, 'unlike');
const comment = await within(iframeDocument).findByTestId('comment-component'); const comment = await within(iframeDocument).findByTestId('comment-component');
@ -346,7 +346,7 @@ describe('Replies', () => {
member member
}); });
jest.spyOn(api.comments, 'browse').mockImplementation(({page}) => { vi.spyOn(api.comments, 'browse').mockImplementation(({page}) => {
if (page === 2) { if (page === 2) {
throw new Error('Not requested'); throw new Error('Not requested');
} }
@ -364,7 +364,7 @@ describe('Replies', () => {
}; };
}); });
const repliesSpy = jest.spyOn(api.comments, 'replies'); const repliesSpy = vi.spyOn(api.comments, 'replies');
const comments = await within(iframeDocument).findAllByTestId('comment-component'); const comments = await within(iframeDocument).findAllByTestId('comment-component');
expect(comments).toHaveLength(limit); expect(comments).toHaveLength(limit);

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; let page = 1;
if (state.pagination && state.pagination.page) { if (state.pagination && state.pagination.page) {
page = state.pagination.page + 1; page = state.pagination.page + 1;
@ -12,7 +16,7 @@ async function loadMoreComments({state, api}) {
}; };
} }
async function loadMoreReplies({state, api, data: {comment, limit}}) { async function loadMoreReplies({state, api, data: {comment, limit}}: {state: AppContextType, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise<Partial<AppContextType>> {
const data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit}); const data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
// Note: we store the comments from new to old, and show them in reverse order // Note: we store the comments from new to old, and show them in reverse order
@ -29,7 +33,7 @@ async function loadMoreReplies({state, api, data: {comment, limit}}) {
}; };
} }
async function addComment({state, api, data: comment}) { async function addComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: AddComment}) {
const data = await api.comments.add({comment}); const data = await api.comments.add({comment});
comment = data.comments[0]; comment = data.comments[0];
@ -39,7 +43,7 @@ async function addComment({state, api, data: comment}) {
}; };
} }
async function addReply({state, api, data: {reply, parent}}) { async function addReply({state, api, data: {reply, parent}}: {state: AppContextType, api: GhostApi, data: {reply: any, parent: any}}) {
let comment = reply; let comment = reply;
comment.parent_id = parent.id; comment.parent_id = parent.id;
@ -69,7 +73,7 @@ async function addReply({state, api, data: {reply, parent}}) {
}; };
} }
async function hideComment({state, adminApi, data: comment}) { async function hideComment({state, adminApi, data: comment}: {state: AppContextType, adminApi: any, data: {id: string}}) {
await adminApi.hideComment(comment.id); await adminApi.hideComment(comment.id);
return { return {
@ -102,7 +106,7 @@ async function hideComment({state, adminApi, data: comment}) {
}; };
} }
async function showComment({state, api, adminApi, data: comment}) { async function showComment({state, api, adminApi, data: comment}: {state: AppContextType, api: GhostApi, adminApi: any, data: {id: string}}) {
await adminApi.showComment(comment.id); await adminApi.showComment(comment.id);
// We need to refetch the comment, to make sure we have an up to date HTML content // We need to refetch the comment, to make sure we have an up to date HTML content
@ -133,7 +137,7 @@ async function showComment({state, api, adminApi, data: comment}) {
}; };
} }
async function likeComment({state, api, data: comment}) { async function likeComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
await api.comments.like({comment}); await api.comments.like({comment});
return { return {
@ -173,13 +177,13 @@ async function likeComment({state, api, data: comment}) {
}; };
} }
async function reportComment({state, api, data: comment}) { async function reportComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
await api.comments.report({comment}); await api.comments.report({comment});
return {}; return {};
} }
async function unlikeComment({state, api, data: comment}) { async function unlikeComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
await api.comments.unlike({comment}); await api.comments.unlike({comment});
return { return {
@ -218,7 +222,7 @@ async function unlikeComment({state, api, data: comment}) {
}; };
} }
async function deleteComment({state, api, data: comment}) { async function deleteComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
await api.comments.edit({ await api.comments.edit({
comment: { comment: {
id: comment.id, id: comment.id,
@ -256,7 +260,7 @@ async function deleteComment({state, api, data: comment}) {
}; };
} }
async function editComment({state, api, data: {comment, parent}}) { async function editComment({state, api, data: {comment, parent}}: {state: AppContextType, api: GhostApi, data: {comment: Partial<Comment> & {id: string}, parent?: Comment}}) {
const data = await api.comments.edit({ const data = await api.comments.edit({
comment comment
}); });
@ -284,10 +288,10 @@ async function editComment({state, api, data: {comment, parent}}) {
}; };
} }
async function updateMember({data, state, api}) { async function updateMember({data, state, api}: {data: {name: string, expertise: string}, state: AppContextType, api: GhostApi}) {
const {name, expertise} = data; const {name, expertise} = data;
const patchData = {}; const patchData: {name?: string, expertise?: string} = {};
const originalName = state?.member?.name; const originalName = state?.member?.name;
if (name && originalName !== name) { if (name && originalName !== name) {
@ -320,7 +324,7 @@ async function updateMember({data, state, api}) {
return null; return null;
} }
function openPopup({data}) { function openPopup({data}: {data: Page}) {
return { return {
popup: data popup: data
}; };
@ -332,27 +336,29 @@ function closePopup() {
}; };
} }
function increaseSecundaryFormCount({state}) { function increaseSecundaryFormCount({state}: {state: AppContextType}) {
return { return {
secundaryFormCount: state.secundaryFormCount + 1 secundaryFormCount: state.secundaryFormCount + 1
}; };
} }
function decreaseSecundaryFormCount({state}) { function decreaseSecundaryFormCount({state}: {state: AppContextType}) {
return { return {
secundaryFormCount: state.secundaryFormCount - 1 secundaryFormCount: state.secundaryFormCount - 1
}; };
} }
// Sync actions make use of setState((currentState) => newState), to avoid 'race' conditions // Sync actions make use of setState((currentState) => newState), to avoid 'race' conditions
const SyncActions = { export const SyncActions = {
openPopup, openPopup,
closePopup, closePopup,
increaseSecundaryFormCount, increaseSecundaryFormCount,
decreaseSecundaryFormCount decreaseSecundaryFormCount
}; };
const Actions = { export type SyncActionType = keyof typeof SyncActions;
export const Actions = {
// Put your actions here // Put your actions here
addComment, addComment,
editComment, editComment,
@ -368,25 +374,27 @@ const Actions = {
updateMember updateMember
}; };
export function isSyncAction(action) { export type ActionType = keyof typeof Actions;
return !!SyncActions[action];
export function isSyncAction(action: string): action is SyncActionType {
return !!(SyncActions as any)[action];
} }
/** Handle actions in the App, returns updated state */ /** Handle actions in the App, returns updated state */
export async function ActionHandler({action, data, state, api, adminApi}) { export async function ActionHandler({action, data, state, api, adminApi}: {action: ActionType, data: any, state: AppContextType, api: GhostApi, adminApi: any}) {
const handler = Actions[action]; const handler = Actions[action];
if (handler) { if (handler) {
return await handler({data, state, api, adminApi}) || {}; return await handler({data, state, api, adminApi} as any) || {};
} }
return {}; return {};
} }
/** Handle actions in the App, returns updated state */ /** Handle actions in the App, returns updated state */
export function SyncActionHandler({action, data, state, api, adminApi}) { export function SyncActionHandler({action, data, state, api, adminApi}: {action: SyncActionType, data: any, state: AppContextType, api: GhostApi, adminApi: any}) {
const handler = SyncActions[action]; const handler = SyncActions[action];
if (handler) { if (handler) {
// Do not await here // Do not await here
return handler({data, state, api, adminApi}) || {}; return handler({data, state, api, adminApi} as any) || {};
} }
return {}; return {};
} }

View File

@ -1,11 +1,14 @@
import AppContext from '../AppContext';
import Content from './content/Content'; import Content from './content/Content';
import Loading from './content/Loading'; import Loading from './content/Loading';
import React, {useContext} from 'react'; import React from 'react';
import {ROOT_DIV_ID} from '../utils/constants'; import {ROOT_DIV_ID} from '../utils/constants';
import {useAppContext} from '../AppContext';
const ContentBox = ({done}) => { type Props = {
const luminance = (r, g, b) => { done: boolean
};
const ContentBox: React.FC<Props> = ({done}) => {
const luminance = (r: number, g: number, b: number) => {
var a = [r, g, b].map(function (v) { var a = [r, g, b].map(function (v) {
v /= 255; v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
@ -13,14 +16,14 @@ const ContentBox = ({done}) => {
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
}; };
const contrast = (rgb1, rgb2) => { const contrast = (rgb1: [number, number, number], rgb2: [number, number, number]) => {
var lum1 = luminance(rgb1[0], rgb1[1], rgb1[2]); var lum1 = luminance(rgb1[0], rgb1[1], rgb1[2]);
var lum2 = luminance(rgb2[0], rgb2[1], rgb2[2]); var lum2 = luminance(rgb2[0], rgb2[1], rgb2[2]);
var brightest = Math.max(lum1, lum2); var brightest = Math.max(lum1, lum2);
var darkest = Math.min(lum1, lum2); var darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05); return (brightest + 0.05) / (darkest + 0.05);
}; };
const {accentColor, colorScheme} = useContext(AppContext); const {accentColor, colorScheme} = useAppContext();
const darkMode = () => { const darkMode = () => {
if (colorScheme === 'light') { if (colorScheme === 'light') {
@ -28,12 +31,16 @@ const ContentBox = ({done}) => {
} else if (colorScheme === 'dark') { } else if (colorScheme === 'dark') {
return true; return true;
} else { } else {
const containerColor = getComputedStyle(document.getElementById(ROOT_DIV_ID).parentNode).getPropertyValue('color'); const el = document.getElementById(ROOT_DIV_ID);
if (!el || !el.parentElement) {
return false;
}
const containerColor = getComputedStyle(el.parentElement).getPropertyValue('color');
const colorsOnly = containerColor.substring(containerColor.indexOf('(') + 1, containerColor.lastIndexOf(')')).split(/,\s*/); const colorsOnly = containerColor.substring(containerColor.indexOf('(') + 1, containerColor.lastIndexOf(')')).split(/,\s*/);
const red = colorsOnly[0]; const red = parseInt(colorsOnly[0]);
const green = colorsOnly[1]; const green = parseInt(colorsOnly[1]);
const blue = colorsOnly[2]; const blue = parseInt(colorsOnly[2]);
return contrast([255, 255, 255], [red, green, blue]) < 5; return contrast([255, 255, 255], [red, green, blue]) < 5;
} }

View File

@ -2,10 +2,20 @@ import IFrame from './IFrame';
import React, {useCallback, useState} from 'react'; import React, {useCallback, useState} from 'react';
import styles from '../styles/iframe.css?inline'; import styles from '../styles/iframe.css?inline';
type FrameProps = {
children: React.ReactNode
};
type TailwindFrameProps = FrameProps & {
style: React.CSSProperties,
title: string,
onResize: (iframeRoot: HTMLElement) => void
};
/** /**
* Loads all the CSS styles inside an iFrame. Only shows the visible content as soon as the CSS file with the tailwind classes has loaded. * Loads all the CSS styles inside an iFrame. Only shows the visible content as soon as the CSS file with the tailwind classes has loaded.
*/ */
const TailwindFrame = ({children, onResize, style, title}) => { const TailwindFrame: React.FC<TailwindFrameProps> = ({children, onResize, style, title}) => {
const head = ( const head = (
<> <>
<style dangerouslySetInnerHTML={{__html: styles}} /> <style dangerouslySetInnerHTML={{__html: styles}} />
@ -21,10 +31,15 @@ const TailwindFrame = ({children, onResize, style, title}) => {
); );
}; };
type ResizableFrameProps = FrameProps & {
style: React.CSSProperties,
title: string
};
/** /**
* This iframe has the same height as it contents and mimics a shadow DOM component * This iframe has the same height as it contents and mimics a shadow DOM component
*/ */
const ResizableFrame = ({children, style, title}) => { const ResizableFrame: React.FC<ResizableFrameProps> = ({children, style, title}) => {
const [iframeStyle, setIframeStyle] = useState(style); const [iframeStyle, setIframeStyle] = useState(style);
const onResize = useCallback((iframeRoot) => { const onResize = useCallback((iframeRoot) => {
setIframeStyle((current) => { setIframeStyle((current) => {
@ -42,7 +57,7 @@ const ResizableFrame = ({children, style, title}) => {
); );
}; };
export const CommentsFrame = ({children}) => { export const CommentsFrame: React.FC<FrameProps> = ({children}) => {
const style = { const style = {
width: '100%', width: '100%',
height: '400px' height: '400px'
@ -54,7 +69,11 @@ export const CommentsFrame = ({children}) => {
); );
}; };
export const PopupFrame = ({children, title}) => { type PopupFrameProps = FrameProps & {
title: string
};
export const PopupFrame: React.FC<PopupFrameProps> = ({children, title}) => {
const style = { const style = {
zIndex: '3999999', zIndex: '3999999',
position: 'fixed', position: 'fixed',

View File

@ -1,13 +1,21 @@
import React, {Component} from 'react'; import {Component} from 'react';
import {createPortal} from 'react-dom'; import {createPortal} from 'react-dom';
export default class IFrame extends Component { /**
constructor() { * This is still a class component because it causes issues with the behaviour (DOM recreation and layout glitches) if we switch to a functional component. Feel free to refactor.
super(); */
export default class IFrame extends Component<any> {
node: any;
iframeHtml: any;
iframeHead: any;
iframeRoot: any;
constructor(props: {onResize?: (el: HTMLElement) => void, children: any}) {
super(props);
this.setNode = this.setNode.bind(this); this.setNode = this.setNode.bind(this);
this.node = null; this.node = null;
} }
componentDidMount() { componentDidMount() {
this.node.addEventListener('load', this.handleLoad); this.node.addEventListener('load', this.handleLoad);
} }
@ -35,7 +43,7 @@ export default class IFrame extends Component {
// because when we want to listen for keydown events, those are only send in the window of iframe that is focused // because when we want to listen for keydown events, those are only send in the window of iframe that is focused
// To get around this, we pass down the keydown events to the main window // To get around this, we pass down the keydown events to the main window
// No need to detach, because the iframe would get removed // No need to detach, because the iframe would get removed
this.node.contentWindow.addEventListener('keydown', (e) => { this.node.contentWindow.addEventListener('keydown', (e: KeyboardEvent | undefined) => {
// dispatch a new event // dispatch a new event
window.dispatchEvent( window.dispatchEvent(
new KeyboardEvent('keydown', e) new KeyboardEvent('keydown', e)
@ -44,8 +52,8 @@ export default class IFrame extends Component {
} }
} }
setNode(node) { setNode(node: any) {
this.node = node; this.node = node;
} }
render() { render() {

View File

@ -1,10 +1,11 @@
import AppContext from '../AppContext';
import GenericPopup from './popups/GenericPopup'; import GenericPopup from './popups/GenericPopup';
import Pages from '../pages'; import {Pages} from '../pages';
import {useContext, useEffect, useState} from 'react'; import {useAppContext} from '../AppContext';
import {useEffect, useState} from 'react';
export default function PopupBox() { type Props = {};
const {popup} = useContext(AppContext); const PopupBox: React.FC<Props> = () => {
const {popup} = useAppContext();
// To make sure we can properly animate a popup that goes away, we keep a state of the last visible popup // To make sure we can properly animate a popup that goes away, we keep a state of the last visible popup
// This way, when the popup context is set to null, we still can show the popup while we transition it away // This way, when the popup context is set to null, we still can show the popup while we transition it away
@ -47,8 +48,10 @@ export default function PopupBox() {
return ( return (
<> <>
<GenericPopup callback={popupProps.callback} show={show} title={type}> <GenericPopup callback={popupProps.callback} show={show} title={type}>
<PageComponent {...popupProps}/> <PageComponent {...popupProps as any}/>
</GenericPopup> </GenericPopup>
</> </>
); );
} };
export default PopupBox;

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 {ReactComponent as AvatarIcon} from '../../images/icons/avatar.svg';
import {Comment, useAppContext} from '../../AppContext';
import {getInitials} from '../../utils/helpers'; import {getInitials} from '../../utils/helpers';
function getDimensionClasses() { function getDimensionClasses() {
@ -18,13 +17,16 @@ export const BlankAvatar = () => {
); );
}; };
export const Avatar = ({comment}) => { type AvatarProps = {
const {member, avatarSaturation} = useContext(AppContext); comment?: Comment;
};
export const Avatar: React.FC<AvatarProps> = ({comment}) => {
const {member, avatarSaturation} = useAppContext();
const dimensionClasses = getDimensionClasses(); const dimensionClasses = getDimensionClasses();
const memberName = member?.name ?? comment?.member?.name; const memberName = member?.name ?? comment?.member?.name;
const getHashOfString = (str) => { const getHashOfString = (str: string) => {
let hash = 0; let hash = 0;
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash); hash = str.charCodeAt(i) + ((hash << 5) - hash);
@ -33,18 +35,18 @@ export const Avatar = ({comment}) => {
return hash; return hash;
}; };
const normalizeHash = (hash, min, max) => { const normalizeHash = (hash: number, min: number, max: number) => {
return Math.floor((hash % (max - min)) + min); return Math.floor((hash % (max - min)) + min);
}; };
const generateHSL = () => { const generateHSL = (): [number, number, number] => {
let commentMember = (comment ? comment.member : member); let commentMember = (comment ? comment.member : member);
if (!commentMember || !commentMember.name) { if (!commentMember || !commentMember.name) {
return [0,0,10]; return [0,0,10];
} }
const saturation = isNaN(avatarSaturation) ? 50 : avatarSaturation; const saturation = avatarSaturation === undefined || isNaN(avatarSaturation) ? 50 : avatarSaturation;
const hRange = [0, 360]; const hRange = [0, 360];
const lRangeTop = Math.round(saturation / (100 / 30)) + 30; const lRangeTop = Math.round(saturation / (100 / 30)) + 30;
@ -54,11 +56,11 @@ export const Avatar = ({comment}) => {
const hash = getHashOfString(commentMember.name); const hash = getHashOfString(commentMember.name);
const h = normalizeHash(hash, hRange[0], hRange[1]); const h = normalizeHash(hash, hRange[0], hRange[1]);
const l = normalizeHash(hash, lRange[0], lRange[1]); const l = normalizeHash(hash, lRange[0], lRange[1]);
return [h, saturation, l]; return [h, saturation, l];
}; };
const HSLtoString = (hsl) => { const HSLtoString = (hsl: [number, number, number]) => {
return `hsl(${hsl[0]}, ${hsl[1]}%, ${hsl[2]}%)`; return `hsl(${hsl[0]}, ${hsl[1]}%, ${hsl[2]}%)`;
}; };
@ -66,7 +68,7 @@ export const Avatar = ({comment}) => {
if (comment && !comment.member) { if (comment && !comment.member) {
return getInitials('Deleted member'); return getInitials('Deleted member');
} }
let commentMember = (comment ? comment.member : member); let commentMember = (comment ? comment.member : member);
if (!commentMember || !commentMember.name) { if (!commentMember || !commentMember.name) {

View File

@ -1,8 +1,11 @@
import AppContext from '../../AppContext'; import {useAppContext} from '../../AppContext';
import {useContext} from 'react';
const CTABox = ({isFirst, isPaid}) => { type Props = {
const {accentColor, publication, member} = useContext(AppContext); isFirst: boolean,
isPaid: boolean
};
const CTABox: React.FC<Props> = ({isFirst, isPaid}) => {
const {accentColor, publication, member} = useAppContext();
const buttonStyle = { const buttonStyle = {
backgroundColor: accentColor backgroundColor: accentColor

View File

@ -1,17 +1,22 @@
import AppContext from '../../AppContext';
import EditForm from './forms/EditForm'; import EditForm from './forms/EditForm';
import LikeButton from './buttons/LikeButton'; import LikeButton from './buttons/LikeButton';
import MoreButton from './buttons/MoreButton'; import MoreButton from './buttons/MoreButton';
import React, {useContext, useState} from 'react';
import Replies from './Replies'; import Replies from './Replies';
import ReplyButton from './buttons/ReplyButton'; import ReplyButton from './buttons/ReplyButton';
import ReplyForm from './forms/ReplyForm'; import ReplyForm from './forms/ReplyForm';
import {Avatar, BlankAvatar} from './Avatar'; import {Avatar, BlankAvatar} from './Avatar';
import {Comment, useAppContext} from '../../AppContext';
import {Transition} from '@headlessui/react'; import {Transition} from '@headlessui/react';
import {formatExplicitTime, isCommentPublished} from '../../utils/helpers'; import {formatExplicitTime, isCommentPublished} from '../../utils/helpers';
import {useRelativeTime} from '../../utils/hooks'; import {useRelativeTime} from '../../utils/hooks';
import {useState} from 'react';
function AnimatedComment({comment, parent}) { type AnimatedCommentProps = {
comment: Comment;
parent?: Comment;
};
const AnimatedComment: React.FC<AnimatedCommentProps> = ({comment, parent}) => {
return ( return (
<Transition <Transition
enter="transition-opacity duration-300 ease-out" enter="transition-opacity duration-300 ease-out"
@ -26,9 +31,10 @@ function AnimatedComment({comment, parent}) {
<EditableComment comment={comment} parent={parent} /> <EditableComment comment={comment} parent={parent} />
</Transition> </Transition>
); );
} };
function EditableComment({comment, parent}) { type EditableCommentProps = AnimatedCommentProps;
const EditableComment: React.FC<EditableCommentProps> = ({comment, parent}) => {
const [isInEditMode, setIsInEditMode] = useState(false); const [isInEditMode, setIsInEditMode] = useState(false);
const closeEditMode = () => { const closeEditMode = () => {
@ -44,22 +50,25 @@ function EditableComment({comment, parent}) {
<EditForm close={closeEditMode} comment={comment} parent={parent} /> <EditForm close={closeEditMode} comment={comment} parent={parent} />
); );
} else { } else {
return (<Comment comment={comment} openEditMode={openEditMode} parent={parent} />); return (<CommentComponent comment={comment} openEditMode={openEditMode} parent={parent} />);
} }
} };
function Comment({comment, parent, openEditMode}) { type CommentProps = AnimatedCommentProps & {
openEditMode: () => void;
};
const CommentComponent: React.FC<CommentProps> = ({comment, parent, openEditMode}) => {
const isPublished = isCommentPublished(comment); const isPublished = isCommentPublished(comment);
if (isPublished) { if (isPublished) {
return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />); return (<PublishedComment comment={comment} openEditMode={openEditMode} parent={parent} />);
} }
return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />); return (<UnpublishedComment comment={comment} openEditMode={openEditMode} />);
} };
function PublishedComment({comment, parent, openEditMode}) { const PublishedComment: React.FC<CommentProps> = ({comment, parent, openEditMode}) => {
const [isInReplyMode, setIsInReplyMode] = useState(false); const [isInReplyMode, setIsInReplyMode] = useState(false);
const {dispatchAction} = useContext(AppContext); const {dispatchAction} = useAppContext();
const toggleReplyMode = async () => { const toggleReplyMode = async () => {
if (!isInReplyMode) { if (!isInReplyMode) {
@ -86,10 +95,14 @@ function PublishedComment({comment, parent, openEditMode}) {
<ReplyFormBox closeReplyMode={closeReplyMode} comment={comment} isInReplyMode={isInReplyMode} /> <ReplyFormBox closeReplyMode={closeReplyMode} comment={comment} isInReplyMode={isInReplyMode} />
</CommentLayout> </CommentLayout>
); );
} };
function UnpublishedComment({comment, openEditMode}) { type UnpublishedCommentProps = {
const {admin} = useContext(AppContext); comment: Comment;
openEditMode: () => void;
}
const UnpublishedComment: React.FC<UnpublishedCommentProps> = ({comment, openEditMode}) => {
const {admin} = useAppContext();
let notPublishedMessage; let notPublishedMessage;
if (admin && comment.status === 'hidden') { if (admin && comment.status === 'hidden') {
@ -114,12 +127,12 @@ function UnpublishedComment({comment, openEditMode}) {
<RepliesContainer comment={comment} /> <RepliesContainer comment={comment} />
</CommentLayout> </CommentLayout>
); );
} };
// Helper components // Helper components
function MemberExpertise({comment}) { const MemberExpertise: React.FC<{comment: Comment}> = ({comment}) => {
const {member} = useContext(AppContext); const {member} = useAppContext();
const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise; const memberExpertise = member && comment.member && comment.member.uuid === member.uuid ? member.expertise : comment?.member?.expertise;
if (!memberExpertise) { if (!memberExpertise) {
@ -129,9 +142,9 @@ function MemberExpertise({comment}) {
return ( return (
<span>{memberExpertise}<span className="mx-[0.3em]">·</span></span> <span>{memberExpertise}<span className="mx-[0.3em]">·</span></span>
); );
} };
function EditedInfo({comment}) { const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => {
if (!comment.edited_at) { if (!comment.edited_at) {
return null; return null;
} }
@ -140,9 +153,9 @@ function EditedInfo({comment}) {
<span className="mx-[0.3em]">·</span>Edited <span className="mx-[0.3em]">·</span>Edited
</span> </span>
); );
} };
function RepliesContainer({comment}) { const RepliesContainer: React.FC<{comment: Comment}> = ({comment}) => {
const hasReplies = comment.replies && comment.replies.length > 0; const hasReplies = comment.replies && comment.replies.length > 0;
if (!hasReplies) { if (!hasReplies) {
@ -154,9 +167,14 @@ function RepliesContainer({comment}) {
<Replies comment={comment} /> <Replies comment={comment} />
</div> </div>
); );
} };
function ReplyFormBox({comment, isInReplyMode, closeReplyMode}) { type ReplyFormBoxProps = {
comment: Comment;
isInReplyMode: boolean;
closeReplyMode: () => void;
};
const ReplyFormBox: React.FC<ReplyFormBoxProps> = ({comment, isInReplyMode, closeReplyMode}) => {
if (!isInReplyMode) { if (!isInReplyMode) {
return null; return null;
} }
@ -166,23 +184,23 @@ function ReplyFormBox({comment, isInReplyMode, closeReplyMode}) {
<ReplyForm close={closeReplyMode} parent={comment} /> <ReplyForm close={closeReplyMode} parent={comment} />
</div> </div>
); );
} };
// //
// -- Published comment components -- // -- Published comment components --
// //
// TODO: move name detection to helper // TODO: move name detection to helper
function AuthorName({comment}) { const AuthorName: React.FC<{comment: Comment}> = ({comment}) => {
const name = !comment.member ? 'Deleted member' : (comment.member.name ? comment.member.name : 'Anonymous'); const name = !comment.member ? 'Deleted member' : (comment.member.name ? comment.member.name : 'Anonymous');
return ( return (
<h4 className="text-[rgb(23,23,23] font-sans text-[17px] font-bold tracking-tight dark:text-[rgba(255,255,255,0.85)]"> <h4 className="text-[rgb(23,23,23] font-sans text-[17px] font-bold tracking-tight dark:text-[rgba(255,255,255,0.85)]">
{name} {name}
</h4> </h4>
); );
} };
function CommentHeader({comment}) { const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => {
const createdAtRelative = useRelativeTime(comment.created_at); const createdAtRelative = useRelativeTime(comment.created_at);
return ( return (
@ -199,21 +217,28 @@ function CommentHeader({comment}) {
</div> </div>
</div> </div>
); );
} };
function CommentBody({html}) { const CommentBody: React.FC<{html: string}> = ({html}) => {
const dangerouslySetInnerHTML = {__html: html}; const dangerouslySetInnerHTML = {__html: html};
return ( return (
<div className="mt mb-2 flex flex-row items-center gap-4 pr-4"> <div className="mt mb-2 flex flex-row items-center gap-4 pr-4">
<p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content font-sans text-[16px] leading-normal text-neutral-900 dark:text-[rgba(255,255,255,0.85)]" data-testid="comment-content"/> <p dangerouslySetInnerHTML={dangerouslySetInnerHTML} className="gh-comment-content font-sans text-[16px] leading-normal text-neutral-900 dark:text-[rgba(255,255,255,0.85)]" data-testid="comment-content"/>
</div> </div>
); );
} };
function CommentMenu({comment, toggleReplyMode, isInReplyMode, openEditMode, parent}) { type CommentMenuProps = {
comment: Comment;
toggleReplyMode: () => void;
isInReplyMode: boolean;
openEditMode: () => void;
parent?: Comment;
};
const CommentMenu: React.FC<CommentMenuProps> = ({comment, toggleReplyMode, isInReplyMode, openEditMode, parent}) => {
// If this comment is from the current member, always override member // If this comment is from the current member, always override member
// with the member from the context, so we update the expertise in existing comments when we change it // with the member from the context, so we update the expertise in existing comments when we change it
const {member, commentsEnabled} = useContext(AppContext); const {member, commentsEnabled} = useAppContext();
const paidOnly = commentsEnabled === 'paid'; const paidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid; const isPaidMember = member && !!member.paid;
@ -222,25 +247,30 @@ function CommentMenu({comment, toggleReplyMode, isInReplyMode, openEditMode, par
return ( return (
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
{<LikeButton comment={comment} />} {<LikeButton comment={comment} />}
{(canReply && <ReplyButton comment={comment} isReplying={isInReplyMode} toggleReply={toggleReplyMode} />)} {(canReply && <ReplyButton isReplying={isInReplyMode} toggleReply={toggleReplyMode} />)}
{<MoreButton comment={comment} toggleEdit={openEditMode} />} {<MoreButton comment={comment} toggleEdit={openEditMode} />}
</div> </div>
); );
} };
// //
// -- Layout -- // -- Layout --
// //
function RepliesLine({hasReplies}) { const RepliesLine: React.FC<{hasReplies: boolean}> = ({hasReplies}) => {
if (!hasReplies) { if (!hasReplies) {
return null; return null;
} }
return (<div className="mb-2 h-full w-[3px] grow rounded bg-gradient-to-b from-[rgba(0,0,0,0.05)] via-[rgba(0,0,0,0.05)] to-transparent dark:from-[rgba(255,255,255,0.08)] dark:via-[rgba(255,255,255,0.08)]" />); return (<div className="mb-2 h-full w-[3px] grow rounded bg-gradient-to-b from-[rgba(0,0,0,0.05)] via-[rgba(0,0,0,0.05)] to-transparent dark:from-[rgba(255,255,255,0.08)] dark:via-[rgba(255,255,255,0.08)]" />);
} };
function CommentLayout({children, avatar, hasReplies}) { type CommentLayoutProps = {
children: React.ReactNode;
avatar: React.ReactNode;
hasReplies: boolean;
}
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies}) => {
return ( return (
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-10'}`} data-testid="comment-component"> <div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-10'}`} data-testid="comment-component">
<div className="mr-3 flex flex-col items-center justify-start"> <div className="mr-3 flex flex-col items-center justify-start">
@ -254,7 +284,7 @@ function CommentLayout({children, avatar, hasReplies}) {
</div> </div>
</div> </div>
); );
} };
// //
// -- Default -- // -- Default --

View File

@ -1,14 +1,14 @@
import AppContext from '../../AppContext';
import CTABox from './CTABox'; import CTABox from './CTABox';
import Comment from './Comment'; import Comment from './Comment';
import ContentTitle from './ContentTitle'; import ContentTitle from './ContentTitle';
import MainForm from './forms/MainForm'; import MainForm from './forms/MainForm';
import Pagination from './Pagination'; import Pagination from './Pagination';
import React, {useContext, useEffect} from 'react';
import {ROOT_DIV_ID} from '../../utils/constants'; import {ROOT_DIV_ID} from '../../utils/constants';
import {useAppContext} from '../../AppContext';
import {useEffect} from 'react';
const Content = () => { const Content = () => {
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, secundaryFormCount} = useContext(AppContext); const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, secundaryFormCount} = useAppContext();
const commentsElements = comments.slice().reverse().map(comment => <Comment key={comment.id} comment={comment} />); const commentsElements = comments.slice().reverse().map(comment => <Comment key={comment.id} comment={comment} />);
const paidOnly = commentsEnabled === 'paid'; const paidOnly = commentsEnabled === 'paid';

View File

@ -1,6 +1,10 @@
import {formatNumber} from '../../utils/helpers'; import {formatNumber} from '../../utils/helpers';
const Count = ({showCount, count}) => { type CountProps = {
showCount: boolean,
count: number
};
const Count: React.FC<CountProps> = ({showCount, count}) => {
if (!showCount) { if (!showCount) {
return null; return null;
} }
@ -16,17 +20,22 @@ const Count = ({showCount, count}) => {
); );
}; };
const Title = ({title}) => { const Title: React.FC<{title: string | null}> = ({title}) => {
if (title === null) { if (title === null) {
return ( return (
<><span className="hidden sm:inline">Member </span><span className="capitalize sm:normal-case">discussion</span></> <><span className="hidden sm:inline">Member </span><span className="capitalize sm:normal-case">discussion</span></>
); );
} }
return title; return <>{title}</>;
}; };
const ContentTitle = ({title, showCount, count}) => { type ContentTitleProps = {
title: string | null,
showCount: boolean,
count: number
};
const ContentTitle: React.FC<ContentTitleProps> = ({title, showCount, count}) => {
// We have to check for null for title because null means default, wheras empty string means empty // We have to check for null for title because null means default, wheras empty string means empty
if (!title && !showCount && title !== null) { if (!title && !showCount && title !== null) {
return null; return null;

View File

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

View File

@ -1,12 +1,11 @@
import AppContext from '../../AppContext';
import React, {useContext} from 'react';
import {formatNumber} from '../../utils/helpers'; import {formatNumber} from '../../utils/helpers';
import {useAppContext} from '../../AppContext';
const Pagination = () => { const Pagination = () => {
const {pagination, dispatchAction} = useContext(AppContext); const {pagination, dispatchAction} = useAppContext();
const loadMore = () => { const loadMore = () => {
dispatchAction('loadMoreComments'); dispatchAction('loadMoreComments', {});
}; };
if (!pagination) { if (!pagination) {

View File

@ -1,10 +1,12 @@
import AppContext from '../../AppContext'; import CommentComponent from './Comment';
import Comment from './Comment';
import RepliesPagination from './RepliesPagination'; import RepliesPagination from './RepliesPagination';
import {useContext} from 'react'; import {Comment, useAppContext} from '../../AppContext';
const Replies = ({comment}) => { type Props = {
const {dispatchAction} = useContext(AppContext); comment: Comment
};
const Replies: React.FC<Props> = ({comment}) => {
const {dispatchAction} = useAppContext();
const repliesLeft = comment.count.replies - comment.replies.length; const repliesLeft = comment.count.replies - comment.replies.length;
@ -14,7 +16,7 @@ const Replies = ({comment}) => {
return ( return (
<div> <div>
{comment.replies.map((reply => <Comment key={reply.id} comment={reply} isReply={true} parent={comment} />))} {comment.replies.map((reply => <CommentComponent key={reply.id} comment={reply} parent={comment} />))}
{repliesLeft > 0 && <RepliesPagination count={repliesLeft} loadMore={loadMore}/>} {repliesLeft > 0 && <RepliesPagination count={repliesLeft} loadMore={loadMore}/>}
</div> </div>
); );

View File

@ -1,7 +1,11 @@
import React from 'react'; import React from 'react';
import {formatNumber} from '../../utils/helpers'; import {formatNumber} from '../../utils/helpers';
const RepliesPagination = ({loadMore, count}) => { type Props = {
loadMore: () => void;
count: number;
};
const RepliesPagination: React.FC<Props> = ({loadMore, count}) => {
return ( return (
<div className="flex w-full items-center justify-start"> <div className="flex w-full items-center justify-start">
<button className="text-md group mb-10 ml-[48px] flex w-auto items-center px-0 pb-2 pt-0 text-left font-sans font-semibold text-neutral-700 dark:text-white sm:mb-12 " data-testid="reply-pagination-button" type="button" onClick={loadMore}> <button className="text-md group mb-10 ml-[48px] flex w-auto items-center px-0 pb-2 pt-0 text-left font-sans font-semibold text-neutral-700 dark:text-white sm:mb-12 " data-testid="reply-pagination-button" type="button" onClick={loadMore}>

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 {ReactComponent as LikeIcon} from '../../../images/icons/like.svg';
import {useContext, useState} from 'react'; import {useState} from 'react';
function LikeButton({comment}) { type Props = {
const {dispatchAction, member, commentsEnabled} = useContext(AppContext); comment: Comment;
};
const LikeButton: React.FC<Props> = ({comment}) => {
const {dispatchAction, member, commentsEnabled} = useAppContext();
const [animationClass, setAnimation] = useState(''); const [animationClass, setAnimation] = useState('');
const paidOnly = commentsEnabled === 'paid'; const paidOnly = commentsEnabled === 'paid';
@ -40,6 +43,6 @@ function LikeButton({comment}) {
{comment.count.likes} {comment.count.likes}
</CustomTag> </CustomTag>
); );
} };
export default LikeButton; export default LikeButton;

View File

@ -1,11 +1,16 @@
import AppContext from '../../../AppContext';
import CommentContextMenu from '../context-menus/CommentContextMenu'; import CommentContextMenu from '../context-menus/CommentContextMenu';
import React, {useContext, useState} from 'react'; import {Comment, useAppContext} from '../../../AppContext';
import {ReactComponent as MoreIcon} from '../../../images/icons/more.svg'; import {ReactComponent as MoreIcon} from '../../../images/icons/more.svg';
import {useState} from 'react';
const MoreButton = ({comment, toggleEdit}) => { type Props = {
comment: Comment;
toggleEdit: () => void;
};
const MoreButton: React.FC<Props> = ({comment, toggleEdit}) => {
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const {member, admin} = useContext(AppContext); const {member, admin} = useAppContext();
const toggleContextMenu = () => { const toggleContextMenu = () => {
setIsContextMenuOpen(current => !current); setIsContextMenuOpen(current => !current);

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 {ReactComponent as ReplyIcon} from '../../../images/icons/reply.svg';
import {useAppContext} from '../../../AppContext';
function ReplyButton({disabled, isReplying, toggleReply}) { type Props = {
const {member} = useContext(AppContext); disabled?: boolean;
isReplying: boolean;
toggleReply: () => void;
};
const ReplyButton: React.FC<Props> = ({disabled, isReplying, toggleReply}) => {
const {member} = useAppContext();
return member ? return member ?
(<button className={`duration-50 group flex items-center font-sans text-sm outline-0 transition-all ease-linear ${isReplying ? 'text-[rgba(0,0,0,0.9)] dark:text-[rgba(255,255,255,0.9)]' : 'text-[rgba(0,0,0,0.5)] hover:text-[rgba(0,0,0,0.75)] dark:text-[rgba(255,255,255,0.5)] dark:hover:text-[rgba(255,255,255,0.25)]'}`} data-testid="reply-button" disabled={!!disabled} type="button" onClick={toggleReply}> (<button className={`duration-50 group flex items-center font-sans text-sm outline-0 transition-all ease-linear ${isReplying ? 'text-[rgba(0,0,0,0.9)] dark:text-[rgba(255,255,255,0.9)]' : 'text-[rgba(0,0,0,0.5)] hover:text-[rgba(0,0,0,0.75)] dark:text-[rgba(255,255,255,0.5)] dark:hover:text-[rgba(255,255,255,0.25)]'}`} data-testid="reply-button" disabled={!!disabled} type="button" onClick={toggleReply}>
<ReplyIcon className={`mr-[6px] ${isReplying ? 'fill-[rgba(0,0,0,0.9)] stroke-[rgba(0,0,0,0.9)] dark:fill-[rgba(255,255,255,0.9)] dark:stroke-[rgba(255,255,255,0.9)]' : 'stroke-[rgba(0,0,0,0.5)] group-hover:stroke-[rgba(0,0,0,0.75)] dark:stroke-[rgba(255,255,255,0.5)] dark:group-hover:stroke-[rgba(255,255,255,0.25)]'} duration-50 transition ease-linear`} />Reply <ReplyIcon className={`mr-[6px] ${isReplying ? 'fill-[rgba(0,0,0,0.9)] stroke-[rgba(0,0,0,0.9)] dark:fill-[rgba(255,255,255,0.9)] dark:stroke-[rgba(255,255,255,0.9)]' : 'stroke-[rgba(0,0,0,0.5)] group-hover:stroke-[rgba(0,0,0,0.75)] dark:stroke-[rgba(255,255,255,0.5)] dark:group-hover:stroke-[rgba(255,255,255,0.25)]'} duration-50 transition ease-linear`} />Reply
</button>) : null; </button>) : null;
} };
export default ReplyButton; export default ReplyButton;

View File

@ -1,8 +1,11 @@
import AppContext from '../../../AppContext'; import {Comment, useAppContext} from '../../../AppContext';
import React, {useContext} from 'react';
const AdminContextMenu = ({comment, close}) => { type Props = {
const {dispatchAction} = useContext(AppContext); comment: Comment;
close: () => void;
};
const AdminContextMenu: React.FC<Props> = ({comment, close}) => {
const {dispatchAction} = useAppContext();
const hideComment = () => { const hideComment = () => {
dispatchAction('hideComment', comment); dispatchAction('hideComment', comment);
@ -19,11 +22,11 @@ const AdminContextMenu = ({comment, close}) => {
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{ {
isHidden ? isHidden ?
<button className="w-full text-left text-[14px]" type="button" onClick={showComment}> <button className="w-full text-left text-[14px]" type="button" onClick={showComment}>
<span>Show </span><span className="hidden sm:inline">comment</span> <span>Show </span><span className="hidden sm:inline">comment</span>
</button> </button>
: :
<button className="w-full text-left text-[14px]" type="button" onClick={hideComment}> <button className="w-full text-left text-[14px]" type="button" onClick={hideComment}>
<span>Hide </span><span className="hidden sm:inline">comment</span> <span>Hide </span><span className="hidden sm:inline">comment</span>
</button> </button>

View File

@ -1,10 +1,15 @@
import AppContext from '../../../AppContext'; import React from 'react';
import React, {useContext} from 'react'; import {Comment, useAppContext} from '../../../AppContext';
const AuthorContextMenu = ({comment, close, toggleEdit}) => { type Props = {
const {dispatchAction} = useContext(AppContext); comment: Comment;
close: () => void;
toggleEdit: () => void;
};
const AuthorContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
const {dispatchAction} = useAppContext();
const deleteComment = (event) => { const deleteComment = () => {
dispatchAction('deleteComment', comment); dispatchAction('deleteComment', comment);
close(); close();
}; };

View File

@ -1,14 +1,19 @@
import AdminContextMenu from './AdminContextMenu'; import AdminContextMenu from './AdminContextMenu';
import AppContext from '../../../AppContext';
import AuthorContextMenu from './AuthorContextMenu'; import AuthorContextMenu from './AuthorContextMenu';
import NotAuthorContextMenu from './NotAuthorContextMenu'; import NotAuthorContextMenu from './NotAuthorContextMenu';
import React, {useContext, useEffect, useRef} from 'react'; import {Comment, useAppContext} from '../../../AppContext';
import {useEffect, useRef} from 'react';
const CommentContextMenu = ({comment, close, toggleEdit}) => { type Props = {
const {member, admin} = useContext(AppContext); comment: Comment;
close: () => void;
toggleEdit: () => void;
};
const CommentContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
const {member, admin} = useAppContext();
const isAuthor = member && comment.member?.uuid === member?.uuid; const isAuthor = member && comment.member?.uuid === member?.uuid;
const isAdmin = !!admin; const isAdmin = !!admin;
const element = useRef(); const element = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const listener = () => { const listener = () => {
@ -24,15 +29,15 @@ const CommentContextMenu = ({comment, close, toggleEdit}) => {
} }
return () => { return () => {
window.removeEventListener('click', listener, {passive: true}); window.removeEventListener('click', listener, {passive: true} as any);
if (el && el !== window) { if (el && el !== window) {
el.removeEventListener('click', listener, {passive: true}); el.removeEventListener('click', listener, {passive: true} as any);
} }
}; };
}, [close]); }, [close]);
useEffect(() => { useEffect(() => {
const listener = (event) => { const listener = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
close(); close();
} }
@ -42,12 +47,12 @@ const CommentContextMenu = ({comment, close, toggleEdit}) => {
window.addEventListener('keydown', listener, {passive: true}); window.addEventListener('keydown', listener, {passive: true});
return () => { return () => {
window.removeEventListener('keydown', listener, {passive: true}); window.removeEventListener('keydown', listener, {passive: true} as any);
}; };
}, [close]); }, [close]);
// Prevent closing the context menu when clicking inside of it // Prevent closing the context menu when clicking inside of it
const stopPropagation = (event) => { const stopPropagation = (event: React.SyntheticEvent) => {
event.stopPropagation(); event.stopPropagation();
}; };

View File

@ -1,8 +1,12 @@
import AppContext from '../../../AppContext'; import React from 'react';
import React, {useContext} from 'react'; import {useAppContext} from '../../../AppContext';
const NotAuthorContextMenu = ({comment, close}) => { type Props = {
const {dispatchAction} = useContext(AppContext); comment: Comment;
close: () => void;
};
const NotAuthorContextMenu: React.FC<Props> = ({comment, close}) => {
const {dispatchAction} = useAppContext();
const openModal = () => { const openModal = () => {
dispatchAction('openPopup', { dispatchAction('openPopup', {

View File

@ -1,15 +1,21 @@
import AppContext from '../../../AppContext';
import SecundaryForm from './SecundaryForm'; import SecundaryForm from './SecundaryForm';
import {default as React, useCallback, useContext, useEffect} from 'react'; import {Comment, useAppContext} from '../../../AppContext';
import {getEditorConfig} from '../../../utils/editor'; import {getEditorConfig} from '../../../utils/editor';
import {useCallback, useEffect} from 'react';
import {useEditor} from '@tiptap/react'; import {useEditor} from '@tiptap/react';
const EditForm = ({comment, parent, close}) => { type Props = {
const {dispatchAction} = useContext(AppContext); comment: Comment;
parent?: Comment;
close: () => void;
};
const EditForm: React.FC<Props> = ({comment, parent, close}) => {
const {dispatchAction} = useAppContext();
const config = { const config = {
placeholder: 'Edit this comment', placeholder: 'Edit this comment',
// warning: we cannot use autofocus on the edit field, because that sets // warning: we cannot use autofocus on the edit field, because that sets
// the cursor position at the beginning of the text field instead of the end // the cursor position at the beginning of the text field instead of the end
autofocus: false, autofocus: false,
content: comment.html content: comment.html

View File

@ -1,13 +1,27 @@
import AppContext from '../../../AppContext'; import React from 'react';
import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
import {Avatar} from '../Avatar'; import {Avatar} from '../Avatar';
import {Comment, useAppContext} from '../../../AppContext';
import {ReactComponent as EditIcon} from '../../../images/icons/edit.svg'; import {ReactComponent as EditIcon} from '../../../images/icons/edit.svg';
import {EditorContent} from '@tiptap/react'; import {Editor, EditorContent} from '@tiptap/react';
import {ReactComponent as SpinnerIcon} from '../../../images/icons/spinner.svg'; import {ReactComponent as SpinnerIcon} from '../../../images/icons/spinner.svg';
import {Transition} from '@headlessui/react'; import {Transition} from '@headlessui/react';
import {useCallback, useEffect, useRef, useState} from 'react';
import {usePopupOpen} from '../../../utils/hooks'; import {usePopupOpen} from '../../../utils/hooks';
const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, editor, submitText, submitSize}) => { type Progress = 'default' | 'sending' | 'sent' | 'error'
export type SubmitSize = 'small' | 'medium' | 'large';
type FormEditorProps = {
submit: (data: {html: string}) => Promise<void>;
progress: Progress;
setProgress: (progress: Progress) => void;
close?: () => void;
reduced?: boolean;
isOpen: boolean;
editor: Editor | null;
submitText: JSX.Element | null;
submitSize: SubmitSize;
};
const FormEditor: React.FC<FormEditorProps> = ({submit, progress, setProgress, close, reduced, isOpen, editor, submitText, submitSize}) => {
let buttonIcon = null; let buttonIcon = null;
if (progress === 'sending') { if (progress === 'sending') {
@ -23,7 +37,7 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
}, [editor]); }, [editor]);
const submitForm = useCallback(async () => { const submitForm = useCallback(async () => {
if (editor.isEmpty) { if (!editor || editor.isEmpty) {
return; return;
} }
@ -52,7 +66,7 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
useEffect(() => { useEffect(() => {
// Add some basic keyboard shortcuts // Add some basic keyboard shortcuts
// ESC to blur the editor // ESC to blur the editor
const keyDownListener = (event) => { const keyDownListener = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) { if (event.metaKey || event.ctrlKey) {
// CMD on MacOS or CTRL // CMD on MacOS or CTRL
@ -83,7 +97,7 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
window.addEventListener('keydown', keyDownListener, {passive: true}); window.addEventListener('keydown', keyDownListener, {passive: true});
return () => { return () => {
window.removeEventListener('keydown', keyDownListener, {passive: true}); window.removeEventListener('keydown', keyDownListener, {passive: true} as any);
}; };
}, [editor, close, submitForm]); }, [editor, close, submitForm]);
@ -115,7 +129,15 @@ const FormEditor = ({submit, progress, setProgress, close, reduced, isOpen, edit
); );
}; };
const FormHeader = ({show, name, expertise, editName, editExpertise}) => { type FormHeaderProps = {
show: boolean;
name: string | null;
expertise: string | null;
editName: () => void;
editExpertise: () => void;
};
const FormHeader: React.FC<FormHeaderProps> = ({show, name, expertise, editName, editExpertise}) => {
return ( return (
<Transition <Transition
enter="transition duration-500 delay-100 ease-in-out" enter="transition duration-500 delay-100 ease-in-out"
@ -147,10 +169,21 @@ const FormHeader = ({show, name, expertise, editName, editExpertise}) => {
); );
}; };
const Form = ({comment, submit, submitText, submitSize, close, editor, reduced, isOpen}) => { type FormProps = {
const {member, dispatchAction} = useContext(AppContext); comment?: Comment;
submit: (data: {html: string}) => Promise<void>;
submitText: JSX.Element;
submitSize: SubmitSize;
close?: () => void;
editor: Editor | null;
reduced: boolean;
isOpen: boolean;
};
const Form: React.FC<FormProps> = ({comment, submit, submitText, submitSize, close, editor, reduced, isOpen}) => {
const {member, dispatchAction} = useAppContext();
const isAskingDetails = usePopupOpen('addDetailsPopup'); const isAskingDetails = usePopupOpen('addDetailsPopup');
const [progress, setProgress] = useState('default'); const [progress, setProgress] = useState<Progress>('default');
const formEl = useRef(null); const formEl = useRef(null);
const memberName = member?.name ?? comment?.member?.name; const memberName = member?.name ?? comment?.member?.name;
@ -161,7 +194,7 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
isOpen = true; isOpen = true;
} }
const preventIfFocused = (event) => { const preventIfFocused = (event: React.SyntheticEvent) => {
if (editor?.isFocused) { if (editor?.isFocused) {
event.preventDefault(); event.preventDefault();
return; return;
@ -174,8 +207,7 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
dispatchAction('openPopup', { dispatchAction('openPopup', {
type: 'addDetailsPopup', type: 'addDetailsPopup',
expertiseAutofocus: options.expertiseAutofocus ?? false, expertiseAutofocus: options.expertiseAutofocus ?? false,
// WIP callback: function (succeeded: boolean) {
callback: function (succeeded) {
if (!editor || !formEl.current) { if (!editor || !formEl.current) {
return; return;
} }
@ -201,6 +233,10 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
}, [openEditDetails]); }, [openEditDetails]);
const focusEditor = useCallback(() => { const focusEditor = useCallback(() => {
if (!editor) {
return;
}
if (editor.isFocused) { if (editor.isFocused) {
return; return;
} }
@ -230,8 +266,8 @@ const Form = ({comment, submit, submitText, submitSize, close, editor, reduced,
<FormEditor close={close} editor={editor} isOpen={isOpen} progress={progress} reduced={reduced} setProgress={setProgress} submit={submit} submitSize={submitSize} submitText={submitText} /> <FormEditor close={close} editor={editor} isOpen={isOpen} progress={progress} reduced={reduced} setProgress={setProgress} submit={submit} submitSize={submitSize} submitText={submitText} />
</div> </div>
<div className='absolute left-0 top-1 flex h-12 w-full items-center justify-start'> <div className='absolute left-0 top-1 flex h-12 w-full items-center justify-start'>
<div className="mr-3 grow-0"> <div className="pointer-events-none mr-3 grow-0">
<Avatar className="pointer-events-none" comment={comment} /> <Avatar comment={comment} />
</div> </div>
<div className="grow-1 w-full"> <div className="grow-1 w-full">
<FormHeader editExpertise={editExpertise} editName={editName} expertise={memberExpertise} name={memberName} show={isOpen} /> <FormHeader editExpertise={editExpertise} editName={editName} expertise={memberExpertise} name={memberName} show={isOpen} />

View File

@ -1,12 +1,15 @@
import AppContext from '../../../AppContext';
import Form from './Form'; import Form from './Form';
import React, {useCallback, useContext, useEffect, useRef} from 'react'; import React, {useCallback, useEffect, useRef} from 'react';
import {getEditorConfig} from '../../../utils/editor'; import {getEditorConfig} from '../../../utils/editor';
import {scrollToElement} from '../../../utils/helpers'; import {scrollToElement} from '../../../utils/helpers';
import {useAppContext} from '../../../AppContext';
import {useEditor} from '@tiptap/react'; import {useEditor} from '@tiptap/react';
const MainForm = ({commentsCount}) => { type Props = {
const {postId, dispatchAction} = useContext(AppContext); commentsCount: number
};
const MainForm: React.FC<Props> = ({commentsCount}) => {
const {postId, dispatchAction} = useAppContext();
const config = { const config = {
placeholder: (commentsCount === 0 ? 'Start the conversation' : 'Join the discussion'), placeholder: (commentsCount === 0 ? 'Start the conversation' : 'Join the discussion'),
@ -36,7 +39,7 @@ const MainForm = ({commentsCount}) => {
// Add some basic keyboard shortcuts // Add some basic keyboard shortcuts
// ESC to blur the editor // ESC to blur the editor
const keyDownListener = (event) => { const keyDownListener = (event: KeyboardEvent) => {
if (!editor) { if (!editor) {
return; return;
} }
@ -47,15 +50,15 @@ const MainForm = ({commentsCount}) => {
return; return;
} }
let focusedElement = document.activeElement; let focusedElement = document.activeElement as HTMLElement | null;
while (focusedElement && focusedElement.tagName === 'IFRAME') { while (focusedElement && focusedElement.tagName === 'IFRAME') {
if (!focusedElement.contentDocument) { if (!(focusedElement as HTMLIFrameElement).contentDocument) {
// CORS issue // CORS issue
// disable the C shortcut when we have a focused external iframe // disable the C shortcut when we have a focused external iframe
break; break;
} }
focusedElement = focusedElement.contentDocument.activeElement; focusedElement = ((focusedElement as HTMLIFrameElement).contentDocument?.activeElement ?? null) as HTMLElement | null;
} }
const hasInputFocused = focusedElement && (focusedElement.tagName === 'INPUT' || focusedElement.tagName === 'TEXTAREA' || focusedElement.tagName === 'IFRAME' || focusedElement.contentEditable === 'true'); const hasInputFocused = focusedElement && (focusedElement.tagName === 'INPUT' || focusedElement.tagName === 'TEXTAREA' || focusedElement.tagName === 'IFRAME' || focusedElement.contentEditable === 'true');
@ -74,7 +77,7 @@ const MainForm = ({commentsCount}) => {
window.addEventListener('keydown', keyDownListener, {passive: true}); window.addEventListener('keydown', keyDownListener, {passive: true});
return () => { return () => {
window.removeEventListener('keydown', keyDownListener, {passive: true}); window.removeEventListener('keydown', keyDownListener, {passive: true} as any);
}; };
}, [editor]); }, [editor]);

View File

@ -1,14 +1,18 @@
import AppContext from '../../../AppContext';
import SecundaryForm from './SecundaryForm'; import SecundaryForm from './SecundaryForm';
import {default as React, useCallback, useContext} from 'react'; import {Comment, useAppContext} from '../../../AppContext';
import {getEditorConfig} from '../../../utils/editor'; import {getEditorConfig} from '../../../utils/editor';
import {scrollToElement} from '../../../utils/helpers'; import {scrollToElement} from '../../../utils/helpers';
import {useCallback} from 'react';
import {useEditor} from '@tiptap/react'; import {useEditor} from '@tiptap/react';
import {useRefCallback} from '../../../utils/hooks'; import {useRefCallback} from '../../../utils/hooks';
const ReplyForm = ({parent, close}) => { type Props = {
const {postId, dispatchAction} = useContext(AppContext); parent: Comment;
const [, setForm] = useRefCallback(scrollToElement); close: () => void;
}
const ReplyForm: React.FC<Props> = ({parent, close}) => {
const {postId, dispatchAction} = useAppContext();
const [, setForm] = useRefCallback<HTMLDivElement>(scrollToElement);
const config = { const config = {
placeholder: 'Reply to comment', placeholder: 'Reply to comment',
@ -18,7 +22,7 @@ const ReplyForm = ({parent, close}) => {
const editor = useEditor({ const editor = useEditor({
...getEditorConfig(config) ...getEditorConfig(config)
}); });
const submit = useCallback(async ({html}) => { const submit = useCallback(async ({html}) => {
// Send comment to server // Send comment to server
await dispatchAction('addReply', { await dispatchAction('addReply', {

View File

@ -1,18 +1,27 @@
import AppContext from '../../../AppContext'; import Form, {SubmitSize} from './Form';
import Form from './Form'; import {Editor} from '@tiptap/react';
import React, {useContext, useEffect} from 'react';
import {isMobile} from '../../../utils/helpers'; import {isMobile} from '../../../utils/helpers';
import {useAppContext} from '../../../AppContext';
import {useEffect} from 'react';
import {useSecondUpdate} from '../../../utils/hooks'; import {useSecondUpdate} from '../../../utils/hooks';
const SecundaryForm = ({editor, submit, close, closeIfNotChanged, submitText, submitSize}) => { type Props = {
const {dispatchAction, secundaryFormCount} = useContext(AppContext); editor: Editor;
submit: (data: {html: string}) => Promise<void>;
close: () => void;
closeIfNotChanged: () => void;
submitText: JSX.Element;
submitSize: SubmitSize
};
const SecundaryForm: React.FC<Props> = ({editor, submit, close, closeIfNotChanged, submitText, submitSize}) => {
const {dispatchAction, secundaryFormCount} = useAppContext();
// Keep track of the amount of open forms // Keep track of the amount of open forms
useEffect(() => { useEffect(() => {
dispatchAction('increaseSecundaryFormCount'); dispatchAction('increaseSecundaryFormCount', {});
return () => { return () => {
dispatchAction('decreaseSecundaryFormCount'); dispatchAction('decreaseSecundaryFormCount', {});
}; };
}, [dispatchAction]); }, [dispatchAction]);

View File

@ -1,13 +1,17 @@
import AppContext from '../../AppContext';
import CloseButton from './CloseButton'; import CloseButton from './CloseButton';
import React, {useContext, useEffect, useRef, useState} from 'react';
import {Transition} from '@headlessui/react'; import {Transition} from '@headlessui/react';
import {isMobile} from '../../utils/helpers'; import {isMobile} from '../../utils/helpers';
import {useAppContext} from '../../AppContext';
import {useEffect, useRef, useState} from 'react';
const AddDetailsPopup = (props) => { type Props = {
const inputNameRef = useRef(null); callback: (succeeded: boolean) => void,
const inputExpertiseRef = useRef(null); expertiseAutofocus?: boolean
const {dispatchAction, member, accentColor} = useContext(AppContext); };
const AddDetailsPopup = (props: Props) => {
const inputNameRef = useRef<HTMLInputElement>(null);
const inputExpertiseRef = useRef<HTMLInputElement>(null);
const {dispatchAction, member, accentColor} = useAppContext();
const [name, setName] = useState(member.name ?? ''); const [name, setName] = useState(member.name ?? '');
const [expertise, setExpertise] = useState(member.expertise ?? ''); const [expertise, setExpertise] = useState(member.expertise ?? '');
@ -21,12 +25,12 @@ const AddDetailsPopup = (props) => {
const [error, setError] = useState({name: '', expertise: ''}); const [error, setError] = useState({name: '', expertise: ''});
const stopPropagation = (event) => { const stopPropagation = (event: Event) => {
event.stopPropagation(); event.stopPropagation();
}; };
const close = (succeeded) => { const close = (succeeded: boolean) => {
dispatchAction('closePopup'); dispatchAction('closePopup', {});
props.callback(succeeded); props.callback(succeeded);
}; };
@ -38,7 +42,7 @@ const AddDetailsPopup = (props) => {
}); });
close(true); close(true);
} else { } else {
setError({name: 'Enter your name'}); setError({name: 'Enter your name', expertise: ''});
setName(''); setName('');
inputNameRef.current?.focus(); inputNameRef.current?.focus();
} }
@ -61,8 +65,8 @@ const AddDetailsPopup = (props) => {
} }
}, [inputNameRef, inputExpertiseRef, props.expertiseAutofocus]); }, [inputNameRef, inputExpertiseRef, props.expertiseAutofocus]);
const renderExampleProfiles = (index) => { const renderExampleProfiles = () => {
const renderEl = (profile) => { const renderEl = (profile: {name: string, avatar: string, expertise: string}) => {
return ( return (
<Transition <Transition
key={profile.name} key={profile.name}
@ -138,18 +142,19 @@ const AddDetailsPopup = (props) => {
ref={inputNameRef} ref={inputNameRef}
className={`flex h-[42px] w-full items-center rounded border border-neutral-200 px-3 font-sans text-[16px] outline-0 transition-[border-color] duration-200 focus:border-neutral-300 ${error.name && 'border-red-500 focus:border-red-500'}`} className={`flex h-[42px] w-full items-center rounded border border-neutral-200 px-3 font-sans text-[16px] outline-0 transition-[border-color] duration-200 focus:border-neutral-300 ${error.name && 'border-red-500 focus:border-red-500'}`}
id="comments-name" id="comments-name"
maxLength="64" maxLength={64}
name="name" name="name"
placeholder="Jamie Larson" placeholder="Jamie Larson"
type="text" type="text"
value={name} value={name}
onChange={(e) => { onChange={(e) => {
setName(e.target.value); setName(e.currentTarget.value);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
setName(e.target.value); setName(e.currentTarget.value);
submit(); // eslint-disable-next-line no-console
submit().catch(console.error);
} }
}} }}
/> />
@ -167,14 +172,15 @@ const AddDetailsPopup = (props) => {
type="text" type="text"
value={expertise} value={expertise}
onChange={(e) => { onChange={(e) => {
let expertiseText = e.target.value; let expertiseText = e.currentTarget.value;
setExpertiseCharsLeft(maxExpertiseChars - expertiseText.length); setExpertiseCharsLeft(maxExpertiseChars - expertiseText.length);
setExpertise(expertiseText); setExpertise(expertiseText);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
setExpertise(e.target.value); setExpertise(e.currentTarget.value);
submit(); // eslint-disable-next-line no-console
submit().catch(console.error);
} }
}} }}
/> />
@ -182,7 +188,10 @@ const AddDetailsPopup = (props) => {
className={`mt-10 flex h-[42px] w-full items-center justify-center rounded-md px-8 font-sans text-[15px] font-semibold text-white opacity-100 transition-opacity duration-200 ease-linear hover:opacity-90`} className={`mt-10 flex h-[42px] w-full items-center justify-center rounded-md px-8 font-sans text-[15px] font-semibold text-white opacity-100 transition-opacity duration-200 ease-linear hover:opacity-90`}
style={{backgroundColor: accentColor ?? '#000000'}} style={{backgroundColor: accentColor ?? '#000000'}}
type="button" type="button"
onClick={submit} onClick={() => {
// eslint-disable-next-line no-console
submit().catch(console.error);
}}
> >
Save Save
</button> </button>

View File

@ -1,9 +1,12 @@
import React from 'react'; import React from 'react';
import {ReactComponent as CloseIcon} from '../../images/icons/close.svg'; import {ReactComponent as CloseIcon} from '../../images/icons/close.svg';
const CloseButton = (props) => { type Props = {
close: () => void;
}
const CloseButton: React.FC<Props> = ({close}) => {
return ( return (
<button className="absolute right-6 top-6 opacity-20 transition-opacity duration-100 ease-out hover:opacity-40 sm:right-8 sm:top-10" type="button" onClick={props.close}> <button className="absolute right-6 top-6 opacity-20 transition-opacity duration-100 ease-out hover:opacity-40 sm:right-8 sm:top-10" type="button" onClick={close}>
<CloseIcon className="h-[20px] w-[20px]" /> <CloseIcon className="h-[20px] w-[20px]" />
</button> </button>
); );

View File

@ -1,21 +1,27 @@
import AppContext from '../../AppContext';
import React, {useContext, useEffect} from 'react';
import {PopupFrame} from '../Frame'; import {PopupFrame} from '../Frame';
import {Transition} from '@headlessui/react'; import {Transition} from '@headlessui/react';
import {useAppContext} from '../../AppContext';
import {useEffect} from 'react';
const GenericPopup = ({show, children, title, callback}) => { type Props = {
show: boolean;
title: string;
callback?: (result: boolean) => void;
children: React.ReactNode;
};
const GenericPopup: React.FC<Props> = ({show, children, title, callback}) => {
// The modal will cover the whole screen, so while it is hidden, we need to disable pointer events // The modal will cover the whole screen, so while it is hidden, we need to disable pointer events
const {dispatchAction} = useContext(AppContext); const {dispatchAction} = useAppContext();
const close = (event) => { const close = () => {
dispatchAction('closePopup'); dispatchAction('closePopup', {});
if (callback) { if (callback) {
callback(false); callback(false);
} }
}; };
useEffect(() => { useEffect(() => {
const listener = (event) => { const listener = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
close(); close();
} }
@ -23,7 +29,7 @@ const GenericPopup = ({show, children, title, callback}) => {
window.addEventListener('keydown', listener, {passive: true}); window.addEventListener('keydown', listener, {passive: true});
return () => { return () => {
window.removeEventListener('keydown', listener, {passive: true}); window.removeEventListener('keydown', listener, {passive: true} as any);
}; };
}); });

View File

@ -1,11 +1,12 @@
import AppContext from '../../AppContext';
import CloseButton from './CloseButton'; import CloseButton from './CloseButton';
import React, {useContext, useState} from 'react'; import {Comment} from '../../AppContext';
import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg'; import {ReactComponent as SpinnerIcon} from '../../images/icons/spinner.svg';
import {ReactComponent as SuccessIcon} from '../../images/icons/success.svg'; import {ReactComponent as SuccessIcon} from '../../images/icons/success.svg';
import {useAppContext} from '../../AppContext';
import {useState} from 'react';
const ReportPopup = (props) => { const ReportPopup = ({comment}: {comment: Comment}) => {
const {dispatchAction} = useContext(AppContext); const {dispatchAction} = useAppContext();
const [progress, setProgress] = useState('default'); const [progress, setProgress] = useState('default');
let buttonColor = 'bg-red-600'; let buttonColor = 'bg-red-600';
@ -27,15 +28,15 @@ const ReportPopup = (props) => {
buttonIcon = <SuccessIcon className="mr-2 h-[16px] w-[16px]" />; buttonIcon = <SuccessIcon className="mr-2 h-[16px] w-[16px]" />;
} }
const stopPropagation = (event) => { const stopPropagation = (event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
}; };
const close = (event) => { const close = () => {
dispatchAction('closePopup'); dispatchAction('closePopup', {});
}; };
const submit = (event) => { const submit = (event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
setProgress('sending'); setProgress('sending');
@ -43,7 +44,7 @@ const ReportPopup = (props) => {
// purposely faking the timing of the report being sent for user feedback purposes // purposely faking the timing of the report being sent for user feedback purposes
setTimeout(() => { setTimeout(() => {
setProgress('sent'); setProgress('sent');
dispatchAction('reportComment', props.comment); dispatchAction('reportComment', comment);
setTimeout(() => { setTimeout(() => {
close(); close();
@ -66,7 +67,7 @@ const ReportPopup = (props) => {
</button> </button>
<button className="font-sans text-sm font-medium text-neutral-500 dark:text-neutral-400" type="button" onClick={close}>Cancel</button> <button className="font-sans text-sm font-medium text-neutral-500 dark:text-neutral-400" type="button" onClick={close}>Cancel</button>
</div> </div>
<CloseButton close={() => close(false)} /> <CloseButton close={close} />
</div> </div>
); );
}; };

View File

@ -3,34 +3,43 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {ROOT_DIV_ID} from './utils/constants'; import {ROOT_DIV_ID} from './utils/constants';
function addRootDiv() { function getScriptTag(): HTMLElement {
let scriptTag = document.currentScript; let scriptTag = document.currentScript as HTMLElement | null;
if (!scriptTag && import.meta.env.DEV) { if (!scriptTag && import.meta.env.DEV) {
// In development mode, use any script tag (because in ESM mode, document.currentScript is not set) // In development mode, use any script tag (because in ESM mode, document.currentScript is not set)
scriptTag = document.querySelector('script[data-ghost-comments]'); scriptTag = document.querySelector('script[data-ghost-comments]');
} }
// We need to inject the comment box at the same place as the script tag if (!scriptTag) {
if (scriptTag) { throw new Error('[Comments-UI] Cannot find current script tag');
const elem = document.createElement('div');
elem.id = ROOT_DIV_ID;
scriptTag.parentElement.insertBefore(elem, scriptTag);
} else if (import.meta.env.DEV) {
const elem = document.createElement('div');
elem.id = ROOT_DIV_ID;
document.body.appendChild(elem);
} else {
// eslint-disable-next-line no-console
console.warn('[Comments] Comment box location was not found: could not load comments box.');
} }
return scriptTag;
} }
function getSiteData() { /**
* Returns a div to mount the React application into, creating it if necessary
*/
function getRootDiv(scriptTag: HTMLElement) {
if (scriptTag.previousElementSibling && scriptTag.previousElementSibling.id === ROOT_DIV_ID) {
return scriptTag.previousElementSibling;
}
if (!scriptTag.parentElement) {
throw new Error('[Comments-UI] Script tag does not have a parent element');
}
const elem = document.createElement('div');
elem.id = ROOT_DIV_ID;
scriptTag.parentElement.insertBefore(elem, scriptTag);
return elem;
}
function getSiteData(scriptTag: HTMLElement) {
/** /**
* @type {HTMLElement} * @type {HTMLElement}
*/ */
const scriptTag = document.querySelector('script[data-ghost-comments]');
let dataset = scriptTag?.dataset; let dataset = scriptTag?.dataset;
if (!scriptTag && process.env.NODE_ENV === 'development') { if (!scriptTag && process.env.NODE_ENV === 'development') {
@ -46,7 +55,7 @@ function getSiteData() {
const adminUrl = dataset.admin; const adminUrl = dataset.admin;
const postId = dataset.postId; const postId = dataset.postId;
const colorScheme = dataset.colorScheme; const colorScheme = dataset.colorScheme;
const avatarSaturation = dataset.avatarSaturation; const avatarSaturation = dataset.avatarSaturation ? parseInt(dataset.avatarSaturation) : undefined;
const accentColor = dataset.accentColor; const accentColor = dataset.accentColor;
const commentsEnabled = dataset.commentsEnabled; const commentsEnabled = dataset.commentsEnabled;
const title = dataset.title === 'null' ? null : dataset.title; const title = dataset.title === 'null' ? null : dataset.title;
@ -64,24 +73,22 @@ function handleTokenUrl() {
} }
} }
function setup({siteUrl}) {
addRootDiv();
handleTokenUrl();
}
function init() { function init() {
const scriptTag = getScriptTag();
const root = getRootDiv(scriptTag);
// const customSiteUrl = getSiteUrl(); // const customSiteUrl = getSiteUrl();
const {siteUrl: customSiteUrl, ...siteData} = getSiteData(); const {siteUrl: customSiteUrl, ...siteData} = getSiteData(scriptTag);
const siteUrl = customSiteUrl || window.location.origin; const siteUrl = customSiteUrl || window.location.origin;
try { try {
setup({siteUrl}); handleTokenUrl();
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
{<App customSiteUrl={customSiteUrl} siteUrl={siteUrl} {...siteData} />} {<App customSiteUrl={customSiteUrl} siteUrl={siteUrl} {...siteData} />}
</React.StrictMode>, </React.StrictMode>,
document.getElementById(ROOT_DIV_ID) root
); );
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console

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'; import setupGhostApi from './api';
test('should call counts endpoint', () => { test('should call counts endpoint', () => {
jest.spyOn(window, 'fetch'); const spy = vi.spyOn(window, 'fetch');
window.fetch.mockResolvedValueOnce({ spy.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({success: true}) json: async () => ({success: true})
}); } as any);
const api = setupGhostApi({}); const api = setupGhostApi({siteUrl: 'http://localhost:3000', apiUrl: '', apiKey: ''});
api.comments.count({postId: null}); api.comments.count({postId: null});
expect(window.fetch).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
expect(window.fetch).toHaveBeenCalledWith( expect(spy).toHaveBeenCalledWith(
'http://localhost:3000/members/api/comments/counts/', 'http://localhost:3000/members/api/comments/counts/',
expect.objectContaining({ expect.objectContaining({
method: 'GET', method: 'GET',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'same-origin', credentials: 'same-origin',
body: undefined body: undefined
}), })
); );
}); });
test('should call counts endpoint with postId query param', () => { test('should call counts endpoint with postId query param', () => {
jest.spyOn(window, 'fetch'); const spy = vi.spyOn(window, 'fetch');
window.fetch.mockResolvedValueOnce({ spy.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({success: true}) json: async () => ({success: true})
}); } as any);
const api = setupGhostApi({}); const api = setupGhostApi({siteUrl: 'http://localhost:3000', apiUrl: '', apiKey: ''});
api.comments.count({postId: '123'}); api.comments.count({postId: '123'});
expect(window.fetch).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
expect(window.fetch).toHaveBeenCalledWith( expect(spy).toHaveBeenCalledWith(
'http://localhost:3000/members/api/comments/counts/?ids=123', 'http://localhost:3000/members/api/comments/counts/?ids=123',
expect.objectContaining({ expect.objectContaining({
method: 'GET', method: 'GET',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'same-origin', credentials: 'same-origin',
body: undefined body: undefined
}), })
); );
}); });

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 test: isTestMode
}; };
export const hasMode = (modes = [], options = {}) => { export const hasMode = (modes: ('dev' | 'test')[] = [], options: {customSiteUrl?: string} = {}) => {
return modes.some((mode) => { return modes.some((mode) => {
const modeFn = modeFns[mode]; const modeFn = modeFns[mode];
return !!(modeFn && modeFn(options)); return !!(modeFn && modeFn(options));

View File

@ -5,8 +5,9 @@ import Link from '@tiptap/extension-link';
import Paragraph from '@tiptap/extension-paragraph'; import Paragraph from '@tiptap/extension-paragraph';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import Text from '@tiptap/extension-text'; import Text from '@tiptap/extension-text';
import {EditorOptions} from '@tiptap/core';
export function getEditorConfig({placeholder, autofocus = false, content = ''}) { export function getEditorConfig({placeholder, autofocus = false, content = ''}: {placeholder: string; autofocus?: boolean; content?: string}): Partial<EditorOptions> {
return { return {
extensions: [ extensions: [
Document, Document,

View File

@ -10,10 +10,10 @@ describe('formatNumber', function () {
}); });
it('handles undefined', function () { it('handles undefined', function () {
expect(helpers.formatNumber()).toEqual(''); expect((helpers.formatNumber as any)()).toEqual('');
}); });
it('handles null', function () { it('handles null', function () {
expect(helpers.formatNumber(null)).toEqual(''); expect((helpers.formatNumber as any)(null)).toEqual('');
}); });
}); });

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; let count = 0;
if (state && state.popupNotification) { if (state && state.popupNotification) {
count = (state.popupNotification.count || 0) + 1; count = (state.popupNotification.count || 0) + 1;
@ -15,7 +26,7 @@ export const createPopupNotification = ({type, status, autoHide, duration = 2600
}; };
}; };
export function formatNumber(number) { export function formatNumber(number: number): string {
if (number !== 0 && !number) { if (number !== 0 && !number) {
return ''; return '';
} }
@ -24,7 +35,7 @@ export function formatNumber(number) {
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
} }
export function formatRelativeTime(dateString) { export function formatRelativeTime(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@ -82,7 +93,7 @@ export function formatRelativeTime(dateString) {
return `${Math.floor(diff)} weeks ago`; return `${Math.floor(diff)} weeks ago`;
} }
export function formatExplicitTime(dateString) { export function formatExplicitTime(dateString: string): string {
const date = new Date(dateString); const date = new Date(dateString);
let day = date.toLocaleDateString('en-us', {day: '2-digit'}); // eg. 01 let day = date.toLocaleDateString('en-us', {day: '2-digit'}); // eg. 01
@ -94,7 +105,7 @@ export function formatExplicitTime(dateString) {
return `${day} ${month} ${year} ${hour}:${minute}`; return `${day} ${month} ${year} ${hour}:${minute}`;
} }
export function getInitials(name) { export function getInitials(name: string): string {
if (!name) { if (!name) {
return ''; return '';
} }
@ -117,22 +128,22 @@ export function isMobile() {
return (Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) < 480); return (Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) < 480);
} }
export function isCommentPublished(comment) { export function isCommentPublished(comment: Comment) {
return comment.status === 'published'; return comment.status === 'published';
} }
/** /**
* Returns the y scroll position (top) of the main window of a given element that is in one or multiple stacked iframes * Returns the y scroll position (top) of the main window of a given element that is in one or multiple stacked iframes
*/ */
export const getScrollToPosition = (element) => { export const getScrollToPosition = (element: HTMLElement) => {
let yOffset = 0; let yOffset = 0;
// Because we are working in an iframe, we need to resolve the position inside this iframe to the position in the top window // Because we are working in an iframe, we need to resolve the position inside this iframe to the position in the top window
// Get the window of the element, not the window (which is the top window) // Get the window of the element, not the window (which is the top window)
let currentWindow = element.ownerDocument.defaultView; let currentWindow: Window | null = element.ownerDocument.defaultView;
// Loop all iframe parents (if we have multiple) // Loop all iframe parents (if we have multiple)
while (currentWindow !== window) { while (currentWindow && currentWindow !== window) {
const currentParentWindow = currentWindow.parent; const currentParentWindow = currentWindow.parent;
for (let idx = 0; idx < currentParentWindow.frames.length; idx++) { for (let idx = 0; idx < currentParentWindow.frames.length; idx++) {
if (currentParentWindow.frames[idx] === currentWindow) { if (currentParentWindow.frames[idx] === currentWindow) {
@ -155,7 +166,7 @@ export const getScrollToPosition = (element) => {
/** /**
* Scroll to an element that is in an iframe, only if it is outside the current viewport * Scroll to an element that is in an iframe, only if it is outside the current viewport
*/ */
export const scrollToElement = (element) => { export const scrollToElement = (element: HTMLElement) => {
// Is the form already in view? // Is the form already in view?
const elementHeight = element.offsetHeight; const elementHeight = element.offsetHeight;

View File

@ -1,25 +1,25 @@
import AppContext from '../AppContext'; import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {formatRelativeTime} from './helpers'; import {formatRelativeTime} from './helpers';
import {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import {useAppContext} from '../AppContext';
/** /**
* Execute a callback when a ref is set and unset. * Execute a callback when a ref is set and unset.
* Warning: make sure setup and clear are both functions that do not change on every rerender. So use useCallback if required on them. * Warning: make sure setup and clear are both functions that do not change on every rerender. So use useCallback if required on them.
*/ */
export function useRefCallback(setup, clear) { export function useRefCallback<T>(setup: (element: T) => void, clear?: (element: T) => void) {
const ref = useRef(null); const ref = useRef<T | null>(null);
const setRef = useCallback((node) => { const setRef = useCallback((node) => {
if (ref.current && clear) { if (ref.current && clear) {
// Make sure to cleanup any events/references added to the last instance // Make sure to cleanup any events/references added to the last instance
clear(ref.current); clear(ref.current);
} }
if (node && setup) { if (node && setup) {
// Check if a node is actually passed. Otherwise node would be null. // Check if a node is actually passed. Otherwise node would be null.
// You can now do what you need to, addEventListeners, measure, etc. // You can now do what you need to, addEventListeners, measure, etc.
setup(node); setup(node);
} }
// Save a reference to the node // Save a reference to the node
ref.current = node; ref.current = node;
}, [setup, clear]); }, [setup, clear]);
@ -28,14 +28,14 @@ export function useRefCallback(setup, clear) {
/** /**
* Sames as useEffect, but ignores the first mounted call and the first update (so first 2 calls ignored) * Sames as useEffect, but ignores the first mounted call and the first update (so first 2 calls ignored)
* @param {Same} fn * @param {Same} fn
* @param {*} inputs * @param {*} inputs
*/ */
export function useSecondUpdate(fn, inputs) { export function useSecondUpdate(fn: () => void, inputs: React.DependencyList) {
const didMountRef = useRef(0); const didMountRef = useRef(0);
useEffect(() => { useEffect(() => {
if (didMountRef.current >= 2) { if (didMountRef.current >= 2) {
return fn(); return fn();
} }
didMountRef.current += 1; didMountRef.current += 1;
@ -44,15 +44,15 @@ export function useSecondUpdate(fn, inputs) {
}, inputs); }, inputs);
} }
export function usePopupOpen(type) { export function usePopupOpen(type: string) {
const {popup} = useContext(AppContext); const {popup} = useAppContext();
return popup?.type === type; return popup?.type === type;
} }
/** /**
* Avoids a rerender of the relative time unless the date changed, and not the current timestamp changed * Avoids a rerender of the relative time unless the date changed, and not the current timestamp changed
*/ */
export function useRelativeTime(dateString) { export function useRelativeTime(dateString: string) {
return useMemo(() => { return useMemo(() => {
return formatRelativeTime(dateString); return formatRelativeTime(dateString);
}, [dateString]); }, [dateString]);

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 // Sort comments on created at + id
this.comments.sort((a, b) => { this.comments.sort((a, b) => {
const aDate = new Date(a.created_at).getTime(); const aDate = new Date(a.created_at).getTime();

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" mdn-data "2.0.4"
source-map "^0.6.1" source-map "^0.6.1"
css-tree@^1.0.0-alpha.39, css-tree@^1.1.2, css-tree@^1.1.3: css-tree@^1.1.2, css-tree@^1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
@ -16537,20 +16537,6 @@ eslint-plugin-babel@5.3.1:
dependencies: dependencies:
eslint-rule-composer "^0.3.0" eslint-rule-composer "^0.3.0"
eslint-plugin-ember@10.5.8:
version "10.5.8"
resolved "https://registry.yarnpkg.com/eslint-plugin-ember/-/eslint-plugin-ember-10.5.8.tgz#87e004a5ebed88f94008364554daf57df2c9c718"
integrity sha512-d21mJ+F+htgi6HhrjwbOfllJojF4ZWGruW13HkBoGS2SaHqKUyvIH/8j3EjSxlsGFiNfhTEUWkNaUSLJxgbtWg==
dependencies:
"@ember-data/rfc395-data" "^0.0.4"
css-tree "^1.0.0-alpha.39"
ember-rfc176-data "^0.3.15"
eslint-utils "^3.0.0"
estraverse "^5.2.0"
lodash.kebabcase "^4.1.1"
requireindex "^1.2.0"
snake-case "^3.0.3"
eslint-plugin-ember@11.8.0: eslint-plugin-ember@11.8.0:
version "11.8.0" version "11.8.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-ember/-/eslint-plugin-ember-11.8.0.tgz#3c984500513a01d930c13dcac0b4661a64ea457b" resolved "https://registry.yarnpkg.com/eslint-plugin-ember/-/eslint-plugin-ember-11.8.0.tgz#3c984500513a01d930c13dcac0b4661a64ea457b"
@ -16577,15 +16563,7 @@ eslint-plugin-es-x@^6.1.0:
"@eslint-community/eslint-utils" "^4.1.2" "@eslint-community/eslint-utils" "^4.1.2"
"@eslint-community/regexpp" "^4.5.0" "@eslint-community/regexpp" "^4.5.0"
eslint-plugin-es@^3.0.0: eslint-plugin-filenames@allouis/eslint-plugin-filenames#15dc354f4e3d155fc2d6ae082dbfc26377539a18:
version "3.0.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz#75a7cdfdccddc0589934aeeb384175f221c57893"
integrity sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==
dependencies:
eslint-utils "^2.0.0"
regexpp "^3.0.0"
eslint-plugin-filenames@1.3.2, eslint-plugin-filenames@allouis/eslint-plugin-filenames#15dc354f4e3d155fc2d6ae082dbfc26377539a18:
version "1.3.2" version "1.3.2"
resolved "https://codeload.github.com/allouis/eslint-plugin-filenames/tar.gz/15dc354f4e3d155fc2d6ae082dbfc26377539a18" resolved "https://codeload.github.com/allouis/eslint-plugin-filenames/tar.gz/15dc354f4e3d155fc2d6ae082dbfc26377539a18"
dependencies: dependencies:
@ -16602,19 +16580,6 @@ eslint-plugin-flowtype@^8.0.3:
lodash "^4.17.21" lodash "^4.17.21"
string-natural-compare "^3.0.1" string-natural-compare "^3.0.1"
eslint-plugin-ghost@2.12.0:
version "2.12.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-ghost/-/eslint-plugin-ghost-2.12.0.tgz#108c86be40eae12f8839131e30fa2cb57195a279"
integrity sha512-YyAmG2RVlYkf66/R9IG/UoCl0nvo3HKVleqzs4Ba/YcKK6gnmYgfRrpOt1k9a3klXEQkxSY1BKesEKTCS60KQw==
dependencies:
"@kapouer/eslint-plugin-no-return-in-loop" "1.0.0"
eslint-plugin-ember "10.5.8"
eslint-plugin-filenames "1.3.2"
eslint-plugin-mocha "7.0.1"
eslint-plugin-node "11.1.0"
eslint-plugin-sort-imports-es6-autofix "0.6.0"
eslint-plugin-unicorn "40.1.0"
eslint-plugin-ghost@3.2.0: eslint-plugin-ghost@3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-ghost/-/eslint-plugin-ghost-3.2.0.tgz#c71a8809cbd51d7eb53203556db67432be065433" resolved "https://registry.yarnpkg.com/eslint-plugin-ghost/-/eslint-plugin-ghost-3.2.0.tgz#c71a8809cbd51d7eb53203556db67432be065433"
@ -16708,18 +16673,6 @@ eslint-plugin-n@^16.0.0:
resolve "^1.22.2" resolve "^1.22.2"
semver "^7.5.0" semver "^7.5.0"
eslint-plugin-node@11.1.0:
version "11.1.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d"
integrity sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==
dependencies:
eslint-plugin-es "^3.0.0"
eslint-utils "^2.0.0"
ignore "^5.1.1"
minimatch "^3.0.4"
resolve "^1.10.1"
semver "^6.1.0"
eslint-plugin-react-hooks@4.6.0, eslint-plugin-react-hooks@^4.3.0: eslint-plugin-react-hooks@4.6.0, eslint-plugin-react-hooks@^4.3.0:
version "4.6.0" version "4.6.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz#4c3e697ad95b77e93f8646aaa1630c1ba607edd3"
@ -16764,14 +16717,6 @@ eslint-plugin-tailwindcss@3.11.0:
fast-glob "^3.2.5" fast-glob "^3.2.5"
postcss "^8.4.4" postcss "^8.4.4"
eslint-plugin-tailwindcss@^3.6.0:
version "3.12.1"
resolved "https://registry.yarnpkg.com/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-3.12.1.tgz#ab7fc554b97872208460aac6921f391683c992f1"
integrity sha512-LyIRV0rx6prTpJZsSCXSNJ34Yry3Nj9OJwvzh1xTsiG6+UCnAPW1Bx41s7vZzUDKMlwFgpUN9Me+NK12T4DHYg==
dependencies:
fast-glob "^3.2.5"
postcss "^8.4.4"
eslint-plugin-testing-library@^5.0.1: eslint-plugin-testing-library@^5.0.1:
version "5.10.2" version "5.10.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.10.2.tgz#12f231ad9b52b6aef45c801fd00aa129a932e0c2" resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.10.2.tgz#12f231ad9b52b6aef45c801fd00aa129a932e0c2"
@ -16779,26 +16724,6 @@ eslint-plugin-testing-library@^5.0.1:
dependencies: dependencies:
"@typescript-eslint/utils" "^5.43.0" "@typescript-eslint/utils" "^5.43.0"
eslint-plugin-unicorn@40.1.0:
version "40.1.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-40.1.0.tgz#48975360e39d23df726e4b33e8dd5d650e184832"
integrity sha512-y5doK2DF9Sr5AqKEHbHxjFllJ167nKDRU01HDcWyv4Tnmaoe9iNxMrBnaybZvWZUaE3OC5Unu0lNIevYamloig==
dependencies:
"@babel/helper-validator-identifier" "^7.15.7"
ci-info "^3.3.0"
clean-regexp "^1.0.0"
eslint-utils "^3.0.0"
esquery "^1.4.0"
indent-string "^4.0.0"
is-builtin-module "^3.1.0"
lodash "^4.17.21"
pluralize "^8.0.0"
read-pkg-up "^7.0.1"
regexp-tree "^0.1.24"
safe-regex "^2.1.1"
semver "^7.3.5"
strip-indent "^3.0.0"
eslint-plugin-unicorn@42.0.0: eslint-plugin-unicorn@42.0.0:
version "42.0.0" version "42.0.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-42.0.0.tgz#47d60c00c263ad743403b052db689e39acbacff1" resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-42.0.0.tgz#47d60c00c263ad743403b052db689e39acbacff1"
@ -16980,51 +16905,6 @@ eslint@8.38.0:
strip-json-comments "^3.1.0" strip-json-comments "^3.1.0"
text-table "^0.2.0" text-table "^0.2.0"
eslint@8.43.0, eslint@^8.3.0:
version "8.43.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.43.0.tgz#3e8c6066a57097adfd9d390b8fc93075f257a094"
integrity sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==
dependencies:
"@eslint-community/eslint-utils" "^4.2.0"
"@eslint-community/regexpp" "^4.4.0"
"@eslint/eslintrc" "^2.0.3"
"@eslint/js" "8.43.0"
"@humanwhocodes/config-array" "^0.11.10"
"@humanwhocodes/module-importer" "^1.0.1"
"@nodelib/fs.walk" "^1.2.8"
ajv "^6.10.0"
chalk "^4.0.0"
cross-spawn "^7.0.2"
debug "^4.3.2"
doctrine "^3.0.0"
escape-string-regexp "^4.0.0"
eslint-scope "^7.2.0"
eslint-visitor-keys "^3.4.1"
espree "^9.5.2"
esquery "^1.4.2"
esutils "^2.0.2"
fast-deep-equal "^3.1.3"
file-entry-cache "^6.0.1"
find-up "^5.0.0"
glob-parent "^6.0.2"
globals "^13.19.0"
graphemer "^1.4.0"
ignore "^5.2.0"
import-fresh "^3.0.0"
imurmurhash "^0.1.4"
is-glob "^4.0.0"
is-path-inside "^3.0.3"
js-yaml "^4.1.0"
json-stable-stringify-without-jsonify "^1.0.1"
levn "^0.4.1"
lodash.merge "^4.6.2"
minimatch "^3.1.2"
natural-compare "^1.4.0"
optionator "^0.9.1"
strip-ansi "^6.0.1"
strip-json-comments "^3.1.0"
text-table "^0.2.0"
eslint@^7.32.0: eslint@^7.32.0:
version "7.32.0" version "7.32.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d"
@ -17071,6 +16951,51 @@ eslint@^7.32.0:
text-table "^0.2.0" text-table "^0.2.0"
v8-compile-cache "^2.0.3" v8-compile-cache "^2.0.3"
eslint@^8.3.0:
version "8.43.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.43.0.tgz#3e8c6066a57097adfd9d390b8fc93075f257a094"
integrity sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==
dependencies:
"@eslint-community/eslint-utils" "^4.2.0"
"@eslint-community/regexpp" "^4.4.0"
"@eslint/eslintrc" "^2.0.3"
"@eslint/js" "8.43.0"
"@humanwhocodes/config-array" "^0.11.10"
"@humanwhocodes/module-importer" "^1.0.1"
"@nodelib/fs.walk" "^1.2.8"
ajv "^6.10.0"
chalk "^4.0.0"
cross-spawn "^7.0.2"
debug "^4.3.2"
doctrine "^3.0.0"
escape-string-regexp "^4.0.0"
eslint-scope "^7.2.0"
eslint-visitor-keys "^3.4.1"
espree "^9.5.2"
esquery "^1.4.2"
esutils "^2.0.2"
fast-deep-equal "^3.1.3"
file-entry-cache "^6.0.1"
find-up "^5.0.0"
glob-parent "^6.0.2"
globals "^13.19.0"
graphemer "^1.4.0"
ignore "^5.2.0"
import-fresh "^3.0.0"
imurmurhash "^0.1.4"
is-glob "^4.0.0"
is-path-inside "^3.0.3"
js-yaml "^4.1.0"
json-stable-stringify-without-jsonify "^1.0.1"
levn "^0.4.1"
lodash.merge "^4.6.2"
minimatch "^3.1.2"
natural-compare "^1.4.0"
optionator "^0.9.1"
strip-ansi "^6.0.1"
strip-json-comments "^3.1.0"
text-table "^0.2.0"
esm@^3.2.25, esm@^3.2.4: esm@^3.2.25, esm@^3.2.4:
version "3.2.25" version "3.2.25"
resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10"
@ -28941,7 +28866,7 @@ regexp.prototype.flags@^1.4.3:
define-properties "^1.1.3" define-properties "^1.1.3"
functions-have-names "^1.2.2" functions-have-names "^1.2.2"
regexpp@^3.0.0, regexpp@^3.1.0: regexpp@^3.1.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
@ -29876,7 +29801,7 @@ semver@7.5.3, semver@^7.0.0, semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"
semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
version "6.3.0" version "6.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==