From cb2150f33c31b694dd7e62e943abf9a940713048 Mon Sep 17 00:00:00 2001 From: Fabien 'egg' O'Carroll Date: Wed, 19 Jun 2024 18:46:02 +0700 Subject: [PATCH] Added Ghost2Ghost ActivityPub feature that uses mock API (#20411) ref https://linear.app/tryghost/issue/MOM-108/ap-phase-two Added a WIP version of the Ghost-to-Ghost ActivityPub feature behind the feature flag. Enabling it will add a new item to the main sidebar nav that lets you interact with our ActivityPub mock API in the following ways: - Shows you the list of sites you follow - Shows you the list of sites that follow you - Shows you the articles published by sites you follow - Shows you activities (who followed you or liked your article) - Shows your liked articles Mock API can be easily updated to simulate working with different types of data and interactions. --- apps/admin-x-activitypub/package.json | 3 +- .../public/styles/reader.css | 1928 ++++++ apps/admin-x-activitypub/src/App.tsx | 17 +- apps/admin-x-activitypub/src/MainContent.tsx | 7 + .../src/components/FollowSite.tsx | 85 + .../src/components/ListIndex.tsx | 278 +- .../src/components/ViewFollowers.tsx | 44 + .../src/components/ViewFollowing.tsx | 59 + .../src/components/articleBodyStyles.ts | 5911 +++++++++++++++++ .../src/components/modals.tsx | 11 + apps/admin-x-activitypub/src/styles/index.css | 24 + .../src/utils/get-username-from-following.ts | 12 + .../src/utils/get-username.ts | 12 + .../test/acceptance/app.test.ts | 2 +- .../test/acceptance/listIndex.test.ts | 52 + .../test/unit/ListIndex.test.tsx | 10 - .../test/unit/utils/get-username.test.tsx | 36 + .../src/global/layout/ViewContainer.tsx | 2 +- apps/admin-x-framework/src/api/activitypub.ts | 113 + apps/admin-x-framework/src/test/acceptance.ts | 15 +- .../test/responses/activitypub/following.json | 13 + .../src/test/responses/activitypub/inbox.json | 155 + .../src/utils/api/fetchApi.ts | 7 +- apps/admin-x-framework/src/utils/api/hooks.ts | 11 +- apps/admin-x-framework/src/utils/helpers.ts | 4 +- 25 files changed, 8759 insertions(+), 52 deletions(-) create mode 100644 apps/admin-x-activitypub/public/styles/reader.css create mode 100644 apps/admin-x-activitypub/src/MainContent.tsx create mode 100644 apps/admin-x-activitypub/src/components/FollowSite.tsx create mode 100644 apps/admin-x-activitypub/src/components/ViewFollowers.tsx create mode 100644 apps/admin-x-activitypub/src/components/ViewFollowing.tsx create mode 100644 apps/admin-x-activitypub/src/components/articleBodyStyles.ts create mode 100644 apps/admin-x-activitypub/src/components/modals.tsx create mode 100644 apps/admin-x-activitypub/src/utils/get-username-from-following.ts create mode 100644 apps/admin-x-activitypub/src/utils/get-username.ts create mode 100644 apps/admin-x-activitypub/test/acceptance/listIndex.test.ts delete mode 100644 apps/admin-x-activitypub/test/unit/ListIndex.test.tsx create mode 100644 apps/admin-x-activitypub/test/unit/utils/get-username.test.tsx create mode 100644 apps/admin-x-framework/src/api/activitypub.ts create mode 100644 apps/admin-x-framework/src/test/responses/activitypub/following.json create mode 100644 apps/admin-x-framework/src/test/responses/activitypub/inbox.json diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 8eb6a11fd3..8431203950 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -25,13 +25,14 @@ "lint": "yarn run lint:code && yarn run lint:test", "lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src", "lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test", - "test:unit": "vitest run", + "test:unit": "yarn nx build && vitest run", "test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test", "test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 yarn test:acceptance --headed", "test:acceptance:full": "ALL_BROWSERS=1 yarn test:acceptance", "preview": "vite preview" }, "devDependencies": { + "@playwright/test": "1.38.1", "@testing-library/react": "14.1.0", "@tryghost/admin-x-design-system": "0.0.0", "@tryghost/admin-x-framework": "0.0.0", diff --git a/apps/admin-x-activitypub/public/styles/reader.css b/apps/admin-x-activitypub/public/styles/reader.css new file mode 100644 index 0000000000..4cdaee420e --- /dev/null +++ b/apps/admin-x-activitypub/public/styles/reader.css @@ -0,0 +1,1928 @@ + +.gh-whats-new-canvas .gh-canvas-header-content { + margin-bottom: -1px; + padding: 8px 0 16px; + align-items: center; +} + +.gh-whats-new { + flex-grow: 2; + color: var(--darkgrey); + font-size: 1.5rem; + letter-spacing: 0; + margin-top: -24px; +} + +.gh-whats-new-heading { + display: flex; + align-items: center; + font-size: 1.5rem; + letter-spacing: 0; + line-height: 1.3em; + font-weight: 700; + margin: 0; +} + +.gh-whats-new-heading svg { + width: 20px; + height: 20px; + margin-top: -2px; + margin-right: 12px; +} + +.gh-whats-new-heading svg path { + fill: var(--pink); +} + +.gh-wn-header { + position: relative; + display: flex; + align-items: center; + margin: -32px -32px 0; + padding: 18px 18px 12px; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + overflow: hidden; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + background: var(--pink); + background: linear-gradient(135deg, color-mod(var(--pink) h(-10) s(+5%) l(-10%)) 0%, rgba(173,38,180,1) 100%); +} + +.gh-wn-header .background-img { + position: absolute; + top: -30px; + left: 0; +} + +.gh-wn-header h2 { + font-size: 1.3rem; + font-weight: 600; + text-transform: uppercase; + color: #FFF; + margin: 0 8px 4px; +} + +.gh-wn-header svg path { + fill: #fff; +} + +.gh-wn-close { + stroke: #FFF; + opacity: 0.6; + transition: all 0.2s ease-in-out; +} + +.gh-wn-close:hover { + opacity: 1.0; +} + +.gh-wn-entry { + margin: 0 0 5vmin; + padding-bottom: 5vmin; + width: 100%; + border-bottom: 1px solid var(--lightgrey-l2); + color: inherit; + text-decoration: none; +} + +.gh-wn-content { + max-width: 620px; +} + +.gh-whats-new-canvas .gh-wn-content { + margin: 0 auto; +} + +.gh-wn-entry h4 { + font-size: 1.2rem; + font-weight: 500; + letter-spacing: 0; + text-transform: uppercase; + margin: 24px 0 4px; + color: var(--midlightgrey); +} + +.gh-wn-entry h1 { + font-size: 3.7rem; + line-height: 1.3em; + font-weight: 700; + letter-spacing: -0.021em; + color: var(--black); + margin-bottom: 16px; +} + +.gh-whats-new-canvas .gh-wn-entry h1, +.gh-whats-new-canvas .gh-wn-entry h4 { + max-width: 620px; + margin-left: auto; + margin-right: auto; +} + +.gh-wn-entry h2 { + border-bottom: none; + font-size: 1.9rem; + padding-bottom: 0; + margin-bottom: 20px; +} + +.gh-wn-entry p, +.gh-wn-entry li { + line-height: 1.6em; +} + +.gh-wn-entry li { + margin-bottom: 12px; +} + +.gh-wn-entry p { + margin: 0 0 20px; + padding: 0; +} + +.gh-wn-entry figure { + margin-bottom: 24px; + overflow: hidden; +} + +.gh-wn-entry img { + height: auto; +} + +.gh-wn-entry hr { + border-top: 1px solid var(--whitegrey-l1); + margin: 24px 0; +} + + +/* Bookmark card details */ +.gh-wn-entry .kg-bookmark-card { + margin-bottom: 20px; +} + +.gh-wn-entry .kg-bookmark-container { + display: flex; + font-family: var(--font-family); + color: var(--darkgrey); + text-decoration: none; + min-height: 148px; + box-shadow: 0px 2px 5px -1px rgba(0, 0, 0, 0.15), 0 0 1px rgba(0, 0, 0, 0.09); + border-radius: 3px; +} + +.gh-wn-entry .kg-bookmark-content { + display: flex; + flex-direction: column; + flex-grow: 1; + align-items: flex-start; + justify-content: flex-start; + padding: 16px; +} + +.gh-wn-entry .kg-bookmark-title { + font-size: 1.3rem; + line-height: 1.5em; + font-weight: 600; + color: color(var(--midgrey) l(-30%)); +} + +.gh-wn-entry .kg-bookmark-container:hover .kg-bookmark-title { + color: var(--blue); +} + +.gh-wn-entry .kg-bookmark-description { + display: -webkit-box; + font-size: 1.25rem; + line-height: 1.5em; + color: color(var(--midgrey) l(-10%)); + font-weight: 400; + margin-top: 12px; + max-height: 36px; + overflow-y: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.gh-wn-entry .kg-bookmark-thumbnail { + position: relative; + min-width: 40%; + max-height: 100%; +} + +.gh-wn-entry .kg-bookmark-thumbnail img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 0 3px 3px 0; +} + +.gh-wn-entry .kg-bookmark-metadata { + display: flex; + align-items: center; + font-size: 1.25rem; + font-weight: 400; + color: color(var(--midgrey) l(-10%)); + margin-top: 14px; + flex-wrap: wrap; +} + +.gh-wn-entry .kg-bookmark-icon { + width: 18px; + height: 18px; + margin-right: 8px; +} + +.gh-wn-entry .kg-bookmark-author { + line-height: 1.5em; +} + +.gh-wn-entry .kg-bookmark-author:after { + content: "•"; + margin: 0 6px; +} + +.gh-wn-entry .kg-bookmark-publisher { + overflow: hidden; + line-height: 1.5em; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 160px; +} + +.gh-wn-entry .gh-wn-footer { + margin: 0 -32px -32px; + padding: 14px 32px 16px; + border-top: 1px solid var(--whitegrey); + justify-content: space-between; +} + +.gh-wn-footer { + position: relative; + margin-top: 14px; + margin-bottom: -13px; +} + +.gh-wn-footer:before { + position: absolute; + content: ""; + top: -14px; + left: -32px; + right: -32px; + height: 6px; + background: rgba(255,255,255,0); + box-shadow: + 0 -0.3px 1px rgba(0, 0, 0, 0.03), + 0 -4px 7px rgba(0, 0, 0, 0.06); +} + +.gh-about-container { + display: grid; + grid-template-columns: 2fr 1fr; + grid-gap: 80px; +} + +.gh-whats-new-canvas .gh-about-container { + display: flex; + grid-template-columns: unset; + grid-gap: unset; + margin: 0 auto; + max-width: 920px; + margin-top: 60px; +} + +.gh-about-container h2 { + font-size: 1.65rem; + line-height: 1.4em; + font-weight: 600; + border-bottom: 1px solid var(--lightgrey-l2); + padding-bottom: 12px; + margin-bottom: 12px; +} + +.gh-about-box { + position: sticky; + top: 96px; + right: 0; + display: flex; + flex-grow: 1; + flex-direction: column; + height: max-content; + border-radius: 3px; + min-width: 300px; +} + +.gh-about-box.grey { + border: none; + background: var(--main-color-content-greybg); +} + +@media (max-width: 1380px) { + .gh-wn-content { + max-width: 36vw; + } +} + +@media (max-width: 1120px) { + .gh-wn-content { + max-width: 680px; + } + + .gh-about-box { + position: relative; + top: unset; + right: unset; + } + + .gh-about-container { + grid-template-columns: unset; + grid-template-rows: auto; + grid-gap: 32px; + } + + .gh-whats-new { + grid-row: 3/4; + } + + + .gh-about-header-actions a { + display: none; + } + + .gh-wn-entry iframe { + max-width: 100%; + } +} + +/* Custom card styles +/* ---------------------------------------------------------- */ + +.gh-whats-new .kg-audio-card { + display: flex; + width: 100%; + min-height: 96px; + border-radius: 3px; + box-shadow: inset 0 0 0 1px rgba(124, 139, 154, 0.25); + margin-bottom: 1.5em; +} + +.gh-whats-new .kg-audio-card+.gh-whats-new .kg-audio-card { + margin-top: 1em; +} + +.gh-whats-new .kg-audio-thumbnail { + display: flex; + justify-content: center; + align-items: center; + width: 80px; + min-width: 80px; + margin: 8px; + background: transparent; + object-fit: cover; + aspect-ratio: 1/1; + border-radius: 2px; +} + +.gh-whats-new .kg-audio-thumbnail.placeholder { + background: var(--accent-color); +} + +.gh-whats-new .kg-audio-thumbnail.placeholder svg { + width: 24px; + height: 24px; + fill: white; +} + +.gh-whats-new .kg-audio-player-container { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + flex: 1; + --seek-before-width: 0%; + --volume-before-width: 100%; + --buffered-width: 0%; +} + +.gh-whats-new .kg-audio-title { + width: 100%; + margin: 8px 0 0 0; + padding: 8px 12px; + border: none; + font-family: inherit; + font-size: 1.15em; + font-weight: 700; + line-height: 1.15em; + background: transparent; +} + +.gh-whats-new .kg-audio-player { + display: flex; + flex-grow: 1; + align-items: center; + padding: 8px 12px; +} + +.gh-whats-new .kg-audio-current-time { + min-width: 38px; + padding: 0 4px; + font-family: inherit; + font-size: .85em; + font-weight: 500; + line-height: 1.4em; + white-space: nowrap; +} + +.gh-whats-new .kg-audio-time { + width: 56px; + color: #ababab; + font-family: inherit; + font-size: .85em; + font-weight: 500; + line-height: 1.4em; + white-space: nowrap; +} + +.gh-whats-new .kg-audio-duration { + padding: 0 4px; +} + +.gh-whats-new .kg-audio-play-icon, +.gh-whats-new .kg-audio-pause-icon { + position: relative; + bottom: 1px; + padding: 0px 4px 0 0; + font-size: 0; + background: transparent; +} + +.gh-whats-new .kg-audio-hide { + display: none !important; +} + +.gh-whats-new .kg-audio-play-icon svg, +.gh-whats-new .kg-audio-pause-icon svg { + width: 14px; + height: 14px; + fill: currentColor; +} + +.gh-whats-new .kg-audio-seek-slider { + flex-grow: 1; + margin: 0 4px; + width: 100%; +} + +@media (max-width: 640px) { + .gh-whats-new .kg-audio-seek-slider { + display: none; + } +} + +.gh-whats-new .kg-audio-playback-rate { + min-width: 37px; + padding: 0 4px; + font-family: inherit; + font-size: .85em; + font-weight: 600; + line-height: 1.4em; + text-align: left; + background: transparent; + white-space: nowrap; +} + +@media (max-width: 640px) { + .gh-whats-new .kg-audio-playback-rate { + padding-left: 8px; + } +} + +.gh-whats-new .kg-audio-mute-icon, +.gh-whats-new .kg-audio-unmute-icon { + position: relative; + bottom: -1px; + padding: 0 4px; + font-size: 0; + background: transparent; +} + +@media (max-width: 640px) { + .gh-whats-new .kg-audio-mute-icon, + .gh-whats-new .kg-audio-unmute-icon { + margin-left: auto; + } +} + +.gh-whats-new .kg-audio-mute-icon svg, +.gh-whats-new .kg-audio-unmute-icon svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.gh-whats-new .kg-audio-volume-slider { + flex-grow: 1; + width: 100%; + min-width: 50px; + max-width: 80px; +} + +@media (max-width: 400px) { + .gh-whats-new .kg-audio-volume-slider { + display: none; + } +} + +.gh-whats-new .kg-audio-seek-slider::before { + content: ""; + position: absolute; + left: 0; + width: var(--seek-before-width) !important; + height: 4px; + cursor: pointer; + background-color: currentColor; + border-radius: 2px; +} + +.gh-whats-new .kg-audio-volume-slider::before { + content: ""; + position: absolute; + left: 0; + width: var(--volume-before-width) !important; + height: 4px; + cursor: pointer; + background-color: currentColor; + border-radius: 2px; +} + +/* Resetting browser styles +/* --------------------------------------------------------------- */ + +.gh-whats-new .kg-audio-player-container input[type=range] { + position: relative; + -webkit-appearance: none; + background: transparent; +} + +.gh-whats-new .kg-audio-player-container input[type=range]:focus { + outline: none; +} + +.gh-whats-new .kg-audio-player-container input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; +} + +.gh-whats-new .kg-audio-player-container input[type=range]::-ms-track { + cursor: pointer; + border-color: transparent; + color: transparent; + background: transparent; +} + +.gh-whats-new .kg-audio-player-container button { + display: flex; + align-items: center; + border: 0; + cursor: pointer; +} + +.gh-whats-new .kg-audio-player-container input[type="range"] { + height: auto; + padding: 0; + border: 0; +} + +/* Chrome & Safari styles +/* --------------------------------------------------------------- */ + +.gh-whats-new .kg-audio-player-container input[type="range"]::-webkit-slider-runnable-track { + width: 100%; + height: 4px; + cursor: pointer; + background: rgba(124, 139, 154, 0.25); + border-radius: 2px; +} + +.gh-whats-new .kg-audio-player-container input[type="range"]::-webkit-slider-thumb { + position: relative; + box-sizing: content-box; + width: 13px; + height: 13px; + margin: -5px 0 0 0; + border: 0; + cursor: pointer; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24); +} + +.gh-whats-new .kg-audio-player-container input[type="range"]:active::-webkit-slider-thumb { + transform: scale(1.2); +} + +/* Firefox styles +/* --------------------------------------------------------------- */ + +.gh-whats-new .kg-audio-player-container input[type="range"]::-moz-range-track { + width: 100%; + height: 4px; + cursor: pointer; + background: rgba(124, 139, 154, 0.25); + border-radius: 2px; +} + +.gh-whats-new .kg-audio-player-container input[type="range"]::-moz-range-progress { + background: currentColor; + border-radius: 2px; +} + +.gh-whats-new .kg-audio-player-container input[type="range"]::-moz-range-thumb { + box-sizing: content-box; + width: 13px; + height: 13px; + border: 0; + cursor: pointer; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24); +} + +.gh-whats-new .kg-audio-player-container input[type="range"]:active::-moz-range-thumb { + transform: scale(1.2); +} + +/* Edge & IE styles +/* --------------------------------------------------------------- */ + +.gh-whats-new .kg-audio-player-container input[type="range"]::-ms-track { + width: 100%; + height: 3px; + border: solid transparent; + color: transparent; + cursor: pointer; + background: transparent; +} + +.gh-whats-new .kg-audio-player-container input[type="range"]::-ms-fill-lower { + background: #fff; +} + +.gh-whats-new .kg-audio-player-container input[type="range"]::-ms-fill-upper { + background: currentColor; +} + +.gh-whats-new .kg-audio-player-container input[type="range"]::-ms-thumb { + box-sizing: content-box; + width: 13px; + height: 13px; + border: 0; + cursor: pointer; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24); +} + +.gh-whats-new .kg-audio-player-container input[type="range"]:active::-ms-thumb { + transform: scale(1.2); +} + +.gh-whats-new .kg-product-card { + display: flex; + align-items: center; + flex-direction: column; + width: 100%; + margin-bottom: 1.5em; +} + +.gh-whats-new .kg-product-card-container { + display: grid; + grid-template-columns: auto min-content; + align-items: center; + grid-row-gap: 16px; + background: transparent; + max-width: 550px; + width: 100%; +} + +.gh-whats-new .kg-product-card-image { + grid-column: 1 / 3; + justify-self: center; +} + +.gh-whats-new .kg-product-card-title-container { + grid-column: 1 / 2; +} + +.gh-whats-new .kg-product-card h4.kg-product-card-title { + font-family: var(--font-family); + text-decoration: none; + font-weight: 700; + font-size: 1.4em; + margin-top: 0; + margin-bottom: 0; + line-height: 1.15em; + text-transform: none; + color: inherit; +} + +.gh-whats-new .kg-product-card-description { + grid-column: 1 / 3; +} + +.gh-whats-new .kg-product-card .kg-product-card-description p, +.gh-whats-new .kg-product-card .kg-product-card-description ol, +.gh-whats-new .kg-product-card .kg-product-card-description ul { + font-family: var(--font-family); + font-size: 0.9em; + line-height: 1.5em; + opacity: .7; +} + +.gh-whats-new .kg-product-card .kg-product-card-description p:not(:first-of-type) { + margin-top: 0.8em; + margin-bottom: 0; +} + +.gh-whats-new .kg-product-card .kg-product-card-description p:first-of-type { + margin-top: -4px; +} + +.gh-whats-new .kg-product-card .kg-product-card-description ul, +.gh-whats-new .kg-product-card .kg-product-card-description ol { + margin-top: 0.95em; +} + +.gh-whats-new .kg-product-card .kg-product-card-description li+li { + margin-top: 0.2em; +} + +.gh-whats-new .kg-product-card-rating { + display: flex; + align-items: center; + grid-column: 2 / 3; + align-self: start; + justify-self: end; + padding-left: 16px; +} + +@media (max-width: 400px) { + .gh-whats-new .kg-product-card-title-container { + grid-column: 1 / 3; + } + + .gh-whats-new .kg-product-card-rating { + grid-column: 1 / 3; + justify-self: start; + margin-top: -15px; + padding-left: 0; + } +} + +.gh-whats-new .kg-product-card-rating-star { + height: 28px; + width: 20px; +} + +.gh-whats-new .kg-product-card-rating-star svg { + width: 16px; + height: 16px; + fill: currentColor; + opacity: 0.15; +} + +.gh-whats-new .kg-product-card-rating-star svg path { + fill: unset; +} + +.gh-whats-new .kg-product-card-rating-active.kg-product-card-rating-star svg { + opacity: 1; +} + +.gh-whats-new .kg-product-card a.kg-product-card-button { + justify-content: center; + grid-column: 1 / 3; + display: flex; + position: static; + align-items: center; + font-family: var(--font-family); + font-size: 0.95em; + font-weight: 600; + line-height: 1em; + text-decoration: none; + width: 100%; + height: 2.4em; + border-radius: 5px; + padding: 0 1.2em; + transition: opacity 0.2s ease-in-out; + margin: 0; +} + +.gh-whats-new .kg-product-card a.kg-product-card-btn-accent { + background-color: var(--accent-color); + color: #fff; +} + +.gh-whats-new .kg-blockquote-alt { + font-size: 1.5em; + font-style: italic; + line-height: 1.7em; + text-align: center; + padding: 0 2.5em; +} + +@media (max-width: 800px) { + .gh-whats-new .kg-blockquote-alt { + font-size: 1.4em; + padding-left: 2em; + padding-right: 2em; + } +} + +@media (max-width: 600px) { + .gh-whats-new .kg-blockquote-alt { + font-size: 1.2em; + padding-left: 1.75em; + padding-right: 1.75em; + } +} + +.gh-whats-new .kg-button-card { + display: flex; + position: static; + align-items: center; + width: 100%; + justify-content: flex-start; + padding: 30px 0; +} + +.gh-whats-new .kg-button-card.kg-align-left { + justify-content: flex-start; +} + +.gh-whats-new .kg-button-card a.kg-btn { + display: flex; + position: static; + align-items: center; + padding: 0 1.2em; + height: 2.4em; + line-height: 1em; + font-family: var(--font-family); + font-size: 0.95em; + font-weight: 600; + text-decoration: none; + border-radius: 5px; + transition: opacity 0.2s ease-in-out; +} + +.gh-whats-new .kg-button-card a.kg-btn:hover { + opacity: 0.85; +} + +.gh-whats-new .kg-button-card a.kg-btn-accent { + background-color: var(--accent-color); + color: #fff; +} + +.gh-whats-new .kg-callout-card { + display: flex; + padding: 1.2em 1.6em; + border-radius: 3px; +} + +.gh-whats-new .kg-callout-card-grey { + background: rgba(124, 139, 154, 0.13); +} + +.gh-whats-new .kg-callout-card-white { + background: transparent; + box-shadow: inset 0 0 0 1px rgba(124, 139, 154, 0.25); +} + +.gh-whats-new .kg-callout-card-blue { + background: rgba(33, 172, 232, 0.12); +} + +.gh-whats-new .kg-callout-card-green { + background: rgba(52, 183, 67, 0.12); +} + +.gh-whats-new .kg-callout-card-yellow { + background: rgba(240, 165, 15, 0.13); +} + +.gh-whats-new .kg-callout-card-red { + background: rgba(209, 46, 46, 0.11); +} + +.gh-whats-new .kg-callout-card-pink { + background: rgba(225, 71, 174, 0.11); +} + +.gh-whats-new .kg-callout-card-purple { + background: rgba(135, 85, 236, 0.12); +} + +.gh-whats-new .kg-callout-card-accent { + background: var(--ghost-accent-color); + color: #fff; +} + +.gh-whats-new .kg-callout-card-accent a { + color: #fff; +} + +.gh-whats-new .kg-callout-card div.kg-callout-emoji { + padding-right: .8em; + line-height: 1.25em; + font-size: 1.15em; +} + +.gh-whats-new .kg-callout-card div.kg-callout-text { + font-size: .95em; + line-height: 1.5em; +} + +.gh-whats-new .kg-callout-card + .kg-callout-card { + margin-top: 1em; +} + +.gh-whats-new .kg-file-card { + display: flex; +} + +.gh-whats-new .kg-file-card a.kg-file-card-container { + display: flex; + align-items: center; + justify-content: space-between; + color: inherit; + padding: 6px; + min-height: 92px; + border: 1px solid rgb(124 139 154 / 25%); + border-radius: 3px; + transition: all ease-in-out 0.35s; + text-decoration: none; + width: 100%; +} + +.gh-whats-new .kg-file-card a.kg-file-card-container:hover { + border: 1px solid rgb(124 139 154 / 35%); +} + +.gh-whats-new .kg-file-card-contents { + display: flex; + flex-direction: column; + justify-content: space-between; + margin: 4px 8px; +} + +.gh-whats-new .kg-file-card-title { + font-size: 1.15em; + font-weight: 700; + line-height: 1.3em; +} + +.gh-whats-new .kg-file-card-caption { + font-size: 0.95em; + line-height: 1.5em; + opacity: 0.6; +} + +.gh-whats-new .kg-file-card-metadata { + display: inline; + font-size: 0.825em; + line-height: 1.5em; + margin-top: 2px; +} + +.gh-whats-new .kg-file-card-filename { + display: inline; + font-weight: 500; +} + +.gh-whats-new .kg-file-card-filesize { + display: inline-block; + font-size: 0.925em; + opacity: 0.6; +} + +.gh-whats-new .kg-file-card-filesize:before { + display: inline-block; + content: "\2022"; + margin-right: 4px; +} + +.gh-whats-new .kg-file-card-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 80px; + min-width: 80px; + height: 100%; +} + +.gh-whats-new .kg-file-card-icon:before { + position: absolute; + display: block; + content: ""; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: currentColor; + opacity: 0.06; + transition: opacity ease-in-out 0.35s; + border-radius: 2px; +} + +.gh-whats-new .kg-file-card a.kg-file-card-container:hover .kg-file-card-icon:before { + opacity: 0.08; +} + +.gh-whats-new .kg-file-card-icon svg { + width: 24px; + height: 24px; + color: var(--ghost-accent-color); +} + +/* Size variations */ +.gh-whats-new .kg-file-card-medium a.kg-file-card-container { + min-height: 72px; +} + +.gh-whats-new .kg-file-card-medium .kg-file-card-caption { + opacity: 1.0; + font-weight: 500; +} + +.gh-whats-new .kg-file-card-small a.kg-file-card-container { + min-height: 52px; +} + +.gh-whats-new .kg-file-card-small .kg-file-card-metadata { + font-size: 1.0em; + margin-top: 0; +} + +.gh-whats-new .kg-file-card-small .kg-file-card-icon svg { + width: 20px; + height: 20px; +} + +.gh-whats-new .kg-file-card + .kg-file-card { + margin-top: 1em; +} + +.gh-whats-new .kg-nft-card { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-left: auto; + margin-right: auto; +} + +.gh-whats-new .kg-nft-card a.kg-nft-card-container { + position: static; + display: flex; + flex: auto; + flex-direction: column; + text-decoration: none; + font-family: var(--font-family); + font-size: 14px; + font-weight: 400; + box-shadow: 0 2px 6px -2px rgb(0 0 0 / 10%), 0 0 1px rgb(0 0 0 / 40%); + width: 100%; + max-width: 512px; + color: #222; + background: #fff; + border-radius: 5px; + transition: none; +} + +.gh-whats-new .kg-nft-card * { + position: static; +} + +.gh-whats-new .kg-nft-metadata { + padding: 20px; + width: 100%; +} + +.gh-whats-new .kg-nft-image { + border-radius: 5px 5px 0 0; + width: 100%; +} + +.gh-whats-new .kg-nft-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; +} + +.gh-whats-new .kg-nft-header h4.kg-nft-title { + font-family: inherit; + font-size: 19px; + font-weight: 700; + line-height: 1.3em; + min-width: unset; + max-width: unset; + margin: 0; + color: #222; +} + +.gh-whats-new .kg-nft-opensea-logo { + margin-top: 2px; + width: 100px; + object-fit: scale-down; +} + +.gh-whats-new .kg-nft-creator { + font-family: inherit; + line-height: 1.4em; + margin: 4px 0 0; + color: #ababab; +} + +.gh-whats-new .kg-nft-creator span { + font-weight: 500; + color: #222; +} + +.gh-whats-new .kg-nft-card p.kg-nft-description { + font-family: inherit; + font-size: 14px; + line-height: 1.4em; + margin: 20px 0 0; + color: #222; +} + +.gh-whats-new .kg-toggle-card { + background: transparent; + box-shadow: inset 0 0 0 1px rgba(124, 139, 154, 0.25); + border-radius: 4px; + padding: 1.2em; +} + +.gh-whats-new .kg-toggle-card[data-kg-toggle-state="close"] .kg-toggle-content{ + height: 0; + overflow: hidden; + transition: opacity .5s ease, top .35s ease; + opacity: 0; + top: -0.5em; + position: relative; +} + +.gh-whats-new .kg-toggle-content { + height: auto; + opacity: 1; + transition: opacity 1s ease, top .35s ease; + top: 0; + position: relative; +} + +.gh-whats-new .kg-toggle-card[data-kg-toggle-state="close"] svg { + transform: unset; +} + +.gh-whats-new .kg-toggle-heading { + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.gh-whats-new .kg-toggle-card h4.kg-toggle-heading-text { + font-size: 1.15em; + font-weight: 700; + line-height: 1.3em; + margin-top: 0; + margin-bottom: 0; + text-transform: none; + color: inherit; +} + +.gh-whats-new .kg-toggle-content p:first-of-type { + margin-top: 0.5em; +} + +.gh-whats-new .kg-toggle-card .kg-toggle-content p, +.gh-whats-new .kg-toggle-card .kg-toggle-content ol, +.gh-whats-new .kg-toggle-card .kg-toggle-content ul { + font-size: 0.95em; + line-height: 1.5em; + margin-top: 0.95em; +} + +.gh-whats-new .kg-toggle-card li + li { + margin-top: 0.5em; +} + +.gh-whats-new .kg-toggle-card-icon { + height: 24px; + width: 24px; + display: flex; + justify-content: center; + align-items: center; + margin-left: 1em; + background: none; + border: 0; +} + +.gh-whats-new .kg-toggle-heading svg { + width: 14px; + color: rgba(124, 139, 154, 0.5); + transition: all 0.3s; + transform: rotate(-180deg); +} + +.gh-whats-new .kg-toggle-heading path { + fill: none; + stroke: currentcolor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; + fill-rule: evenodd; +} + +.gh-whats-new .kg-toggle-card + .kg-toggle-card { + margin-top: 1em; +} + +.gh-whats-new .kg-video-card { + position: relative; + --seek-before-width: 0%; + --volume-before-width: 100%; + --buffered-width: 0%; +} + +.gh-whats-new .kg-video-card video { + display: block; + max-width: 100%; + height: auto; +} + +.gh-whats-new .kg-video-container { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} + +.gh-whats-new .kg-video-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + background-image: linear-gradient(180deg,rgba(0,0,0,0.3) 0,transparent 70%,transparent 100%); + z-index: 99; + transition: opacity .2s ease-in-out; +} + +.gh-whats-new .kg-video-large-play-icon { + display: flex; + justify-content: center; + align-items: center; + width: 72px; + height: 72px; + padding: 0; + background: rgba(0, 0, 0, 0.5); + border-radius: 50%; + transition: opacity .2s ease-in-out; +} + +.gh-whats-new .kg-video-large-play-icon svg { + width: 20px; + height: auto; + margin-left: 2px; + fill: #fff; +} + +.gh-whats-new .kg-video-player-container { + position: absolute; + bottom: 0; + width: 100%; + height: 80px; + background: linear-gradient(rgba(0,0,0,0), rgba(0,0,0,.5)); + z-index: 99; + transition: opacity .2s ease-in-out; + +} + +.gh-whats-new .kg-video-player { + position: absolute; + bottom: 0; + display: flex; + align-items: center; + width: 100%; + z-index: 99; + padding: 12px 16px; +} + +.gh-whats-new .kg-video-current-time { + min-width: 38px; + padding: 0 4px; + color: #fff; + font-family: inherit; + font-size: .85em; + font-weight: 500; + line-height: 1.4em; + white-space: nowrap; +} + +.gh-whats-new .kg-video-time { + color: rgba(255, 255, 255, 0.6); + font-family: inherit; + font-size: .85em; + font-weight: 500; + line-height: 1.4em; + white-space: nowrap; +} + +.gh-whats-new .kg-video-duration { + padding: 0 4px; +} + +.gh-whats-new .kg-video-play-icon, +.gh-whats-new .kg-video-pause-icon { + position: relative; + padding: 0px 4px 0 0; + font-size: 0; + background: transparent; +} + +.gh-whats-new .kg-video-hide { + display: none !important; +} + +.gh-whats-new .kg-video-hide-animated { + opacity: 0 !important; + transition: opacity .2s ease-in-out; + cursor: initial; +} + +.gh-whats-new .kg-video-play-icon svg, +.gh-whats-new .kg-video-pause-icon svg { + width: 14px; + height: 14px; + fill: #fff; +} + +.gh-whats-new .kg-video-seek-slider { + flex-grow: 1; + margin: 0 4px; +} + +@media (max-width: 520px) { + .gh-whats-new .kg-video-seek-slider { + display: none; + } +} + +.gh-whats-new .kg-video-playback-rate { + min-width: 37px; + padding: 0 4px; + color: #fff; + font-family: inherit; + font-size: .85em; + font-weight: 600; + line-height: 1.4em; + text-align: left; + background: transparent; + white-space: nowrap; +} + +@media (max-width: 520px) { + .gh-whats-new .kg-video-playback-rate { + padding-left: 8px; + } +} + +.gh-whats-new .kg-video-mute-icon, +.gh-whats-new .kg-video-unmute-icon { + position: relative; + bottom: -1px; + padding: 0 4px; + font-size: 0; + background: transparent; +} + +@media (max-width: 520px) { + .gh-whats-new .kg-video-mute-icon, + .gh-whats-new .kg-video-unmute-icon { + margin-left: auto; + } +} + +.gh-whats-new .kg-video-mute-icon svg, +.gh-whats-new .kg-video-unmute-icon svg { + width: 16px; + height: 16px; + fill: #fff; +} + +.gh-whats-new .kg-video-volume-slider { + width: 80px; +} + +@media (max-width: 300px) { + .gh-whats-new .kg-video-volume-slider { + display: none; + } +} + +.gh-whats-new .kg-video-seek-slider::before { + content: ""; + position: absolute; + left: 0; + width: var(--seek-before-width) !important; + height: 4px; + cursor: pointer; + background-color: #EBEEF0; + border-radius: 2px; +} + +.gh-whats-new .kg-video-volume-slider::before { + content: ""; + position: absolute; + left: 0; + width: var(--volume-before-width) !important; + height: 4px; + cursor: pointer; + background-color: #EBEEF0; + border-radius: 2px; +} + +/* Resetting browser styles +/* --------------------------------------------------------------- */ + +.gh-whats-new .kg-video-card input[type=range] { + position: relative; + -webkit-appearance: none; + background: transparent; +} + +.gh-whats-new .kg-video-card input[type=range]:focus { + outline: none; +} + +.gh-whats-new .kg-video-card input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; +} + +.gh-whats-new .kg-video-card input[type=range]::-ms-track { + cursor: pointer; + border-color: transparent; + color: transparent; + background: transparent; +} + +.gh-whats-new .kg-video-card button { + display: flex; + align-items: center; + border: 0; + cursor: pointer; +} + +.gh-whats-new .kg-video-card input[type="range"] { + height: auto; + padding: 0; + border: 0; +} + +/* Chrome & Safari styles +/* --------------------------------------------------------------- */ + +.gh-whats-new .kg-video-card input[type="range"]::-webkit-slider-runnable-track { + width: 100%; + height: 4px; + cursor: pointer; + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; +} + +.gh-whats-new .kg-video-card input[type="range"]::-webkit-slider-thumb { + position: relative; + box-sizing: content-box; + width: 13px; + height: 13px; + margin: -5px 0 0 0; + border: 0; + cursor: pointer; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24); +} + +.gh-whats-new .kg-video-card input[type="range"]:active::-webkit-slider-thumb { + transform: scale(1.2); +} + +/* Firefox styles +/* --------------------------------------------------------------- */ + +.gh-whats-new .kg-video-card input[type="range"]::-moz-range-track { + width: 100%; + height: 4px; + cursor: pointer; + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; +} + +.gh-whats-new .kg-video-card input[type="range"]::-moz-range-progress { + background: #EBEEF0; + border-radius: 2px; +} + +.gh-whats-new .kg-video-card input[type="range"]::-moz-range-thumb { + box-sizing: content-box; + width: 13px; + height: 13px; + border: 0; + cursor: pointer; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24); +} + +.gh-whats-new .kg-video-card input[type="range"]:active::-moz-range-thumb { + transform: scale(1.2); +} + +/* Edge & IE styles +/* --------------------------------------------------------------- */ + +.gh-whats-new .kg-video-card input[type="range"]::-ms-track { + width: 100%; + height: 3px; + border: solid transparent; + color: transparent; + cursor: pointer; + background: transparent; +} + +.gh-whats-new .kg-video-card input[type="range"]::-ms-fill-lower { + background: #fff; +} + +.gh-whats-new .kg-video-card input[type="range"]::-ms-fill-upper { + background: #EBEEF0; +} + +.gh-whats-new .kg-video-card input[type="range"]::-ms-thumb { + box-sizing: content-box; + width: 13px; + height: 13px; + border: 0; + cursor: pointer; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 1px rgba(0,0,0,.08), 0 1px 4px rgba(0,0,0,0.24); +} + +.gh-whats-new .kg-video-card input[type="range"]:active::-ms-thumb { + transform: scale(1.2); +} + +/* File card styles */ +.gh-whats-new .kg-file-card { + display: flex; +} + +.gh-whats-new .kg-file-card a.kg-file-card-container { + display: flex; + align-items: stretch; + justify-content: space-between; + color: inherit; + padding: 6px; + min-height: 92px; + border: 1px solid rgb(124 139 154 / 25%); + border-radius: 3px; + transition: all ease-in-out 0.35s; + text-decoration: none; + width: 100%; +} + +.gh-whats-new .kg-file-card a.kg-file-card-container:hover { + border: 1px solid rgb(124 139 154 / 35%); +} + +.gh-whats-new .kg-file-card-contents { + display: flex; + flex-direction: column; + justify-content: space-between; + margin: 4px 8px; + width: 100% +} + +.gh-whats-new .kg-file-card-title { + font-size: 1.15em; + font-weight: 700; + line-height: 1.3em; +} + +.gh-whats-new .kg-file-card-caption { + font-size: 0.95em; + line-height: 1.3em; + opacity: 0.6; +} + +.gh-whats-new .kg-file-card-title + .kg-file-card-caption { + margin-top: -6px; +} + +.gh-whats-new .kg-file-card-metadata { + display: inline; + font-size: 0.825em; + line-height: 1.3em; + margin-top: 2px; +} + +.gh-whats-new .kg-file-card-filename { + display: inline; + font-weight: 500; +} + +.gh-whats-new .kg-file-card-filesize { + display: inline-block; + font-size: 0.925em; + opacity: 0.6; +} + +.gh-whats-new .kg-file-card-filesize:before { + display: inline-block; + content: "\2022"; + margin-right: 4px; +} + +.gh-whats-new .kg-file-card-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 80px; + min-width: 80px; + height: 100%; +} + +.gh-whats-new .kg-file-card-icon:before { + position: absolute; + display: block; + content: ""; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: currentColor; + opacity: 0.06; + transition: opacity ease-in-out 0.35s; + border-radius: 2px; +} + +.gh-whats-new .kg-file-card a.kg-file-card-container:hover .kg-file-card-icon:before { + opacity: 0.08; +} + +.gh-whats-new .kg-file-card-icon svg { + width: 24px; + height: 24px; + color: var(--ghost-accent-color); +} + +.gh-whats-new .kg-file-card-medium a.kg-file-card-container { + min-height: 72px; +} + +.gh-whats-new .kg-file-card-medium .kg-file-card-caption { + opacity: 1.0; + font-weight: 500; +} + +.gh-whats-new .kg-file-card-small a.kg-file-card-container { + align-items: center; + min-height: 52px; +} + +.gh-whats-new .kg-file-card-small .kg-file-card-metadata { + font-size: 1.0em; + margin-top: 0; +} + +.gh-whats-new .kg-file-card-small .kg-file-card-icon svg { + width: 20px; + height: 20px; +} + +.gh-whats-new .kg-file-card + .kg-file-card { + margin-top: 1em; +} + +/* Header card */ + +.gh-whats-new .kg-header-card { + padding: 12vmin 4em; + min-height: 20vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + margin-bottom: 1.5em; +} + +.gh-whats-new .kg-header-card.kg-size-small { + padding-top: 8vmin; + padding-bottom: 8vmin; + min-height: 12vh; +} + +.gh-whats-new .kg-header-card.kg-size-large { + padding-top: 12vmin; + padding-bottom: 12vmin; + min-height: 40vh; +} + +.gh-whats-new .kg-header-card.kg-align-left { + text-align: left; + align-items: flex-start; +} + +.gh-whats-new .kg-header-card.kg-style-dark { + background: #151515; + color: #ffffff; +} + +.gh-whats-new .kg-header-card.kg-style-light { + background-color: #fafafa; +} + +.gh-whats-new .kg-header-card.kg-style-accent { + background-color: var(--accent-color); +} + +.gh-whats-new .kg-header-card.kg-style-image { + position: relative; + background-color: #e7e7e7; + background-size: cover; + background-position: center; +} + +.gh-whats-new .kg-header-card.kg-style-image::before { + position: absolute; + display: block; + content: ""; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: linear-gradient(0deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2)); +} + +.gh-whats-new .kg-header-card h2.kg-header-card-header { + font-size: 5em; + font-weight: 700; + line-height: 1.1em; + letter-spacing: -0.01em; + margin: 0; +} + +.gh-whats-new .kg-header-card h2.kg-header-card-header strong { + font-weight: 800; +} + +.gh-whats-new .kg-header-card.kg-size-small h2.kg-header-card-header { + font-size: 4em; +} + +.gh-whats-new .kg-header-card.kg-size-large h2.kg-header-card-header { + font-size: 6em; +} + +.gh-whats-new .kg-header-card h3.kg-header-card-subheader { + font-size: 1.5em; + font-weight: 500; + line-height: 1.4em; + margin: 0; + max-width: 40em; +} + +.gh-whats-new .kg-header-card h2 + h3.kg-header-card-subheader { + margin: 0.35em 0 0; +} + +.gh-whats-new .kg-header-card h3.kg-header-card-subheader strong { + font-weight: 600; +} + +.gh-whats-new .kg-header-card.kg-size-small h3.kg-header-card-subheader { + font-size: 1.25em; +} + +.gh-whats-new .kg-header-card.kg-size-large h3.kg-header-card-subheader { + font-size: 1.75em; +} + +.gh-whats-new .kg-header-card:not(.kg-style-light) h2.kg-header-card-header, +.gh-whats-new .kg-header-card:not(.kg-style-light) h3.kg-header-card-subheader { + color: #ffffff; +} + +.gh-whats-new .kg-header-card.kg-style-accent h3.kg-header-card-subheader, +.gh-whats-new .kg-header-card.kg-style-image h3.kg-header-card-subheader { + opacity: 1.0; +} + +.gh-whats-new .kg-header-card.kg-style-image h2.kg-header-card-header, +.gh-whats-new .kg-header-card.kg-style-image h3.kg-header-card-subheader, +.gh-whats-new .kg-header-card.kg-style-image a.kg-header-card-button { + z-index: 99; +} + +.gh-whats-new .kg-header-card h2.kg-header-card-header a, +.gh-whats-new .kg-header-card h3.kg-header-card-subheader a { + color: var(--ghost-accent-color); +} + +.gh-whats-new .kg-header-card.kg-style-accent h2.kg-header-card-header a, +.gh-whats-new .kg-header-card.kg-style-accent h3.kg-header-card-subheader a, +.gh-whats-new .kg-header-card.kg-style-image h2.kg-header-card-header a, +.gh-whats-new .kg-header-card.kg-style-image h3.kg-header-card-subheader a { + color: #fff; +} + +.gh-whats-new .kg-header-card a.kg-header-card-button { + display: flex; + position: static; + align-items: center; + fill: #fff; + background: #fff; + border-radius: 3px; + outline: none; + font-family: var(--font-family); + font-size: 1.05em; + font-weight: 600; + line-height: 1em; + text-align: center; + text-decoration: none; + letter-spacing: .2px; + white-space: nowrap; + text-overflow: ellipsis; + color: #151515; + height: 2.7em; + padding: 0 1.2em; + transition: opacity .2s ease; +} + +.gh-whats-new .kg-header-card h2 + a.kg-header-card-button, +.gh-whats-new .kg-header-card h3 + a.kg-header-card-button { + margin: 1.75em 0 0; +} + +.gh-whats-new .kg-header-card a.kg-header-card-button:hover { + opacity: 0.85; +} + +.gh-whats-new .kg-header-card.kg-size-large a.kg-header-card-button { + font-size: 1.1em; + height: 2.9em; +} + +.gh-whats-new .kg-header-card.kg-size-large h2 + a.kg-header-card-button, +.gh-whats-new .kg-header-card.kg-size-large h3 + a.kg-header-card-button { + margin-top: 2em; +} + +.gh-whats-new .kg-header-card.kg-size-small a.kg-header-card-button { + height: 2.4em; + font-size: 1em; +} + +.gh-whats-new .kg-header-card.kg-size-small h2 + a.kg-header-card-button, +.gh-whats-new .kg-header-card.kg-size-small h3 + a.kg-header-card-button { + margin-top: 1.5em; +} + +.gh-whats-new .kg-header-card.kg-style-image a.kg-header-card-button, +.gh-whats-new .kg-header-card.kg-style-dark a.kg-header-card-button { + background: #fff; + color: #151515; +} + +.gh-whats-new .kg-header-card.kg-style-light a.kg-header-card-button { + background: var(--ghost-accent-color); + color: #fff; +} + +.gh-whats-new .kg-header-card.kg-style-accent a.kg-header-card-button { + background: #fff; + color: #151515; +} + diff --git a/apps/admin-x-activitypub/src/App.tsx b/apps/admin-x-activitypub/src/App.tsx index efb1b2fdd2..983751ea45 100644 --- a/apps/admin-x-activitypub/src/App.tsx +++ b/apps/admin-x-activitypub/src/App.tsx @@ -1,4 +1,4 @@ -import ListIndex from './components/ListIndex'; +import MainContent from './MainContent'; import {DesignSystemApp, DesignSystemAppProps} from '@tryghost/admin-x-design-system'; import {FrameworkProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework'; import {RoutingProvider} from '@tryghost/admin-x-framework/routing'; @@ -8,16 +8,25 @@ interface AppProps { designSystem: DesignSystemAppProps; } +const modals = { + paths: { + 'follow-site': 'FollowSite', + 'view-following': 'ViewFollowing', + 'view-followers': 'ViewFollowers' + }, + load: async () => import('./components/modals') +}; + const App: React.FC = ({framework, designSystem}) => { return ( - + - + ); }; -export default App; +export default App; \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/MainContent.tsx b/apps/admin-x-activitypub/src/MainContent.tsx new file mode 100644 index 0000000000..31ddedf3f8 --- /dev/null +++ b/apps/admin-x-activitypub/src/MainContent.tsx @@ -0,0 +1,7 @@ +import ActivityPubComponent from './components/ListIndex'; + +const MainContent = () => { + return ; +}; + +export default MainContent; diff --git a/apps/admin-x-activitypub/src/components/FollowSite.tsx b/apps/admin-x-activitypub/src/components/FollowSite.tsx new file mode 100644 index 0000000000..c137e9f870 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/FollowSite.tsx @@ -0,0 +1,85 @@ +import NiceModal from '@ebay/nice-modal-react'; +import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system'; +import {useFollow} from '@tryghost/admin-x-framework/api/activitypub'; +import {useQueryClient} from '@tryghost/admin-x-framework'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; +import {useState} from 'react'; + +// const sleep = (ms: number) => ( +// new Promise((resolve) => { +// setTimeout(resolve, ms); +// }) +// ); + +const FollowSite = NiceModal.create(() => { + const {updateRoute} = useRouting(); + const modal = NiceModal.useModal(); + const mutation = useFollow(); + const client = useQueryClient(); + + // mutation.isPending + // mutation.isError + // mutation.isSuccess + // mutation.mutate({username: '@index@site.com'}) + // mutation.reset(); + + // State to manage the text field value + const [profileName, setProfileName] = useState(''); + // const [success, setSuccess] = useState(false); + const [errorMessage, setError] = useState(null); + + const handleFollow = async () => { + try { + // Perform the mutation + await mutation.mutateAsync({username: profileName}); + // If successful, set the success state to true + // setSuccess(true); + showToast({ + message: 'Site followed', + type: 'success' + }); + + // // Because we don't return the new follower data from the API, we need to wait a bit to let it process and then update the query. + // // This is a dirty hack and should be replaced with a better solution. + // await sleep(2000); + + modal.remove(); + // Refetch the following data. + // At this point it might not be updated yet, but it will be eventually. + await client.refetchQueries({queryKey: ['FollowingResponseData'], type: 'active'}); + updateRoute(''); + } catch (error) { + // If there's an error, set the error state + setError(errorMessage); + } + }; + + return ( + { + mutation.reset(); + updateRoute(''); + }} + cancelLabel='Cancel' + okLabel='Follow' + size='sm' + title='Follow a Ghost site' + onOk={handleFollow} + > +
+ setProfileName(e.target.value)} + /> +
+
+ ); +}); + +export default FollowSite; diff --git a/apps/admin-x-activitypub/src/components/ListIndex.tsx b/apps/admin-x-activitypub/src/components/ListIndex.tsx index 7156dba145..76c2b3e401 100644 --- a/apps/admin-x-activitypub/src/components/ListIndex.tsx +++ b/apps/admin-x-activitypub/src/components/ListIndex.tsx @@ -1,26 +1,260 @@ -const ListIndex = () => { - return ( -
-

ActivityPub Demo

-
-
-

This is a post title

-

This is some very short post content

-

Publish McPublisher

-
-
-

This is a post title

-

This is some very short post content

-

Publish McPublisher

-
-
-

This is a post title

-

This is some very short post content

-

Publish McPublisher

-
+// import NiceModal from '@ebay/nice-modal-react'; +import React, {useState} from 'react'; +import articleBodyStyles from './articleBodyStyles'; +import getUsername from '../utils/get-username'; +import {ActorProperties, ObjectProperties, useBrowseFollowersForUser, useBrowseFollowingForUser, useBrowseInboxForUser} from '@tryghost/admin-x-framework/api/activitypub'; +import {Avatar, Button, Heading, List, ListItem, Page, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; + +interface ViewArticleProps { + object: ObjectProperties, + onBackToList: () => void; +} + +const ActivityPubComponent: React.FC = () => { + const {updateRoute} = useRouting(); + + // TODO: Replace with actual user ID + const {data: {orderedItems: activities = []} = {}} = useBrowseInboxForUser('index'); + const {data: {totalItems: followingCount = 0} = {}} = useBrowseFollowingForUser('index'); + const {data: {totalItems: followersCount = 0} = {}} = useBrowseFollowersForUser('index'); + + const [articleContent, setArticleContent] = useState(null); + const [, setArticleActor] = useState(null); + + const handleViewContent = (object: ObjectProperties, actor: ActorProperties) => { + setArticleContent(object); + setArticleActor(actor); + }; + + const handleBackToList = () => { + setArticleContent(null); + }; + + const [selectedTab, setSelectedTab] = useState('inbox'); + + const tabs: ViewTab[] = [ + { + id: 'inbox', + title: 'Inbox', + contents:
+
    + {activities && activities.slice().reverse().map(activity => ( + activity.type === 'Create' && activity.object.type === 'Article' && +
  • handleViewContent(activity.object, activity.actor)}> + +
  • + ))} +
+
-
+ }, + { + id: 'activity', + title: 'Activity', + contents:
+ {activities && activities.slice().reverse().map(activity => ( + activity.type === 'Like' && } id='list-item' title={
{activity.actor.name} liked your post {activity.object.name}
}>
+ ))} +
+ +
+ }, + { + id: 'likes', + title: 'Likes', + contents:
+
    + {activities && activities.slice().reverse().map(activity => ( + activity.type === 'Create' && activity.object.type === 'Article' && +
  • handleViewContent(activity.object, activity.actor)}> + +
  • + ))} +
