diff --git a/.envrc b/.envrc deleted file mode 100644 index 7f0f48d8c3..0000000000 --- a/.envrc +++ /dev/null @@ -1,2 +0,0 @@ -layout node -use flake . diff --git a/.github/scripts/docker-compose.yml b/.github/scripts/docker-compose.yml index b8f14042ce..a8d89941f7 100644 --- a/.github/scripts/docker-compose.yml +++ b/.github/scripts/docker-compose.yml @@ -27,3 +27,13 @@ services: ports: - "6379:6379" restart: always + jaeger: + image: jaegertracing/all-in-one:1.58 + container_name: ghost-jaeger + ports: + - "4318:4318" + - "16686:16686" + - "9411:9411" + restart: always + environment: + COLLECTOR_ZIPKIN_HOST_PORT: :9411 \ No newline at end of file diff --git a/.github/workflows/migration-review.yml b/.github/workflows/migration-review.yml index 18d0adbcf1..885fa98f7e 100644 --- a/.github/workflows/migration-review.yml +++ b/.github/workflows/migration-review.yml @@ -38,6 +38,7 @@ jobs: - [ ] Uses the correct utils - [ ] Contains a minimal changeset - [ ] Does not mix DDL/DML operations + - [ ] Tested in MySQL and SQLite ### Schema changes diff --git a/.gitignore b/.gitignore index ac45d84b1d..3162730395 100644 --- a/.gitignore +++ b/.gitignore @@ -66,9 +66,6 @@ typings/ # dotenv environment variables file .env -# direnv -.direnv - # IDE .idea/* *.iml diff --git a/LICENSE b/LICENSE index b52cfae194..ce0968e726 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2013-2023 Ghost Foundation +Copyright (c) 2013-2024 Ghost Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/README.md b/README.md index abc822bbe1..7fa794ca2c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Twitter

- Downloads + Downloads Latest release @@ -82,7 +82,7 @@ For anyone wishing to contribute to Ghost or to hack/customize core files we rec # Ghost sponsors -We'd like to extend big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart: +A big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart: **[DigitalOcean](https://m.do.co/c/9ff29836d717)** • **[Fastly](https://www.fastly.com/)** @@ -90,12 +90,13 @@ We'd like to extend big thanks to our sponsors and partners who make Ghost possi # Getting help -You can find answers to a huge variety of questions, along with a large community of helpful developers over on the [Ghost forum](https://forum.ghost.org/) - replies are generally very quick. **Ghost(Pro)** customers also have access to 24/7 email support. +Everyone can get help and support from a large community of developers over on the [Ghost forum](https://forum.ghost.org/). **Ghost(Pro)** customers have access to 24/7 email support. -To stay up to date with all the latest news and product updates, make sure you [subscribe to our blog](https://ghost.org/blog/) — or you can always follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle: +To stay up to date with all the latest news and product updates, make sure you [subscribe to our changelog newsletter](https://ghost.org/changelog/) — or follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:   # Copyright & license -Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. +Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE). +Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage. diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 8eb6a11fd3..19146b51d3 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -32,13 +32,17 @@ "preview": "vite preview" }, "devDependencies": { - "@testing-library/react": "14.1.0", + "@playwright/test": "1.38.1", + "@testing-library/react": "14.3.1", "@tryghost/admin-x-design-system": "0.0.0", "@tryghost/admin-x-framework": "0.0.0", + "@types/jest": "29.5.12", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", + "jest": "29.7.0", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "ts-jest": "29.1.5" }, "nx": { "targets": { 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..62145ea0ea 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', + 'profile/following': 'ViewFollowing', + 'profile/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..e8f05cba87 --- /dev/null +++ b/apps/admin-x-activitypub/src/MainContent.tsx @@ -0,0 +1,46 @@ +import Activities from './components/Activities'; +import Inbox from './components/Inbox'; +import Profile from './components/Profile'; +import Search from './components/Search'; +import {ActivityPubAPI} from './api/activitypub'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useQuery} from '@tanstack/react-query'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; + +export function useBrowseInboxForUser(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useQuery({ + queryKey: [`inbox:${handle}`], + async queryFn() { + return api.getInbox(); + } + }); +} + +const MainContent = () => { + const {route} = useRouting(); + const mainRoute = route.split('/')[0]; + switch (mainRoute) { + case 'search': + return ; + break; + case 'activity': + return ; + break; + case 'profile': + return ; + break; + default: + return ; + break; + } +}; + +export default MainContent; diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts new file mode 100644 index 0000000000..e194474e2c --- /dev/null +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -0,0 +1,483 @@ +import {Activity, ActivityPubAPI} from './activitypub'; + +function NotFound() { + return new Response(null, { + status: 404 + }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function JSONResponse(data: any, contentType = 'application/json', status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': contentType + } + }); +} + +type Spec = { + response: Response, + assert?: (resource: URL, init?: RequestInit) => Promise +}; + +function Fetch(specs: Record) { + return async function (resource: URL, init?: RequestInit): Promise { + const spec = specs[resource.href]; + if (!spec) { + return NotFound(); + } + if (spec.assert) { + await spec.assert(resource, init); + } + return spec.response; + }; +} + +describe('ActivityPubAPI', function () { + describe('getInbox', function () { + test('It passes the token to the inbox endpoint', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/inbox/index': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await api.getInbox(); + }); + + test('Returns an empty array when the inbox is empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/inbox/index': { + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getInbox(); + const expected: never[] = []; + + expect(actual).toEqual(expected); + }); + + test('Returns all the items array when the inbox is not empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/inbox/index': { + response: + JSONResponse({ + type: 'Collection', + items: [{ + type: 'Create', + object: { + type: 'Note' + } + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getInbox(); + const expected: Activity[] = [ + { + type: 'Create', + object: { + type: 'Note' + } + } + ]; + + expect(actual).toEqual(expected); + }); + + test('Returns an array when the items key is a single object', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/inbox/index': { + response: + JSONResponse({ + type: 'Collection', + items: { + type: 'Create', + object: { + type: 'Note' + } + } + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getInbox(); + const expected: Activity[] = [ + { + type: 'Create', + object: { + type: 'Note' + } + } + ]; + + expect(actual).toEqual(expected); + }); + }); + + describe('getFollowing', function () { + test('It passes the token to the following endpoint', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await api.getFollowing(); + }); + + test('Returns an empty array when the following is empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index': { + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowing(); + const expected: never[] = []; + + expect(actual).toEqual(expected); + }); + + test('Returns all the items array when the following is not empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index': { + response: + JSONResponse({ + type: 'Collection', + items: [{ + type: 'Person' + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowing(); + const expected: Activity[] = [ + { + type: 'Person' + } + ]; + + expect(actual).toEqual(expected); + }); + + test('Returns an array when the items key is a single object', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index': { + response: + JSONResponse({ + type: 'Collection', + items: { + type: 'Person' + } + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowing(); + const expected: Activity[] = [ + { + type: 'Person' + } + ]; + + expect(actual).toEqual(expected); + }); + }); + + describe('getFollowers', function () { + test('It passes the token to the followers endpoint', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await api.getFollowers(); + }); + + test('Returns an empty array when the followers is empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index': { + response: JSONResponse({ + type: 'Collection', + items: [] + }) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowers(); + const expected: never[] = []; + + expect(actual).toEqual(expected); + }); + + test('Returns all the items array when the followers is not empty', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index': { + response: + JSONResponse({ + type: 'Collection', + items: [{ + type: 'Person' + }] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowers(); + const expected: Activity[] = [ + { + type: 'Person' + } + ]; + + expect(actual).toEqual(expected); + }); + + test('Returns an array when the items key is a single object', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index': { + response: + JSONResponse({ + type: 'Collection', + items: { + type: 'Person' + } + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getFollowers(); + const expected: Activity[] = [ + { + type: 'Person' + } + ]; + + expect(actual).toEqual(expected); + }); + }); + + describe('follow', function () { + test('It passes the token to the follow endpoint', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/actions/follow/@user@domain.com': { + async assert(_resource, init) { + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toContain('fake-token'); + }, + response: JSONResponse({}) + } + }); + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + await api.follow('@user@domain.com'); + }); + }); +}); diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts new file mode 100644 index 0000000000..edb0da0ac1 --- /dev/null +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -0,0 +1,109 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Actor = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Activity = any; + +export class ActivityPubAPI { + constructor( + private readonly apiUrl: URL, + private readonly authApiUrl: URL, + private readonly handle: string, + private readonly fetch: (resource: URL, init?: RequestInit) => Promise = window.fetch.bind(window) + ) {} + + private async getToken(): Promise { + try { + const response = await this.fetch(this.authApiUrl); + const json = await response.json(); + return json?.identities?.[0]?.token || null; + } catch (err) { + // TODO: Ping sentry? + return null; + } + } + + private async fetchJSON(url: URL, method: 'GET' | 'POST' = 'GET'): Promise { + const token = await this.getToken(); + const response = await this.fetch(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/activity+json' + } + }); + const json = await response.json(); + return json; + } + + get inboxApiUrl() { + return new URL(`.ghost/activitypub/inbox/${this.handle}`, this.apiUrl); + } + + async getInbox(): Promise { + const json = await this.fetchJSON(this.inboxApiUrl); + if (json === null) { + return []; + } + if ('items' in json) { + return Array.isArray(json.items) ? json.items : [json.items]; + } + return []; + } + + get followingApiUrl() { + return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl); + } + + async getFollowing(): Promise { + const json = await this.fetchJSON(this.followingApiUrl); + if (json === null) { + return []; + } + if ('items' in json) { + return Array.isArray(json.items) ? json.items : [json.items]; + } + return []; + } + + async getFollowingCount(): Promise { + const json = await this.fetchJSON(this.followingApiUrl); + if (json === null) { + return 0; + } + if ('totalItems' in json && typeof json.totalItems === 'number') { + return json.totalItems; + } + return 0; + } + + get followersApiUrl() { + return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl); + } + + async getFollowers(): Promise { + const json = await this.fetchJSON(this.followersApiUrl); + if (json === null) { + return []; + } + if ('items' in json) { + return Array.isArray(json.items) ? json.items : [json.items]; + } + return []; + } + + async getFollowersCount(): Promise { + const json = await this.fetchJSON(this.followersApiUrl); + if (json === null) { + return 0; + } + if ('totalItems' in json && typeof json.totalItems === 'number') { + return json.totalItems; + } + return 0; + } + + async follow(username: string): Promise { + const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl); + await this.fetchJSON(url, 'POST'); + } +} diff --git a/apps/admin-x-activitypub/src/assets/images/ap-welcome.png b/apps/admin-x-activitypub/src/assets/images/ap-welcome.png new file mode 100644 index 0000000000..189768bcc5 Binary files /dev/null and b/apps/admin-x-activitypub/src/assets/images/ap-welcome.png differ diff --git a/apps/admin-x-activitypub/src/components/Activities.tsx b/apps/admin-x-activitypub/src/components/Activities.tsx new file mode 100644 index 0000000000..14363cea24 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -0,0 +1,52 @@ +import APAvatar from './global/APAvatar'; +import ActivityItem from './activities/ActivityItem'; +import MainNavigation from './navigation/MainNavigation'; +import React from 'react'; + +interface ActivitiesProps {} + +const Activities: React.FC = ({}) => { + // const fakeAuthor = + return ( + <> + +
+
+ + +
+
Lydia Mango @username@domain.com
+
Followed you
+
+
+ + + +
+
Tiana Passaquindici Arcand @username@domain.com
+
Followed you
+
+
+ + + +
+
Gretchen Press @username@domain.com
+
Followed you
+
+
+ + + +
+
Leo Lubin @username@domain.com
+
Followed you
+
+
+
+
+ + ); +}; + +export default Activities; \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/components/Inbox.tsx b/apps/admin-x-activitypub/src/components/Inbox.tsx new file mode 100644 index 0000000000..f4256444df --- /dev/null +++ b/apps/admin-x-activitypub/src/components/Inbox.tsx @@ -0,0 +1,83 @@ +import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png'; +import ArticleModal from './feed/ArticleModal'; +import FeedItem from './feed/FeedItem'; +import MainNavigation from './navigation/MainNavigation'; +import NiceModal from '@ebay/nice-modal-react'; +import React, {useState} from 'react'; +import {Activity} from './activities/ActivityItem'; +import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {Button, Heading} from '@tryghost/admin-x-design-system'; +import {useBrowseInboxForUser} from '../MainContent'; + +interface InboxProps {} + +const Inbox: React.FC = ({}) => { + const {data: activities = []} = useBrowseInboxForUser('index'); + const [, setArticleContent] = useState(null); + const [, setArticleActor] = useState(null); + + const inboxTabActivities = activities.filter((activity: Activity) => { + const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type); + const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note'; + + return isCreate || isAnnounce; + }); + + const handleViewContent = (object: ObjectProperties, actor: ActorProperties) => { + setArticleContent(object); + setArticleActor(actor); + NiceModal.show(ArticleModal, { + object: object + }); + }; + + return ( + <> + +
+
+ {inboxTabActivities.length > 0 ? ( +
    + {inboxTabActivities.reverse().map(activity => ( +
  • handleViewContent(activity.object, activity.actor)} + > + +
  • + ))} +
+ ) : ( +
+
+ Ghost site logos + + Welcome to ActivityPub + +

+ We’re so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost. +

+

+ You can see all of the users on the right—find your favorite ones and give them a follow. +

+
+
+ )} +
+
+ + ); +}; + +export default Inbox; \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/components/ListIndex.tsx b/apps/admin-x-activitypub/src/components/ListIndex.tsx deleted file mode 100644 index 7156dba145..0000000000 --- a/apps/admin-x-activitypub/src/components/ListIndex.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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

-
-
-
- ); -}; - -export default ListIndex; diff --git a/apps/admin-x-activitypub/src/components/Profile.tsx b/apps/admin-x-activitypub/src/components/Profile.tsx new file mode 100644 index 0000000000..f0d00f3c89 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/Profile.tsx @@ -0,0 +1,72 @@ +import MainNavigation from './navigation/MainNavigation'; +import React from 'react'; +import {ActivityPubAPI} from '../api/activitypub'; +import {SettingValue} from '@tryghost/admin-x-design-system'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useQuery} from '@tanstack/react-query'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; + +interface ProfileProps {} + +function useFollowersCountForUser(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useQuery({ + queryKey: [`followersCount:${handle}`], + async queryFn() { + return api.getFollowersCount(); + } + }); +} + +function useFollowingCountForUser(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useQuery({ + queryKey: [`followingCount:${handle}`], + async queryFn() { + return api.getFollowingCount(); + } + }); +} + +const Profile: React.FC = ({}) => { + const {updateRoute} = useRouting(); + const {data: followersCount = 0} = useFollowersCountForUser('index'); + const {data: followingCount = 0} = useFollowingCountForUser('index'); + + return ( + <> + +
+
+
+
+
updateRoute('/profile/following')}> + {followingCount} + Following +
+
updateRoute('/profile/followers')}> + {followersCount} + Followers +
+
+
+
+ + ); +}; + +export default Profile; \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/components/Search.tsx b/apps/admin-x-activitypub/src/components/Search.tsx new file mode 100644 index 0000000000..8ed44b58e5 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/Search.tsx @@ -0,0 +1,18 @@ +import MainNavigation from './navigation/MainNavigation'; +import React from 'react'; +import {Icon} from '@tryghost/admin-x-design-system'; + +interface SearchProps {} + +const Search: React.FC = ({}) => { + return ( + <> + +
+
Search the Fediverse
+
+ + ); +}; + +export default Search; \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/components/_ObsoleteListIndex.tsx b/apps/admin-x-activitypub/src/components/_ObsoleteListIndex.tsx new file mode 100644 index 0000000000..021cbbf1b6 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/_ObsoleteListIndex.tsx @@ -0,0 +1,538 @@ +import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png'; +import React, {useEffect, useRef, useState} from 'react'; +import articleBodyStyles from './articleBodyStyles'; +import getRelativeTimestamp from '../utils/get-relative-timestamp'; +import getUsername from '../utils/get-username'; +import {ActivityPubAPI} from '../api/activitypub'; +import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {Avatar, Button, ButtonGroup, Heading, Icon, List, ListItem, Page, SelectOption, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useQuery} from '@tanstack/react-query'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; + +interface ViewArticleProps { + object: ObjectProperties, + onBackToList: () => void; +} + +type Activity = { + type: string, + object: { + type: string + } +} + +export function useBrowseInboxForUser(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useQuery({ + queryKey: [`inbox:${handle}`], + async queryFn() { + return api.getInbox(); + } + }); +} + +function useFollowersCountForUser(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useQuery({ + queryKey: [`followersCount:${handle}`], + async queryFn() { + return api.getFollowersCount(); + } + }); +} + +function useFollowingCountForUser(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useQuery({ + queryKey: [`followingCount:${handle}`], + async queryFn() { + return api.getFollowingCount(); + } + }); +} + +const ActivityPubComponent: React.FC = () => { + const {updateRoute} = useRouting(); + + // TODO: Replace with actual user ID + const {data: activities = []} = useBrowseInboxForUser('index'); + const {data: followersCount = 0} = useFollowersCountForUser('index'); + const {data: followingCount = 0} = useFollowingCountForUser('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 [selectedOption, setSelectedOption] = useState({label: 'Feed', value: 'feed'}); + + const [selectedTab, setSelectedTab] = useState('inbox'); + + const inboxTabActivities = activities.filter((activity: Activity) => { + const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type); + const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note'; + + return isCreate || isAnnounce; + }); + const activityTabActivities = activities.filter((activity: Activity) => activity.type === 'Create' && activity.object.type === 'Article'); + const likeTabActivies = activities.filter((activity: Activity) => activity.type === 'Like'); + + const tabs: ViewTab[] = [ + { + id: 'inbox', + title: 'Inbox', + contents: ( +
+ {inboxTabActivities.length > 0 ? ( +
    + {inboxTabActivities.reverse().map(activity => ( +
  • handleViewContent(activity.object, activity.actor)} + > + +
  • + ))} +
+ ) : ( +
+
+ Ghost site logos + + Welcome to ActivityPub + +

+ We’re so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost. +

+

+ You can see all of the users on the right—find your favorite ones and give them a follow. +

+
+
+ )} +
+ ) + }, + { + id: 'activity', + title: 'Activity', + contents: ( +
+
    + {activityTabActivities.reverse().map(activity => ( +
  • handleViewContent(activity.object, activity.actor)} + > + +
  • + ))} +
+
+ ) + }, + { + id: 'likes', + title: 'Likes', + contents: ( +
+ + {likeTabActivies.reverse().map(activity => ( + } + id='list-item' + title={ +
+ {activity.actor.name} + liked your post + {activity.object.name} +
+ } + /> + ))} +
+
+ ) + }, + { + id: 'profile', + title: 'Profile', + contents: ( +
+
+
+
+
updateRoute('/view-following')}> + {followingCount} + Following +
+
updateRoute('/view-followers')}> + {followersCount} + Followers +
+
+
+
+ ) + } + ]; + + return ( + <> + + {!articleContent ? ( + { + setSelectedOption({label: 'Feed', value: 'feed'}); + } + + }, + { + icon: 'cardview', + size: 'sm', + iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500', + onClick: () => { + setSelectedOption({label: 'Inbox', value: 'inbox'}); + } + } + ]} clearBg={true} link outlineOnMobile />]} + firstOnPage={true} + primaryAction={{ + title: 'Follow', + onClick: () => { + updateRoute('follow-site'); + }, + icon: 'add' + }} + selectedTab={selectedTab} + stickyHeader={true} + tabs={tabs} + title='ActivityPub' + toolbarBorder={true} + type='page' + onTabChange={setSelectedTab} + > + + + ) : ( + + )} + + + + ); +}; + +const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => { + const site = useBrowseSite(); + const siteData = site.data?.site; + + const iframeRef = useRef(null); + + const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, '')); + + const htmlContent = ` + + + ${cssContent} + + +
+

${heading}

+${image && + `
+ ${heading} +
` +} +
+
+ ${html} +
+ + +`; + + useEffect(() => { + const iframe = iframeRef.current; + if (iframe) { + iframe.srcdoc = htmlContent; + } + }, [htmlContent]); + + return ( +
+ +
+ ); +}; + +function renderAttachment(object: ObjectProperties) { + let attachment; + if (object.image) { + attachment = object.image; + } + + if (object.type === 'Note' && !attachment) { + attachment = object.attachment; + } + + if (!attachment) { + return null; + } + + if (Array.isArray(attachment)) { + const attachmentCount = attachment.length; + + let gridClass = ''; + if (attachmentCount === 1) { + gridClass = 'grid-cols-1'; // Single image, full width + } else if (attachmentCount === 2) { + gridClass = 'grid-cols-2'; // Two images, side by side + } else if (attachmentCount === 3 || attachmentCount === 4) { + gridClass = 'grid-cols-2'; // Three or four images, two per row + } + + return ( +
+ {attachment.map((item, index) => ( + {`attachment-${index}`} + ))} +
+ ); + } + + switch (attachment.mediaType) { + case 'image/jpeg': + case 'image/png': + case 'image/gif': + return attachment; + case 'video/mp4': + case 'video/webm': + return
+
; + + case 'audio/mpeg': + case 'audio/ogg': + return
+
; + default: + return null; + } +} + +const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string, type: string }> = ({actor, object, layout, type}) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(object.content || '', 'text/html'); + + const plainTextContent = doc.body.textContent; + let previewContent = ''; + if (object.preview) { + const previewDoc = parser.parseFromString(object.preview.content || '', 'text/html'); + previewContent = previewDoc.body.textContent || ''; + } else if (object.type === 'Note') { + previewContent = plainTextContent || ''; + } + + 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 date = new Date(object?.published ?? new Date()); + + 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 + }; + + let author = actor; + if (type === 'Announce' && object.type === 'Note') { + author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor; + } + + if (layout === 'feed') { + return ( + <> + {object && ( +
+ {(type === 'Announce' && object.type === 'Note') &&
+
+ {actor.name} reposted +
} +
+ +
+
+
+ {author.name} + {getRelativeTimestamp(date)} +
+
+ {getUsername(author)} +
+
+
+
+ {object.name && {object.name}} +
+ {/*

{object.content}

*/} + {renderAttachment(object)} +
+
+
+
+
+
+
+
+ )} + + ); + } else if (layout === 'inbox') { + return ( + <> + {object && ( +
+
+ + {actor.name} + {/* {getUsername(actor)} */} + {timestamp} +
+
+
+
+ {object.name} +
+

{previewContent}

+
+
+
+ {/* {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 ( + + +
+
+
+
+
+
+
+
+
+
+
+ {object.type === 'Note' && ( +
+ {object.content &&
} + {renderAttachment(object)} +
)} + {object.type === 'Article' && } +
+
+
+ ); +}; + +export default ActivityPubComponent; diff --git a/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx b/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx new file mode 100644 index 0000000000..af3ea48b3a --- /dev/null +++ b/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx @@ -0,0 +1,27 @@ +import React, {ReactNode} from 'react'; + +export type Activity = { + type: string, + object: { + type: string + } +} + +interface ActivityItemProps { + children?: ReactNode; +} + +const ActivityItem: React.FC = ({children}) => { + const childrenArray = React.Children.toArray(children); + + return ( +
+
+ {childrenArray[0]} + {childrenArray[1]} +
+
+ ); +}; + +export default ActivityItem; 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/feed/ArticleModal.tsx b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx new file mode 100644 index 0000000000..9da2d3f10f --- /dev/null +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -0,0 +1,94 @@ +import MainHeader from '../navigation/MainHeader'; +import NiceModal, {useModal} from '@ebay/nice-modal-react'; +import React, {useEffect, useRef} from 'react'; +import articleBodyStyles from '../articleBodyStyles'; +import {Button, Modal} from '@tryghost/admin-x-design-system'; +import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {renderAttachment} from './FeedItem'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; + +interface ArticleModalProps { + object: ObjectProperties; +} + +const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => { + const site = useBrowseSite(); + const siteData = site.data?.site; + + const iframeRef = useRef(null); + + const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, '')); + + const htmlContent = ` + + + ${cssContent} + + +
+

${heading}

+${image && + `
+ ${heading} +
` +} +
+
+ ${html} +
+ + +`; + + useEffect(() => { + const iframe = iframeRef.current; + if (iframe) { + iframe.srcdoc = htmlContent; + } + }, [htmlContent]); + + return ( +
+ +
+ ); +}; + +const ArticleModal: React.FC = ({object}) => { + const modal = useModal(); + return ( + } + height={'full'} + padding={false} + size='bleed' + width={640} + > + +
+
+
+
+ {object.type === 'Note' && ( +
+ {object.content &&
} + {renderAttachment(object)} +
)} + {object.type === 'Article' && } +
+
+ ); +}; + +export default NiceModal.create(ArticleModal); \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx new file mode 100644 index 0000000000..e35f2edd64 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx @@ -0,0 +1,179 @@ +import APAvatar from '../global/APAvatar'; +import React, {useState} from 'react'; +import getRelativeTimestamp from '../../utils/get-relative-timestamp'; +import getUsername from '../../utils/get-username'; +import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {Button, Heading, Icon} from '@tryghost/admin-x-design-system'; + +export function renderAttachment(object: ObjectProperties) { + let attachment; + if (object.image) { + attachment = object.image; + } + + if (object.type === 'Note' && !attachment) { + attachment = object.attachment; + } + + if (!attachment) { + return null; + } + + if (Array.isArray(attachment)) { + const attachmentCount = attachment.length; + + let gridClass = ''; + if (attachmentCount === 1) { + gridClass = 'grid-cols-1'; // Single image, full width + } else if (attachmentCount === 2) { + gridClass = 'grid-cols-2'; // Two images, side by side + } else if (attachmentCount === 3 || attachmentCount === 4) { + gridClass = 'grid-cols-2'; // Three or four images, two per row + } + + return ( +
+ {attachment.map((item, index) => ( + {`attachment-${index}`} + ))} +
+ ); + } + + switch (attachment.mediaType) { + case 'image/jpeg': + case 'image/png': + case 'image/gif': + return attachment; + case 'video/mp4': + case 'video/webm': + return
+
; + + case 'audio/mpeg': + case 'audio/ogg': + return
+
; + default: + return null; + } +} + +interface FeedItemProps { + actor: ActorProperties; + object: ObjectProperties; + layout: string; + type: string; +} + +const FeedItem: React.FC = ({actor, object, layout, type}) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(object.content || '', 'text/html'); + + const plainTextContent = doc.body.textContent; + let previewContent = ''; + if (object.preview) { + const previewDoc = parser.parseFromString(object.preview.content || '', 'text/html'); + previewContent = previewDoc.body.textContent || ''; + } else if (object.type === 'Note') { + previewContent = plainTextContent || ''; + } + + 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 date = new Date(object?.published ?? new Date()); + + 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 + }; + + let author = actor; + if (type === 'Announce' && object.type === 'Note') { + author = typeof object.attributedTo === 'object' ? object.attributedTo as ActorProperties : actor; + } + + if (layout === 'feed') { + return ( + <> + {object && ( +
+ {(type === 'Announce' && object.type === 'Note') &&
+
+ {actor.name} reposted +
} +
+ +
+
+
+ {author.name} + {getRelativeTimestamp(date)} +
+
+ {getUsername(author)} +
+
+
+
+ {object.name && {object.name}} +
+ {/*

{object.content}

*/} + {renderAttachment(object)} +
+
+
+
+
+
+
+
+ )} + + ); + } else if (layout === 'inbox') { + return ( + <> + {object && ( +
+
+ + {actor.name} + {/* {getUsername(actor)} */} + {timestamp} +
+
+
+
+ {object.name} +
+

{previewContent}

+
+
+
+ {/* {image &&
+ +
} */} +
+
+ {/*
*/} +
+ )} + + ); + } +}; + +export default FeedItem; \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/components/global/APAvatar.tsx b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx new file mode 100644 index 0000000000..1bfe40176d --- /dev/null +++ b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {Icon} from '@tryghost/admin-x-design-system'; + +interface APAvatarProps { + author?: ActorProperties; +} + +const APAvatar: React.FC = ({author}) => { + return ( + <> + {author && author!.icon?.url ? :
} + + ); +}; + +export default APAvatar; diff --git a/apps/admin-x-activitypub/src/components/inbox/FollowSiteModal.tsx b/apps/admin-x-activitypub/src/components/inbox/FollowSiteModal.tsx new file mode 100644 index 0000000000..76ea0e4bb2 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/inbox/FollowSiteModal.tsx @@ -0,0 +1,75 @@ +import NiceModal from '@ebay/nice-modal-react'; +import {ActivityPubAPI} from '../../api/activitypub'; +import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useMutation} from '@tanstack/react-query'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; +import {useState} from 'react'; + +function useFollow(handle: string, onSuccess: () => void, onError: () => void) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useMutation({ + async mutationFn(username: string) { + return api.follow(username); + }, + onSuccess, + onError + }); +} + +const FollowSite = NiceModal.create(() => { + const {updateRoute} = useRouting(); + const modal = NiceModal.useModal(); + const [profileName, setProfileName] = useState(''); + const [errorMessage, setError] = useState(null); + + async function onSuccess() { + showToast({ + message: 'Site followed', + type: 'success' + }); + + modal.remove(); + updateRoute(''); + } + async function onError() { + setError(errorMessage); + } + const mutation = useFollow('index', onSuccess, onError); + + return ( + { + mutation.reset(); + updateRoute(''); + }} + cancelLabel='Cancel' + okLabel='Follow' + size='sm' + title='Follow a Ghost site' + onOk={() => mutation.mutate(profileName)} + > +
+ setProfileName(e.target.value)} + /> +
+
+ ); +}); + +export default FollowSite; 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..93361b1c0a --- /dev/null +++ b/apps/admin-x-activitypub/src/components/modals.tsx @@ -0,0 +1,11 @@ +import FollowSite from './inbox/FollowSiteModal'; +import ViewFollowers from './profile/ViewFollowersModal'; +import ViewFollowing from './profile/ViewFollowingModal'; +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/components/navigation/MainHeader.tsx b/apps/admin-x-activitypub/src/components/navigation/MainHeader.tsx new file mode 100644 index 0000000000..db4ab93ba7 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/navigation/MainHeader.tsx @@ -0,0 +1,17 @@ +import React, {ReactNode} from 'react'; + +interface MainHeaderProps { + children?: ReactNode; +} + +const MainHeader: React.FC = ({children}) => { + return ( +
+
+ {children} +
+
+ ); +}; + +export default MainHeader; \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/components/navigation/MainNavigation.tsx b/apps/admin-x-activitypub/src/components/navigation/MainNavigation.tsx new file mode 100644 index 0000000000..a3e9736cf6 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/navigation/MainNavigation.tsx @@ -0,0 +1,29 @@ +import MainHeader from './MainHeader'; +import React from 'react'; +import {Button} from '@tryghost/admin-x-design-system'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; + +interface MainNavigationProps {} + +const MainNavigation: React.FC = ({}) => { + const {route, updateRoute} = useRouting(); + const mainRoute = route.split('/')[0]; + + return ( + +
+
+
+
+
+ ); +}; + +export default MainNavigation; \ No newline at end of file diff --git a/apps/admin-x-activitypub/src/components/profile/ViewFollowersModal.tsx b/apps/admin-x-activitypub/src/components/profile/ViewFollowersModal.tsx new file mode 100644 index 0000000000..db0dd424f3 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/profile/ViewFollowersModal.tsx @@ -0,0 +1,74 @@ +import NiceModal from '@ebay/nice-modal-react'; +import getUsername from '../../utils/get-username'; +import {ActivityPubAPI} from '../../api/activitypub'; +import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system'; +import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useMutation, useQuery} from '@tanstack/react-query'; + +function useFollowersForUser(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useQuery({ + queryKey: [`followers:${handle}`], + async queryFn() { + return api.getFollowers(); + } + }); +} + +function useFollow(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useMutation({ + async mutationFn(username: string) { + return api.follow(username); + } + }); +} + +const ViewFollowersModal: React.FC = ({}) => { + const {updateRoute} = useRouting(); + // const modal = NiceModal.useModal(); + const mutation = useFollow('index'); + + const {data: items = []} = useFollowersForUser('index'); + + const followers = Array.isArray(items) ? items : [items]; + return ( + { + mutation.reset(); + updateRoute('profile'); + }} + cancelLabel='' + footer={false} + okLabel='' + size='md' + title='Followers' + topRightContent='close' + > +
+ + {followers.map(item => ( + mutation.mutate(getUsername(item))} />} avatar={} detail={getUsername(item)} id='list-item' title={item.name}> + ))} + +
+
+ ); +}; + +export default NiceModal.create(ViewFollowersModal); diff --git a/apps/admin-x-activitypub/src/components/profile/ViewFollowingModal.tsx b/apps/admin-x-activitypub/src/components/profile/ViewFollowingModal.tsx new file mode 100644 index 0000000000..6b58dc94bd --- /dev/null +++ b/apps/admin-x-activitypub/src/components/profile/ViewFollowingModal.tsx @@ -0,0 +1,71 @@ +import NiceModal from '@ebay/nice-modal-react'; +import getUsername from '../../utils/get-username'; +import {ActivityPubAPI} from '../../api/activitypub'; +import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system'; +import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useQuery} from '@tanstack/react-query'; + +function useFollowingForUser(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useQuery({ + queryKey: [`following:${handle}`], + async queryFn() { + return api.getFollowing(); + } + }); +} + +const ViewFollowingModal: React.FC = ({}) => { + const {updateRoute} = useRouting(); + + const {data: items = []} = useFollowingForUser('index'); + + const following = Array.isArray(items) ? items : [items]; + return ( + { + updateRoute('profile'); + }} + cancelLabel='' + footer={false} + okLabel='' + size='md' + title='Following' + topRightContent='close' + > +
+ + {following.map(item => ( + } avatar={} detail={getUsername(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/styles/index.css b/apps/admin-x-activitypub/src/styles/index.css index d1f1f198ed..d9a471ebfb 100644 --- a/apps/admin-x-activitypub/src/styles/index.css +++ b/apps/admin-x-activitypub/src/styles/index.css @@ -1 +1,39 @@ @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; +} + +.ap-note-content a { + color: rgb(236 72 153) !important; +} + +.ap-note-content a:hover { + color: rgb(190, 25, 99) !important; + text-decoration: underline !important; +} + +.ap-note-content p + p { + margin-top: 1.5rem !important; +} + diff --git a/apps/admin-x-activitypub/src/utils/get-relative-timestamp.ts b/apps/admin-x-activitypub/src/utils/get-relative-timestamp.ts new file mode 100644 index 0000000000..be70d68c24 --- /dev/null +++ b/apps/admin-x-activitypub/src/utils/get-relative-timestamp.ts @@ -0,0 +1,33 @@ +export const getRelativeTimestamp = (date: Date): string => { + const now = new Date(); + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + let interval = Math.floor(seconds / 31536000); + if (interval > 1) { + return `${interval}y`; + } + + interval = Math.floor(seconds / 2592000); + if (interval > 1) { + return `${interval}m`; + } + + interval = Math.floor(seconds / 86400); + if (interval >= 1) { + return `${interval}d`; + } + + interval = Math.floor(seconds / 3600); + if (interval > 1) { + return `${interval}h`; + } + + interval = Math.floor(seconds / 60); + if (interval > 1) { + return `${interval}m`; + } + + return `${seconds} seconds`; +}; + +export default getRelativeTimestamp; \ No newline at end of file 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-activitypub/tsconfig.json b/apps/admin-x-activitypub/tsconfig.json index 621d5ec558..585a411882 100644 --- a/apps/admin-x-activitypub/tsconfig.json +++ b/apps/admin-x-activitypub/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", "skipLibCheck": true, + "types": ["vite/client", "jest"], /* Bundler mode */ "moduleResolution": "bundler", diff --git a/apps/admin-x-activitypub/vite.config.mjs b/apps/admin-x-activitypub/vite.config.mjs index ae5b996d87..2521075119 100644 --- a/apps/admin-x-activitypub/vite.config.mjs +++ b/apps/admin-x-activitypub/vite.config.mjs @@ -5,6 +5,14 @@ import {resolve} from 'path'; export default (function viteConfig() { return adminXViteConfig({ packageName: pkg.name, - entry: resolve(__dirname, 'src/index.tsx') + entry: resolve(__dirname, 'src/index.tsx'), + overrides: { + test: { + include: [ + './test/unit/**/*', + './src/**/*.test.ts' + ] + } + } }); }); diff --git a/apps/admin-x-demo/package.json b/apps/admin-x-demo/package.json index 426e969f5c..abb1fe1b9f 100644 --- a/apps/admin-x-demo/package.json +++ b/apps/admin-x-demo/package.json @@ -32,7 +32,7 @@ "preview": "vite preview" }, "devDependencies": { - "@testing-library/react": "14.1.0", + "@testing-library/react": "14.3.1", "@tryghost/admin-x-design-system": "0.0.0", "@tryghost/admin-x-framework": "0.0.0", "@types/react": "18.3.3", diff --git a/apps/admin-x-demo/src/ListPage.tsx b/apps/admin-x-demo/src/ListPage.tsx index 9a075bc08e..5432c8f570 100644 --- a/apps/admin-x-demo/src/ListPage.tsx +++ b/apps/admin-x-demo/src/ListPage.tsx @@ -27,7 +27,7 @@ const ListPage = () => { label: 'Open Rate' } ]} - position="left" + position="start" onDirectionChange={() => {}} onSortChange={() => {}} />, diff --git a/apps/admin-x-design-system/package.json b/apps/admin-x-design-system/package.json index b645c092c6..047e01cfe6 100644 --- a/apps/admin-x-design-system/package.json +++ b/apps/admin-x-design-system/package.json @@ -11,7 +11,8 @@ "scripts": { "build": "concurrently \"vite build\" \"tsc -p tsconfig.declaration.json\"", "prepare": "yarn build", - "test": "yarn test:types", + "test": "yarn test:unit && yarn test:types", + "test:unit": "yarn nx build && vitest run", "test:types": "tsc --noEmit", "lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache", "lint": "yarn lint:code && yarn lint:test", @@ -27,27 +28,30 @@ ], "devDependencies": { "@codemirror/lang-html": "6.4.9", - "@storybook/addon-essentials": "7.6.19", - "@storybook/addon-interactions": "7.6.19", - "@storybook/addon-links": "7.6.19", + "@storybook/addon-essentials": "7.6.20", + "@storybook/addon-interactions": "7.6.20", + "@storybook/addon-links": "7.6.20", + "@radix-ui/react-tooltip": "1.1.2", "@storybook/addon-styling": "1.3.7", - "@storybook/blocks": "7.6.19", - "@storybook/react": "7.6.19", + "@storybook/blocks": "7.6.20", + "@storybook/react": "7.6.20", "@storybook/react-vite": "7.6.4", "@storybook/testing-library": "0.2.2", - "@testing-library/react": "14.1.0", + "@testing-library/react": "14.3.1", + "@testing-library/react-hooks" : "8.0.1", "@vitejs/plugin-react": "4.2.1", "c8": "8.0.1", "eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-refresh": "0.4.3", "eslint-plugin-tailwindcss": "3.13.0", - "jsdom": "24.1.0", + "jsdom": "24.1.1", "mocha": "10.2.0", + "chai": "4.3.8", "react": "18.3.1", "react-dom": "18.3.1", "rollup-plugin-node-builtins": "2.1.2", "sinon": "17.0.0", - "storybook": "7.6.19", + "storybook": "7.6.20", "ts-node": "10.9.2", "typescript": "5.4.5", "vite": "4.5.3", @@ -57,18 +61,27 @@ "@dnd-kit/core": "6.1.0", "@dnd-kit/sortable": "7.0.2", "@ebay/nice-modal-react": "1.2.13", - "@sentry/react": "7.116.0", + "@radix-ui/react-avatar": "1.1.0", + "@radix-ui/react-checkbox": "1.1.1", + "@radix-ui/react-form": "0.0.3", + "@radix-ui/react-popover": "1.1.1", + "@radix-ui/react-radio-group": "1.2.0", + "@radix-ui/react-separator": "1.1.0", + "@radix-ui/react-switch": "1.1.0", + "@radix-ui/react-tabs": "1.1.0", + "@radix-ui/react-tooltip": "1.1.2", + "@sentry/react": "7.119.0", "@tailwindcss/forms": "0.5.7", "@tailwindcss/line-clamp": "0.4.4", - "@uiw/react-codemirror": "4.22.1", + "@uiw/react-codemirror": "4.23.0", "autoprefixer": "10.4.19", "clsx": "2.1.1", - "postcss": "8.4.38", + "postcss": "8.4.39", "postcss-import": "16.1.0", "react-colorful": "5.6.1", "react-hot-toast": "2.4.1", "react-select": "5.8.0", - "tailwindcss": "3.4.3" + "tailwindcss": "3.4.10" }, "peerDependencies": { "react": "^18.2.0", diff --git a/apps/admin-x-design-system/src/DesignSystemApp.tsx b/apps/admin-x-design-system/src/DesignSystemApp.tsx index 6606867af1..9ea7f4a730 100644 --- a/apps/admin-x-design-system/src/DesignSystemApp.tsx +++ b/apps/admin-x-design-system/src/DesignSystemApp.tsx @@ -17,7 +17,7 @@ const DesignSystemApp: React.FC = ({darkMode, fetchKoenigL return (
- + {children}
diff --git a/apps/admin-x-design-system/src/assets/icons/bell.svg b/apps/admin-x-design-system/src/assets/icons/bell.svg new file mode 100644 index 0000000000..e9b771d42d --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/bell.svg @@ -0,0 +1 @@ +Alarm Bell Streamline Icon: https://streamlinehq.comalarm-bell \ No newline at end of file diff --git a/apps/admin-x-design-system/src/assets/icons/home.svg b/apps/admin-x-design-system/src/assets/icons/home.svg new file mode 100644 index 0000000000..9f44dc3fac --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/home.svg @@ -0,0 +1 @@ +House Entrance Streamline Icon: https://streamlinehq.comhouse-entrance \ No newline at end of file diff --git a/apps/admin-x-design-system/src/assets/icons/hyperlink-circle.svg b/apps/admin-x-design-system/src/assets/icons/hyperlink-circle.svg index 54cb97d847..86106cffb9 100644 --- a/apps/admin-x-design-system/src/assets/icons/hyperlink-circle.svg +++ b/apps/admin-x-design-system/src/assets/icons/hyperlink-circle.svg @@ -1 +1 @@ - \ No newline at end of file +Hyperlink Circle Streamline Icon: https://streamlinehq.com diff --git a/apps/admin-x-design-system/src/assets/icons/reload.svg b/apps/admin-x-design-system/src/assets/icons/reload.svg new file mode 100644 index 0000000000..5afa3c7227 --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/reload.svg @@ -0,0 +1 @@ +Button Refresh Arrows Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/apps/admin-x-design-system/src/assets/icons/share.svg b/apps/admin-x-design-system/src/assets/icons/share.svg new file mode 100644 index 0000000000..6feac81448 --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/share.svg @@ -0,0 +1 @@ +Share 1 Streamline Icon: https://streamlinehq.comshare-1 \ No newline at end of file diff --git a/apps/admin-x-design-system/src/assets/icons/user.svg b/apps/admin-x-design-system/src/assets/icons/user.svg new file mode 100644 index 0000000000..e954801d94 --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/user.svg @@ -0,0 +1 @@ +Single Neutral Streamline Icon: https://streamlinehq.comsingle-neutral \ No newline at end of file diff --git a/apps/admin-x-design-system/src/global/Avatar.tsx b/apps/admin-x-design-system/src/global/Avatar.tsx index 07b1c26377..88e7553458 100644 --- a/apps/admin-x-design-system/src/global/Avatar.tsx +++ b/apps/admin-x-design-system/src/global/Avatar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {ReactComponent as UserIcon} from '../assets/icons/single-user-fill.svg'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; type AvatarSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl'; @@ -44,21 +45,17 @@ const Avatar: React.FC = ({image, label, labelColor, bgColor, size, break; } - if (image) { - return ( - - ); - } else if (label) { - return ( -
{label}
- ); - } else { - return ( -
- -
- ); - } + return ( + + {image ? + : + {label} + } + + + + + ); }; export default Avatar; diff --git a/apps/admin-x-design-system/src/global/Button.tsx b/apps/admin-x-design-system/src/global/Button.tsx index 2464e98f15..a3cd14da59 100644 --- a/apps/admin-x-design-system/src/global/Button.tsx +++ b/apps/admin-x-design-system/src/global/Button.tsx @@ -30,7 +30,7 @@ export interface ButtonProps extends Omit, 'label' testId?: string; } -const Button: React.FC = ({ +const Button: React.FC = React.forwardRef(({ testId, size = 'md', label = '', @@ -51,7 +51,7 @@ const Button: React.FC = ({ outlineOnMobile = false, onClick, ...props -}) => { +}, ref) => { if (!color) { color = 'clear'; } @@ -155,9 +155,12 @@ const Button: React.FC = ({ disabled: disabled, type: 'button', onClick: onClick, + ref: ref, ...props}, buttonChildren); return buttonElement; -}; +}); + +Button.displayName = 'Button'; export default Button; diff --git a/apps/admin-x-design-system/src/global/Icon.tsx b/apps/admin-x-design-system/src/global/Icon.tsx index 61e1621f8d..47fc5e7eaa 100644 --- a/apps/admin-x-design-system/src/global/Icon.tsx +++ b/apps/admin-x-design-system/src/global/Icon.tsx @@ -30,42 +30,50 @@ export interface IconProps { const Icon: React.FC = ({name, size = 'md', colorClass = '', className = ''}) => { const {ReactComponent: SvgComponent} = icons[`../assets/icons/${name}.svg`]; - let styles = ''; + let classes = ''; + let styles = {}; - if (!styles) { + if (typeof size === 'number') { + styles = { + width: `${size}px`, + height: `${size}px` + }; + } + + if (!classes) { switch (size) { case 'custom': break; case '2xs': - styles = 'w-2 h-2'; + classes = 'w-2 h-2'; break; case 'xs': - styles = 'w-3 h-3'; + classes = 'w-3 h-3'; break; case 'sm': - styles = 'w-4 h-4'; + classes = 'w-4 h-4'; break; case 'lg': - styles = 'w-8 h-8'; + classes = 'w-8 h-8'; break; case 'xl': - styles = 'w-10 h-10'; + classes = 'w-10 h-10'; break; default: - styles = 'w-5 h-5'; + classes = 'w-5 h-5'; break; } } - styles = clsx( - styles, + classes = clsx( + classes, colorClass ); if (SvgComponent) { return ( - + ); } return null; diff --git a/apps/admin-x-design-system/src/global/Menu.stories.tsx b/apps/admin-x-design-system/src/global/Menu.stories.tsx index d6c4a568ba..77d5373f6f 100644 --- a/apps/admin-x-design-system/src/global/Menu.stories.tsx +++ b/apps/admin-x-design-system/src/global/Menu.stories.tsx @@ -24,7 +24,7 @@ export const Default: Story = { args: { trigger: , items: items, - position: 'left' + position: 'start' }, decorators: [ ThisStory => ( @@ -37,7 +37,7 @@ export const Right: Story = { args: { trigger: , items: items, - position: 'right' + position: 'end' }, decorators: [ ThisStory => ( diff --git a/apps/admin-x-design-system/src/global/Menu.tsx b/apps/admin-x-design-system/src/global/Menu.tsx index 480604eacc..c9a99e7372 100644 --- a/apps/admin-x-design-system/src/global/Menu.tsx +++ b/apps/admin-x-design-system/src/global/Menu.tsx @@ -20,7 +20,7 @@ const Menu: React.FC = ({ trigger, triggerButtonProps, items, - position = 'left' + position = 'start' }) => { if (!trigger) { trigger = + {(typeof counter === 'number') && {counter}} + ); }; @@ -71,7 +71,6 @@ export const TabList: React.FC = ({ handleTabChange, border, buttonBorder, - selectedTab, topRightContent }) => { const containerClasses = clsx( @@ -82,24 +81,26 @@ export const TabList: React.FC = ({ border && 'border-b border-grey-300 dark:border-grey-900' ); return ( -
- {tabs.map(tab => ( -
- -
- ))} - {topRightContent !== null ? -
{topRightContent}
: - null - } -
+ +
+ {tabs.map(tab => ( +
+ +
+ ))} + {topRightContent !== null ? +
{topRightContent}
: + null + } +
+
); }; @@ -140,7 +141,7 @@ function TabView({ }; return ( -
+ ({ /> {tabs.map((tab) => { return ( - <> - {tab.contents && -
-
{tab.contents}
-
- } - + +
{tab.contents}
+
); })} -
+ ); }; diff --git a/apps/admin-x-design-system/src/global/Toast.stories.tsx b/apps/admin-x-design-system/src/global/Toast.stories.tsx index 90cef8de10..bdb5deb86b 100644 --- a/apps/admin-x-design-system/src/global/Toast.stories.tsx +++ b/apps/admin-x-design-system/src/global/Toast.stories.tsx @@ -1,7 +1,6 @@ import type {Meta, StoryObj} from '@storybook/react'; import {ReactNode} from 'react'; -import {Toaster} from 'react-hot-toast'; import Button from './Button'; import {ShowToastProps, showToast} from './Toast'; @@ -25,7 +24,6 @@ const meta = { tags: ['autodocs'], decorators: [(_story: () => ReactNode) => ( <> - {_story()} )] diff --git a/apps/admin-x-design-system/src/global/Tooltip.stories.tsx b/apps/admin-x-design-system/src/global/Tooltip.stories.tsx index ca6669aaa4..91f7f43dc6 100644 --- a/apps/admin-x-design-system/src/global/Tooltip.stories.tsx +++ b/apps/admin-x-design-system/src/global/Tooltip.stories.tsx @@ -36,7 +36,7 @@ export const Left: Story = { args: { content: 'Hello tooltip on the left', children: - -
- {donateUrl} -
-
-
- - ) - } - ]} - /> - ); - - const inputFields = ( - - group.options).find(option => option.value === donationsCurrency)} - title='Currency' - hideTitle - isSearchable - onSelect={option => updateSetting('donations_currency', option?.value || 'USD')} - /> - )} - title='Suggested amount' - valueInCents={parseInt(donationsSuggestedAmount)} - onBlur={validate} - onChange={cents => updateSetting('donations_suggested_amount', cents.toString())} - onKeyDown={() => clearError('donationsSuggestedAmount')} - /> - - ); - - return ( - - {isEditing ? inputFields : values} - - ); -}; - -export default withErrorBoundary(TipsOrDonations, 'Tips or donations'); diff --git a/apps/admin-x-settings/src/components/settings/growth/embedSignup/EmbedSignupSidebar.tsx b/apps/admin-x-settings/src/components/settings/growth/embedSignup/EmbedSignupSidebar.tsx index 508d284974..2c9945f889 100644 --- a/apps/admin-x-settings/src/components/settings/growth/embedSignup/EmbedSignupSidebar.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/embedSignup/EmbedSignupSidebar.tsx @@ -129,7 +129,6 @@ const EmbedSignupSidebar: React.FC = ({selectedLayout, /> + {submitEnabled && - {{/if}} + Back to dashboard

{{/if}} {{/if}} diff --git a/ghost/admin/app/components/editor/modals/publish-flow/confirm.js b/ghost/admin/app/components/editor/modals/publish-flow/confirm.js index 54410befbe..9152a8b825 100644 --- a/ghost/admin/app/components/editor/modals/publish-flow/confirm.js +++ b/ghost/admin/app/components/editor/modals/publish-flow/confirm.js @@ -13,6 +13,7 @@ function isString(str) { export default class PublishFlowOptions extends Component { @service settings; + @service feature; @tracked errorMessage; @@ -91,6 +92,15 @@ export default class PublishFlowOptions extends Component { try { yield this.args.saveTask.perform(); + if (this.feature.publishFlowEndScreen) { + if (this.args.publishOptions.isScheduled) { + localStorage.setItem('ghost-last-scheduled-post', this.args.publishOptions.post.id); + window.location.href = '/ghost/#/posts?type=scheduled'; + } else { + localStorage.setItem('ghost-last-published-post', this.args.publishOptions.post.id); + window.location.href = `/ghost/#/posts/analytics/${this.args.publishOptions.post.id}`; + } + } } catch (e) { if (e === undefined && this.args.publishOptions.post.errors.length !== 0) { // validation error diff --git a/ghost/admin/app/components/editor/publish-management.js b/ghost/admin/app/components/editor/publish-management.js index 7d97a31906..eb2906522b 100644 --- a/ghost/admin/app/components/editor/publish-management.js +++ b/ghost/admin/app/components/editor/publish-management.js @@ -56,7 +56,7 @@ export default class PublishManagement extends Component { } } - if (isValid && !this.publishFlowModal || this.publishFlowModal?.isClosing) { + if (isValid && (!this.publishFlowModal || this.publishFlowModal?.isClosing)) { this.publishOptions.resetPastScheduledAt(); this.publishFlowModal = this.modals.open(PublishFlowModal, { @@ -83,7 +83,7 @@ export default class PublishManagement extends Component { const isValid = await this._validatePost(); - if (isValid && !this.updateFlowModal || this.updateFlowModal.isClosing) { + if (isValid && (!this.updateFlowModal || this.updateFlowModal.isClosing)) { this.updateFlowModal = this.modals.open(UpdateFlowModal, { publishOptions: this.publishOptions, saveTask: this.publishTask @@ -99,10 +99,12 @@ export default class PublishManagement extends Component { } @action - openPreview(event, {skipAnimation} = {}) { + async openPreview(event, {skipAnimation} = {}) { event?.preventDefault(); - if (!this.previewModal || this.previewModal.isClosing) { + const isValid = await this._validatePost(); + + if (isValid && (!this.previewModal || this.previewModal.isClosing)) { // open publish flow modal underneath to offer quick switching // without restarting the flow or causing flicker diff --git a/ghost/admin/app/components/gh-alert.js b/ghost/admin/app/components/gh-alert.js index ca22911459..2114ae97a1 100644 --- a/ghost/admin/app/components/gh-alert.js +++ b/ghost/admin/app/components/gh-alert.js @@ -9,8 +9,8 @@ export default class GhAlert extends Component { const typeMapping = { success: 'green', error: 'red', - warn: 'blue', - info: 'blue' + warn: 'black', + info: 'black' }; const type = this.args.message.type; diff --git a/ghost/admin/app/components/gh-context-menu.js b/ghost/admin/app/components/gh-context-menu.js index 8b3de5a54b..205e488bb1 100644 --- a/ghost/admin/app/components/gh-context-menu.js +++ b/ghost/admin/app/components/gh-context-menu.js @@ -1,5 +1,5 @@ import Component from '@glimmer/component'; -import SelectionList from '../utils/selection-list'; +import SelectionList from './posts-list/selection-list'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency'; diff --git a/ghost/admin/app/components/gh-editor-feature-image.hbs b/ghost/admin/app/components/gh-editor-feature-image.hbs index 66775bb48d..0d934a7d87 100644 --- a/ghost/admin/app/components/gh-editor-feature-image.hbs +++ b/ghost/admin/app/components/gh-editor-feature-image.hbs @@ -46,11 +46,11 @@ @imageSrc={{@image}} @saveImage={{fn this.saveImage uploader.setFiles}} /> - -
+
{{#if this.isEditingAlt}} + + {{#if (feature 'editorExcerpt')}} +
+ + {{#if @excerptHasTk}} +
+ TK +
+ {{/if}} +
+ {{#if @excerptErrorMessage}} +
+ {{@excerptErrorMessage}} +
+ {{/if}} +
+ {{/if}}
diff --git a/ghost/admin/app/components/gh-koenig-editor-lexical.js b/ghost/admin/app/components/gh-koenig-editor-lexical.js index abc7eb7c4b..be28f73a5f 100644 --- a/ghost/admin/app/components/gh-koenig-editor-lexical.js +++ b/ghost/admin/app/components/gh-koenig-editor-lexical.js @@ -4,15 +4,18 @@ import {action} from '@ember/object'; import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; -export default class GhKoenigEditorReactComponent extends Component { +export default class GhKoenigEditorLexical extends Component { @service settings; + @service feature; containerElement = null; titleElement = null; + excerptElement = null; mousedownY = 0; uploadUrl = `${ghostPaths().apiRoot}/images/upload/`; editorAPI = null; + secondaryEditorAPI = null; skipFocusEditor = false; @tracked titleIsHovered = false; @@ -30,6 +33,10 @@ export default class GhKoenigEditorReactComponent extends Component { return color; } + get excerpt() { + return this.args.excerpt || ''; + } + @action registerElement(element) { this.containerElement = element; @@ -106,25 +113,106 @@ export default class GhKoenigEditorReactComponent extends Component { this.titleElement.focus(); } - // move cursor to the editor on - // - Tab - // - Arrow Down/Right when input is empty or caret at end of input - // - Enter, creating an empty paragraph when editor is not empty @action onTitleKeydown(event) { - const {editorAPI} = this; + if (this.feature.editorExcerpt) { + // move cursor to the excerpt on + // - Tab (handled by browser) + // - Arrow Down/Right when input is empty or caret at end of input + // - Enter + const {key} = event; + const {value, selectionStart} = event.target; - if (!editorAPI || event.originalEvent.isComposing) { - return; + if (key === 'Enter') { + event.preventDefault(); + this.excerptElement?.focus(); + } + + if ((key === 'ArrowDown' || key === 'ArrowRight') && !event.shiftKey) { + const couldLeaveTitle = !value || selectionStart === value.length; + + if (couldLeaveTitle) { + event.preventDefault(); + this.excerptElement?.focus(); + } + } + } else { + // move cursor to the editor on + // - Tab + // - Arrow Down/Right when input is empty or caret at end of input + // - Enter, creating an empty paragraph when editor is not empty + const {editorAPI} = this; + + if (!editorAPI || event.originalEvent.isComposing) { + return; + } + + const {key} = event; + const {value, selectionStart} = event.target; + + const couldLeaveTitle = !value || selectionStart === value.length; + const arrowLeavingTitle = ['ArrowDown', 'ArrowRight'].includes(key) && couldLeaveTitle; + + if (key === 'Enter' || key === 'Tab' || arrowLeavingTitle) { + event.preventDefault(); + + if (key === 'Enter' && !editorAPI.editorIsEmpty()) { + editorAPI.insertParagraphAtTop({focus: true}); + } else { + editorAPI.focusEditor({position: 'top'}); + } + } } + } + // Subtitle ("excerpt") Actions ------------------------------------------- + + @action + registerExcerptElement(element) { + this.excerptElement = element; + } + + @action + focusExcerpt() { + this.excerptElement?.focus(); + + // timeout ensures this occurs after the keyboard events + setTimeout(() => { + this.excerptElement?.setSelectionRange(-1, -1); + }, 0); + } + + @action + onExcerptInput(event) { + this.args.setExcerpt?.(event.target.value); + } + + @action + onExcerptKeydown(event) { + // move cursor to the title on + // - Shift+Tab (handled by the browser) + // - Arrow Up/Left when input is empty or caret at start of input + // move cursor to the editor on + // - Tab + // - Arrow Down/Right when input is empty or caret at end of input + // - Enter, creating an empty paragraph when editor is not empty const {key} = event; const {value, selectionStart} = event.target; - const couldLeaveTitle = !value || selectionStart === value.length; - const arrowLeavingTitle = ['ArrowDown', 'ArrowRight'].includes(key) && couldLeaveTitle; + if ((key === 'ArrowUp' || key === 'ArrowLeft') && !event.shiftKey) { + const couldLeaveTitle = !value || selectionStart === 0; - if (key === 'Enter' || key === 'Tab' || arrowLeavingTitle) { + if (couldLeaveTitle) { + event.preventDefault(); + this.focusTitle(); + } + } + + const {editorAPI} = this; + const couldLeaveTitle = !value || selectionStart === value.length; + const arrowLeavingTitle = (key === 'ArrowRight' || key === 'ArrowDown') && couldLeaveTitle; + + if (key === 'Enter' || (key === 'Tab' && !event.shiftKey) || arrowLeavingTitle) { event.preventDefault(); if (key === 'Enter' && !editorAPI.editorIsEmpty()) { @@ -135,6 +223,8 @@ export default class GhKoenigEditorReactComponent extends Component { } } + // move cursor to the editor on + // Body actions ------------------------------------------------------------ @action @@ -143,11 +233,17 @@ export default class GhKoenigEditorReactComponent extends Component { this.args.registerAPI(API); } + @action + registerSecondaryEditorAPI(API) { + this.secondaryEditorAPI = API; + this.args.registerSecondaryAPI(API); + } + // focus the editor when the editor canvas is clicked below the editor content, // otherwise the browser will defocus the editor and the cursor will disappear @action focusEditor(event) { - if (!this.skipFocusEditor && event.target.classList.contains('gh-koenig-editor-pane')) { + if (!this.skipFocusEditor && event.target.classList.contains('gh-koenig-editor-pane') && this.editorAPI) { let editorCanvas = this.editorAPI.editorInstance.getRootElement(); let {bottom} = editorCanvas.getBoundingClientRect(); diff --git a/ghost/admin/app/components/gh-member-settings-form.hbs b/ghost/admin/app/components/gh-member-settings-form.hbs index f1a7630274..c6a3234c0a 100644 --- a/ghost/admin/app/components/gh-member-settings-form.hbs +++ b/ghost/admin/app/components/gh-member-settings-form.hbs @@ -111,126 +111,104 @@
{{#each tier.subscriptions as |sub index|}} -
-
-
- {{sub.price.currencySymbol}} - {{format-number sub.price.nonDecimalAmount}} +
+
+
+ {{sub.price.currencySymbol}} + {{format-number sub.price.nonDecimalAmount}} +
+
{{if (eq sub.price.interval "year") "yearly" "monthly"}}
-
{{if (eq sub.price.interval "year") "yearly" "monthly"}}
-
-
-

- {{tier.name}} - {{#if (eq sub.status "canceled")}} - Canceled - {{else if sub.cancel_at_period_end}} - Canceled - {{else if sub.compExpiry}} - Active - {{else if sub.trialUntil}} - Active - {{else}} - Active - {{/if}} - {{#if (gt tier.subscriptions.length 1)}} - {{tier.subscriptions.length}} subscriptions - {{/if}} -

-
- {{#if sub.trialUntil}} - Free trial - {{else}} - {{#if (or (eq sub.price.nickname "Monthly") (eq sub.price.nickname "Yearly"))}} - {{else}} - {{sub.price.nickname}} - {{/if}} - {{/if}} - - {{#if sub.isComplimentary}} - {{#if sub.compExpiry}} - Expires {{sub.compExpiry}} - {{/if}} - {{else}} - {{#if sub.hasEnded}} - Ended {{sub.validUntil}} - {{else if sub.willEndSoon}} - Has access until {{sub.validUntil}} +
+

+ {{tier.name}} + {{#if (eq sub.status "canceled")}} + Canceled + {{else if sub.cancel_at_period_end}} + Canceled + {{else if sub.compExpiry}} + Active {{else if sub.trialUntil}} - Ends {{sub.trialUntil}} + Active {{else}} - Renews {{sub.validUntil}} + Active {{/if}} - {{/if}} + {{#if (gt tier.subscriptions.length 1)}} + {{tier.subscriptions.length}} subscriptions + {{/if}} +

+
+ {{sub.priceLabel}} + {{sub.validityDetails}} +
+
- -
- {{#if sub.isComplimentary}} - - - - {{svg-jar "dotdotdot"}} - - - - -
  • - -
  • -
    -
    - {{else}} - - - - {{svg-jar "dotdotdot"}} - - - - -
  • - - View Stripe customer - -
  • -
  • -
  • - - View Stripe subscription - -
  • -
  • - {{#if (not-eq sub.status "canceled")}} - {{#if sub.cancel_at_period_end}} - - {{else}} - +
  • +
    +
    + {{else}} + + + + {{svg-jar "dotdotdot"}} + + + + +
  • + + View Stripe customer + +
  • +
  • +
  • + + View Stripe subscription + +
  • +
  • + {{#if (not-eq sub.status "canceled")}} + {{#if sub.cancel_at_period_end}} + + {{else}} + + {{/if}} {{/if}} - {{/if}} -
  • -
    -
    - {{/if}} -
    + + + + {{/if}} +
    {{/each}} {{#if (eq tier.subscriptions.length 0)}} diff --git a/ghost/admin/app/components/gh-member-settings-form.js b/ghost/admin/app/components/gh-member-settings-form.js index 8596af3b45..bcd9193bf5 100644 --- a/ghost/admin/app/components/gh-member-settings-form.js +++ b/ghost/admin/app/components/gh-member-settings-form.js @@ -1,9 +1,8 @@ import Component from '@glimmer/component'; -import moment from 'moment-timezone'; import {action} from '@ember/object'; -import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency'; +import {didCancel, task} from 'ember-concurrency'; +import {getSubscriptionData} from 'ghost-admin/utils/subscription-data'; import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; export default class extends Component { @@ -60,41 +59,9 @@ export default class extends Component { return typeof value.id !== 'undefined' && self.findIndex(element => (element.tier_id || element.id) === (value.tier_id || value.id)) === index; }); - let subscriptionData = subscriptions.filter((sub) => { - return !!sub.price; - }).map((sub) => { - const periodEnded = sub.current_period_end && new Date(sub.current_period_end) < new Date(); - const data = { - ...sub, - attribution: { - ...sub.attribution, - referrerSource: sub.attribution?.referrer_source || 'Unknown', - referrerMedium: sub.attribution?.referrer_medium || '-' - }, - startDate: sub.start_date ? moment(sub.start_date).format('D MMM YYYY') : '-', - validUntil: sub.current_period_end ? moment(sub.current_period_end).format('D MMM YYYY') : '-', - hasEnded: sub.status === 'canceled' && periodEnded, - willEndSoon: sub.cancel_at_period_end || (sub.status === 'canceled' && !periodEnded), - cancellationReason: sub.cancellation_reason, - price: { - ...sub.price, - currencySymbol: getSymbol(sub.price.currency), - nonDecimalAmount: getNonDecimal(sub.price.amount) - }, - isComplimentary: !sub.id - }; - if (sub.trial_end_at) { - const inTrialMode = moment(sub.trial_end_at).isAfter(new Date(), 'day'); - if (inTrialMode) { - data.trialUntil = moment(sub.trial_end_at).format('D MMM YYYY'); - } - } + let subsWithPrice = subscriptions.filter(sub => !!sub.price); + let subscriptionData = subsWithPrice.map(sub => getSubscriptionData(sub)); - if (!sub.id && sub.tier?.expiry_at) { - data.compExpiry = moment(sub.tier.expiry_at).utc().format('D MMM YYYY'); - } - return data; - }); return tiers.map((tier) => { let tierSubscriptions = subscriptionData.filter((subscription) => { return subscription?.price?.tier?.tier_id === (tier.tier_id || tier.id); @@ -137,8 +104,17 @@ export default class extends Component { @action setup() { - this.fetchTiers.perform(); - this.fetchNewsletters.perform(); + try { + this.fetchTiers.perform(); + this.fetchNewsletters.perform(); + } catch (e) { + // Do not throw cancellation errors + if (didCancel(e)) { + return; + } + + throw e; + } } @action diff --git a/ghost/admin/app/components/gh-members-no-members.hbs b/ghost/admin/app/components/gh-members-no-members.hbs index 32926e99f8..e7563464ca 100644 --- a/ghost/admin/app/components/gh-members-no-members.hbs +++ b/ghost/admin/app/components/gh-members-no-members.hbs @@ -9,7 +9,7 @@

    Have members already? Add them manually or import from CSV

    {{else}}

    Memberships have been disabled. Adjust your Subscription Access settings to start adding members.

    - + Membership settings {{/if}} diff --git a/ghost/admin/app/components/gh-post-settings-menu.hbs b/ghost/admin/app/components/gh-post-settings-menu.hbs index 06963c9eba..ee91b2af80 100644 --- a/ghost/admin/app/components/gh-post-settings-menu.hbs +++ b/ghost/admin/app/components/gh-post-settings-menu.hbs @@ -93,7 +93,7 @@ {{/if}} {{/if}} - + {{#unless (feature 'editorExcerpt')}} + {{/unless}} {{#unless this.session.user.isAuthorOrContributor}} @@ -852,6 +853,7 @@ post=this.post editorAPI=this.editorAPI toggleSettingsMenu=this.toggleSettingsMenu + secondaryEditorAPI=this.secondaryEditorAPI }} @close={{this.closePostHistory}} @modifier="total-overlay post-history" /> diff --git a/ghost/admin/app/components/gh-token-input/label-token.js b/ghost/admin/app/components/gh-token-input/label-token.js index d82786d068..0327c9c392 100644 --- a/ghost/admin/app/components/gh-token-input/label-token.js +++ b/ghost/admin/app/components/gh-token-input/label-token.js @@ -1,10 +1,17 @@ import DraggableObject from 'ember-drag-drop/components/draggable-object'; import classic from 'ember-classic-decorator'; +import {alias} from '@ember/object/computed'; import {attributeBindings, classNames} from '@ember-decorators/component'; +import {computed} from '@ember/object'; @classic @attributeBindings('title') @classNames('label-token') export default class LabelToken extends DraggableObject { - title = this.name ?? 'Label'; + @alias('content.name') name; + + @computed('name') + get title() { + return this.name ?? 'Label'; + } } diff --git a/ghost/admin/app/components/koenig-image-editor.hbs b/ghost/admin/app/components/koenig-image-editor.hbs index f518062917..f58b2a165e 100644 --- a/ghost/admin/app/components/koenig-image-editor.hbs +++ b/ghost/admin/app/components/koenig-image-editor.hbs @@ -6,10 +6,10 @@ {{/if}} diff --git a/ghost/admin/app/components/koenig-lexical-editor.js b/ghost/admin/app/components/koenig-lexical-editor.js index da1770b383..f2a471a463 100644 --- a/ghost/admin/app/components/koenig-lexical-editor.js +++ b/ghost/admin/app/components/koenig-lexical-editor.js @@ -83,6 +83,28 @@ export function decoratePostSearchResult(item, settings) { } } +/** + * Fetches the URLs of all active offers + * @returns {Promise<{label: string, value: string}[]>} + */ +export async function offerUrls() { + let offers = []; + + try { + offers = await this.fetchOffersTask.perform(); + } catch (e) { + // No-op: if offers are not available (e.g. missing permissions), return an empty array + return []; + } + + return offers.map((offer) => { + return { + label: `Offer — ${offer.name}`, + value: this.config.getSiteUrl(offer.code) + }; + }); +} + class ErrorHandler extends React.Component { state = { hasError: false @@ -273,8 +295,6 @@ export default class KoenigLexicalEditor extends Component { }; const fetchAutocompleteLinks = async () => { - const offers = await this.fetchOffersTask.perform(); - const defaults = [ {label: 'Homepage', value: window.location.origin + '/'}, {label: 'Free signup', value: '#/portal/signup/free'} @@ -300,7 +320,7 @@ export default class KoenigLexicalEditor extends Component { const donationLink = () => { if (this.feature.tipsAndDonations && this.settings.donationsEnabled) { return [{ - label: 'Tip or donation', + label: 'Tips and donations', value: '#/portal/support' }]; } @@ -319,18 +339,23 @@ export default class KoenigLexicalEditor extends Component { return []; }; - const offersLinks = offers.toArray().map((offer) => { - return { - label: `Offer - ${offer.name}`, - value: this.config.getSiteUrl(offer.code) - }; - }); + const offersLinks = await offerUrls.call(this); return [...defaults, ...memberLinks(), ...donationLink(), ...recommendationLink(), ...offersLinks]; }; const fetchLabels = async () => { - const labels = await this.fetchLabelsTask.perform(); + let labels = []; + try { + labels = await this.fetchLabelsTask.perform(); + } catch (e) { + // Do not throw cancellation errors + if (didCancel(e)) { + return; + } + + throw e; + } return labels.map(label => label.name); }; @@ -372,12 +397,21 @@ export default class KoenigLexicalEditor extends Component { if (!didCancel(error)) { throw error; } + return; } - // only published posts/pages have URLs + // only published posts/pages and staff with posts have URLs const filteredResults = []; results.forEach((group) => { - const items = (group.groupName === 'Posts' || group.groupName === 'Pages') ? group.options.filter(i => i.status === 'published') : group.options; + let items = group.options; + + if (group.groupName === 'Posts' || group.groupName === 'Pages') { + items = items.filter(i => i.status === 'published'); + } + + if (group.groupName === 'Staff') { + items = items.filter(i => !/\/404\//.test(i.url)); + } if (items.length === 0) { return; @@ -407,6 +441,16 @@ export default class KoenigLexicalEditor extends Component { } }; + const checkStripeEnabled = () => { + const hasDirectKeys = !!(this.settings.stripeSecretKey && this.settings.stripePublishableKey); + const hasConnectKeys = !!(this.settings.stripeConnectSecretKey && this.settings.stripeConnectPublishableKey); + + if (this.config.stripeDirect) { + return hasDirectKeys; + } + return hasDirectKeys || hasConnectKeys; + }; + const defaultCardConfig = { unsplash: this.settings.unsplash ? unsplashConfig.defaultHeaders : null, tenor: this.config.tenor?.googleApiKey ? this.config.tenor : null, @@ -414,18 +458,21 @@ export default class KoenigLexicalEditor extends Component { fetchCollectionPosts, fetchEmbed, fetchLabels, + renderLabels: !this.session.user.isContributor, feature: { collectionsCard: this.feature.collectionsCard, collections: this.feature.collections, - internalLinking: this.feature.internalLinking + contentVisibility: this.feature.contentVisibility }, - deprecated: { + deprecated: { // todo fix typo headerV1: true // if false, shows header v1 in the menu }, membersEnabled: this.settings.membersSignupAccess === 'all', searchLinks, siteTitle: this.settings.title, - siteDescription: this.settings.description + siteDescription: this.settings.description, + siteUrl: this.config.getSiteUrl('/'), + stripeEnabled: checkStripeEnabled() // returns a boolean }; const cardConfig = Object.assign({}, defaultCardConfig, props.cardConfig, {pinturaConfig: this.pinturaConfig}); @@ -631,34 +678,43 @@ export default class KoenigLexicalEditor extends Component { const multiplayerDocId = cardConfig.post.id; const multiplayerUsername = this.session.user.name; + const KGEditorComponent = ({isInitInstance}) => { + return ( +
    + + + {} : this.args.updateWordCount} /> + {} : this.args.updatePostTkCount} /> + +
    + ); + }; + return (
    Loading editor...

    }> - - - - - + +
    diff --git a/ghost/admin/app/components/members-activity/event-type-filter.js b/ghost/admin/app/components/members-activity/event-type-filter.js index 957eafeec0..50029841c6 100644 --- a/ghost/admin/app/components/members-activity/event-type-filter.js +++ b/ghost/admin/app/components/members-activity/event-type-filter.js @@ -11,7 +11,8 @@ const ALL_EVENT_TYPES = [ {event: 'email_opened_event', icon: 'filter-dropdown-email-opened', name: 'Email opened', group: 'emails'}, {event: 'email_delivered_event', icon: 'filter-dropdown-email-received', name: 'Email received', group: 'emails'}, {event: 'email_complaint_event', icon: 'filter-dropdown-email-flagged-as-spam', name: 'Email flagged as spam', group: 'emails'}, - {event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'} + {event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'}, + {event: 'email_change_event', icon: 'filter-dropdown-email-address-changed', name: 'Email address changed', group: 'emails'} ]; export default class MembersActivityEventTypeFilter extends Component { diff --git a/ghost/admin/app/components/members/filter.js b/ghost/admin/app/components/members/filter.js index 475ddf53eb..46da839176 100644 --- a/ghost/admin/app/components/members/filter.js +++ b/ghost/admin/app/components/members/filter.js @@ -4,8 +4,8 @@ import nql from '@tryghost/nql-lang'; import {AUDIENCE_FEEDBACK_FILTER, CREATED_AT_FILTER, EMAIL_CLICKED_FILTER, EMAIL_COUNT_FILTER, EMAIL_FILTER, EMAIL_OPENED_COUNT_FILTER, EMAIL_OPENED_FILTER, EMAIL_OPEN_RATE_FILTER, EMAIL_SENT_FILTER, LABEL_FILTER, LAST_SEEN_FILTER, NAME_FILTER, NEWSLETTERS_FILTERS, NEXT_BILLING_DATE_FILTER, OFFERS_FILTER, PLAN_INTERVAL_FILTER, SIGNUP_ATTRIBUTION_FILTER, STATUS_FILTER, SUBSCRIBED_FILTER, SUBSCRIPTION_ATTRIBUTION_FILTER, SUBSCRIPTION_START_DATE_FILTER, SUBSCRIPTION_STATUS_FILTER, TIER_FILTER} from './filters'; import {TrackedArray} from 'tracked-built-ins'; import {action} from '@ember/object'; +import {didCancel, task} from 'ember-concurrency'; import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; function escapeNqlString(value) { @@ -137,6 +137,10 @@ class Filter { return this.properties.options ?? []; } + get group() { + return this.properties.group; + } + get isValid() { if (Array.isArray(this.value)) { return !!this.value.length; @@ -234,9 +238,19 @@ export default class MembersFilter extends Component { async parseDefaultFilters() { // we need to make sure all the filters are loaded before parsing the default filter // otherwise the filter will be parsed with the wrong properties - await this.fetchTiers.perform(); - await this.fetchNewsletters.perform(); - await this.fetchOffers.perform(); + try { + await this.fetchTiers.perform(); + await this.fetchNewsletters.perform(); + await this.fetchOffers.perform(); + } catch (e) { + // Do not throw cancellation errors + if (didCancel(e)) { + return; + } + + throw e; + } + if (this.args.defaultFilterParam) { // check if it is different before parsing const validFilters = this.validFilters; diff --git a/ghost/admin/app/components/members/filters/created-at.js b/ghost/admin/app/components/members/filters/created-at.js index 9ded43af78..f857771115 100644 --- a/ghost/admin/app/components/members/filters/created-at.js +++ b/ghost/admin/app/components/members/filters/created-at.js @@ -1,8 +1,8 @@ import {DATE_RELATION_OPTIONS} from './relation-options'; export const CREATED_AT_FILTER = { - label: 'Created', - name: 'created_at', - valueType: 'date', + label: 'Created', + name: 'created_at', + valueType: 'date', relationOptions: DATE_RELATION_OPTIONS }; diff --git a/ghost/admin/app/components/members/filters/email-clicked.js b/ghost/admin/app/components/members/filters/email-clicked.js index d24798b197..c78dae0c41 100644 --- a/ghost/admin/app/components/members/filters/email-clicked.js +++ b/ghost/admin/app/components/members/filters/email-clicked.js @@ -1,10 +1,10 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; export const EMAIL_CLICKED_FILTER = { - label: 'Clicked email', - name: 'clicked_links.post_id', - valueType: 'string', - resource: 'email', + label: 'Clicked email', + name: 'clicked_links.post_id', + valueType: 'string', + resource: 'email', relationOptions: MATCH_RELATION_OPTIONS, columnLabel: 'Clicked email', setting: 'emailTrackClicks', diff --git a/ghost/admin/app/components/members/filters/email-count.js b/ghost/admin/app/components/members/filters/email-count.js index ef8396c574..03a0a4242f 100644 --- a/ghost/admin/app/components/members/filters/email-count.js +++ b/ghost/admin/app/components/members/filters/email-count.js @@ -2,10 +2,10 @@ import {NUMBER_RELATION_OPTIONS} from './relation-options'; import {formatNumber} from 'ghost-admin/helpers/format-number'; export const EMAIL_COUNT_FILTER = { - label: 'Emails sent (all time)', - name: 'email_count', - columnLabel: 'Email count', - valueType: 'number', + label: 'Emails sent (all time)', + name: 'email_count', + columnLabel: 'Email count', + valueType: 'number', relationOptions: NUMBER_RELATION_OPTIONS, getColumnValue: (member) => { return { diff --git a/ghost/admin/app/components/members/filters/email-open-rate.js b/ghost/admin/app/components/members/filters/email-open-rate.js index 5990199a4d..6361239b05 100644 --- a/ghost/admin/app/components/members/filters/email-open-rate.js +++ b/ghost/admin/app/components/members/filters/email-open-rate.js @@ -1,9 +1,9 @@ import {NUMBER_RELATION_OPTIONS} from './relation-options'; export const EMAIL_OPEN_RATE_FILTER = { - label: 'Open rate (all time)', - name: 'email_open_rate', - valueType: 'number', + label: 'Open rate (all time)', + name: 'email_open_rate', + valueType: 'number', setting: 'emailTrackOpens', relationOptions: NUMBER_RELATION_OPTIONS }; diff --git a/ghost/admin/app/components/members/filters/email-opened-count.js b/ghost/admin/app/components/members/filters/email-opened-count.js index ff4d2e3363..dece4042ad 100644 --- a/ghost/admin/app/components/members/filters/email-opened-count.js +++ b/ghost/admin/app/components/members/filters/email-opened-count.js @@ -2,10 +2,10 @@ import {NUMBER_RELATION_OPTIONS} from './relation-options'; import {formatNumber} from 'ghost-admin/helpers/format-number'; export const EMAIL_OPENED_COUNT_FILTER = { - label: 'Emails opened (all time)', - name: 'email_opened_count', - columnLabel: 'Email opened count', - valueType: 'number', + label: 'Emails opened (all time)', + name: 'email_opened_count', + columnLabel: 'Email opened count', + valueType: 'number', relationOptions: NUMBER_RELATION_OPTIONS, getColumnValue: (member) => { return { diff --git a/ghost/admin/app/components/members/filters/email-opened.js b/ghost/admin/app/components/members/filters/email-opened.js index 5f284e16a5..d9535523aa 100644 --- a/ghost/admin/app/components/members/filters/email-opened.js +++ b/ghost/admin/app/components/members/filters/email-opened.js @@ -1,10 +1,10 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; export const EMAIL_OPENED_FILTER = { - label: 'Opened email', - name: 'opened_emails.post_id', - valueType: 'string', - resource: 'email', + label: 'Opened email', + name: 'opened_emails.post_id', + valueType: 'string', + resource: 'email', relationOptions: MATCH_RELATION_OPTIONS, columnLabel: 'Opened email', setting: 'emailTrackOpens', diff --git a/ghost/admin/app/components/members/filters/email.js b/ghost/admin/app/components/members/filters/email.js index 3645374c34..1aa094a395 100644 --- a/ghost/admin/app/components/members/filters/email.js +++ b/ghost/admin/app/components/members/filters/email.js @@ -1,8 +1,8 @@ import {CONTAINS_RELATION_OPTIONS} from './relation-options'; export const EMAIL_FILTER = { - label: 'Email', + label: 'Email', name: 'email', - valueType: 'string', + valueType: 'string', relationOptions: CONTAINS_RELATION_OPTIONS }; diff --git a/ghost/admin/app/components/members/filters/label.js b/ghost/admin/app/components/members/filters/label.js index 7538d60df5..857845a2db 100644 --- a/ghost/admin/app/components/members/filters/label.js +++ b/ghost/admin/app/components/members/filters/label.js @@ -1,10 +1,10 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; export const LABEL_FILTER = { - label: 'Label', - name: 'label', - valueType: 'array', - columnLabel: 'Label', + label: 'Label', + name: 'label', + valueType: 'array', + columnLabel: 'Label', relationOptions: MATCH_RELATION_OPTIONS, getColumnValue: (member) => { return { diff --git a/ghost/admin/app/components/members/filters/last-seen.js b/ghost/admin/app/components/members/filters/last-seen.js index 22eb950daf..d0667dbda3 100644 --- a/ghost/admin/app/components/members/filters/last-seen.js +++ b/ghost/admin/app/components/members/filters/last-seen.js @@ -2,10 +2,10 @@ import {DATE_RELATION_OPTIONS} from './relation-options'; import {getDateColumnValue} from './columns/date-column'; export const LAST_SEEN_FILTER = { - label: 'Last seen', - name: 'last_seen_at', - valueType: 'date', - columnLabel: 'Last seen at', + label: 'Last seen', + name: 'last_seen_at', + valueType: 'date', + columnLabel: 'Last seen at', relationOptions: DATE_RELATION_OPTIONS, getColumnValue: (member, filter) => { return getDateColumnValue(member.lastSeenAtUTC, filter); diff --git a/ghost/admin/app/components/members/filters/name.js b/ghost/admin/app/components/members/filters/name.js index 875d50e5fa..c5571d5567 100644 --- a/ghost/admin/app/components/members/filters/name.js +++ b/ghost/admin/app/components/members/filters/name.js @@ -1,8 +1,8 @@ import {CONTAINS_RELATION_OPTIONS} from './relation-options'; export const NAME_FILTER = { - label: 'Name', - name: 'name', - valueType: 'string', + label: 'Name', + name: 'name', + valueType: 'string', relationOptions: CONTAINS_RELATION_OPTIONS }; diff --git a/ghost/admin/app/components/members/filters/offers.js b/ghost/admin/app/components/members/filters/offers.js index 0039f04c19..854c46e4e7 100644 --- a/ghost/admin/app/components/members/filters/offers.js +++ b/ghost/admin/app/components/members/filters/offers.js @@ -1,7 +1,7 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; export const OFFERS_FILTER = { - label: 'Offers', + label: 'Offers', name: 'offer_redemptions', group: 'Subscription', relationOptions: MATCH_RELATION_OPTIONS, diff --git a/ghost/admin/app/components/members/filters/signup-attribution.js b/ghost/admin/app/components/members/filters/signup-attribution.js index 49a396e071..6bcf2b2762 100644 --- a/ghost/admin/app/components/members/filters/signup-attribution.js +++ b/ghost/admin/app/components/members/filters/signup-attribution.js @@ -1,10 +1,10 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; export const SIGNUP_ATTRIBUTION_FILTER = { - label: 'Signed up on post/page', - name: 'signup', - valueType: 'string', - resource: 'post', + label: 'Signed up on post/page', + name: 'signup', + valueType: 'string', + resource: 'post', relationOptions: MATCH_RELATION_OPTIONS, columnLabel: 'Signed up on', setting: 'membersTrackSources', diff --git a/ghost/admin/app/components/members/filters/status.js b/ghost/admin/app/components/members/filters/status.js index e6d31dd697..08cbbef1a9 100644 --- a/ghost/admin/app/components/members/filters/status.js +++ b/ghost/admin/app/components/members/filters/status.js @@ -1,8 +1,8 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; export const STATUS_FILTER = { - label: 'Member status', - name: 'status', + label: 'Member status', + name: 'status', relationOptions: MATCH_RELATION_OPTIONS, valueType: 'options', options: [ diff --git a/ghost/admin/app/components/members/filters/subscribed.js b/ghost/admin/app/components/members/filters/subscribed.js index f825b36231..9a4d143e84 100644 --- a/ghost/admin/app/components/members/filters/subscribed.js +++ b/ghost/admin/app/components/members/filters/subscribed.js @@ -1,126 +1,54 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; -export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => { - if (feature.filterEmailDisabled) { - return { - label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription', - name: 'subscribed', - columnLabel: 'Subscribed', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - group: newsletters.length > 1 ? 'Newsletters' : group, - // Only show the filter for multiple newsletters if feature flag is enabled - feature: newsletters.length > 1 ? 'filterEmailDisabled' : undefined, - buildNqlFilter: (flt) => { - const relation = flt.relation; - const value = flt.value; - - if (value === 'email-disabled') { - if (relation === 'is') { - return '(email_disabled:1)'; - } - return '(email_disabled:0)'; - } - - if (relation === 'is') { - if (value === 'subscribed') { - return '(subscribed:true+email_disabled:0)'; - } - return '(subscribed:false+email_disabled:0)'; - } - - // relation === 'is-not' - if (value === 'subscribed') { - return '(subscribed:false,email_disabled:1)'; - } - return '(subscribed:true,email_disabled:1)'; - }, - parseNqlFilter: (flt) => { - const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility - - if (!comparator || comparator.length !== 2) { - const filter = flt; - if (filter && filter.email_disabled !== undefined) { - if (filter.email_disabled) { - return { - value: 'email-disabled', - relation: 'is' - }; - } - return { - value: 'email-disabled', - relation: 'is-not' - }; - } - return; - } - - if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) { - return; - } - - const usedOr = flt.$or !== undefined; - const subscribed = comparator[0].subscribed; - - if (usedOr) { - // Is not - return { - value: !subscribed ? 'subscribed' : 'unsubscribed', - relation: 'is-not' - }; - } - - return { - value: subscribed ? 'subscribed' : 'unsubscribed', - relation: 'is' - }; - }, - options: [ - {label: newsletters.length > 1 ? 'Subscribed to at least one' : 'Subscribed', name: 'subscribed'}, - {label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'}, - {label: 'Email disabled', name: 'email-disabled'} - ], - getColumnValue: (member) => { - if (member.emailSuppression && member.emailSuppression.suppressed) { - return { - text: 'Email disabled' - }; - } - - return member.newsletters.length > 0 ? { - text: 'Subscribed' - } : { - text: 'Unsubscribed' - }; - } - }; - } - - if (newsletters.length > 1) { - // Disable - // Only show the filter for multiple newsletters if feature flag is enabled - return []; - } - +export const SUBSCRIBED_FILTER = ({newsletters, group}) => { return { - label: 'Newsletter subscription', + label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription', name: 'subscribed', columnLabel: 'Subscribed', relationOptions: MATCH_RELATION_OPTIONS, valueType: 'options', - group: group, + group: newsletters.length > 1 ? 'Newsletters' : group, buildNqlFilter: (flt) => { const relation = flt.relation; const value = flt.value; - return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') - ? '(subscribed:true+email_disabled:0)' - : '(subscribed:false,email_disabled:1)'; + if (value === 'email-disabled') { + if (relation === 'is') { + return '(email_disabled:1)'; + } + return '(email_disabled:0)'; + } + + if (relation === 'is') { + if (value === 'subscribed') { + return '(subscribed:true+email_disabled:0)'; + } + return '(subscribed:false+email_disabled:0)'; + } + + // relation === 'is-not' + if (value === 'subscribed') { + return '(subscribed:false,email_disabled:1)'; + } + return '(subscribed:true,email_disabled:1)'; }, parseNqlFilter: (flt) => { - const comparator = flt.$and || flt.$or; + const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility if (!comparator || comparator.length !== 2) { + const filter = flt; + if (filter && filter.email_disabled !== undefined) { + if (filter.email_disabled) { + return { + value: 'email-disabled', + relation: 'is' + }; + } + return { + value: 'email-disabled', + relation: 'is-not' + }; + } return; } @@ -128,31 +56,44 @@ export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => { return; } + const usedOr = flt.$or !== undefined; const subscribed = comparator[0].subscribed; + if (usedOr) { + // Is not + return { + value: !subscribed ? 'subscribed' : 'unsubscribed', + relation: 'is-not' + }; + } + return { - value: subscribed ? 'true' : 'false', + value: subscribed ? 'subscribed' : 'unsubscribed', relation: 'is' }; }, options: [ - {label: 'Subscribed', name: 'true'}, - {label: 'Unsubscribed', name: 'false'} + {label: newsletters.length > 1 ? 'Subscribed to at least one' : 'Subscribed', name: 'subscribed'}, + {label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'}, + {label: 'Email disabled', name: 'email-disabled'} ], - getColumnValue: (member, flt) => { - const relation = flt.relation; - const value = flt.value; + getColumnValue: (member) => { + if (member.emailSuppression && member.emailSuppression.suppressed) { + return { + text: 'Email disabled' + }; + } - return { - text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') - ? 'Subscribed' - : 'Unsubscribed' + return member.newsletters.length > 0 ? { + text: 'Subscribed' + } : { + text: 'Unsubscribed' }; } }; }; -export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => { +export const NEWSLETTERS_FILTERS = ({newsletters, group}) => { if (newsletters.length <= 1) { return []; } @@ -210,12 +151,10 @@ export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => { const relation = flt.relation; const value = flt.value; - if (feature.filterEmailDisabled) { - if (member.emailSuppression && member.emailSuppression.suppressed) { - return { - text: 'Email disabled' - }; - } + if (member.emailSuppression && member.emailSuppression.suppressed) { + return { + text: 'Email disabled' + }; } return { diff --git a/ghost/admin/app/components/members/filters/subscription-attribution.js b/ghost/admin/app/components/members/filters/subscription-attribution.js index 84688dac5d..373b51c0b3 100644 --- a/ghost/admin/app/components/members/filters/subscription-attribution.js +++ b/ghost/admin/app/components/members/filters/subscription-attribution.js @@ -1,10 +1,10 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; export const SUBSCRIPTION_ATTRIBUTION_FILTER = { - label: 'Subscription started on post/page', - name: 'conversion', - valueType: 'string', - resource: 'post', + label: 'Subscription started on post/page', + name: 'conversion', + valueType: 'string', + resource: 'post', relationOptions: MATCH_RELATION_OPTIONS, columnLabel: 'Subscription started on', setting: 'membersTrackSources', diff --git a/ghost/admin/app/components/members/filters/tier.js b/ghost/admin/app/components/members/filters/tier.js index b219d0fc4f..bbd1c00e2d 100644 --- a/ghost/admin/app/components/members/filters/tier.js +++ b/ghost/admin/app/components/members/filters/tier.js @@ -1,10 +1,10 @@ import {MATCH_RELATION_OPTIONS} from './relation-options'; export const TIER_FILTER = { - label: 'Membership tier', - name: 'tier_id', - valueType: 'array', - columnLabel: 'Membership tier', + label: 'Membership tier', + name: 'tier_id', + valueType: 'array', + columnLabel: 'Membership tier', relationOptions: MATCH_RELATION_OPTIONS, getColumnValue: (member) => { return { diff --git a/ghost/admin/app/components/modal-member-tier.js b/ghost/admin/app/components/modal-member-tier.js index bc94dc2d1e..733d08e3e6 100644 --- a/ghost/admin/app/components/modal-member-tier.js +++ b/ghost/admin/app/components/modal-member-tier.js @@ -1,8 +1,8 @@ import ModalComponent from 'ghost-admin/components/modal-base'; import moment from 'moment-timezone'; import {action} from '@ember/object'; +import {didCancel, task} from 'ember-concurrency'; import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; export default class ModalMemberTier extends ModalComponent { @@ -77,7 +77,16 @@ export default class ModalMemberTier extends ModalComponent { @action setup() { this.loadingTiers = true; - this.fetchTiers.perform(); + try { + this.fetchTiers.perform(); + } catch (e) { + // Do not throw cancellation errors + if (didCancel(e)) { + return; + } + + throw e; + } } @action diff --git a/ghost/admin/app/components/modal-post-history.hbs b/ghost/admin/app/components/modal-post-history.hbs index 4795ea8103..f7f8ab1261 100644 --- a/ghost/admin/app/components/modal-post-history.hbs +++ b/ghost/admin/app/components/modal-post-history.hbs @@ -1,6 +1,6 @@ {{!-- template-lint-disable no-invalid-interactive --}}
    -
    +
    {{#if this.selectedHTML}} {{{this.selectedHTML}}} @@ -9,15 +9,32 @@
    {{#if this.selectedRevision.feature_image}} - + {{this.selectedRevision.feature_image_alt}} {{/if}} {{#if this.selectedRevision.feature_image_caption}} -

    {{{this.selectedRevision.feature_image_caption}}}

    +

    {{{this.selectedRevision.feature_image_caption}}}

    {{/if}}
    -
    {{this.currentTitle}}
    - +
    + {{this.currentTitle}} +
    + {{#if (feature "editorExcerpt")}} +
    + {{this.selectedRevision.custom_excerpt}} +
    +
    + {{/if}} +
    @@ -28,7 +45,8 @@ aria-label="Close meta data panel" class="back settings-menu-header-action" data-test-button="close-psm-subview" - type="button" {{action "closeModal"}} + type="button" + {{on "click" this.closeModal}} {{on "mousedown" (optional this.noop)}} > {{svg-jar "arrow-left"}} @@ -39,19 +57,19 @@
    - - {{svg-jar "pen" title=""}}Edit post - + {{#if (feature "publishFlowEndScreen")}} +
    + {{#if (feature "postAnalyticsRefresh")}} + + {{/if}} + {{#unless this.post.emailOnly}} + + {{/unless}} + + + + + {{svg-jar "dotdotdot"}} + + + + +
  • + Edit post +
  • +
  • + View in browser +
  • +
  • + +
  • +
    +
    +
    + {{else}} + + {{svg-jar "pen" title=""}}Edit post + + {{/if}}
    @@ -201,4 +253,4 @@
    {{/if}} - + \ No newline at end of file diff --git a/ghost/admin/app/components/posts/analytics.js b/ghost/admin/app/components/posts/analytics.js index 3ec21fff26..3fe7e136d3 100644 --- a/ghost/admin/app/components/posts/analytics.js +++ b/ghost/admin/app/components/posts/analytics.js @@ -1,4 +1,6 @@ import Component from '@glimmer/component'; +import DeletePostModal from '../modals/delete-post'; +import PostSuccessModal from '../modal-post-success'; import {action} from '@ember/object'; import {didCancel, task} from 'ember-concurrency'; import {inject as service} from '@ember/service'; @@ -24,6 +26,9 @@ export default class Analytics extends Component { @service utils; @service feature; @service store; + @service router; + @service modals; + @service notifications; @tracked sources = null; @tracked links = null; @@ -31,12 +36,47 @@ export default class Analytics extends Component { @tracked sortColumn = 'signups'; @tracked showSuccess; @tracked updateLinkId; + @tracked _post = null; + @tracked postCount = null; + @tracked showPostCount = false; displayOptions = DISPLAY_OPTIONS; + constructor() { + super(...arguments); + if (this.feature.publishFlowEndScreen) { + this.checkPublishFlowModal(); + } + } + + openPublishFlowModal() { + this.modals.open(PostSuccessModal, { + post: this.post, + postCount: this.postCount, + showPostCount: this.showPostCount + }); + } + + async checkPublishFlowModal() { + if (localStorage.getItem('ghost-last-published-post')) { + await this.fetchPostCountTask.perform(); + this.showPostCount = true; + this.openPublishFlowModal(); + localStorage.removeItem('ghost-last-published-post'); + } + } + get post() { + if (this.feature.publishFlowEndScreen) { + return this._post ?? this.args.post; + } + return this.args.post; } + set post(value) { + this._post = value; + } + get allowedDisplayOptions() { if (!this.hasPaidConversionData) { return this.displayOptions.filter(d => d.value === 'signups'); @@ -142,6 +182,19 @@ export default class Analytics extends Component { } } + @action + togglePublishFlowModal() { + this.showPostCount = false; + this.openPublishFlowModal(); + } + + @action + confirmDeleteMember() { + this.modals.open(DeletePostModal, { + post: this.post + }); + } + updateLinkData(linksData) { let updatedLinks; if (this.links?.length) { @@ -201,18 +254,30 @@ export default class Analytics extends Component { } return this._fetchReferrersStats.perform(); } catch (e) { - if (!didCancel(e)) { - // re-throw the non-cancelation error - throw e; + // Do not throw cancellation errors + if (didCancel(e)) { + return; } + + throw e; } } async fetchLinks() { - if (this._fetchLinks.isRunning) { - return this._fetchLinks.last; + try { + if (this._fetchLinks.isRunning) { + return this._fetchLinks.last; + } + + return this._fetchLinks.perform(); + } catch (e) { + // Do not throw cancellation errors + if (didCancel(e)) { + return; + } + + throw e; } - return this._fetchLinks.perform(); } @task @@ -290,6 +355,29 @@ export default class Analytics extends Component { this.mentions = yield this.store.query('mention', {limit: 5, order: 'created_at desc', filter}); } + @task + *fetchPostCountTask() { + if (!this.post.emailOnly) { + const result = yield this.store.query('post', {filter: 'status:published', limit: 1}); + let count = result.meta.pagination.total; + + this.postCount = count; + } + } + + @task + *fetchPostTask() { + const result = yield this.store.query('post', {filter: `id:${this.post.id}`, limit: 1}); + this.post = result.toArray()[0]; + + if (this.post.email) { + this.notifications.showNotification('Post analytics refreshing', { + description: 'It can take up to five minutes for all data to show.', + type: 'success' + }); + } + } + get showLinks() { return this.post.showEmailClickAnalytics; } diff --git a/ghost/admin/app/components/posts/old-analytics.js b/ghost/admin/app/components/posts/old-analytics.js index dbc66cf564..a57bfb3706 100644 --- a/ghost/admin/app/components/posts/old-analytics.js +++ b/ghost/admin/app/components/posts/old-analytics.js @@ -171,18 +171,30 @@ export default class Analytics extends Component { } return this._fetchReferrersStats.perform(); } catch (e) { - if (!didCancel(e)) { - // re-throw the non-cancelation error - throw e; + // Do not throw cancellation errors + if (didCancel(e)) { + return; } + + throw e; } } async fetchLinks() { - if (this._fetchLinks.isRunning) { - return this._fetchLinks.last; + try { + if (this._fetchLinks.isRunning) { + return this._fetchLinks.last; + } + + return this._fetchLinks.perform(); + } catch (e) { + // Do not throw cancellation errors + if (didCancel(e)) { + return; + } + + throw e; } - return this._fetchLinks.perform(); } @task @@ -226,7 +238,7 @@ export default class Analytics extends Component { }, 2000); } - @task + @task({drop: true}) *_fetchReferrersStats() { let statsUrl = this.ghostPaths.url.api(`stats/referrers/posts/${this.post.id}`); let result = yield this.ajax.request(statsUrl); @@ -239,7 +251,7 @@ export default class Analytics extends Component { }); } - @task + @task({drop: true}) *_fetchLinks() { const filter = `post_id:'${this.post.id}'`; let statsUrl = this.ghostPaths.url.api(`links/`) + `?filter=${encodeURIComponent(filter)}`; diff --git a/ghost/admin/app/components/websockets.hbs b/ghost/admin/app/components/websockets.hbs deleted file mode 100644 index ff7065798d..0000000000 --- a/ghost/admin/app/components/websockets.hbs +++ /dev/null @@ -1,8 +0,0 @@ -
    -
    -

    Counter

    -

    Current counter value: {{this.counter}}

    -

    This counter will reset when Ghost reboots.

    -
    - -
    \ No newline at end of file diff --git a/ghost/admin/app/components/websockets.js b/ghost/admin/app/components/websockets.js deleted file mode 100644 index 5dc9a025fe..0000000000 --- a/ghost/admin/app/components/websockets.js +++ /dev/null @@ -1,31 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -export default class Websockets extends Component { - @service('socket-io') socketIOService; - - constructor(...args) { - super(...args); - // initialize connection - - // TODO: ensure this works with subdirectories - let origin = window.location.origin; // this gives us host:port - let socket = this.socketIOService.socketFor(origin); - // add listener - socket.on('addCount', (value) => { - this.counter = value; - }); - } - - // button counter - @tracked counter = 0; - - // handle button/event - @action handleClick() { - let socket = this.socketIOService.socketFor(origin); - this.counter = 1 + this.counter; - socket.emit('addCount', this.counter); - } -} diff --git a/ghost/admin/app/controllers/application.js b/ghost/admin/app/controllers/application.js index 6c15e2840d..c014d571aa 100644 --- a/ghost/admin/app/controllers/application.js +++ b/ghost/admin/app/controllers/application.js @@ -27,10 +27,6 @@ export default class ApplicationController extends Controller { return this.config.clientExtensions?.script; } - get cacheBuster() { - return Date.now(); - } - get showNavMenu() { let {router, session, ui} = this; diff --git a/ghost/admin/app/controllers/lexical-editor.js b/ghost/admin/app/controllers/lexical-editor.js index 48416986f0..338b2ae991 100644 --- a/ghost/admin/app/controllers/lexical-editor.js +++ b/ghost/admin/app/controllers/lexical-editor.js @@ -16,7 +16,6 @@ import {GENERIC_ERROR_MESSAGE} from '../services/notifications'; import {action, computed} from '@ember/object'; import {alias, mapBy} from '@ember/object/computed'; import {capitalizeFirstLetter} from '../helpers/capitalize-first-letter'; -import {captureMessage} from '@sentry/ember'; import {dropTask, enqueueTask, restartableTask, task, taskGroup, timeout} from 'ember-concurrency'; import {htmlSafe} from '@ember/template'; import {inject} from 'ghost-admin/decorators/inject'; @@ -26,10 +25,13 @@ import {isHostLimitError, isServerUnreachableError, isVersionMismatchError} from import {isInvalidError} from 'ember-ajax/errors'; import {mobiledocToLexical} from '@tryghost/kg-converters'; import {inject as service} from '@ember/service'; +import {slugify} from '@tryghost/string'; +import {tracked} from '@glimmer/tracking'; const DEFAULT_TITLE = '(Untitled)'; // suffix that is applied to the title of a post when it has been duplicated const DUPLICATED_POST_TITLE_SUFFIX = '(Copy)'; + // time in ms to save after last content edit const AUTOSAVE_TIMEOUT = 3000; // time in ms to force a save if the user is continuously typing @@ -102,6 +104,45 @@ const messageMap = { } }; +function textHasTk(text) { + let matchArr = TK_REGEX.exec(text); + + if (matchArr === null) { + return false; + } + + function isValidMatch(match) { + // negative lookbehind isn't supported before Safari 16.4 + // so we capture the preceding char and test it here + if (match[1] && match[1].trim() && WORD_CHAR_REGEX.test(match[1])) { + return false; + } + + // we also check any following char in code to avoid an overly + // complex regex when looking for word-chars following the optional + // trailing symbol char + if (match[4] && match[4].trim() && WORD_CHAR_REGEX.test(match[4])) { + return false; + } + + return true; + } + + // our regex will match invalid TKs because we can't use negative lookbehind + // so we need to loop through the matches discarding any that are invalid + // and moving on to any subsequent matches + while (matchArr !== null && !isValidMatch(matchArr)) { + text = text.slice(matchArr.index + matchArr[0].length - 1); + matchArr = TK_REGEX.exec(text); + } + + if (matchArr === null) { + return false; + } + + return true; +} + @classic export default class LexicalEditorController extends Controller { @controller application; @@ -119,6 +160,8 @@ export default class LexicalEditorController extends Controller { @inject config; + @tracked excerptErrorMessage = ''; + /* public properties -----------------------------------------------------*/ shouldFocusTitle = false; @@ -225,48 +268,23 @@ export default class LexicalEditorController extends Controller { @computed('post.titleScratch') get titleHasTk() { - let text = this.post.titleScratch; - let matchArr = TK_REGEX.exec(text); - - if (matchArr === null) { - return false; - } - - function isValidMatch(match) { - // negative lookbehind isn't supported before Safari 16.4 - // so we capture the preceding char and test it here - if (match[1] && match[1].trim() && WORD_CHAR_REGEX.test(match[1])) { - return false; - } - - // we also check any following char in code to avoid an overly - // complex regex when looking for word-chars following the optional - // trailing symbol char - if (match[4] && match[4].trim() && WORD_CHAR_REGEX.test(match[4])) { - return false; - } - - return true; - } - - // our regex will match invalid TKs because we can't use negative lookbehind - // so we need to loop through the matches discarding any that are invalid - // and moving on to any subsequent matches - while (matchArr !== null && !isValidMatch(matchArr)) { - text = text.slice(matchArr.index + matchArr[0].length - 1); - matchArr = TK_REGEX.exec(text); - } - - if (matchArr === null) { - return false; - } - - return true; + return textHasTk(this.post.titleScratch); } - @computed('titleHasTk', 'postTkCount', 'featureImageTkCount') + @computed('post.customExcerpt') + get excerptHasTk() { + if (!this.feature.editorExcerpt) { + return false; + } + + return textHasTk(this.post.customExcerpt || ''); + } + + @computed('titleHasTk', 'excerptHasTk', 'postTkCount', 'featureImageTkCount') get tkCount() { - return (this.titleHasTk ? 1 : 0) + this.postTkCount + this.featureImageTkCount; + const titleTk = this.titleHasTk ? 1 : 0; + const excerptTk = (this.feature.editorExcerpt && this.excerptHasTk) ? 1 : 0; + return titleTk + excerptTk + this.postTkCount + this.featureImageTkCount; } @action @@ -279,11 +297,39 @@ export default class LexicalEditorController extends Controller { this._timedSaveTask.perform(); } + @action + updateSecondaryInstanceModel(lexical) { + this.set('post.secondaryLexicalState', JSON.stringify(lexical)); + } + @action updateTitleScratch(title) { this.set('post.titleScratch', title); } + @action + async updateExcerpt(excerpt) { + this.post.customExcerpt = excerpt; + try { + await this.post.validate({property: 'customExcerpt'}); + this.excerptErrorMessage = ''; + } catch (e) { + // validator throws undefined on validation error + if (e === undefined) { + this.excerptErrorMessage = this.post.errors.errorsFor('customExcerpt')?.[0]?.message; + return; + } + throw e; + } + } + + @task + *saveExcerptTask() { + if (this.post.status === 'draft') { + yield this.autosaveTask.perform(); + } + } + // updates local willPublish/Schedule values, does not get applied to // the post's `status` value until a save is triggered @action @@ -389,6 +435,11 @@ export default class LexicalEditorController extends Controller { this.editorAPI = API; } + @action + registerSecondaryEditorAPI(API) { + this.secondaryEditorAPI = API; + } + @action clearFeatureImage() { this.post.set('featureImage', null); @@ -610,8 +661,7 @@ export default class LexicalEditorController extends Controller { if (!options.silent) { let errorOrMessages = error || this.get('post.errors.messages'); this._showErrorAlert(prevStatus, this.get('post.status'), errorOrMessages); - // simulate a validation error for upstream tasks - throw undefined; + return; } return this.post; @@ -760,22 +810,11 @@ export default class LexicalEditorController extends Controller { try { yield post.save(options); - - // log if a save is slow - if (this.config.sentry_dsn && (Date.now() - startTime > 2000)) { - captureMessage('Successful Lexical save took > 2s', (scope) => { - scope.setTag('save_time', Math.ceil((Date.now() - startTime) / 1000)); - scope.setTag('post_type', post.isPage ? 'page' : 'post'); - scope.setTag('save_revision', options.adapterOptions?.saveRevision); - scope.setTag('email_segment', options.adapterOptions?.emailSegment); - scope.setTag('convert_to_lexical', options.adapterOptions?.convertToLexical); - }); - } } catch (error) { this.post.set('emailOnly', previousEmailOnlyValue); if (this.config.sentry_dsn && (Date.now() - startTime > 2000)) { - captureMessage('Failed Lexical save took > 2s', (scope) => { + Sentry.captureException('Failed Lexical save took > 2s', (scope) => { scope.setTag('save_time', Math.ceil((Date.now() - startTime) / 1000)); scope.setTag('post_type', post.isPage ? 'page' : 'post'); scope.setTag('save_revision', options.adapterOptions?.saveRevision); @@ -840,39 +879,44 @@ export default class LexicalEditorController extends Controller { // this is necessary to force a save when the title is blank this.set('hasDirtyAttributes', true); - // generate slug if post - // - is new and doesn't have a title yet - // - still has the default title - // - previously had a title that ended with the duplicated post title suffix - if ( - (post.get('isNew') && !currentTitle) || - (currentTitle === DEFAULT_TITLE) || - currentTitle?.endsWith(DUPLICATED_POST_TITLE_SUFFIX) - ) { - yield this.generateSlugTask.perform(); - } - + // always save updates automatically for drafts if (this.get('post.isDraft')) { + yield this.generateSlugTask.perform(); yield this.autosaveTask.perform(); } this.ui.updateDocumentTitle(); } + /* + // sync the post slug with the post title, except when: + // - the user has already typed a custom slug, which should not be overwritten + // - the post has been published, so that published URLs are not broken + */ @enqueueTask *generateSlugTask() { - let title = this.get('post.titleScratch'); + const currentTitle = this.get('post.title'); + const newTitle = this.get('post.titleScratch'); + const currentSlug = this.get('post.slug'); // Only set an "untitled" slug once per post - if (title === DEFAULT_TITLE && this.get('post.slug')) { + if (newTitle === DEFAULT_TITLE && currentSlug) { + return; + } + + // Update the slug unless the slug looks to be a custom slug or the title is a default/has been cleared out + if ( + (currentSlug && slugify(currentTitle) !== currentSlug) + && !(currentTitle === DEFAULT_TITLE || currentTitle?.endsWith(DUPLICATED_POST_TITLE_SUFFIX)) + ) { return; } try { - let slug = yield this.slugGenerator.generateSlug('post', title); + const newSlug = yield this.slugGenerator.generateSlug('post', newTitle); - if (!isBlank(slug)) { - this.set('post.slug', slug); + if (!isBlank(newSlug)) { + this.set('post.slug', newSlug); } } catch (error) { // Nothing to do (would be nice to log this somewhere though), @@ -889,7 +933,7 @@ export default class LexicalEditorController extends Controller { *backgroundLoaderTask() { yield this.store.query('snippet', {limit: 'all'}); - if (this.post.displayName === 'page' && this.feature.get('collections') && this.feature.get('collectionsCard')) { + if (this.post?.displayName === 'page' && this.feature.get('collections') && this.feature.get('collectionsCard')) { yield this.store.query('collection', {limit: 'all'}); } @@ -1064,7 +1108,8 @@ export default class LexicalEditorController extends Controller { && (state.isSaving || !state.hasDirtyAttributes); // If leaving the editor and the post has changed since we last saved a revision (and it's not deleted), always save a new revision - if (!this._saveOnLeavePerformed && hasChangedSinceLastRevision && hasDirtyAttributes && !state.isDeleted) { + // but we should never autosave when leaving published or soon-to-be published content (scheduled); this should require the user to intervene + if (!this._saveOnLeavePerformed && hasChangedSinceLastRevision && hasDirtyAttributes && !state.isDeleted && post.get('status') === 'draft') { transition.abort(); if (this._autosaveRunning) { this.cancelAutosave(); @@ -1103,6 +1148,7 @@ export default class LexicalEditorController extends Controller { if (this.post) { Object.assign(this._leaveModalReason, {status: this.post.status}); } + Sentry.captureMessage('showing leave editor modal', {extra: this._leaveModalReason}); console.log('showing leave editor modal', this._leaveModalReason); // eslint-disable-line const reallyLeave = await this.modals.open(ConfirmEditorLeaveModal); @@ -1200,8 +1246,7 @@ export default class LexicalEditorController extends Controller { return false; } - // if the Adapter failed to save the post isError will be true - // and we should consider the post still dirty. + // If the Adapter failed to save the post, isError will be true, and we should consider the post still dirty. if (post.get('isError')) { this._leaveModalReason = {reason: 'isError', context: post.errors.messages}; return true; @@ -1216,40 +1261,53 @@ export default class LexicalEditorController extends Controller { return true; } - // titleScratch isn't an attr so needs a manual dirty check + // Title scratch comparison if (post.titleScratch !== post.title) { this._leaveModalReason = {reason: 'title is different', context: {current: post.title, scratch: post.titleScratch}}; return true; } - // scratch isn't an attr so needs a manual dirty check + // Lexical and scratch comparison let lexical = post.get('lexical'); let scratch = post.get('lexicalScratch'); - // additional guard in case we are trying to compare null with undefined - if (scratch || lexical) { - if (scratch !== lexical) { - this._leaveModalReason = {reason: 'lexical is different', context: {current: lexical, scratch}}; - return true; - } + let secondaryLexical = post.get('secondaryLexicalState'); + + let lexicalChildNodes = lexical ? JSON.parse(lexical).root?.children : []; + let scratchChildNodes = scratch ? JSON.parse(scratch).root?.children : []; + let secondaryLexicalChildNodes = secondaryLexical ? JSON.parse(secondaryLexical).root?.children : []; + + lexicalChildNodes.forEach(child => child.direction = null); + scratchChildNodes.forEach(child => child.direction = null); + secondaryLexicalChildNodes.forEach(child => child.direction = null); + + // Compare initLexical with scratch + let isSecondaryDirty = secondaryLexical && scratch && JSON.stringify(secondaryLexicalChildNodes) !== JSON.stringify(scratchChildNodes); + + // Compare lexical with scratch + let isLexicalDirty = lexical && scratch && JSON.stringify(lexicalChildNodes) !== JSON.stringify(scratchChildNodes); + + // If both comparisons are dirty, consider the post dirty + if (isSecondaryDirty && isLexicalDirty) { + this._leaveModalReason = {reason: 'initLexical and lexical are different from scratch', context: {secondaryLexical, lexical, scratch}}; + return true; } - // new+unsaved posts always return `hasDirtyAttributes: true` + // New+unsaved posts always return `hasDirtyAttributes: true` // so we need a manual check to see if any if (post.get('isNew')) { - let changedAttributes = Object.keys(post.changedAttributes()); - + let changedAttributes = Object.keys(post.changedAttributes() || {}); if (changedAttributes.length) { this._leaveModalReason = {reason: 'post.changedAttributes.length > 0', context: post.changedAttributes()}; } return changedAttributes.length ? true : false; } - // we've covered all the non-tracked cases we care about so fall + // We've covered all the non-tracked cases we care about so fall // back on Ember Data's default dirty attribute checks let {hasDirtyAttributes} = post; - if (hasDirtyAttributes) { this._leaveModalReason = {reason: 'post.hasDirtyAttributes === true', context: post.changedAttributes()}; + return true; } return hasDirtyAttributes; diff --git a/ghost/admin/app/controllers/member.js b/ghost/admin/app/controllers/member.js index d93e04dbfc..0978126d08 100644 --- a/ghost/admin/app/controllers/member.js +++ b/ghost/admin/app/controllers/member.js @@ -212,6 +212,7 @@ export default class MemberController extends Controller { member.hasValidated.pushObject(payloadError.property); } } + return; } throw error; diff --git a/ghost/admin/app/controllers/members.js b/ghost/admin/app/controllers/members.js index 45598c6d5c..9279418d23 100644 --- a/ghost/admin/app/controllers/members.js +++ b/ghost/admin/app/controllers/members.js @@ -7,11 +7,11 @@ import ghostPaths from 'ghost-admin/utils/ghost-paths'; import moment from 'moment-timezone'; import {A} from '@ember/array'; import {action} from '@ember/object'; +import {didCancel, task, timeout} from 'ember-concurrency'; import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize'; import {inject} from 'ghost-admin/decorators/inject'; import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params'; import {inject as service} from '@ember/service'; -import {task, timeout} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; const PAID_PARAMS = [{ @@ -209,6 +209,46 @@ export default class MembersController extends Controller { return uniqueColumns.splice(0, 2); // Maximum 2 columns } + /* Due to a limitation with NQL when multiple member filters are used in combination, we currently have a safeguard around member bulk deletion. + * Member bulk deletion is not permitted when: + * 1) Multiple newsletters exist, and 2 or more newsletter filters are in use + * 2) If any of the following Stripe filters are used, even once: + * - Billing period + * - Stripe subscription status + * - Paid start date + * - Next billing date + * - Subscription started on post/page + * - Offers + * + * See issue https://linear.app/tryghost/issue/ENG-1484 for more context + */ + get isBulkDeletePermitted() { + if (!this.isFiltered) { + return false; + } + + const newsletterFilters = this.filters.filter(f => f.group === 'Newsletters'); + + if (newsletterFilters && newsletterFilters.length >= 2) { + return false; + } + + const stripeFilters = this.filters.filter(f => [ + 'subscriptions.plan_interval', + 'subscriptions.status', + 'subscriptions.start_date', + 'subscriptions.current_period_end', + 'conversion', + 'offer_redemptions' + ].includes(f.type)); + + if (stripeFilters && stripeFilters.length >= 1) { + return false; + } + + return true; + } + includeTierQuery() { const availableFilters = this.filters.length ? this.filters : this.softFilters; return availableFilters.some((f) => { @@ -258,8 +298,18 @@ export default class MembersController extends Controller { @action refreshData() { - this.fetchMembersTask.perform(); - this.fetchLabelsTask.perform(); + try { + this.fetchMembersTask.perform(); + this.fetchLabelsTask.perform(); + } catch (e) { + // Do not throw cancellation errors + if (didCancel(e)) { + return; + } + + throw e; + } + this.membersStats.invalidate(); this.membersStats.fetchCounts(); this.membersStats.fetchMemberCount(); @@ -415,7 +465,7 @@ export default class MembersController extends Controller { this.searchParam = query; } - @task + @task({restartable: true}) *fetchLabelsTask() { yield this.store.query('label', {limit: 'all'}); } diff --git a/ghost/admin/app/controllers/offer.js b/ghost/admin/app/controllers/offer.js index 3fe7e73c22..5949efe9f8 100644 --- a/ghost/admin/app/controllers/offer.js +++ b/ghost/admin/app/controllers/offer.js @@ -4,11 +4,11 @@ import UnarchiveOfferModal from '../components/modals/offers/unarchive'; import config from 'ghost-admin/config/environment'; import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard'; import {action} from '@ember/object'; +import {didCancel, task} from 'ember-concurrency'; import {getSymbol} from 'ghost-admin/utils/currency'; import {inject} from 'ghost-admin/decorators/inject'; import {inject as service} from '@ember/service'; import {slugify} from '@tryghost/string'; -import {task} from 'ember-concurrency'; import {timeout} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; @@ -258,7 +258,16 @@ export default class OffersController extends Controller { @action setup() { - this.fetchTiers.perform(); + try { + this.fetchTiers.perform(); + } catch (e) { + // Do not throw cancellation errors + if (didCancel(e)) { + return; + } + + throw e; + } } @action diff --git a/ghost/admin/app/controllers/pages.js b/ghost/admin/app/controllers/pages.js index 6a011d1564..cd62cc80b5 100644 --- a/ghost/admin/app/controllers/pages.js +++ b/ghost/admin/app/controllers/pages.js @@ -40,4 +40,4 @@ export default class PagesController extends PostsController { openEditor(page) { this.router.transitionTo('lexical-editor.edit', 'page', page.get('id')); } -} +} \ No newline at end of file diff --git a/ghost/admin/app/controllers/posts.js b/ghost/admin/app/controllers/posts.js index 014cad0f47..8f48fca2db 100644 --- a/ghost/admin/app/controllers/posts.js +++ b/ghost/admin/app/controllers/posts.js @@ -1,5 +1,5 @@ import Controller from '@ember/controller'; -import SelectionList from 'ghost-admin/utils/selection-list'; +import SelectionList from 'ghost-admin/components/posts-list/selection-list'; import {DEFAULT_QUERY_PARAMS} from 'ghost-admin/helpers/reset-query-params'; import {action} from '@ember/object'; import {inject} from 'ghost-admin/decorators/inject'; @@ -85,14 +85,6 @@ export default class PostsController extends Controller { Object.assign(this, DEFAULT_QUERY_PARAMS.posts); } - get postsInfinityModel() { - return this.model; - } - - get totalPosts() { - return this.model.meta?.pagination?.total ?? 0; - } - get showingAll() { const {type, author, tag, visibility} = this; diff --git a/ghost/admin/app/controllers/reset.js b/ghost/admin/app/controllers/reset.js index 0c045c9762..2ce6090ee8 100644 --- a/ghost/admin/app/controllers/reset.js +++ b/ghost/admin/app/controllers/reset.js @@ -62,7 +62,10 @@ export default class ResetController extends Controller.extend(ValidationEngine) password_reset: [{newPassword, ne2Password, token}] } }); - this.notifications.showAlert(resp.password_reset[0].message, {type: 'warn', delayed: true, key: 'password.reset'}); + this.notifications.showNotification( + resp.password_reset[0].message, + {type: 'info', delayed: true, key: 'password.reset'} + ); this.session.authenticate('authenticator:cookie', email, newPassword); return true; } catch (error) { diff --git a/ghost/admin/app/controllers/setup/done.js b/ghost/admin/app/controllers/setup/done.js deleted file mode 100644 index bd2480fc79..0000000000 --- a/ghost/admin/app/controllers/setup/done.js +++ /dev/null @@ -1,72 +0,0 @@ -import Controller from '@ember/controller'; -import {isEmpty} from '@ember/utils'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -const THEME_PROPERTIES = { - casper: ['description', 'color', 'coverImage'], - source: ['description', 'color', 'coverImage'], - edition: ['description', 'color', 'coverImage'], - dawn: ['description', 'color', 'icon'], - dope: ['description', 'color', 'logo'], - bulletin: ['description', 'color', 'logo'], - alto: ['description', 'color', 'logo'], - journal: ['description', 'color', 'logo'], - wave: ['description', 'color', 'logo', 'coverImage'], - edge: ['description', 'color', 'logo'], - ease: ['description', 'color', 'logo', 'coverImage'], - ruby: ['description', 'color', 'logo', 'coverImage'], - london: ['description', 'color', 'logo'], - digest: ['description', 'color', 'logo'] -}; - -export default class SetupFinishingTouchesController extends Controller { - @service modals; - @service router; - @service settings; - @service store; - @service themeManagement; - - @tracked descriptionVisible; - @tracked colorVisible; - @tracked logoVisible; - @tracked iconVisible; - @tracked coverImageVisible; - - themes = null; - - // Default properties to display - themeProperties = ['description', 'color', 'coverImage']; - - constructor() { - super(...arguments); - this.initThemeProperties.perform(); - } - - @task({drop: true}) - *initThemeProperties() { - this.themes = yield this.store.peekAll('theme'); - if (isEmpty(this.themes)) { - this.themes = yield this.store.findAll('theme'); - } - - const activeTheme = this.themes.findBy('active', true); - - if (activeTheme && THEME_PROPERTIES[activeTheme.name]) { - this.themeProperties = THEME_PROPERTIES[activeTheme.name]; - } - - this.descriptionVisible = this.themeProperties.includes('description'); - this.logoVisible = this.themeProperties.includes('logo'); - this.iconVisible = this.themeProperties.includes('icon'); - this.colorVisible = this.themeProperties.includes('color'); - this.coverImageVisible = this.themeProperties.includes('coverImage'); - } - - @task - *saveAndContinueTask() { - yield this.settings.save(); - this.router.transitionTo('home'); - } -} diff --git a/ghost/admin/app/controllers/signin.js b/ghost/admin/app/controllers/signin.js index 3ef67c2569..1c4531cf3a 100644 --- a/ghost/admin/app/controllers/signin.js +++ b/ghost/admin/app/controllers/signin.js @@ -25,6 +25,7 @@ export default class SigninController extends Controller.extend(ValidationEngine @tracked submitting = false; @tracked loggingIn = false; + @tracked flowNotification = ''; @tracked flowErrors = ''; @tracked passwordResetEmailSent = false; @@ -123,21 +124,19 @@ export default class SigninController extends Controller.extend(ValidationEngine let notifications = this.notifications; this.flowErrors = ''; + this.flowNotification = ''; // This is a bit dirty, but there's no other way to ensure the properties are set as well as 'forgotPassword' this.hasValidated.addObject('identification'); try { yield this.validate({property: 'forgotPassword'}); yield this.ajax.post(forgottenUrl, {data: {password_reset: [{email}]}}); - notifications.showAlert( - 'Please check your email for instructions.', - {type: 'info', key: 'forgot-password.send.success'} - ); + this.flowNotification = 'An email with password reset instructions has been sent.'; return true; } catch (error) { // ValidationEngine throws "undefined" for failed validation if (!error) { - return this.flowErrors = 'We need your email address to reset your password!'; + return this.flowErrors = 'We need your email address to reset your password.'; } if (isVersionMismatchError(error)) { diff --git a/ghost/admin/app/controllers/websockets.js b/ghost/admin/app/controllers/websockets.js deleted file mode 100644 index 148a720962..0000000000 --- a/ghost/admin/app/controllers/websockets.js +++ /dev/null @@ -1,13 +0,0 @@ -import Controller from '@ember/controller'; -/* eslint-disable ghost/ember/alias-model-in-controller */ -import classic from 'ember-classic-decorator'; -import {inject as service} from '@ember/service'; - -@classic -export default class WebsocketsController extends Controller { - @service feature; - - init() { - super.init(...arguments); - } -} \ No newline at end of file diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js index f0581eb3b6..8a3a045633 100644 --- a/ghost/admin/app/helpers/parse-member-event.js +++ b/ghost/admin/app/helpers/parse-member-event.js @@ -114,6 +114,10 @@ export default class ParseMemberEventHelper extends Helper { icon = 'subscriptions'; } + if (event.type === 'email_change_event') { + icon = 'email-changed'; + } + return 'event-' + icon; } @@ -208,8 +212,15 @@ export default class ParseMemberEventHelper extends Helper { return 'less like this'; } + if (event.type === 'email_change_event') { + if (event.data.from_email && event.data.to_email) { + return `Email address changed from ${event.data.from_email} to ${event.data.to_email}`; + } + return 'Email address changed'; + } + if (event.type === 'donation_event') { - return `Made a one-time payment`; + return 'Made a one-time payment'; } } @@ -330,7 +341,7 @@ export default class ParseMemberEventHelper extends Helper { * Get internal route props for a clickable object */ getRoute(event) { - if (['comment_event', 'click_event', 'feedback_event'].includes(event.type)) { + if (['click_event', 'feedback_event'].includes(event.type)) { if (event.data.post) { return { name: 'posts.analytics', diff --git a/ghost/admin/app/models/newsletter.js b/ghost/admin/app/models/newsletter.js index 4c725c0399..041b10f500 100644 --- a/ghost/admin/app/models/newsletter.js +++ b/ghost/admin/app/models/newsletter.js @@ -24,6 +24,7 @@ export default class Newsletter extends Model.extend(ValidationEngine) { @attr({defaultValue: true}) showHeaderTitle; @attr({defaultValue: true}) showHeaderName; @attr({defaultValue: true}) showPostTitleSection; + @attr({defaultValue: false}) showExcerpt; @attr({defaultValue: true}) showCommentCta; @attr({defaultValue: false}) showSubscriptionDetails; @attr({defaultValue: false}) showLatestPosts; diff --git a/ghost/admin/app/models/post-revision.js b/ghost/admin/app/models/post-revision.js index 689811f8c8..c0a42b2e97 100644 --- a/ghost/admin/app/models/post-revision.js +++ b/ghost/admin/app/models/post-revision.js @@ -3,6 +3,7 @@ import Model, {attr, belongsTo} from '@ember-data/model'; export default class PostRevisionModel extends Model { @attr('string') lexical; @attr('string') title; + @attr('string') customExcerpt; @attr('string') featureImage; @attr('string') featureImageAlt; @attr('string') featureImageCaption; diff --git a/ghost/admin/app/models/post.js b/ghost/admin/app/models/post.js index 1ffb06d8d0..835d24d0a2 100644 --- a/ghost/admin/app/models/post.js +++ b/ghost/admin/app/models/post.js @@ -136,6 +136,9 @@ export default Model.extend(Comparable, ValidationEngine, { scratch: null, lexicalScratch: null, titleScratch: null, + //This is used to store the initial lexical state from the + // secondary editor to get the schema up to date in case its outdated + secondaryLexicalState: null, // For use by date/time pickers - will be validated then converted to UTC // on save. Updated by an observer whenever publishedAtUTC changes. diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 07ba4f1da8..758f09ebb6 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -60,9 +60,6 @@ Router.map(function () { this.route('activitypub-x', {path: '/*sub'}); }); - // testing websockets - this.route('websockets'); - this.route('explore', function () { // actual Ember route, not rendered in iframe this.route('connect'); diff --git a/ghost/admin/app/routes/application.js b/ghost/admin/app/routes/application.js index f76207c5b2..49a3362a03 100644 --- a/ghost/admin/app/routes/application.js +++ b/ghost/admin/app/routes/application.js @@ -8,6 +8,7 @@ import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route'; import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; import windowProxy from 'ghost-admin/utils/window-proxy'; import {Debug} from '@sentry/integrations'; +import {Replay} from '@sentry/replay'; import {beforeSend} from 'ghost-admin/utils/sentry'; import {importComponent} from '../components/admin-x/admin-x-component'; import {inject} from 'ghost-admin/decorators/inject'; @@ -184,21 +185,54 @@ export default Route.extend(ShortcutsRoute, { release: `ghost@${this.config.version}`, beforeSend, ignoreErrors: [ + // Browser autoplay policies (this regex covers a few) + /The play\(\) request was interrupted.*/, + /The request is not allowed by the user agent or the platform in the current context/, + + // Network errors that we don't control + /Server was unreachable/, + /NetworkError when attempting to fetch resource./, + /Failed to fetch/, + /Load failed/, + /The operation was aborted./, + // TransitionAborted errors surface from normal application behaviour // - https://github.com/emberjs/ember.js/issues/12505 /^TransitionAborted$/, // ResizeObserver loop errors occur often from extensions and // embedded content, generally harmless and not useful to report - /^ResizeObserver loop completed with undelivered notifications/ + /^ResizeObserver loop completed with undelivered notifications/, + /^ResizeObserver loop limit exceeded/, + // When tasks in ember-concurrency are canceled, they sometimes lead to unhandled Promise rejections + // This doesn't affect the application and is not useful to report + // - http://ember-concurrency.com/docs/cancelation + 'TaskCancelation' ], + integrations: [] + }; + try { // Session Replay on errors // Docs: https://docs.sentry.io/platforms/javascript/session-replay - replaysOnErrorSampleRate: 1.0 + sentryConfig.replaysOnErrorSampleRate = 0.5; + sentryConfig.integrations.push( + // Replace with `Sentry.replayIntegration()` once we've migrated to @sentry/ember 8.x + // Docs: https://docs.sentry.io/platforms/javascript/migration/v7-to-v8/#removal-of-sentryreplay-package + new Replay({ + mask: ['.koenig-lexical', '.gh-dashboard'], + unmask: ['[role="menu"]', '[data-testid="settings-panel"]', '.gh-nav'], + maskAllText: false, + maskAllInputs: true, + blockAllMedia: true + }) + ); + } catch (e) { + // no-op, Session Replay is not critical + console.error('Error enabling Sentry Replay:', e); // eslint-disable-line no-console + } - }; if (this.config.sentry_env === 'development') { - sentryConfig.integrations = [new Debug()]; + sentryConfig.integrations.push(new Debug()); } Sentry.init(sentryConfig); } diff --git a/ghost/admin/app/routes/collection.js b/ghost/admin/app/routes/collection.js index 7110da473f..f19f609f76 100644 --- a/ghost/admin/app/routes/collection.js +++ b/ghost/admin/app/routes/collection.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/ember'; import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; import {action} from '@ember/object'; @@ -77,6 +78,7 @@ export default class CollectionRoute extends AuthenticatedRoute { async confirmUnsavedChanges() { if (this.controller.model?.hasDirtyAttributes) { + Sentry.captureMessage('showing unsaved changes modal for collections route'); this.confirmModal = this.modals .open(ConfirmUnsavedChangesModal) .finally(() => { diff --git a/ghost/admin/app/routes/member.js b/ghost/admin/app/routes/member.js index b0b245376b..ab9bdc8c75 100644 --- a/ghost/admin/app/routes/member.js +++ b/ghost/admin/app/routes/member.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/ember'; import AdminRoute from 'ghost-admin/routes/admin'; import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; import {action} from '@ember/object'; @@ -96,6 +97,7 @@ export default class MembersRoute extends AdminRoute { } async confirmUnsavedChanges() { + Sentry.captureMessage('showing unsaved changes modal for members route'); this.confirmModal = this.modals .open(ConfirmUnsavedChangesModal) .finally(() => { diff --git a/ghost/admin/app/routes/members.js b/ghost/admin/app/routes/members.js index 67207e2620..235ed2d67d 100644 --- a/ghost/admin/app/routes/members.js +++ b/ghost/admin/app/routes/members.js @@ -1,4 +1,5 @@ import AdminRoute from 'ghost-admin/routes/admin'; +import {didCancel} from 'ember-concurrency'; import {inject as service} from '@ember/service'; export default class MembersRoute extends AdminRoute { @@ -27,7 +28,17 @@ export default class MembersRoute extends AdminRoute { // trigger a background load of members plus labels for filter dropdown setupController(controller) { super.setupController(...arguments); - controller.fetchLabelsTask.perform(); + + try { + controller.fetchLabelsTask.perform(); + } catch (e) { + // Do not throw cancellation errors + if (didCancel(e)) { + return; + } + + throw e; + } } resetController(controller, _isExiting, transition) { diff --git a/ghost/admin/app/routes/posts.js b/ghost/admin/app/routes/posts.js index 93e7d5d4ab..a1c11aac27 100644 --- a/ghost/admin/app/routes/posts.js +++ b/ghost/admin/app/routes/posts.js @@ -1,4 +1,5 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import RSVP from 'rsvp'; import {action} from '@ember/object'; import {assign} from '@ember/polyfills'; import {isBlank} from '@ember/utils'; @@ -39,43 +40,54 @@ export default class PostsRoute extends AuthenticatedRoute { model(params) { const user = this.session.user; - let queryParams = {}; let filterParams = {tag: params.tag, visibility: params.visibility}; let paginationParams = { perPageParam: 'limit', totalPagesParam: 'meta.pagination.pages' }; - + + // type filters are actually mapping statuses assign(filterParams, this._getTypeFilters(params.type)); - + if (params.type === 'featured') { filterParams.featured = true; } - + + // authors and contributors can only view their own posts if (user.isAuthor) { - // authors can only view their own posts filterParams.authors = user.slug; } else if (user.isContributor) { - // Contributors can only view their own draft posts filterParams.authors = user.slug; - // filterParams.status = 'draft'; + // otherwise we need to filter by author if present } else if (params.author) { filterParams.authors = params.author; } - - let filter = this._filterString(filterParams); - if (!isBlank(filter)) { - queryParams.filter = filter; - } - - if (!isBlank(params.order)) { - queryParams.order = params.order; - } - + let perPage = this.perPage; - let paginationSettings = assign({perPage, startingPage: 1}, paginationParams, queryParams); + + const filterStatuses = filterParams.status; + let queryParams = {allFilter: this._filterString({...filterParams})}; // pass along the parent filter so it's easier to apply the params filter to each infinity model + let models = {}; - return this.infinity.model(this.modelName, paginationSettings); + if (filterStatuses.includes('scheduled')) { + let scheduledInfinityModelParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: 'scheduled'})}; + models.scheduledInfinityModel = this.infinity.model(this.modelName, assign({perPage, startingPage: 1}, paginationParams, scheduledInfinityModelParams)); + } + if (filterStatuses.includes('draft')) { + let draftInfinityModelParams = {...queryParams, order: params.order || 'updated_at desc', filter: this._filterString({...filterParams, status: 'draft'})}; + models.draftInfinityModel = this.infinity.model(this.modelName, assign({perPage, startingPage: 1}, paginationParams, draftInfinityModelParams)); + } + if (filterStatuses.includes('published') || filterStatuses.includes('sent')) { + let publishedAndSentInfinityModelParams; + if (filterStatuses.includes('published') && filterStatuses.includes('sent')) { + publishedAndSentInfinityModelParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: '[published,sent]'})}; + } else { + publishedAndSentInfinityModelParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: filterStatuses.includes('published') ? 'published' : 'sent'})}; + } + models.publishedAndSentInfinityModel = this.infinity.model(this.modelName, assign({perPage, startingPage: 1}, paginationParams, publishedAndSentInfinityModelParams)); + } + + return RSVP.hash(models); } // trigger a background load of all tags and authors for use in filter dropdowns @@ -120,6 +132,12 @@ export default class PostsRoute extends AuthenticatedRoute { }; } + /** + * Returns an object containing the status filter based on the given type. + * + * @param {string} type - The type of filter to generate (draft, published, scheduled, sent). + * @returns {Object} - An object containing the status filter. + */ _getTypeFilters(type) { let status = '[draft,scheduled,published,sent]'; diff --git a/ghost/admin/app/routes/setup/done.js b/ghost/admin/app/routes/setup/done.js index ced49eb2f6..caca0c63e9 100644 --- a/ghost/admin/app/routes/setup/done.js +++ b/ghost/admin/app/routes/setup/done.js @@ -12,23 +12,10 @@ export default class SetupFinishingTouchesRoute extends AuthenticatedRoute { beforeModel() { super.beforeModel(...arguments); - if (!this.session.user.isOwnerOnly) { - return; - } - - if (this.feature.onboardingChecklist) { + if (this.session.user.isOwnerOnly) { this.onboarding.startChecklist(); - return this.router.transitionTo('dashboard'); } - } - model() { - this.themeManagement.setPreviewType('homepage'); - this.themeManagement.updatePreviewHtmlTask.perform(); - } - - deactivate() { - // rollback any unsaved setting changes when leaving - this.settings.rollbackAttributes(); + return this.router.transitionTo('dashboard'); } } diff --git a/ghost/admin/app/routes/setup/index.js b/ghost/admin/app/routes/setup/index.js index 827eb3d8af..9bc7190de9 100644 --- a/ghost/admin/app/routes/setup/index.js +++ b/ghost/admin/app/routes/setup/index.js @@ -1,8 +1,11 @@ import Route from '@ember/routing/route'; +import {inject as service} from '@ember/service'; export default class IndexRoute extends Route { + @service router; + beforeModel() { super.beforeModel(...arguments); - this.transitionTo('setup.one'); + this.router.transitionTo('setup.one'); } } diff --git a/ghost/admin/app/routes/tag.js b/ghost/admin/app/routes/tag.js index ada63bba91..17443be1dc 100644 --- a/ghost/admin/app/routes/tag.js +++ b/ghost/admin/app/routes/tag.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/ember'; import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; import {action} from '@ember/object'; @@ -77,6 +78,7 @@ export default class TagRoute extends AuthenticatedRoute { async confirmUnsavedChanges() { if (this.controller.model?.hasDirtyAttributes) { + Sentry.captureMessage('showing unsaved changes modal for tags route'); this.confirmModal = this.modals .open(ConfirmUnsavedChangesModal) .finally(() => { diff --git a/ghost/admin/app/routes/websockets.js b/ghost/admin/app/routes/websockets.js deleted file mode 100644 index a01ad7a824..0000000000 --- a/ghost/admin/app/routes/websockets.js +++ /dev/null @@ -1,18 +0,0 @@ -import AuthenticatedRoute from './authenticated'; -import {inject as service} from '@ember/service'; - -// need this to be authenticated -export default class WebsocketRoute extends AuthenticatedRoute { - @service session; - @service router; - - beforeModel() { - super.beforeModel(...arguments); - - const user = this.session.user; - - if (!user.isAdmin) { - return this.router.transitionTo('settings-x.settings-x', `staff/${user.slug}`); - } - } -} diff --git a/ghost/admin/app/serializers/post-revision.js b/ghost/admin/app/serializers/post-revision.js index ff94d1e54c..7b281fdd58 100644 --- a/ghost/admin/app/serializers/post-revision.js +++ b/ghost/admin/app/serializers/post-revision.js @@ -5,15 +5,6 @@ import {EmbeddedRecordsMixin} from '@ember-data/serializer/rest'; export default class PostRevisionSerializer extends ApplicationSerializer.extend(EmbeddedRecordsMixin) { // settings for the EmbeddedRecordsMixin. attrs = { - author: {embedded: 'always'}, - lexical: {key: 'lexical'}, - title: {key: 'title'}, - createdAt: {key: 'created_at'}, - postStatus: {key: 'post_status'}, - reason: {key: 'reason'}, - featureImage: {key: 'feature_image'}, - featureImageAlt: {key: 'feature_image_alt'}, - featureImageCaption: {key: 'feature_image_caption'}, - postIdLocal: {key: 'post_id'} + author: {embedded: 'always'} }; } diff --git a/ghost/admin/app/services/dashboard-stats.js b/ghost/admin/app/services/dashboard-stats.js index e23b17b51a..c55d5f5363 100644 --- a/ghost/admin/app/services/dashboard-stats.js +++ b/ghost/admin/app/services/dashboard-stats.js @@ -3,6 +3,8 @@ import moment from 'moment-timezone'; import {task} from 'ember-concurrency'; import {tracked} from '@glimmer/tracking'; +import mergeStatsByDate from 'ghost-admin/utils/merge-stats-by-date'; + /** * @typedef MrrStat * @type {Object} @@ -241,14 +243,16 @@ export default class DashboardStatsService extends Service { return []; } + const firstChartDay = moment().add(-this.chartDays, 'days').format('YYYY-MM-DD'); + return this.memberAttributionStats.filter((stat) => { if (this.chartDays === 'all') { return true; } - return stat.date >= moment().add(-this.chartDays, 'days').format('YYYY-MM-DD'); + return stat.date >= firstChartDay; }).reduce((acc, stat) => { const statSource = stat.source ?? ''; - const existingSource = acc.find(s => s.source === statSource); + const existingSource = acc.find(s => s.source.toLowerCase() === statSource.toLowerCase()); if (existingSource) { existingSource.signups += stat.signups || 0; existingSource.paidConversions += stat.paidConversions || 0; @@ -461,39 +465,7 @@ export default class DashboardStatsService extends Service { } } - function mergeDates(list, entry) { - const [current, ...rest] = list; - - if (!current) { - return entry ? [entry] : []; - } - - if (!entry) { - return mergeDates(rest, { - date: current.date, - count: current.count, - positiveDelta: current.positive_delta, - negativeDelta: current.negative_delta, - signups: current.signups, - cancellations: current.cancellations - }); - } - - if (current.date === entry.date) { - return mergeDates(rest, { - date: entry.date, - count: entry.count + current.count, - positiveDelta: entry.positiveDelta + current.positive_delta, - negativeDelta: entry.negativeDelta + current.negative_delta, - signups: entry.signups + current.signups, - cancellations: entry.cancellations + current.cancellations - }); - } - - return [entry].concat(mergeDates(list)); - } - - const subscriptionCountStats = mergeDates(result.stats); + const subscriptionCountStats = mergeStatsByDate(result.stats); this.paidMembersByCadence = paidMembersByCadence; this.paidMembersByTier = paidMembersByTier; @@ -524,7 +496,7 @@ export default class DashboardStatsService extends Service { this.memberCountStats = this.dashboardMocks.memberCountStats; return; } - + const stats = yield this.membersStats.fetchMemberCount(); this.memberCountStats = stats.stats.map((d) => { return { diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index 318a398807..14be59d768 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -63,26 +63,23 @@ export default class FeatureService extends Service { @feature('lexicalMultiplayer') lexicalMultiplayer; @feature('audienceFeedback') audienceFeedback; @feature('webmentions') webmentions; - @feature('websockets') websockets; @feature('stripeAutomaticTax') stripeAutomaticTax; @feature('emailCustomization') emailCustomization; @feature('i18n') i18n; @feature('announcementBar') announcementBar; @feature('signupCard') signupCard; - @feature('signupForm') signupForm; @feature('collections') collections; @feature('mailEvents') mailEvents; @feature('collectionsCard') collectionsCard; @feature('importMemberTier') importMemberTier; @feature('tipsAndDonations') tipsAndDonations; - @feature('recommendations') recommendations; @feature('lexicalIndicators') lexicalIndicators; - @feature('filterEmailDisabled') filterEmailDisabled; @feature('adminXDemo') adminXDemo; - @feature('portalImprovements') portalImprovements; - @feature('onboardingChecklist') onboardingChecklist; @feature('ActivityPub') ActivityPub; - @feature('internalLinking') internalLinking; + @feature('editorExcerpt') editorExcerpt; + @feature('contentVisibility') contentVisibility; + @feature('publishFlowEndScreen') publishFlowEndScreen; + @feature('postAnalyticsRefresh') postAnalyticsRefresh; _user = null; diff --git a/ghost/admin/app/services/onboarding.js b/ghost/admin/app/services/onboarding.js index 56f8d868f8..d5a8d5ce3a 100644 --- a/ghost/admin/app/services/onboarding.js +++ b/ghost/admin/app/services/onboarding.js @@ -7,7 +7,6 @@ const EMPTY_SETTINGS = { }; export default class OnboardingService extends Service { - @service feature; @service session; ONBOARDING_STEPS = [ @@ -24,8 +23,7 @@ export default class OnboardingService extends Service { } get isChecklistShown() { - return this.feature.onboardingChecklist - && this.session.user.isOwnerOnly + return this.session.user.isOwnerOnly && this.checklistStarted && !this.checklistCompleted && !this.checklistDismissed; diff --git a/ghost/admin/app/services/search-provider-basic.js b/ghost/admin/app/services/search-provider-basic.js new file mode 100644 index 0000000000..cb73ee2820 --- /dev/null +++ b/ghost/admin/app/services/search-provider-basic.js @@ -0,0 +1,116 @@ +import RSVP from 'rsvp'; +import Service from '@ember/service'; +import {isEmpty} from '@ember/utils'; +import {pluralize} from 'ember-inflector'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +export const SEARCHABLES = [ + { + name: 'Staff', + model: 'user', + fields: ['id', 'slug', 'url', 'name'], // id not used but required for API to have correct url + idField: 'slug', + titleField: 'name' + }, + { + name: 'Tags', + model: 'tag', + fields: ['slug', 'url', 'name'], + idField: 'slug', + titleField: 'name' + }, + { + name: 'Posts', + model: 'post', + fields: ['id', 'url', 'title', 'status', 'published_at', 'visibility'], + idField: 'id', + titleField: 'title' + }, + { + name: 'Pages', + model: 'page', + fields: ['id', 'url', 'title', 'status', 'published_at', 'visibility'], + idField: 'id', + titleField: 'title' + } +]; + +export default class SearchProviderBasicService extends Service { + @service ajax; + @service notifications; + @service store; + + content = []; + + /* eslint-disable require-yield */ + @task + *searchTask(term) { + const normalizedTerm = term.toString().toLowerCase(); + const results = []; + + SEARCHABLES.forEach((searchable) => { + const matchedContent = this.content.filter((item) => { + const normalizedTitle = item.title.toString().toLowerCase(); + return ( + item.groupName === searchable.name && + normalizedTitle.indexOf(normalizedTerm) >= 0 + ); + }); + + if (!isEmpty(matchedContent)) { + results.push({ + groupName: searchable.name, + options: matchedContent + }); + } + }); + + return results; + } + /* eslint-enable require-yield */ + + @task + *refreshContentTask() { + const content = []; + const promises = SEARCHABLES.map(searchable => this._loadSearchable(searchable, content)); + + try { + yield RSVP.all(promises); + this.content = content; + } catch (error) { + // eslint-disable-next-line + console.error(error); + } + } + + async _loadSearchable(searchable, content) { + const url = `${this.store.adapterFor(searchable.model).urlForQuery({}, searchable.model)}/`; + const maxSearchableLimit = '10000'; + const query = {fields: searchable.fields, limit: maxSearchableLimit}; + + try { + const response = await this.ajax.request(url, {data: query}); + + const items = response[pluralize(searchable.model)].map( + item => ({ + id: `${searchable.model}.${item[searchable.idField]}`, + url: item.url, + title: item[searchable.titleField], + groupName: searchable.name, + status: item.status, + visibility: item.visibility, + publishedAt: item.published_at + }) + ); + + content.push(...items); + } catch (error) { + console.error(error); // eslint-disable-line + + this.notifications.showAPIError(error, { + key: `search.load${searchable.name}.error` + }); + } + } +} diff --git a/ghost/admin/app/services/search-provider-flex.js b/ghost/admin/app/services/search-provider-flex.js new file mode 100644 index 0000000000..92a1b5e6d1 --- /dev/null +++ b/ghost/admin/app/services/search-provider-flex.js @@ -0,0 +1,139 @@ +import RSVP from 'rsvp'; +import Service from '@ember/service'; +import {default as Flexsearch} from 'flexsearch'; +import {isEmpty} from '@ember/utils'; +import {pluralize} from 'ember-inflector'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; + +const {Document} = Flexsearch; + +export const SEARCHABLES = [ + { + name: 'Staff', + model: 'user', + fields: ['id', 'slug', 'url', 'name', 'profile_image'], + pathField: 'slug', + titleField: 'name', + index: ['name'] + }, + { + name: 'Tags', + model: 'tag', + fields: ['id', 'slug', 'url', 'name'], + pathField: 'slug', + titleField: 'name', + index: ['name'] + }, + { + name: 'Posts', + model: 'post', + fields: ['id', 'url', 'title', 'status', 'published_at', 'visibility'], + order: 'updated_at desc', // ensure we use a simple rather than default order for faster response + pathField: 'id', + titleField: 'title', + index: ['title'] + }, + { + name: 'Pages', + model: 'page', + fields: ['id', 'url', 'title', 'status', 'published_at', 'visibility'], + order: 'updated_at desc', // ensure we use a simple rather than default order for faster response + pathField: 'id', + titleField: 'title', + index: ['title'] + } +]; + +export default class SearchProviderFlexService extends Service { + @service ajax; + @service notifications; + @service store; + + indexes = SEARCHABLES.reduce((indexes, searchable) => { + indexes[searchable.model] = new Document({ + tokenize: 'forward', + document: { + id: 'id', + index: searchable.index, + store: true + } + }); + + return indexes; + }, {}); + + /* eslint-disable require-yield */ + @task + *searchTask(term) { + const results = []; + + SEARCHABLES.forEach((searchable) => { + const searchResults = this.indexes[searchable.model].search(term, {enrich: true}); + const usedIds = new Set(); + const groupResults = []; + + searchResults.forEach((field) => { + field.result.forEach((searchResult) => { + const {id, doc} = searchResult; + + if (usedIds.has(id)) { + return; + } + + usedIds.add(id); + + groupResults.push({ + groupName: searchable.name, + id: `${searchable.model}.${doc[searchable.pathField]}`, + title: doc[searchable.titleField], + url: doc.url, + status: doc.status, + visibility: doc.visibility, + publishedAt: doc.published_at + }); + }); + }); + + if (!isEmpty(groupResults)) { + results.push({ + groupName: searchable.name, + options: groupResults + }); + } + }); + + return results; + } + /* eslint-enable require-yield */ + + @task + *refreshContentTask() { + try { + const promises = SEARCHABLES.map(searchable => this.#loadSearchable(searchable)); + yield RSVP.all(promises); + } catch (error) { + // eslint-disable-next-line + console.error(error); + } + } + + async #loadSearchable(searchable) { + const url = `${this.store.adapterFor(searchable.model).urlForQuery({}, searchable.model)}/`; + const query = {fields: searchable.fields, limit: 10000, order: searchable.order}; + + try { + const response = await this.ajax.request(url, {data: query}); + + response[pluralize(searchable.model)].forEach((item) => { + this.indexes[searchable.model].add(item); + }); + } catch (error) { + console.error(error); // eslint-disable-line + + this.notifications.showAPIError(error, { + key: `search.load${searchable.name}.error` + }); + } + } +} diff --git a/ghost/admin/app/services/search.js b/ghost/admin/app/services/search.js index 9b5ea0508b..f8e3f9e500 100644 --- a/ghost/admin/app/services/search.js +++ b/ghost/admin/app/services/search.js @@ -1,49 +1,24 @@ -import RSVP from 'rsvp'; import Service from '@ember/service'; import {action} from '@ember/object'; -import {isBlank, isEmpty} from '@ember/utils'; -import {pluralize} from 'ember-inflector'; +import {isBlank} from '@ember/utils'; import {inject as service} from '@ember/service'; import {task, timeout} from 'ember-concurrency'; export default class SearchService extends Service { @service ajax; + @service feature; @service notifications; + @service searchProviderBasic; + @service searchProviderFlex; + @service settings; @service store; - content = []; isContentStale = true; - searchables = [ - { - name: 'Staff', - model: 'user', - fields: ['id', 'slug', 'url', 'name'], // id not used but required for API to have correct url - idField: 'slug', - titleField: 'name' - }, - { - name: 'Tags', - model: 'tag', - fields: ['slug', 'url', 'name'], - idField: 'slug', - titleField: 'name' - }, - { - name: 'Posts', - model: 'post', - fields: ['id', 'url', 'title', 'status', 'published_at', 'visibility'], - idField: 'id', - titleField: 'title' - }, - { - name: 'Pages', - model: 'page', - fields: ['id', 'url', 'title', 'status', 'published_at', 'visibility'], - idField: 'id', - titleField: 'title' - } - ]; + get provider() { + const isEnglish = this.settings.locale?.toLowerCase().startsWith('en') ?? true; + return isEnglish ? this.searchProviderFlex : this.searchProviderBasic; + } @action expireContent() { @@ -67,33 +42,7 @@ export default class SearchService extends Service { yield this.refreshContentTask.lastRunning; } - const searchResult = this._searchContent(term); - - return searchResult; - } - - _searchContent(term) { - const normalizedTerm = term.toString().toLowerCase(); - const results = []; - - this.searchables.forEach((searchable) => { - const matchedContent = this.content.filter((item) => { - const normalizedTitle = item.title.toString().toLowerCase(); - return ( - item.groupName === searchable.name && - normalizedTitle.indexOf(normalizedTerm) >= 0 - ); - }); - - if (!isEmpty(matchedContent)) { - results.push({ - groupName: searchable.name, - options: matchedContent - }); - } - }); - - return results; + return yield this.provider.searchTask.perform(term); } @task({drop: true}) @@ -104,47 +53,8 @@ export default class SearchService extends Service { this.isContentStale = true; - const content = []; - const promises = this.searchables.map(searchable => this._loadSearchable(searchable, content)); - - try { - yield RSVP.all(promises); - this.content = content; - } catch (error) { - // eslint-disable-next-line - console.error(error); - } + yield this.provider.refreshContentTask.perform(); this.isContentStale = false; } - - async _loadSearchable(searchable, content) { - const url = `${this.store.adapterFor(searchable.model).urlForQuery({}, searchable.model)}/`; - const maxSearchableLimit = '10000'; - const query = {fields: searchable.fields, limit: maxSearchableLimit}; - - try { - const response = await this.ajax.request(url, {data: query}); - - const items = response[pluralize(searchable.model)].map( - item => ({ - id: `${searchable.model}.${item[searchable.idField]}`, - url: item.url, - title: item[searchable.titleField], - groupName: searchable.name, - status: item.status, - visibility: item.visibility, - publishedAt: item.published_at - }) - ); - - content.push(...items); - } catch (error) { - console.error(error); // eslint-disable-line - - this.notifications.showAPIError(error, { - key: `search.load${searchable.name}.error` - }); - } - } } diff --git a/ghost/admin/app/styles/app-dark.css b/ghost/admin/app/styles/app-dark.css index 2d1f21d051..a6f40e77ff 100644 --- a/ghost/admin/app/styles/app-dark.css +++ b/ghost/admin/app/styles/app-dark.css @@ -744,6 +744,11 @@ input:focus, /* Editor */ +.gh-editor-title, +.gh-editor-excerpt { + background: var(--white); +} + .gh-editor-title::placeholder { color: var(--midlightgrey-d2); } @@ -1430,4 +1435,4 @@ Onboarding checklist: Share publication modal */ .gh-sidebar-banner.gh-error-banner { background: var(--lightgrey-d1); -} \ No newline at end of file +} diff --git a/ghost/admin/app/styles/components/dropdowns.css b/ghost/admin/app/styles/components/dropdowns.css index 099644e81d..96a1b808a1 100644 --- a/ghost/admin/app/styles/components/dropdowns.css +++ b/ghost/admin/app/styles/components/dropdowns.css @@ -393,7 +393,8 @@ Post context menu stroke-width: 1.8px; } -.gh-posts-context-menu li:last-child::before { +.gh-posts-context-menu li:last-child::before, +.gh-analytics-actions-menu li:last-child::before { display: block; position: relative; content: ""; diff --git a/ghost/admin/app/styles/components/notifications.css b/ghost/admin/app/styles/components/notifications.css index 8c008a4764..20fcb6e6da 100644 --- a/ghost/admin/app/styles/components/notifications.css +++ b/ghost/admin/app/styles/components/notifications.css @@ -312,8 +312,8 @@ /* ---------------------------------------------------------- */ .gh-alert-black { - border-bottom: color-mod(var(--darkgrey) lightness(-10%)) 1px solid; - background: var(--darkgrey); + border-bottom: 1px solid var(--black); + background: var(--black); color: #fff; } .gh-alert-black a { diff --git a/ghost/admin/app/styles/components/publishmenu.css b/ghost/admin/app/styles/components/publishmenu.css index 6a781a9d63..d96af237e7 100644 --- a/ghost/admin/app/styles/components/publishmenu.css +++ b/ghost/admin/app/styles/components/publishmenu.css @@ -880,3 +880,135 @@ height: 20px; margin-right: 6px; } + +/* Publish flow modal +/* ---------------------------------------------------------- */ + +.modal-post-success { + max-width: 600px; + --padding: 36px; + --radius: 12px; +} + +.modal-post-success .modal-content { + padding: var(--padding); + border-radius: var(--radius); +} + +.modal-post-success .modal-image { + aspect-ratio: 16 / 7.55; + overflow: hidden; + margin: calc(var(--padding) * -1) calc(var(--padding) * -1) var(--padding); +} + +.modal-post-success .modal-image img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: var(--radius) var(--radius) 0 0; +} + +.modal-post-success .modal-header { + margin: 0; +} + +.modal-post-success .modal-header h1 { + display: flex; + flex-direction: column; + margin: 0; + font-size: 3.2rem; + font-weight: 700; + letter-spacing: -0.03em; +} + +.modal-post-success .modal-header h1 span:has(+ span) { + color: var(--green); +} + +.modal-post-success .modal-body { + margin-top: 10px; + font-size: 1.6rem; + line-height: 1.4; + letter-spacing: -0.01em; + text-wrap: pretty; +} + +.modal-post-success .modal-body strong.nowrap { + text-wrap: nowrap; +} + +.modal-post-success .modal-footer { + gap: 16px; + margin-top: var(--padding); +} + +.modal-post-success .modal-footer .gh-btn { + min-width: 64px; + height: 40px; + border-radius: 4px; +} + +.modal-post-success .modal-footer .gh-btn:not(:first-child) { + margin: 0; +} + +.modal-post-success .modal-footer .gh-btn span { + padding-inline: 18px; + font-size: 1.4rem; +} + +.modal-post-success .modal-footer .gh-btn-primary { + min-width: 80px; +} + +.modal-post-success .modal-footer:has(.twitter) .gh-btn-primary { + flex-grow: 1; +} + +.modal-post-success .modal-footer .gh-btn:is(.twitter, .threads, .facebook, .linkedin) { + width: 56px; +} + +.modal-post-success .modal-footer .gh-btn:is(.twitter, .threads, .facebook, .linkedin) span { + font-size: 0; +} + +.modal-post-success .modal-footer .gh-btn svg { + width: 18px; + height: 18px; +} + +.modal-post-success .modal-footer .gh-btn.twitter svg path { + fill: black; +} + +.modal-post-success .close { + top: 24px; + right: 24px; +} + +.modal-post-success:has(.modal-image) .close { + display: flex; + justify-content: center; + align-items: center; + width: 32px; + height: 32px; + top: 16px; + right: 16px; + background-color: rgba(0, 0, 0, 0.4); + border-radius: 50%; +} + +.modal-post-success:has(.modal-image) .close:hover { + background-color: rgba(0, 0, 0, 0.3); +} + +.modal-post-success:has(.modal-image) .close svg { + width: 14px; + height: 14px; +} + +.modal-post-success:has(.modal-image) .close svg path { + fill: white; +} diff --git a/ghost/admin/app/styles/layouts/content.css b/ghost/admin/app/styles/layouts/content.css index 886f1d1ca4..3964ce4374 100644 --- a/ghost/admin/app/styles/layouts/content.css +++ b/ghost/admin/app/styles/layouts/content.css @@ -776,6 +776,17 @@ border-radius: var(--border-radius); } +.gh-analytics-actions-menu { + top: calc(100% + 6px); + left: auto; + right: 0; +} + +.gh-analytics-actions-menu.fade-out { + animation-duration: .001s; + pointer-events: none; +} + .feature-audienceFeedback .gh-post-analytics-box.gh-post-analytics-newsletter-clicks, .feature-audienceFeedback .gh-post-analytics-box.gh-post-analytics-source-attribution, .gh-post-analytics-box.gh-post-analytics-mentions { @@ -842,13 +853,12 @@ background: var(--white); border-radius: var(--border-radius); box-shadow: 0 1px 4px -1px rgba(0,0,0,.1); -} - -.gh-post-analytics-resource { flex: 1; + align-items: flex-start; display: grid; grid-template-columns: 2fr 3fr; grid-gap: 24px; + min-width: 0; } .gh-post-analytics-resource .thumbnail { @@ -866,6 +876,7 @@ .gh-post-analytics-resource h3 { font-size: 1.8rem; font-weight: 700; + text-wrap: pretty; } .gh-post-analytics-box h4.gh-main-section-header.small { @@ -1523,6 +1534,10 @@ transition: all .1s linear; } +span.dropdown .gh-post-list-cta > span { + padding: 0; +} + .gh-post-list-cta.edit.is-hovered > *, .gh-post-list-cta.edit.is-hovered:hover > *, .gh-post-list-cta.edit:not(.is-hovered):hover > * { @@ -1534,7 +1549,7 @@ color: var(--green-d1); } -@media screen and (max-width: 1080px) { +@media screen and (max-width: 1200px) { .gh-post-analytics-box.resources { flex-direction: column; } diff --git a/ghost/admin/app/styles/layouts/editor.css b/ghost/admin/app/styles/layouts/editor.css index ee0c1b7427..2a9ad8304f 100644 --- a/ghost/admin/app/styles/layouts/editor.css +++ b/ghost/admin/app/styles/layouts/editor.css @@ -160,8 +160,8 @@ .gh-og-preview { background: #fff; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1) inset, - 0 -1px 0 0 rgba(0, 0, 0, 0.06) inset, - 0 1px 4px rgba(0, 0, 0, 0.1); + 0 -1px 0 0 rgba(0, 0, 0, 0.06) inset, + 0 1px 4px rgba(0, 0, 0, 0.1); } .gh-og-preview-image { @@ -229,7 +229,7 @@ border-style: solid; border-color: #e1e8ed; color: #292f33; - font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 1.4rem; line-height: 1.3em; background: #fff; @@ -283,7 +283,7 @@ /* NEW editor /* ---------------------------------------------------------- */ -.gh-main > section.gh-editor-fullscreen { +.gh-main>section.gh-editor-fullscreen { position: fixed; top: 0; left: 0; @@ -449,7 +449,7 @@ .gh-editor-feedback-dropdown { min-width: 400px; border-radius: 3px; - box-shadow: 0 0 0 1px rgba(0,0,0,.04), 0 8px 20px -3px rgba(0,0,0,.2); + box-shadow: 0 0 0 1px rgba(0, 0, 0, .04), 0 8px 20px -3px rgba(0, 0, 0, .2); padding: 20px; background-color: #fff; background-clip: padding-box; @@ -553,13 +553,20 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { height: 4px; } +.gh-editor-feature-image { + position: relative; +} + +.gh-editor-feature-image img { + display: block; +} .gh-editor-feature-image-overlay { position: absolute; top: 0; left: 0; right: 0; - bottom: 100px; - background-image: linear-gradient(180deg,rgba(0,0,0,.2),transparent 40%,transparent); + bottom: 0; + background-image: linear-gradient(180deg, rgba(0, 0, 0, .2), transparent 40%, transparent); transition: all .1s ease-in; opacity: 0; } @@ -581,7 +588,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { padding: 0; background: var(--white); color: var(--darkgrey); - border-radius: 4px; + border-radius: 6px; transition: all .1s ease-in; opacity: 0; } @@ -604,7 +611,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { } .gh-editor-feature-image .image-action svg path { - fill: var(--darkgrey); + stroke-width: 2; } .gh-editor-feature-image-add { @@ -677,12 +684,19 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { stroke: var(--midgrey-l2); } +.gh-editor-feature-image-caption-container { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + padding: .8rem 0; +} .gh-editor-feature-image-alttext, .gh-editor-feature-image-caption { width: 100%; min-height: 24px; - margin: 0 0 1.7em 0; - padding: 0; + margin: 0; + padding: 0 3.6rem 0 0; outline: none; border-width: 0; border-style: none; @@ -717,12 +731,25 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { opacity: .5; } +.gh-editor-title-container { + position: relative; + max-width: 740px; + width: 100%; + margin-right: auto; + margin-left: auto; + border: none; + background: transparent; +} + .gh-editor-title { display: block; width: 100%; + max-width: unset; min-height: auto; - margin-bottom: 1.2rem; + margin: 0 0 1.6rem; + padding: 0 0 4px; border: none; + background: transparent; color: var(--black); font-size: 4.8rem; letter-spacing: -0.017em; @@ -732,6 +759,18 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { box-shadow: none; } +@media (min-width: 500px) and (max-width: 768px) { + .gh-editor-title { + font-size: 3.6rem; + } +} + +@media (max-width: 500px) { + .gh-editor-title { + font-size: 2.8rem; + } +} + .gh-editor-title:focus { box-shadow: none !important; border: none !important; @@ -741,6 +780,69 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { opacity: .5; } +.gh-editor-title::placeholder { + color: var(--lightgrey-d1); + font-weight: 700; + opacity: 1; +} + +.gh-editor-hidden-indicator { + position: absolute; + top: -1px; + height: 2.4rem; + margin-left: -6rem; + color: var(--midgrey-l2); +} + +.gh-editor-title-container .gh-editor-hidden-indicator { + top: 1.8rem; +} + +.gh-editor-hidden-indicator svg { + height: 2.4rem; +} + +.gh-editor-excerpt { + display: block; + width: 100%; + max-width: unset; + min-width: auto; + min-height: auto; /* Allows element to collapse for auto-expand calculation */ + margin: 0; + padding: 0; + border: none; + background: transparent; + color: var(--darkgrey); + font-size: 2.0rem; + font-weight: 440; + line-height: 1.5em; + letter-spacing: -.018em; + overflow: hidden; + box-shadow: none; +} + +.gh-editor-excerpt:focus { + box-shadow: none !important; + border: none !important; +} + +.gh-editor-excerpt-error { + margin-top: .8rem; + margin-bottom: 4.8rem; + color: var(--red-d1); + font-size: 1.4rem; + font-weight: 400; +} + +.gh-editor-title-divider { + margin: 1.6rem 0 4.8rem; +} + +.gh-editor-title-divider-error { + margin: 1.6rem 0 0; + border-top: 1px solid var(--red); +} + .gh-editor .tk-indicator { position: absolute; top: 15px; @@ -752,6 +854,10 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { cursor: pointer; } +.gh-editor .tk-indicator-excerpt { + top: -1px; +} + .gh-editor-feature-image-container .tk-indicator { top: 0; padding: 0 .4rem; @@ -927,21 +1033,6 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { } } -@media (max-width: 500px) { - .gh-editor-title { - font-size: 3.4rem; - } -} - -.gh-editor-title { - padding: 0 0 4px; -} - -.gh-editor-title::placeholder { - color: var(--lightgrey-d1); - font-weight: 700; - opacity: 1; -} .gh-editor .editor-preview { height: auto; margin-top: 4px; @@ -960,7 +1051,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { .gh-editor .editor-preview h3, .gh-editor .editor-preview h4, .gh-editor .editor-preview h5, -.gh-editor .editor-preview h6, { +.gh-editor .editor-preview h6 { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, "Open Sans", "Helvetica Neue", sans-serif; } @@ -981,7 +1072,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { width: 100%; height: 100%; border: 2px solid var(--blue); - background-color: rgba(255,255,255,0.6); + background-color: rgba(255, 255, 255, 0.6); } .gh-editor-drop-target .drop-target-message { @@ -1032,6 +1123,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone { position: relative; vertical-align: bottom; } + .editor-toolbar .fa-check:before { position: absolute; right: 3px; @@ -1120,8 +1212,8 @@ figure { left: -20px; width: 20px; height: 100%; - background: rgb(255,255,255); - background: linear-gradient(90deg, rgba(255,255,255,1) 0%, rgba(255,255,255,0) 100%); + background: rgb(255, 255, 255); + background: linear-gradient(90deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%); z-index: 999; opacity: 0; transition: all 250ms ease-out; @@ -1134,22 +1226,22 @@ figure { right: 0; width: 20px; height: 100%; - background: rgb(255,255,255); - background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%); + background: rgb(255, 255, 255); + background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%); z-index: 999; } .ember-power-select-option[aria-current="true"] .kg-settings-link-url.scroller::before { opacity: 1; left: 0; - background: linear-gradient(90deg, rgba(244,245,245,1) 0%, rgba(244,245,245,0) 100%); + background: linear-gradient(90deg, rgba(244, 245, 245, 1) 0%, rgba(244, 245, 245, 0) 100%); } .ember-power-select-option[aria-current="true"] .kg-settings-link-url::after { - background: linear-gradient(90deg, rgba(244,245,245,0) 0%, rgba(244,245,245,1) 100%); + background: linear-gradient(90deg, rgba(244, 245, 245, 0) 0%, rgba(244, 245, 245, 1) 100%); } -.kg-settings-link-url > span { +.kg-settings-link-url>span { display: inline-block; font-weight: 400; font-size: 1.2rem; @@ -1168,65 +1260,10 @@ figure { /* Labs /* ---------------------------------------------------------- */ -.gh-editor-title-container { - position: relative; - max-width: 740px; - width: 100%; - margin-right: auto; - margin-left: auto; - border: none; - background: transparent; -} - -.gh-editor .gh-editor-title { - display: block; - width: 100%; - max-width: unset; - min-height: auto; - margin: 0 0 1.2rem; - border: none; - background: transparent; - color: var(--black); - font-size: 4.8rem; - letter-spacing: -0.017em; - line-height: 1.1em; - font-weight: 700; - overflow: hidden; - box-shadow: none; -} - -@media (min-width: 500px) and (max-width: 768px) { - .gh-editor .gh-editor-title { - font-size: 3.6rem; - } -} - -@media (max-width: 500px) { - .gh-editor .gh-editor-title { - font-size: 2.8rem; - } -} - -.gh-editor-hidden-indicator { - position: absolute; - top: -1px; - height: 2.4rem; - margin-left: -6rem; - color: var(--midgrey-l2); -} - -.gh-editor-title-container .gh-editor-hidden-indicator { - top: 1.8rem; -} - -.gh-editor-hidden-indicator svg { - height: 2.4rem; -} - .gh-setting-error { margin-top: 1em; line-height: 1.3em; color: var(--red); font-weight: 300; letter-spacing: 0.3px; -} +} \ No newline at end of file diff --git a/ghost/admin/app/styles/layouts/flow.css b/ghost/admin/app/styles/layouts/flow.css index bf22e34cc6..c669f6ea04 100644 --- a/ghost/admin/app/styles/layouts/flow.css +++ b/ghost/admin/app/styles/layouts/flow.css @@ -271,7 +271,18 @@ .gh-setup .gh-flow-content .main-error { margin-top: 16px; color: var(--red); - font-size: 1.35rem; + font-size: 1.4rem; + line-height: 1.5; + text-align: center; + text-wrap: balance; +} + +.gh-flow-content .main-notification, +.gh-setup .gh-flow-content .main-notification { + margin-top: 16px; + color: var(--black); + font-size: 1.4rem; + font-weight: 400; line-height: 1.5; text-align: center; text-wrap: balance; diff --git a/ghost/admin/app/styles/layouts/main.css b/ghost/admin/app/styles/layouts/main.css index 816b08c568..c542e368ff 100644 --- a/ghost/admin/app/styles/layouts/main.css +++ b/ghost/admin/app/styles/layouts/main.css @@ -112,7 +112,15 @@ border-right: none; background: unset; width: 100%; - pointer-events: none; + overflow: unset; +} + +.gh-nav-contributor .gh-nav-body { + overflow: unset; +} + +.gh-nav-contributor .gh-sidebar-banner { + max-width: 300px; } .gh-nav-menu { diff --git a/ghost/admin/app/styles/layouts/members.css b/ghost/admin/app/styles/layouts/members.css index e9c7383ee5..bfbd1ce2e3 100644 --- a/ghost/admin/app/styles/layouts/members.css +++ b/ghost/admin/app/styles/layouts/members.css @@ -68,6 +68,7 @@ @media (max-width: 1450px) { .members-list-container-stretch { min-height: calc(100vh - 176px); + overflow: hidden; } } diff --git a/ghost/admin/app/styles/layouts/post-history.css b/ghost/admin/app/styles/layouts/post-history.css index b9a3c1f219..590550a85f 100644 --- a/ghost/admin/app/styles/layouts/post-history.css +++ b/ghost/admin/app/styles/layouts/post-history.css @@ -199,3 +199,7 @@ .gh-post-history-hidden-lexical { display: none; } + +.gh-post-history .gh-editor-feature-image p { + margin: 0 0 1.2rem; +} diff --git a/ghost/admin/app/styles/patterns/forms.css b/ghost/admin/app/styles/patterns/forms.css index 82e216964b..bf0a74b085 100644 --- a/ghost/admin/app/styles/patterns/forms.css +++ b/ghost/admin/app/styles/patterns/forms.css @@ -225,20 +225,20 @@ select { .gh-select.error, .error .gh-input-append, select.error { - border-color: var(--red); + border-color: var(--red)!important; } .gh-input:focus, .gh-input.focus { outline: 0; - border-color: color-mod(var(--green)) !important; + border-color: var(--green); box-shadow: inset 0 0 0 1px var(--green); background: var(--white); } .error .gh-input:focus, .error .gh-input.focus { - border-color: color-mod(var(--red)) !important; + border-color: var(--red); box-shadow: inset 0 0 0 1px var(--red); } diff --git a/ghost/admin/app/styles/spirit/_custom-styles.css b/ghost/admin/app/styles/spirit/_custom-styles.css index de443ae1a4..870b2278a5 100644 --- a/ghost/admin/app/styles/spirit/_custom-styles.css +++ b/ghost/admin/app/styles/spirit/_custom-styles.css @@ -184,9 +184,9 @@ button, .btn-base { bottom: calc(100% + 4px); left: 50%; white-space: nowrap; - padding: 3px 7px; - border-radius: 3px; - background-color: var(--darkgrey); + padding: 4px 10px; + border-radius: 6px; + background-color: var(--black); color: var(--white); content: attr(data-tooltip); text-align: center; diff --git a/ghost/admin/app/templates/application.hbs b/ghost/admin/app/templates/application.hbs index dbc514172e..fe955babf1 100644 --- a/ghost/admin/app/templates/application.hbs +++ b/ghost/admin/app/templates/application.hbs @@ -40,7 +40,7 @@ {{#if this.showScriptExtension}} {{{this.showScriptExtension.container}}} {{!-- template-lint-disable no-forbidden-elements --}} - + {{/if}} {{#if this.settings.accentColor}} diff --git a/ghost/admin/app/templates/dashboard.hbs b/ghost/admin/app/templates/dashboard.hbs index b04c643a96..ce1f815381 100644 --- a/ghost/admin/app/templates/dashboard.hbs +++ b/ghost/admin/app/templates/dashboard.hbs @@ -85,38 +85,34 @@ {{#if this.isLoading }} {{else}} - {{#if this.areMembersEnabled}} - - {{#if this.onboarding.isChecklistShown}} - + {{#if this.onboarding.isChecklistShown}} + + {{/if}} + {{#if (and this.areMembersEnabled (not this.onboarding.isChecklistShown))}} + {{#if this.hasPaidTiers}} + {{/if}} - - {{#unless this.onboarding.isChecklistShown}} +
    + {{#if this.hasPaidTiers}} - +
    +
    + + +
    +
    + {{/if}} + {{#unless this.membersUtils.isMembersInviteOnly}} + + {{/unless}} + {{#if this.areNewslettersEnabled}} + {{/if}} -
    - - {{#if this.hasPaidTiers}} -
    -
    - - -
    -
    - {{/if}} - {{#unless this.membersUtils.isMembersInviteOnly}} - - {{/unless}} - {{#if this.areNewslettersEnabled}} - - {{/if}} - {{#if this.isTotalMembersZero}} - - {{/if}} -
    - {{/unless}} + {{#if this.isTotalMembersZero}} + + {{/if}} +
    {{/if}} {{#unless this.onboarding.isChecklistShown}} diff --git a/ghost/admin/app/templates/lexical-editor.hbs b/ghost/admin/app/templates/lexical-editor.hbs index 015bbe9969..252dc8129b 100644 --- a/ghost/admin/app/templates/lexical-editor.hbs +++ b/ghost/admin/app/templates/lexical-editor.hbs @@ -61,14 +61,20 @@ --}}
    @@ -131,6 +139,7 @@ @updateSlugTask={{this.updateSlugTask}} @savePostTask={{this.savePostTask}} @editorAPI={{this.editorAPI}} + @secondaryEditorAPI={{this.secondaryEditorAPI}} @toggleSettingsMenu={{this.toggleSettingsMenu}} /> {{/if}} diff --git a/ghost/admin/app/templates/members.hbs b/ghost/admin/app/templates/members.hbs index 9bd9c98cdc..c965f8389b 100644 --- a/ghost/admin/app/templates/members.hbs +++ b/ghost/admin/app/templates/members.hbs @@ -104,12 +104,14 @@ {{/if}} -
  • -
  • - -
  • + {{#if this.isBulkDeletePermitted}} +
  • +
  • + +
  • + {{/if}} {{/if}} @@ -143,7 +145,7 @@ {{/each}} - + {{#if member.is_loading}}
  • @@ -41,7 +41,7 @@ {{else}}

    No pages match the current filter

    - + Show all pages {{/if}} @@ -49,11 +49,26 @@
  • + {{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}} + {{#if @model.scheduledInfinityModel}} - + {{/if}} + {{#if (and @model.draftInfinityModel (or (not @model.scheduledInfinityModel) (and @model.scheduledInfinityModel @model.scheduledInfinityModel.reachedInfinity)))}} + + {{/if}} + {{#if (and @model.publishedAndSentInfinityModel (and (or (not @model.scheduledInfinityModel) @model.scheduledInfinityModel.reachedInfinity) (or (not @model.draftInfinityModel) @model.draftInfinityModel.reachedInfinity)))}} + + {{/if}} + {{outlet}} diff --git a/ghost/admin/app/templates/posts.hbs b/ghost/admin/app/templates/posts.hbs index f0d0b6bbe8..dc3e26361d 100644 --- a/ghost/admin/app/templates/posts.hbs +++ b/ghost/admin/app/templates/posts.hbs @@ -30,7 +30,7 @@
  • @@ -43,7 +43,7 @@ {{else}}

    No posts match the current filter

    - + Show all posts {{/if}} @@ -51,11 +51,26 @@
  • + {{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}} + {{#if @model.scheduledInfinityModel}} -
    + {{/if}} + {{#if (and @model.draftInfinityModel (or (not @model.scheduledInfinityModel) (and @model.scheduledInfinityModel @model.scheduledInfinityModel.reachedInfinity)))}} + + {{/if}} + {{#if (and @model.publishedAndSentInfinityModel (and (or (not @model.scheduledInfinityModel) @model.scheduledInfinityModel.reachedInfinity) (or (not @model.draftInfinityModel) @model.draftInfinityModel.reachedInfinity)))}} + + {{/if}} + {{outlet}} - + \ No newline at end of file diff --git a/ghost/admin/app/templates/setup/done.hbs b/ghost/admin/app/templates/setup/done.hbs deleted file mode 100644 index 5dd1e9e805..0000000000 --- a/ghost/admin/app/templates/setup/done.hbs +++ /dev/null @@ -1,46 +0,0 @@ -
    -
    -
    -
    -
    -
    -
    -
    -

    All done!

    -

    Your brand new publication is set up and ready to go.

    -
    -

    What do you want to do first?

    -
    - - {{svg-jar "posts"}} -
    Write your first post
    -

    Test out the editor and get a feel for creating content inside Ghost.

    -
    - - {{svg-jar "paint-palette"}} -
    Customize your site
    -

    Review your settings and tweak the design to make your site just right.

    -
    - - {{svg-jar "members"}} -
    Import members
    -

    Move your audience over to Ghost with our migration tools and guides.

    -
    - - {{svg-jar "house"}} -
    Explore Ghost admin
    -

    View the dashboard, click around, and explore Ghost for yourself.

    -
    -
    -
    -
    -
    -
    -
    -
    - - - -
    -
    -
    \ No newline at end of file diff --git a/ghost/admin/app/templates/signin.hbs b/ghost/admin/app/templates/signin.hbs index 9c1991a999..fdaffa1e9d 100644 --- a/ghost/admin/app/templates/signin.hbs +++ b/ghost/admin/app/templates/signin.hbs @@ -12,7 +12,7 @@
    {{else}} - +

    {{this.config.blogTitle}}

    @@ -73,7 +73,7 @@ data-test-button="sign-in" /> -

    {{if this.flowErrors this.flowErrors}} 

    +

    {{if this.flowErrors this.flowErrors this.flowNotification}} 

    {{/if}}
    diff --git a/ghost/admin/app/templates/signup.hbs b/ghost/admin/app/templates/signup.hbs index 025b422d55..252ad4a758 100644 --- a/ghost/admin/app/templates/signup.hbs +++ b/ghost/admin/app/templates/signup.hbs @@ -7,7 +7,7 @@

    Create your account.

    -