+ +
+ } + ]; + + return ( + + {!articleContent ? ( + { + updateRoute('follow-site'); + }, + icon: 'add' + }} + selectedTab={selectedTab} + stickyHeader={true} + tabs={tabs} + toolbarBorder={false} + type='page' + onTabChange={setSelectedTab} + > + + + ) : ( + + )} + + ); }; -export default ListIndex; +const Sidebar: React.FC<{followingCount: number, followersCount: number, updateRoute: (route: string) => void}> = ({followingCount, followersCount, updateRoute}) => ( +
+
+
+
updateRoute('/view-following')}> + {followingCount} + Following +
+
updateRoute('/view-followers')}> + {followersCount} + Followers +
+
+
+); + +const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => { + // const dangerouslySetInnerHTML = {__html: html}; + // const cssFile = '../index.css'; + const site = useBrowseSite(); + const siteData = site.data?.site; + + const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, '')); + + const htmlContent = ` + + + ${cssContent} + + +
+

${heading}

+${image && + `
+ ${heading} +
` +} +
+
+ ${html} +
+ + +`; + + return ( + + ); +}; + +const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties }> = ({actor, object}) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(object.content || '', 'text/html'); + + const plainTextContent = doc.body.textContent; + const timestamp = + new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'}); + + const [isClicked, setIsClicked] = useState(false); + const [isLiked, setIsLiked] = useState(false); + + const handleLikeClick = (event: React.MouseEvent | undefined) => { + event?.stopPropagation(); + setIsClicked(true); + setIsLiked(!isLiked); + setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms + }; + + return ( + <> + {object && ( +
+
+ + {actor.name} + {getUsername(actor)} + {timestamp} +
+
+
+
+ {object.name} +
+

{plainTextContent}

+
+
+
+ {object.image &&
+ +
} +
+
+ {/*
*/} +
+ )} + + ); +}; + +const ViewArticle: React.FC = ({object, onBackToList}) => { + const {updateRoute} = useRouting(); + + const [isClicked, setIsClicked] = useState(false); + const [isLiked, setIsLiked] = useState(false); + + const handleLikeClick = (event: React.MouseEvent | undefined) => { + event?.stopPropagation(); + setIsClicked(true); + setIsLiked(!isLiked); + setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms + }; + + return ( + + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ ); +}; + +export default ActivityPubComponent; diff --git a/apps/admin-x-activitypub/src/components/ViewFollowers.tsx b/apps/admin-x-activitypub/src/components/ViewFollowers.tsx new file mode 100644 index 0000000000..b1a92fda9a --- /dev/null +++ b/apps/admin-x-activitypub/src/components/ViewFollowers.tsx @@ -0,0 +1,44 @@ +import {} from '@tryghost/admin-x-framework/api/activitypub'; +import NiceModal from '@ebay/nice-modal-react'; +import getUsernameFromFollowing from '../utils/get-username-from-following'; +import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system'; +import {FollowingResponseData, useBrowseFollowersForUser, useUnfollow} from '@tryghost/admin-x-framework/api/activitypub'; +import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; + +interface ViewFollowersModalProps { + following: FollowingResponseData[], + animate?: boolean +} + +const ViewFollowersModal: React.FC = ({}) => { + const {updateRoute} = useRouting(); + // const modal = NiceModal.useModal(); + const mutation = useUnfollow(); + + const {data: {orderedItems: followers = []} = {}} = useBrowseFollowersForUser('inbox'); + + return ( + { + mutation.reset(); + updateRoute(''); + }} + cancelLabel='' + footer={false} + okLabel='' + size='md' + title='Followers' + topRightContent='close' + > +
+ + {followers.map(item => ( + mutation.mutate({username: item.username})} />} avatar={} detail={getUsernameFromFollowing(item)} id='list-item' title={item.name}> + ))} + +
+
+ ); +}; + +export default NiceModal.create(ViewFollowersModal); diff --git a/apps/admin-x-activitypub/src/components/ViewFollowing.tsx b/apps/admin-x-activitypub/src/components/ViewFollowing.tsx new file mode 100644 index 0000000000..dc5dc2021c --- /dev/null +++ b/apps/admin-x-activitypub/src/components/ViewFollowing.tsx @@ -0,0 +1,59 @@ +import {} from '@tryghost/admin-x-framework/api/activitypub'; +import NiceModal from '@ebay/nice-modal-react'; +import getUsernameFromFollowing from '../utils/get-username-from-following'; +import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system'; +import {FollowingResponseData, useBrowseFollowingForUser, useUnfollow} from '@tryghost/admin-x-framework/api/activitypub'; +import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; + +interface ViewFollowingModalProps { + following: FollowingResponseData[], + animate?: boolean +} + +const ViewFollowingModal: React.FC = ({}) => { + const {updateRoute} = useRouting(); + const mutation = useUnfollow(); + + const {data: {orderedItems: following = []} = {}} = useBrowseFollowingForUser('inbox'); + + return ( + { + mutation.reset(); + updateRoute(''); + }} + cancelLabel='' + footer={false} + okLabel='' + size='md' + title='Following' + topRightContent='close' + > +
+ + {following.map(item => ( + mutation.mutate({username: getUsernameFromFollowing(item)})} />} avatar={} detail={getUsernameFromFollowing(item)} id='list-item' title={item.name}> + ))} + + {/* + + +
+
+
+ + Platformer Platformer Platformer Platformer Platformer + @index@platformerplatformerplatformerplatformer.news +
+
+
+
+
Unfollow
+
+
*/} +
+
+ ); +}; + +export default NiceModal.create(ViewFollowingModal); diff --git a/apps/admin-x-activitypub/src/components/articleBodyStyles.ts b/apps/admin-x-activitypub/src/components/articleBodyStyles.ts new file mode 100644 index 0000000000..4475737005 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/articleBodyStyles.ts @@ -0,0 +1,5911 @@ +const articleBodyStyles = (siteUrl: string|undefined) => { + return ``; +}; + +export default articleBodyStyles; diff --git a/apps/admin-x-activitypub/src/components/modals.tsx b/apps/admin-x-activitypub/src/components/modals.tsx new file mode 100644 index 0000000000..5764840ba3 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/modals.tsx @@ -0,0 +1,11 @@ +import FollowSite from './FollowSite'; +import ViewFollowers from './ViewFollowers'; +import ViewFollowing from './ViewFollowing'; +import {ModalComponent} from '@tryghost/admin-x-framework/routing'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const modals = {FollowSite, ViewFollowing, ViewFollowers} satisfies {[key: string]: ModalComponent}; + +export default modals; + +export type ModalName = keyof typeof modals; diff --git a/apps/admin-x-activitypub/src/styles/index.css b/apps/admin-x-activitypub/src/styles/index.css index d1f1f198ed..c3b58ac682 100644 --- a/apps/admin-x-activitypub/src/styles/index.css +++ b/apps/admin-x-activitypub/src/styles/index.css @@ -1 +1,25 @@ @import '@tryghost/admin-x-design-system/styles.css'; + +.admin-x-base.admin-x-activitypub { + animation-name: none; +} + +@keyframes bump { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } + } + +.bump { +animation: bump 0.3s ease-in-out; +} + +.ap-red-heart path { + fill: #F50B23; +} \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/utils/get-username-from-following.ts b/apps/admin-x-activitypub/src/utils/get-username-from-following.ts new file mode 100644 index 0000000000..598caa5f6e --- /dev/null +++ b/apps/admin-x-activitypub/src/utils/get-username-from-following.ts @@ -0,0 +1,12 @@ +function getUsernameFromFollowing(followItem: {username: string; id: string|null;}) { + if (!followItem.username || !followItem.id) { + return '@unknown@unknown'; + } + try { + return `@${followItem.username}@${(new URL(followItem.id)).hostname}`; + } catch (err) { + return '@unknown@unknown'; + } +} + +export default getUsernameFromFollowing; diff --git a/apps/admin-x-activitypub/src/utils/get-username.ts b/apps/admin-x-activitypub/src/utils/get-username.ts new file mode 100644 index 0000000000..2fd6099e33 --- /dev/null +++ b/apps/admin-x-activitypub/src/utils/get-username.ts @@ -0,0 +1,12 @@ +function getUsername(actor: {preferredUsername: string; id: string|null;}) { + if (!actor.preferredUsername || !actor.id) { + return '@unknown@unknown'; + } + try { + return `@${actor.preferredUsername}@${(new URL(actor.id)).hostname}`; + } catch (err) { + return '@unknown@unknown'; + } +} + +export default getUsername; diff --git a/apps/admin-x-activitypub/test/acceptance/app.test.ts b/apps/admin-x-activitypub/test/acceptance/app.test.ts index 9e84a05428..90fa60d842 100644 --- a/apps/admin-x-activitypub/test/acceptance/app.test.ts +++ b/apps/admin-x-activitypub/test/acceptance/app.test.ts @@ -5,6 +5,6 @@ test.describe('Demo', async () => { test('Renders the list page', async ({page}) => { await page.goto('/'); - await expect(page.locator('body')).toContainText('ActivityPub Demo'); + await expect(page.locator('body')).toContainText('ActivityPub Inbox'); }); }); diff --git a/apps/admin-x-activitypub/test/acceptance/listIndex.test.ts b/apps/admin-x-activitypub/test/acceptance/listIndex.test.ts new file mode 100644 index 0000000000..8e855d1e21 --- /dev/null +++ b/apps/admin-x-activitypub/test/acceptance/listIndex.test.ts @@ -0,0 +1,52 @@ +import {expect, test} from '@playwright/test'; +import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance'; + +test.describe('ListIndex', async () => { + test('Renders the list page', async ({page}) => { + const userId = 'index'; + await mockApi({ + page, + requests: { + useBrowseInboxForUser: {method: 'GET', path: `/inbox/${userId}`, response: responseFixtures.activitypubInbox}, + useBrowseFollowingForUser: {method: 'GET', path: `/following/${userId}`, response: responseFixtures.activitypubFollowing} + }, + options: {useActivityPub: true} + }); + + // Printing browser consol logs + page.on('console', (msg) => { + console.log(`Browser console log: ${msg.type()}: ${msg.text()}`); /* eslint-disable-line no-console */ + }); + + await page.goto('/'); + + await expect(page.locator('body')).toContainText('ActivityPub Inbox'); + + // following list + const followingUser = await page.locator('[data-test-following] > li').textContent(); + await expect(followingUser).toEqual('@index@main.ghost.org'); + const followingCount = await page.locator('[data-test-following-count]').textContent(); + await expect(followingCount).toEqual('1'); + + // following button + const followingList = await page.locator('[data-test-following-modal]'); + await expect(followingList).toBeVisible(); + + // activities + const activity = await page.locator('[data-test-activity-heading]').textContent(); + await expect(activity).toEqual('Testing ActivityPub'); + + // click on article + const articleBtn = await page.locator('[data-test-view-article]'); + await articleBtn.click(); + + // article is expanded + const frameLocator = page.frameLocator('#gh-ap-article-iframe'); + const textElement = await frameLocator.locator('[data-test-article-heading]').innerText(); + expect(textElement).toContain('Testing ActivityPub'); + + // go back to list + const backBtn = await page.locator('[data-test-back-button]'); + await backBtn.click(); + }); +}); diff --git a/apps/admin-x-activitypub/test/unit/ListIndex.test.tsx b/apps/admin-x-activitypub/test/unit/ListIndex.test.tsx deleted file mode 100644 index 50459c8584..0000000000 --- a/apps/admin-x-activitypub/test/unit/ListIndex.test.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import ListIndex from '../../src/components/ListIndex'; -import {render, screen} from '@testing-library/react'; - -describe('Demo', function () { - it('renders a component', async function () { - render(); - - expect(screen.getAllByRole('heading')[0].textContent).toEqual('ActivityPub Demo'); - }); -}); diff --git a/apps/admin-x-activitypub/test/unit/utils/get-username.test.tsx b/apps/admin-x-activitypub/test/unit/utils/get-username.test.tsx new file mode 100644 index 0000000000..8ca868b1c9 --- /dev/null +++ b/apps/admin-x-activitypub/test/unit/utils/get-username.test.tsx @@ -0,0 +1,36 @@ +import getUsername from '../../../src/utils/get-username'; + +describe('getUsername', function () { + it('returns the formatted username', async function () { + const user = { + preferredUsername: 'index', + id: 'https://www.platformer.news/' + }; + + const result = getUsername(user); + + expect(result).toBe('@index@www.platformer.news'); + }); + + it('returns a default username if the user object is missing data', async function () { + const user = { + preferredUsername: '', + id: '' + }; + + const result = getUsername(user); + + expect(result).toBe('@unknown@unknown'); + }); + + it('returns a default username if url parsing fails', async function () { + const user = { + preferredUsername: 'index', + id: 'not-a-url' + }; + + const result = getUsername(user); + + expect(result).toBe('@unknown@unknown'); + }); +}); diff --git a/apps/admin-x-design-system/src/global/layout/ViewContainer.tsx b/apps/admin-x-design-system/src/global/layout/ViewContainer.tsx index f7878b71db..38289b4bad 100644 --- a/apps/admin-x-design-system/src/global/layout/ViewContainer.tsx +++ b/apps/admin-x-design-system/src/global/layout/ViewContainer.tsx @@ -251,7 +251,7 @@ const ViewContainer: React.FC = ({ return (
- {(title || actions || headerContent) && toolbar} + {(title || actions || headerContent || tabs) && toolbar}
{mainContent}
diff --git a/apps/admin-x-framework/src/api/activitypub.ts b/apps/admin-x-framework/src/api/activitypub.ts new file mode 100644 index 0000000000..83ecfdd95c --- /dev/null +++ b/apps/admin-x-framework/src/api/activitypub.ts @@ -0,0 +1,113 @@ +import {createMutation, createQueryWithId} from '../utils/api/hooks'; + +export type FollowItem = { + id: string; + username: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [x: string]: any +}; + +export type ObjectProperties = { + '@context': string | (string | object)[]; + type: 'Article' | 'Link'; + name: string; + content: string; + url?: string | undefined; + attributedTo?: string | object[] | undefined; + image?: string; + published?: string; + preview?: {type: string, content: string}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [x: string]: any; +} + +export type ActorProperties = { + '@context': string | (string | object)[]; + attachment: object[]; + discoverable: boolean; + featured: string; + followers: string; + following: string; + id: string | null; + image: string; + inbox: string; + manuallyApprovesFollowers: boolean; + name: string; + outbox: string; + preferredUsername: string; + publicKey: { + id: string; + owner: string; + publicKeyPem: string; + }; + published: string; + summary: string; + type: 'Person'; + url: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [x: string]: any; +} + +export type Activity = { + '@context': string; + id: string; + type: string; + actor: ActorProperties; + object: ObjectProperties; + to: string; +} + +export type InboxResponseData = { + '@context': string; + id: string; + summary: string; + type: 'OrderedCollection'; + totalItems: number; + orderedItems: Activity[]; +} + +export type FollowingResponseData = { + '@context': string; + id: string; + summary: string; + type: string; + totalItems: number; + orderedItems: FollowItem[]; +} + +type FollowRequestProps = { + username: string +} + +export const useFollow = createMutation({ + method: 'POST', + useActivityPub: true, + path: data => `/follow/${data.username}` +}); + +export const useUnfollow = createMutation({ + method: 'POST', + useActivityPub: true, + path: data => `/unfollow/${data.username}` +}); + +// This is a frontend root, not using the Ghost admin API +export const useBrowseInboxForUser = createQueryWithId({ + dataType: 'InboxResponseData', + useActivityPub: true, + path: id => `/inbox/${id}` +}); + +// This is a frontend root, not using the Ghost admin API +export const useBrowseFollowingForUser = createQueryWithId({ + dataType: 'FollowingResponseData', + useActivityPub: true, + path: id => `/following/${id}` +}); + +// This is a frontend root, not using the Ghost admin API +export const useBrowseFollowersForUser = createQueryWithId({ + dataType: 'FollowingResponseData', + useActivityPub: true, + path: id => `/followers/${id}` +}); diff --git a/apps/admin-x-framework/src/test/acceptance.ts b/apps/admin-x-framework/src/test/acceptance.ts index 6bebb95f4e..b22e4ac92a 100644 --- a/apps/admin-x-framework/src/test/acceptance.ts +++ b/apps/admin-x-framework/src/test/acceptance.ts @@ -16,6 +16,8 @@ import siteFixture from './responses/site.json'; import themesFixture from './responses/themes.json'; import tiersFixture from './responses/tiers.json'; import usersFixture from './responses/users.json'; +import activitypubInboxFixture from './responses/activitypub/inbox.json'; +import activitypubFollowingFixture from './responses/activitypub/following.json'; import {ActionsResponseType} from '../api/actions'; import {ConfigResponseType} from '../api/config'; @@ -63,7 +65,9 @@ export const responseFixtures = { themes: themesFixture as ThemesResponseType, newsletters: newslettersFixture as NewslettersResponseType, actions: actionsFixture as ActionsResponseType, - latestPost: {posts: [{id: '1', url: `${siteFixture.site.url}/test-post/`}]} + latestPost: {posts: [{id: '1', url: `${siteFixture.site.url}/test-post/`}]}, + activitypubInbox: activitypubInboxFixture, + activitypubFollowing: activitypubFollowingFixture }; const defaultLabFlags = { @@ -145,7 +149,7 @@ export const limitRequests = { browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: responseFixtures.newsletters} }; -export async function mockApi>({page, requests}: {page: Page, requests: Requests}) { +export async function mockApi>({page, requests, options = {}}: {page: Page, requests: Requests, options?: {useActivityPub?: boolean}}) { const lastApiRequests: {[key in keyof Requests]?: RequestRecord} = {}; const namedRequests = Object.entries(requests).reduce( @@ -153,8 +157,11 @@ export async function mockApi [] as Array ); - await page.route(/\/ghost\/api\/admin\//, async (route) => { - const apiPath = route.request().url().replace(/^.*\/ghost\/api\/admin/, ''); + const routeRegex = options?.useActivityPub ? /\/activitypub\// : /\/ghost\/api\/admin\//; + const routeReplaceRegex = options.useActivityPub ? /^.*\/activitypub/ : /^.*\/ghost\/api\/admin/; + + await page.route(routeRegex, async (route) => { + const apiPath = route.request().url().replace(routeReplaceRegex, ''); const matchingMock = namedRequests.find((request) => { if (request.method !== route.request().method()) { diff --git a/apps/admin-x-framework/src/test/responses/activitypub/following.json b/apps/admin-x-framework/src/test/responses/activitypub/following.json new file mode 100644 index 0000000000..f374bedb4e --- /dev/null +++ b/apps/admin-x-framework/src/test/responses/activitypub/following.json @@ -0,0 +1,13 @@ +{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://0a2e-129-222-88-174.ngrok-free.app/activitypub/following/deadbeefdeadbeefdeadbeef", + "summary": "Following collection for index", + "type": "Collection", + "totalItems": 1, + "items": [ + { + "id": "https://main.ghost.org/activitypub/actor/deadbeefdeadbeefdeadbeef", + "username": "@index@main.ghost.org" + } + ] + } diff --git a/apps/admin-x-framework/src/test/responses/activitypub/inbox.json b/apps/admin-x-framework/src/test/responses/activitypub/inbox.json new file mode 100644 index 0000000000..550fda9489 --- /dev/null +++ b/apps/admin-x-framework/src/test/responses/activitypub/inbox.json @@ -0,0 +1,155 @@ +{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/activitypub/inbox/index", + "summary": "Inbox for index", + "type": "OrderedCollection", + "totalItems": 2, + "orderedItems": [ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://main.ghost.org/activitypub/activity/664cf007fd27b20001a76d72", + "type": "Accept", + "actor": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "featured": { + "@id": "http://joinmastodon.org/ns#featured", + "@type": "@id" + } + }, + { + "discoverable": { + "@id": "http://joinmastodon.org/ns#discoverable", + "@type": "@id" + } + }, + { + "manuallyApprovesFollowers": { + "@id": "http://joinmastodon.org/ns#manuallyApprovesFollowers", + "@type": "@id" + } + }, + { + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "type": "Person", + "id": "https://main.ghost.org/activitypub/actor/index", + "name": "The Main", + "preferredUsername": "index", + "summary": "The bio for the actor", + "url": "https://main.ghost.org/activitypub/actor/index", + "icon": "", + "image": "", + "published": "1970-01-01T00:00:00Z", + "manuallyApprovesFollowers": false, + "discoverable": true, + "attachment": [ + { + "type": "PropertyValue", + "name": "Website", + "value": "main.ghost.org" + } + ], + "following": "https://main.ghost.org/activitypub/following/index", + "followers": "https://main.ghost.org/activitypub/followers/index", + "inbox": "https://main.ghost.org/activitypub/inbox/index", + "outbox": "https://main.ghost.org/activitypub/outbox/index", + "featured": "https://main.ghost.org/activitypub/featured/index", + "publicKey": { + "id": "https://main.ghost.org/activitypub/actor/index#main-key", + "owner": "https://main.ghost.org/activitypub/actor/index", + "publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIGJAoGBANRpUrwk7x7bJDddHmrYSWVw9enVPMFm5qAW7fTgoZ7x2PoJUIqy/bkqpXZ0SmZs\nsLO3UZm+yN/DqxioD8BnhhD0N8Ydv6+UniT7hE2tHvsMxQIq2jet1auSBZNFmUIWodsBxI/R\ntm+KwFBFk+P+MvVsGZ2K3Rkd4K0dv0/45dtXAgMBAAE=\n-----END RSA PUBLIC KEY-----\n" + } + }, + "object": { + "id": "https://0a2e-129-222-88-174.ngrok-free.app/activitypub/activity/664cf0074daa2f8183ba6ea6", + "type": "Follow" + }, + "to": "https://0a2e-129-222-88-174.ngrok-free.app/activitypub/actor/index" + }, + { + "type": "Create", + "actor": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "featured": { + "@id": "http://joinmastodon.org/ns#featured", + "@type": "@id" + } + }, + { + "discoverable": { + "@id": "http://joinmastodon.org/ns#discoverable", + "@type": "@id" + } + }, + { + "manuallyApprovesFollowers": { + "@id": "http://joinmastodon.org/ns#manuallyApprovesFollowers", + "@type": "@id" + } + }, + { + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "type": "Person", + "id": "https://main.ghost.org/activitypub/actor/index", + "name": "The Main", + "preferredUsername": "index", + "summary": "The bio for the actor", + "url": "https://main.ghost.org/activitypub/actor/index", + "icon": "", + "image": "", + "published": "1970-01-01T00:00:00Z", + "manuallyApprovesFollowers": false, + "discoverable": true, + "attachment": [ + { + "type": "PropertyValue", + "name": "Website", + "value": "main.ghost.org" + } + ], + "following": "https://main.ghost.org/activitypub/following/index", + "followers": "https://main.ghost.org/activitypub/followers/index", + "inbox": "https://main.ghost.org/activitypub/inbox/index", + "outbox": "https://main.ghost.org/activitypub/outbox/index", + "featured": "https://main.ghost.org/activitypub/featured/index", + "publicKey": { + "id": "https://main.ghost.org/activitypub/actor/index#main-key", + "owner": "https://main.ghost.org/activitypub/actor/index", + "publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIGJAoGBANRpUrwk7x7bJDddHmrYSWVw9enVPMFm5qAW7fTgoZ7x2PoJUIqy/bkqpXZ0SmZs\nsLO3UZm+yN/DqxioD8BnhhD0N8Ydv6+UniT7hE2tHvsMxQIq2jet1auSBZNFmUIWodsBxI/R\ntm+KwFBFk+P+MvVsGZ2K3Rkd4K0dv0/45dtXAgMBAAE=\n-----END RSA PUBLIC KEY-----\n" + } + }, + "object": { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Article", + "id": "https://main.ghost.org/activitypub/article/my-article/", + "name": "Testing ActivityPub", + "content": "

Super long test

", + "url": "https://main.ghost.org/my-article/", + "image": "https://main.ghost.org/content/images/2021/08/ghost-logo.png", + "published": "2024-05-09T00:00:00Z", + "attributedTo": { + "type": "Person", + "name": "The Main" + }, + "preview": { + "type": "Link", + "href": "https://main.ghost.org/my-article/", + "name": "Testing ActivityPub" + } + } + } + ] +} diff --git a/apps/admin-x-framework/src/utils/api/fetchApi.ts b/apps/admin-x-framework/src/utils/api/fetchApi.ts index a26121767a..352fd2f3db 100644 --- a/apps/admin-x-framework/src/utils/api/fetchApi.ts +++ b/apps/admin-x-framework/src/utils/api/fetchApi.ts @@ -111,10 +111,11 @@ export const useFetchApi = () => { }; }; -const {apiRoot} = getGhostPaths(); +const {apiRoot, activityPubRoot} = getGhostPaths(); -export const apiUrl = (path: string, searchParams: Record = {}) => { - const url = new URL(`${apiRoot}${path}`, window.location.origin); +export const apiUrl = (path: string, searchParams: Record = {}, useActivityPub: boolean = false) => { + const root = useActivityPub ? activityPubRoot : apiRoot; + const url = new URL(`${root}${path}`, window.location.origin); url.search = new URLSearchParams(searchParams).toString(); return url.toString(); }; diff --git a/apps/admin-x-framework/src/utils/api/hooks.ts b/apps/admin-x-framework/src/utils/api/hooks.ts index 8d26701507..d2818cdb9c 100644 --- a/apps/admin-x-framework/src/utils/api/hooks.ts +++ b/apps/admin-x-framework/src/utils/api/hooks.ts @@ -24,6 +24,7 @@ interface QueryOptions { defaultSearchParams?: Record; permissions?: string[]; returnData?: (originalData: unknown) => ResponseData; + useActivityPub?: boolean; } type QueryHookOptions = UseQueryOptions & { @@ -32,7 +33,7 @@ type QueryHookOptions = UseQueryOptions & { }; export const createQuery = (options: QueryOptions) => ({searchParams, ...query}: QueryHookOptions = {}): Omit, 'data'> & {data: ResponseData | undefined} => { - const url = apiUrl(options.path, searchParams || options.defaultSearchParams); + const url = apiUrl(options.path, searchParams || options.defaultSearchParams, options?.useActivityPub); const fetchApi = useFetchApi(); const handleError = useHandleError(); @@ -66,7 +67,7 @@ export const createPaginatedQuery = (options const paginatedSearchParams = searchParams || options.defaultSearchParams || {}; paginatedSearchParams.page = page.toString(); - const url = apiUrl(options.path, paginatedSearchParams); + const url = apiUrl(options.path, paginatedSearchParams, options?.useActivityPub); const fetchApi = useFetchApi(); const handleError = useHandleError(); @@ -119,8 +120,8 @@ export const createInfiniteQuery = (options: InfiniteQueryOptions< const nextPageParams = getNextPageParams || options.defaultNextPageParams || (() => ({})); const result = useInfiniteQuery({ - queryKey: [options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams)], - queryFn: ({pageParam}) => fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams)), + queryKey: [options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams, options?.useActivityPub)], + queryFn: ({pageParam}) => fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams, options?.useActivityPub)), getNextPageParam: data => nextPageParams(data, searchParams || options.defaultSearchParams || {}), ...query }); @@ -161,7 +162,7 @@ const mutate = ({fetchApi, path, payload, searchParams, o options: Omit, 'path'> }) => { const {defaultSearchParams, body, ...requestOptions} = options; - const url = apiUrl(path, searchParams || defaultSearchParams); + const url = apiUrl(path, searchParams || defaultSearchParams, options?.useActivityPub); const generatedBody = payload && body?.(payload); let requestBody: string | FormData | undefined = undefined; diff --git a/apps/admin-x-framework/src/utils/helpers.ts b/apps/admin-x-framework/src/utils/helpers.ts index d05d5a4ef4..f3aaf6c1e3 100644 --- a/apps/admin-x-framework/src/utils/helpers.ts +++ b/apps/admin-x-framework/src/utils/helpers.ts @@ -3,6 +3,7 @@ export interface IGhostPaths { adminRoot: string; assetRoot: string; apiRoot: string; + activityPubRoot: string; } export function getGhostPaths(): IGhostPaths { @@ -11,7 +12,8 @@ export function getGhostPaths(): IGhostPaths { const adminRoot = `${subdir}/ghost/`; const assetRoot = `${subdir}/ghost/assets/`; const apiRoot = `${subdir}/ghost/api/admin`; - return {subdir, adminRoot, assetRoot, apiRoot}; + const activityPubRoot = `${subdir}/activitypub`; + return {subdir, adminRoot, assetRoot, apiRoot, activityPubRoot}; } export function downloadFile(url: string) {