diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bb1523940d1..20ca24ba1bb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,8 +7,3 @@ Please include a description of your change & check your PR against this list, t - [ ] The build will pass (run `yarn test:all` and `yarn lint`) We appreciate your contribution! - ---- - - -copilot:summary diff --git a/.github/actions/restore-cache/action.yml b/.github/actions/restore-cache/action.yml index fcd4fddbebc..fb6a08de204 100644 --- a/.github/actions/restore-cache/action.yml +++ b/.github/actions/restore-cache/action.yml @@ -6,13 +6,13 @@ runs: steps: - name: Check dependency cache id: dep-cache - uses: actions/cache/restore@v3 + uses: actions/cache/restore@v4 with: path: ${{ env.CACHED_DEPENDENCY_PATHS }} key: ${{ env.DEPENDENCY_CACHE_KEY }} - name: Check build cache - uses: actions/cache/restore@v3 + uses: actions/cache/restore@v4 id: build-cache with: path: ${{ env.CACHED_BUILD_PATHS }} diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js index e022c50b55c..83c9f30c955 100644 --- a/.github/scripts/dev.js +++ b/.github/scripts/dev.js @@ -1,7 +1,9 @@ +const fs = require('fs'); const path = require('path'); const util = require('util'); const exec = util.promisify(require('child_process').exec); +const chalk = require('chalk'); const concurrently = require('concurrently'); // check we're running on Node 18 and above @@ -15,6 +17,20 @@ const config = require('../../ghost/core/core/shared/config/loader').loadNconf({ customConfigPath: path.join(__dirname, '../../ghost/core') }); +const tsPackages = fs.readdirSync(path.resolve(__dirname, '../../ghost'), {withFileTypes: true}) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + .filter(packageFolder => { + try { + const packageJson = require(path.resolve(__dirname, `../../ghost/${packageFolder}/package.json`)); + return packageJson.scripts?.['build:ts']; + } catch (err) { + return false; + } + }) + .map(packageFolder => `ghost/${packageFolder}`) + .join(','); + const liveReloadBaseUrl = config.getSubdir() || '/ghost/'; const siteUrl = config.getSiteUrl(); @@ -44,32 +60,40 @@ const COMMAND_ADMIN = { const COMMAND_TYPESCRIPT = { name: 'ts', - command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations -- nx run \\$NX_PROJECT_NAME:build:ts', + command: `while [ 1 ]; do nx watch --projects=${tsPackages} -- nx run \\$NX_PROJECT_NAME:build:ts; done`, cwd: path.resolve(__dirname, '../../'), prefixColor: 'cyan', env: {} }; -const COMMAND_ADMINX = { +const adminXApps = '@tryghost/admin-x-demo,@tryghost/admin-x-settings,@tryghost/admin-x-activitypub'; + +const COMMANDS_ADMINX = [{ + name: 'adminXDeps', + command: 'while [ 1 ]; do nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework -- nx run \\$NX_PROJECT_NAME:build; done', + cwd: path.resolve(__dirname, '../..'), + prefixColor: '#C72AF7', + env: {} +}, { name: 'adminX', - command: 'yarn dev', - cwd: path.resolve(__dirname, '../../apps/admin-x-settings'), - prefixColor: '#C35831', + command: `nx run-many --projects=${adminXApps} --parallel=${adminXApps.length} --targets=dev`, + cwd: path.resolve(__dirname, '../../apps/admin-x-settings', '../../apps/admin-x-activitypub'), + prefixColor: '#C72AF7', env: {} -}; +}]; if (DASH_DASH_ARGS.includes('ghost')) { commands = [COMMAND_GHOST, COMMAND_TYPESCRIPT]; } else if (DASH_DASH_ARGS.includes('admin')) { - commands = [COMMAND_ADMIN, COMMAND_ADMINX]; + commands = [COMMAND_ADMIN, ...COMMANDS_ADMINX]; } else { - commands = [COMMAND_GHOST, COMMAND_TYPESCRIPT, COMMAND_ADMIN, COMMAND_ADMINX]; + commands = [COMMAND_GHOST, COMMAND_TYPESCRIPT, COMMAND_ADMIN, ...COMMANDS_ADMINX]; } if (DASH_DASH_ARGS.includes('portal') || DASH_DASH_ARGS.includes('all')) { commands.push({ name: 'portal', - command: 'yarn dev', + command: 'nx run @tryghost/portal:dev', cwd: path.resolve(__dirname, '../../apps/portal'), prefixColor: 'magenta', env: {} @@ -92,7 +116,7 @@ if (DASH_DASH_ARGS.includes('portal') || DASH_DASH_ARGS.includes('all')) { if (DASH_DASH_ARGS.includes('signup') || DASH_DASH_ARGS.includes('all')) { commands.push({ name: 'signup-form', - command: DASH_DASH_ARGS.includes('signup') ? 'yarn dev' : 'yarn preview', + command: DASH_DASH_ARGS.includes('signup') ? 'nx run @tryghost/signup-form:dev' : 'nx run @tryghost/signup-form:preview', cwd: path.resolve(__dirname, '../../apps/signup-form'), prefixColor: 'magenta', env: {} @@ -103,7 +127,7 @@ if (DASH_DASH_ARGS.includes('signup') || DASH_DASH_ARGS.includes('all')) { if (DASH_DASH_ARGS.includes('announcement-bar') || DASH_DASH_ARGS.includes('announcementBar') || DASH_DASH_ARGS.includes('announcementbar') || DASH_DASH_ARGS.includes('all')) { commands.push({ name: 'announcement-bar', - command: 'yarn dev', + command: 'nx run @tryghost/announcement-bar:dev', cwd: path.resolve(__dirname, '../../apps/announcement-bar'), prefixColor: '#DC9D00', env: {} @@ -114,7 +138,7 @@ if (DASH_DASH_ARGS.includes('announcement-bar') || DASH_DASH_ARGS.includes('anno if (DASH_DASH_ARGS.includes('search') || DASH_DASH_ARGS.includes('all')) { commands.push({ name: 'search', - command: 'yarn dev', + command: 'nx run @tryghost/sodo-search:dev', cwd: path.resolve(__dirname, '../../apps/sodo-search'), prefixColor: '#23de43', env: {} @@ -153,7 +177,7 @@ if (DASH_DASH_ARGS.includes('comments') || DASH_DASH_ARGS.includes('all')) { commands.push({ name: 'comments', - command: 'yarn dev', + command: 'nx run @tryghost/comments-ui:dev', cwd: path.resolve(__dirname, '../../apps/comments-ui'), prefixColor: '#E55137', env: {} @@ -165,7 +189,6 @@ async function handleStripe() { if (DASH_DASH_ARGS.includes('offline')) { return; } - console.log('Fetching Stripe secret token..'); let stripeSecret; try { @@ -198,6 +221,8 @@ async function handleStripe() { process.exit(0); } + console.log(`Running projects: ${commands.map(c => chalk.green(c.name)).join(', ')}`); + const {result} = concurrently(commands, { prefix: 'name', killOthers: ['failure', 'success'] @@ -206,6 +231,10 @@ async function handleStripe() { try { await result; } catch (err) { - console.error('\nExecuting dev command failed, ensure dependencies are up-to-date by running `yarn fix`\n'); + console.error(); + console.error(chalk.red(`Executing dev command failed:`) + `\n`); + console.error(chalk.red(`If you've recently done a \`yarn main\`, dependencies might be out of sync. Try running \`${chalk.green('yarn fix')}\` to fix this.`)); + console.error(chalk.red(`If not, something else went wrong. Please report this to the Ghost team.`)); + console.error(); } })(); diff --git a/.github/scripts/docker-compose.yml b/.github/scripts/docker-compose.yml new file mode 100644 index 00000000000..a8d89941f7d --- /dev/null +++ b/.github/scripts/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +name: ghost + +services: + mysql: + image: mysql:8.0.35 + container_name: ghost-mysql + # We'll need to look into how we can further fine tune the memory usage/performance here + command: --innodb-buffer-pool-size=1G --innodb-log-buffer-size=500M --innodb-change-buffer-max-size=50 --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: ghost + restart: always + volumes: + # Turns out you can drop .sql or .sql.gz files in here, cool! + - ./mysql-preload:/docker-entrypoint-initdb.d + healthcheck: + test: "mysql -uroot -proot ghost -e 'select 1'" + interval: 1s + retries: 120 + redis: + image: redis:7.0 + container_name: ghost-redis + 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/scripts/mysql-preload/.keep b/.github/scripts/mysql-preload/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.github/scripts/setup.js b/.github/scripts/setup.js new file mode 100644 index 00000000000..f0558b298d7 --- /dev/null +++ b/.github/scripts/setup.js @@ -0,0 +1,138 @@ +const {spawn} = require('child_process'); +const fs = require('fs').promises; +const path = require('path'); + +const chalk = require('chalk'); +const inquirer = require('inquirer'); + +/** + * Run a command and stream output to the console + * + * @param {string} command + * @param {string[]} args + * @param {object} options + */ +async function runAndStream(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + ...options + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(code); + } else { + reject(new Error(`'${command} ${args.join(' ')}' exited with code ${code}`)); + } + }); + + }); +} + +(async () => { + if (process.env.NODE_ENV !== 'development') { + console.log(chalk.yellow(`NODE_ENV is not development, skipping setup`)); + return; + } + + const coreFolder = path.join(__dirname, '../../ghost/core'); + const rootFolder = path.join(__dirname, '../..'); + const config = require('../../ghost/core/core/shared/config/loader').loadNconf({ + customConfigPath: coreFolder + }); + + const dbClient = config.get('database:client'); + const isUsingDocker = config.get('database:docker'); + + // Only reset data if we are using Docker + let resetData = false; + + if (!dbClient.includes('mysql')) { + let mysqlSetup = false; + console.log(chalk.blue(`Attempting to setup MySQL via Docker`)); + try { + await runAndStream('yarn', ['docker:reset'], {cwd: path.join(__dirname, '../../')}); + mysqlSetup = true; + } catch (err) { + console.error(chalk.red('Failed to run MySQL Docker container'), err); + console.error(chalk.red('Hint: is Docker installed and running?')); + } + + if (mysqlSetup) { + resetData = true; + console.log(chalk.blue(`Adding MySQL credentials to config.local.json`)); + const currentConfigPath = path.join(coreFolder, 'config.local.json'); + + let currentConfig; + try { + currentConfig = require(currentConfigPath); + } catch (err) { + currentConfig = {}; + } + + currentConfig.database = { + client: 'mysql', + docker: true, + connection: { + host: '127.0.0.1', + user: 'root', + password: 'root', + database: 'ghost' + } + }; + + try { + await fs.writeFile(currentConfigPath, JSON.stringify(currentConfig, null, 4)); + } catch (err) { + console.error(chalk.red('Failed to write config.local.json'), err); + console.log(chalk.yellow(`Please add the following to config.local.json:\n`), JSON.stringify(currentConfig, null, 4)); + process.exit(1); + } + } + } else { + if (isUsingDocker) { + const yesAll = process.argv.includes('-y'); + const noAll = process.argv.includes('-n'); + const {confirmed} = + yesAll ? {confirmed: true} + : ( + noAll ? {confirmed: false} + : await inquirer.prompt({name: 'confirmed', type:'confirm', message: 'MySQL is running via Docker, do you want to reset the Docker container? This will delete all existing data.', default: false}) + ); + + if (confirmed) { + console.log(chalk.yellow(`Resetting Docker container`)); + + try { + await runAndStream('yarn', ['docker:reset'], {cwd: path.join(__dirname, '../../')}); + resetData = true; + } catch (err) { + console.error(chalk.red('Failed to run MySQL Docker container'), err); + console.error(chalk.red('Hint: is Docker installed and running?')); + } + } + } else { + console.log(chalk.green(`MySQL already configured locally. Stop your local database and delete your "database" configuration in config.local.json to switch to Docker.`)); + } + } + + console.log(chalk.blue(`Running knex-migrator init`)); + await runAndStream('yarn', ['knex-migrator', 'init'], {cwd: coreFolder}); + if (process.argv.includes('--no-seed')) { + console.log(chalk.yellow(`Skipping seed data`)); + console.log(chalk.yellow(`Done`)); + return; + } + if (resetData) { + const xxl = process.argv.includes('--xxl'); + + if (xxl) { + console.log(chalk.blue(`Resetting all data (with xxl)`)); + await runAndStream('yarn', ['reset:data:xxl'], {cwd: rootFolder}); + } else { + console.log(chalk.blue(`Resetting all data`)); + await runAndStream('yarn', ['reset:data'], {cwd: rootFolder}); + } + } +})(); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f42641eaafa..e09d3b804cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] push: branches: - main @@ -12,7 +13,6 @@ on: env: FORCE_COLOR: 1 HEAD_COMMIT: ${{ github.sha }} - GITHUB_CONTEXT: ${{ toJson(github) }} CACHED_DEPENDENCY_PATHS: | ${{ github.workspace }}/node_modules ${{ github.workspace }}/apps/*/node_modules @@ -43,6 +43,11 @@ jobs: ref: ${{ env.HEAD_COMMIT }} fetch-depth: 2 + - name: Output GitHub context + run: echo "$GITHUB_CONTEXT" + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + - name: Get metadata (push) if: github.event_name == 'push' run: | @@ -58,7 +63,7 @@ jobs: echo "BASE_COMMIT=$BASE_COMMIT" >> $GITHUB_ENV - name: Determine added packages - uses: dorny/paths-filter@v2.11.1 + uses: dorny/paths-filter@v2.12.0 id: added with: filters: | @@ -144,7 +149,7 @@ jobs: echo "$EOF" >> "$GITHUB_ENV" - name: Nx cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache_nx with: path: .nxcache @@ -152,7 +157,7 @@ jobs: restore-keys: ${{needs.job_get_metadata.outputs.is_main == 'false' && env.NX_CACHE_RESTORE_KEYS || 'nx-never-restore'}} - name: Check dependency cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache_dependencies with: path: ${{ env.CACHED_DEPENDENCY_PATHS }} @@ -160,14 +165,14 @@ jobs: restore-keys: ${{needs.job_get_metadata.outputs.is_main == 'false' && env.DEPENDENCY_CACHE_RESTORE_KEYS || 'dep-never-restore'}} - name: Check build cache - uses: actions/cache@v3 + uses: actions/cache@v4 id: cache_built_packages with: path: ${{ env.CACHED_BUILD_PATHS }} key: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -192,7 +197,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 100 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -203,7 +208,7 @@ jobs: env: DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: ghost/**/.eslintcache key: eslint-cache @@ -228,7 +233,7 @@ jobs: || needs.job_get_metadata.outputs.changed_core == 'true' steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: "18.12.1" @@ -252,7 +257,7 @@ jobs: COVERAGE: true steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: "18.12.1" @@ -269,7 +274,7 @@ jobs: - name: Merge Admin test coverage run: yarn ember coverage-merge working-directory: ghost/admin - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: admin-coverage path: ghost/*/coverage/cobertura-coverage.xml @@ -285,7 +290,7 @@ jobs: name: Browser tests timeout-minutes: 60 runs-on: - labels: ubuntu-latest-4-cores + labels: ubuntu-latest needs: [job_get_metadata, job_install_deps] if: needs.job_get_metadata.outputs.changed_any_code == 'true' && (needs.job_get_metadata.outputs.is_main == 'true' || needs.job_get_metadata.outputs.has_browser_tests_label == 'true') concurrency: @@ -294,7 +299,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: true - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -321,7 +326,7 @@ jobs: - name: Get Playwright version id: playwright-version run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Check if Playwright browser is cached id: playwright-cache with: @@ -352,26 +357,74 @@ jobs: env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: browser-tests-playwright-report path: ghost/core/playwright-report retention-days: 30 + job_perf-tests: + runs-on: + labels: ubuntu-latest-4-cores + needs: [job_get_metadata, job_install_deps] + if: needs.job_get_metadata.outputs.changed_core == 'true' && needs.job_get_metadata.outputs.is_main == 'true' + name: Performance tests + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-node@v4 + env: + FORCE_COLOR: 0 + with: + node-version: '18.12.1' + + - name: Restore caches + uses: ./.github/actions/restore-cache + env: + DEPENDENCY_CACHE_KEY: ${{ needs.job_install_deps.outputs.dependency_cache_key }} + + - name: Install hyperfine + run: | + export HYPERFINE_VERSION=1.18.0 + wget https://github.com/sharkdp/hyperfine/releases/download/v$HYPERFINE_VERSION/hyperfine-v$HYPERFINE_VERSION-x86_64-unknown-linux-gnu.tar.gz + tar -zxvf hyperfine-v$HYPERFINE_VERSION-x86_64-unknown-linux-gnu.tar.gz + mv hyperfine-v$HYPERFINE_VERSION-x86_64-unknown-linux-gnu/hyperfine /usr/local/bin + chmod +x /usr/local/bin/hyperfine + + - name: Run hyperfine on boot + working-directory: ghost/core + run: hyperfine --show-output --warmup 3 'GHOST_CI_SHUTDOWN_AFTER_BOOT=1 node index.js' --export-json boot-perf.json + + - name: Convert data + working-directory: ghost/core + run: | + jq '[{ name: "Boot time", unit: "s", value: .results[0].median, range: ((.results[0].max - .results[0].min) | tostring) }]' < boot-perf.json > boot-perf-formatted.json + + - name: Run analysis + uses: benchmark-action/github-action-benchmark@v1.20.3 + with: + tool: 'customSmallerIsBetter' + output-file-path: ghost/core/boot-perf-formatted.json + benchmark-data-dir-path: "" + gh-repository: github.com/TryGhost/Ghost-Benchmarks + github-token: ${{ secrets.CANARY_DOCKER_BUILD }} + auto-push: true + job_unit-tests: runs-on: ubuntu-latest needs: [job_get_metadata, job_install_deps] if: needs.job_get_metadata.outputs.changed_any_code == 'true' strategy: matrix: - node: [ '18.12.1' ] + node: [ '18.12.1', '20.11.1' ] name: Unit tests (Node ${{ matrix.node }}) steps: - uses: actions/checkout@v4 with: fetch-depth: 100 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -384,7 +437,7 @@ jobs: - run: yarn nx affected -t test:unit --base=${{ needs.job_get_metadata.outputs.BASE_COMMIT }} - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: startsWith(matrix.node, '18') with: name: unit-coverage @@ -403,7 +456,7 @@ jobs: if: needs.job_get_metadata.outputs.changed_core == 'true' strategy: matrix: - node: [ '18.12.1' ] + node: [ '18.12.1', '20.11.1' ] env: - DB: mysql8 NODE_ENV: testing-mysql @@ -418,7 +471,7 @@ jobs: name: Database tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }}) steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -467,7 +520,7 @@ jobs: endTime="$(date +%s)" echo "test_time=$(($endTime-$startTime))" >> $GITHUB_ENV - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: startsWith(matrix.node, '18') && contains(matrix.env.DB, 'mysql') with: name: e2e-coverage @@ -539,7 +592,9 @@ jobs: name: Regression tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }}) steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + with: + submodules: true + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -590,7 +645,7 @@ jobs: CI: true steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -604,7 +659,7 @@ jobs: - name: Get Playwright version id: playwright-version run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Check if Playwright browser is cached id: playwright-cache with: @@ -621,7 +676,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: admin-x-settings-playwright-report path: apps/admin-x-settings/playwright-report @@ -643,7 +698,7 @@ jobs: CI: true steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -657,7 +712,7 @@ jobs: - name: Get Playwright version id: playwright-version run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Check if Playwright browser is cached id: playwright-cache with: @@ -674,7 +729,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: comments-ui-playwright-report path: apps/comments-ui/playwright-report @@ -696,7 +751,7 @@ jobs: CI: true steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -710,7 +765,7 @@ jobs: - name: Get Playwright version id: playwright-version run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT - - uses: actions/cache@v3 + - uses: actions/cache@v4 name: Check if Playwright browser is cached id: playwright-cache with: @@ -727,7 +782,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: signup-form-playwright-report path: apps/signup-form/playwright-report @@ -750,7 +805,7 @@ jobs: with: fetch-depth: 0 submodules: true - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -779,7 +834,7 @@ jobs: echo "V4_DIR=$DIR" >> $GITHUB_ENV ghost install v4 --local -d $DIR - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 env: FORCE_COLOR: 0 with: @@ -789,6 +844,13 @@ jobs: run: | ghost update -f -d $V4_DIR --archive $(pwd)/ghost/core/ghost.tgz + - name: Save Ghost CLI Debug Logs + if: failure() + uses: actions/upload-artifact@v3 + with: + name: ghost-cli-debug-logs + path: /home/runner/.ghost/logs/ + - name: Clean Install run: | DIR=$(mktemp -d) @@ -825,7 +887,7 @@ jobs: - name: Restore Admin coverage if: contains(needs.job_admin-tests.result, 'success') - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: admin-coverage @@ -842,7 +904,7 @@ jobs: - name: Restore E2E coverage if: contains(needs.job_database-tests.result, 'success') - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: e2e-coverage diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml deleted file mode 100644 index 9e8e243cf9a..00000000000 --- a/.github/workflows/i18n.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: i18n check -on: - pull_request_target: - types: [opened] - paths: - - 'ghost/i18n/locales/**' -jobs: - create-label: - runs-on: ubuntu-latest - if: github.repository_owner == 'TryGhost' - name: Add i18n label - steps: - - uses: actions/github-script@v6 - with: - script: | - github.rest.issues.addLabels({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - labels: ["i18n"] - }) diff --git a/.github/workflows/migration-review.yml b/.github/workflows/migration-review.yml index 18d0adbcf1b..885fa98f7e5 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 3162730395a..b3e179754d2 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,7 @@ typings/ *.db *.db-journal +/ghost/core/test-results/ /ghost/core/core/server/data/export/exported* /ghost/core/content/tmp/* /ghost/core/content/data/* @@ -133,7 +134,7 @@ Caddyfile /apps/comments-ui/umd /apps/comments-ui/playwright-report /ghost/comments-ui/playwright/.cache/ -/ghost/comments-ui/test-results/ +/apps/comments-ui/test-results/ # Portal !/apps/portal/.env @@ -166,3 +167,8 @@ tsconfig.tsbuildinfo /apps/admin-x-settings/test-results/ /apps/admin-x-settings/playwright-report/ /apps/admin-x-settings/playwright/.cache/ + +# Tinybird +.tinyb +.venv +.diff_tmp diff --git a/.vscode/launch.json b/.vscode/launch.json index 3e00ee2b0b8..698afa15d2b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,21 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Backend", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/.github/scripts/dev.js", + "args": [ + "--ghost" + ], + "autoAttachChildProcesses": true, + "outputCapture": "std", + "console": "integratedTerminal", + }, { "type": "node", "request": "launch", diff --git a/LICENSE b/LICENSE index b52cfae1945..ce0968e726b 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/PRIVACY.md b/PRIVACY.md index 5d6bd677029..667393649c3 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -44,8 +44,4 @@ Ghost outputs basic meta tags to allow rich snippets of your content to be recog - Schema.org - http://schema.org/docs/documents.html - Open Graph - http://ogp.me/ -- Twitter cards - https://dev.twitter.com/cards/overview - -### Default Theme - -The default theme which comes with Ghost loads a copy of jQuery from the jQuery Foundation's [public CDN](https://code.jquery.com/jquery-3.4.1.min.js). +- Twitter cards - https://dev.twitter.com/cards/overview \ No newline at end of file diff --git a/README.md b/README.md index dff64aaeda9..7fa794ca2ce 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Twitter

- Downloads + Downloads Latest release @@ -29,9 +29,7 @@ Contributors

-

- Love open source? We're hiring DevOps engineers to work on Ghost full-time. -

+   @@ -84,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/)** @@ -92,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/.eslintignore b/apps/admin-x-activitypub/.eslintignore new file mode 100644 index 00000000000..9944eccea22 --- /dev/null +++ b/apps/admin-x-activitypub/.eslintignore @@ -0,0 +1 @@ +tailwind.config.cjs diff --git a/apps/admin-x-activitypub/.eslintrc.cjs b/apps/admin-x-activitypub/.eslintrc.cjs new file mode 100644 index 00000000000..919b0f2cdf6 --- /dev/null +++ b/apps/admin-x-activitypub/.eslintrc.cjs @@ -0,0 +1,56 @@ +/* eslint-env node */ +module.exports = { + root: true, + extends: [ + 'plugin:ghost/ts', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended' + ], + plugins: [ + 'ghost', + 'react-refresh', + 'tailwindcss' + ], + settings: { + react: { + version: 'detect' + } + }, + rules: { + // sort multiple import lines into alphabetical groups + 'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', { + memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple'] + }], + + // TODO: re-enable this (maybe fixed fast refresh?) + 'react-refresh/only-export-components': 'off', + + // suppress errors for missing 'import React' in JSX files, as we don't need it + 'react/react-in-jsx-scope': 'off', + // ignore prop-types for now + 'react/prop-types': 'off', + + // TODO: re-enable these if deemed useful + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-empty-function': 'off', + + // custom react rules + 'react/jsx-sort-props': ['error', { + reservedFirst: true, + callbacksLast: true, + shorthandLast: true, + locale: 'en' + }], + 'react/button-has-type': 'error', + 'react/no-array-index-key': 'error', + 'react/jsx-key': 'off', + + 'tailwindcss/classnames-order': ['error', {config: 'tailwind.config.cjs'}], + 'tailwindcss/enforces-negative-arbitrary-values': ['warn', {config: 'tailwind.config.cjs'}], + 'tailwindcss/enforces-shorthand': ['warn', {config: 'tailwind.config.cjs'}], + 'tailwindcss/migration-from-tailwind-2': ['warn', {config: 'tailwind.config.cjs'}], + 'tailwindcss/no-arbitrary-value': 'off', + 'tailwindcss/no-custom-classname': 'off', + 'tailwindcss/no-contradicting-classname': ['error', {config: 'tailwind.config.cjs'}] + } +}; diff --git a/apps/admin-x-activitypub/.gitignore b/apps/admin-x-activitypub/.gitignore new file mode 100644 index 00000000000..68565785a7f --- /dev/null +++ b/apps/admin-x-activitypub/.gitignore @@ -0,0 +1,3 @@ +dist +playwright-report +test-results diff --git a/apps/admin-x-activitypub/index.html b/apps/admin-x-activitypub/index.html new file mode 100644 index 00000000000..60bd860b4a7 --- /dev/null +++ b/apps/admin-x-activitypub/index.html @@ -0,0 +1,13 @@ + + + + + + + AdminX Standalone + + +
+ + + diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json new file mode 100644 index 00000000000..d73be09910f --- /dev/null +++ b/apps/admin-x-activitypub/package.json @@ -0,0 +1,73 @@ +{ + "name": "@tryghost/admin-x-activitypub", + "version": "0.0.1", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TryGhost/Ghost/tree/main/apps/admin-x-activitypub" + }, + "author": "Ghost Foundation", + "files": [ + "LICENSE", + "README.md", + "dist/" + ], + "main": "./dist/admin-x-activitypub.umd.cjs", + "module": "./dist/admin-x-activitypub.js", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "dev": "vite build --watch", + "dev:start": "vite", + "build": "tsc && vite build", + "lint": "yarn run lint:code && yarn run lint:test", + "lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src", + "lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test", + "test:unit": "vitest run", + "test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test", + "test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 yarn test:acceptance --headed", + "test:acceptance:full": "ALL_BROWSERS=1 yarn test:acceptance", + "preview": "vite preview" + }, + "devDependencies": { + "@playwright/test": "1.38.1", + "@testing-library/react": "14.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", + "ts-jest": "29.1.5" + }, + "nx": { + "targets": { + "build": { + "dependsOn": [ + "^build" + ] + }, + "dev": { + "dependsOn": [ + "^build" + ] + }, + "test:unit": { + "dependsOn": [ + "^build", + "test:unit" + ] + }, + "test:acceptance": { + "dependsOn": [ + "^build", + "test:acceptance" + ] + } + } + } +} diff --git a/apps/admin-x-activitypub/playwright.config.mjs b/apps/admin-x-activitypub/playwright.config.mjs new file mode 100644 index 00000000000..8fa59553e54 --- /dev/null +++ b/apps/admin-x-activitypub/playwright.config.mjs @@ -0,0 +1,3 @@ +import {adminXPlaywrightConfig} from '@tryghost/admin-x-framework/playwright'; + +export default adminXPlaywrightConfig(); diff --git a/apps/admin-x-activitypub/postcss.config.cjs b/apps/admin-x-activitypub/postcss.config.cjs new file mode 100644 index 00000000000..8799f4acf82 --- /dev/null +++ b/apps/admin-x-activitypub/postcss.config.cjs @@ -0,0 +1 @@ +module.exports = require('@tryghost/admin-x-design-system/postcss.config.cjs'); 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 00000000000..4cdaee420e1 --- /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 new file mode 100644 index 00000000000..62145ea0ea9 --- /dev/null +++ b/apps/admin-x-activitypub/src/App.tsx @@ -0,0 +1,32 @@ +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'; + +interface AppProps { + framework: TopLevelFrameworkProps; + 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; \ 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 00000000000..807ec785fb9 --- /dev/null +++ b/apps/admin-x-activitypub/src/MainContent.tsx @@ -0,0 +1,63 @@ +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(); + } + }); +} + +export 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(); + } + }); +} + +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 00000000000..7d505974470 --- /dev/null +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -0,0 +1,446 @@ +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', + orderedItems: [] + }) + } + }); + 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', + orderedItems: [] + }) + } + }); + 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', + orderedItems: [{ + 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 00000000000..0549e5994f5 --- /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 ('orderedItems' in json) { + return json.orderedItems as Activity[]; + } + 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 00000000000..189768bcc5d 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 00000000000..9f784beed20 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -0,0 +1,132 @@ +import APAvatar, {AvatarBadge} from './global/APAvatar'; +import ActivityItem from './activities/ActivityItem'; +import MainNavigation from './navigation/MainNavigation'; +import React from 'react'; +import {Button} from '@tryghost/admin-x-design-system'; +import {useBrowseInboxForUser, useFollowersForUser} from '../MainContent'; + +interface ActivitiesProps {} + +// eslint-disable-next-line no-shadow +enum ACTVITY_TYPE { + LIKE = 'Like', + FOLLOW = 'Follow' +} + +type Actor = { + id: string + name: string + preferredUsername: string + url: string +} + +type ActivityObject = { + name: string + url: string +} + +type Activity = { + id: string + type: ACTVITY_TYPE + object?: ActivityObject + actor: Actor +} + +const getActorUsername = (actor: Actor): string => { + const url = new URL(actor.url); + const domain = url.hostname; + + return `@${actor.preferredUsername}@${domain}`; +}; + +const getActivityDescription = (activity: Activity): string => { + switch (activity.type) { + case ACTVITY_TYPE.FOLLOW: + return 'Followed you'; + case ACTVITY_TYPE.LIKE: + if (activity.object) { + return `Liked your article "${activity.object.name}"`; + } + } + + return ''; +}; + +const getActivityUrl = (activity: Activity): string | null => { + if (activity.object) { + return activity.object.url; + } + + return null; +}; + +const getActorUrl = (activity: Activity): string | null => { + if (activity.actor) { + return activity.actor.url; + } + + return null; +}; + +const getActivityBadge = (activity: Activity): AvatarBadge => { + switch (activity.type) { + case ACTVITY_TYPE.FOLLOW: + return 'user-fill'; + case ACTVITY_TYPE.LIKE: + if (activity.object) { + return 'heart-fill'; + } + } +}; + +const isFollower = (id: string, followerIds: string[]): boolean => { + return followerIds.includes(id); +}; + +const Activities: React.FC = ({}) => { + const user = 'index'; + const {data: activityData} = useBrowseInboxForUser(user); + const activities = (activityData || []) + .filter((activity) => { + return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type); + }) + .reverse(); // Endpoint currently returns items oldest-newest + const {data: followerData} = useFollowersForUser(user); + const followers = followerData || []; + + return ( + <> + +
+ {activities.length === 0 && ( +
This is an empty state when there are no activities
+ )} + {activities.length > 0 && ( +
+ {activities?.map(activity => ( + + +
+
+ {activity.actor.name} + {getActorUsername(activity.actor)} +
+
{getActivityDescription(activity)}
+
+ {isFollower(activity.actor.id, followers) === false && ( +
+ )} +
+ + ); +}; + +export default Activities; 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 00000000000..482c956fa23 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/Inbox.tsx @@ -0,0 +1,92 @@ +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 [layout, setLayout] = 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 handleViewContent = (object: ObjectProperties, actor: ActorProperties) => { + setArticleContent(object); + setArticleActor(actor); + NiceModal.show(ArticleModal, { + object: object, + actor: actor + }); + }; + + const handleLayoutChange = (newLayout: string) => { + setLayout(newLayout); + }; + + return ( + <> + +
+
+ {inboxTabActivities.length > 0 ? ( +
    + {inboxTabActivities.reverse().map((activity, index) => ( +
  • handleViewContent(activity.object, activity.actor)} + > + + {index < inboxTabActivities.length - 1 && ( +
    + )} +
  • + ))} +
+ ) : ( +
+
+ 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; 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 00000000000..859df3e0997 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/Profile.tsx @@ -0,0 +1,122 @@ +import APAvatar from './global/APAvatar'; +import MainNavigation from './navigation/MainNavigation'; +import React, {useState} from 'react'; +import {ActivityPubAPI} from '../api/activitypub'; +import {Heading, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useQuery} from '@tanstack/react-query'; + +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 {data: followersCount = 0} = useFollowersCountForUser('index'); + const {data: followingCount = 0} = useFollowingCountForUser('index'); + + type ProfileTab = 'posts' | 'likes' | 'following' | 'followers'; + + const [selectedTab, setSelectedTab] = useState('posts'); + + const tabs = [ + { + id: 'posts', + title: 'Posts', + contents: (
+ You haven’t posted anything yet. +
), + counter: 240 + }, + { + id: 'likes', + title: 'Likes', + contents: (
+ You haven’t liked anything yet. +
), + counter: 27 + }, + { + id: 'following', + title: 'Following', + contents: (
+ You haven’t followed anyone yet. +
), + counter: followingCount + }, + { + id: 'followers', + title: 'Followers', + contents: (
+ Nobody’s following you yet. Their loss! +
), + counter: followersCount + } + ].filter(Boolean) as Tab[]; + + return ( + <> + +
+
+
+
+
+
+ +
+ John Doe + @index@site.com +

This is a summary/bio/etc which could be kinda long in certain cases but not always, so...

+ www.coolsite.com + containerClassName='mt-6' selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} /> +
+ + {/*
+
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 00000000000..71956458c4e --- /dev/null +++ b/apps/admin-x-activitypub/src/components/Search.tsx @@ -0,0 +1,52 @@ +import APAvatar from './global/APAvatar'; +import ActivityItem from './activities/ActivityItem'; +import MainNavigation from './navigation/MainNavigation'; +import React from 'react'; +import {Button, Icon} from '@tryghost/admin-x-design-system'; + +interface SearchProps {} + +const Search: React.FC = ({}) => { + return ( + <> + +
+
Search the Fediverse
+ + +
+
Lydia Mango @username@domain.com
+
1,535 followers
+
+
+ + ); +}; + +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 00000000000..021cbbf1b65 --- /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 00000000000..6db9b4186c1 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx @@ -0,0 +1,39 @@ +import React, {ReactNode} from 'react'; + +export type Activity = { + type: string, + object: { + type: string + } +} + +interface ActivityItemProps { + children?: ReactNode; + url?: string | null; +} + +const ActivityItem: React.FC = ({children, url = null}) => { + const childrenArray = React.Children.toArray(children); + + const Item = ( +
+
+ {childrenArray[0]} + {childrenArray[1]} + {childrenArray[2]} +
+
+ ); + + if (url) { + return ( + + {Item} + + ); + } + + return Item; +}; + +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 00000000000..4475737005c --- /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 00000000000..1f1fd1386c9 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx @@ -0,0 +1,108 @@ +import FeedItem from './FeedItem'; +import MainHeader from '../navigation/MainHeader'; +import NiceModal, {useModal} from '@ebay/nice-modal-react'; +import React, {useEffect, useRef} from 'react'; +import articleBodyStyles from '../articleBodyStyles'; +import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {Button, Modal} from '@tryghost/admin-x-design-system'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; + +interface ArticleModalProps { + object: ObjectProperties; + actor: ActorProperties; +} + +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, actor}) => { + const modal = useModal(); + return ( + } + height={'full'} + padding={false} + size='bleed' + width={640} + > + +
+
+
+ {object.type} +
+
+
+
+
+ {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 00000000000..87d41de12ce --- /dev/null +++ b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx @@ -0,0 +1,321 @@ +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 renderFeedAttachment(object: ObjectProperties, layout: string) { + 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 (layout === 'modal') { + gridClass = 'grid-cols-1'; // Single image, full width + } else if (attachmentCount === 2) { + gridClass = 'grid-cols-2 auto-rows-[150px]'; // Two images, side by side + } else if (attachmentCount === 3 || attachmentCount === 4) { + gridClass = 'grid-cols-2 auto-rows-[150px]'; // 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; + } +} + +function renderInboxAttachment(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 === 2) { + // gridClass = 'grid-cols-2 auto-rows-[150px]'; // Two images, side by side + // } else if (attachmentCount === 3 || attachmentCount === 4) { + // gridClass = 'grid-cols-2 auto-rows-[150px]'; // Three or four images, two per row + // } + return ( +
+
+ +
+ {attachmentCount - 1}
+
+
+ ); + } + + switch (attachment.mediaType) { + case 'image/jpeg': + case 'image/png': + case 'image/gif': + return ( +
+ +
+ ); + 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; + last?: boolean; +} + +const FeedItem: React.FC = ({actor, object, layout, type, last}) => { + 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}} +
+ {renderFeedAttachment(object, layout)} +
+
+
+
+ {/*
*/} +
+
+
+ )} + + ); + } else if (layout === 'modal') { + return ( + <> + {object && ( +
+
+ {(type === 'Announce' && object.type === 'Note') &&
+
+ {actor.name} reposted +
} +
+
+ +
+ {/*
*/} +
+
+ {author.name} + {getRelativeTimestamp(date)} +
+
+ {getUsername(author)} +
+
+
+
+ {object.name && {object.name}} +
+ {renderFeedAttachment(object, layout)} + {/*
+
*/} +
+
+
+
+ {/*
*/} +
+
+
+
+
+ + )} + + ); + } else if (layout === 'reply') { + return ( + <> + {object && ( +
+ {(type === 'Announce' && object.type === 'Note') &&
+
+ {actor.name} reposted +
} +
+
+ +
+ {/*
*/} +
+
+ {author.name} + {getRelativeTimestamp(date)} +
+
+ {getUsername(author)} +
+
+
+
+ {object.name && {object.name}} +
+ {renderFeedAttachment(object, layout)} +
+
+
+
+ {/*
*/} +
+
+ {!last &&
} +
+ )} + + ); + } else if (layout === 'inbox') { + return ( + <> + {object && ( +
+
+ +
+
+ {author.name} +  {getUsername(author)} + {getRelativeTimestamp(date)} +
+
+
+ {object.name && {object.name}} +
+
+ {renderInboxAttachment(object)} +
+
+
+
+ )} + + ); + } + + return (<>); +}; + +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 00000000000..8b3a23bc9e8 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/global/APAvatar.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {Icon} from '@tryghost/admin-x-design-system'; + +type AvatarSize = 'xs' | 'sm' | 'lg'; +export type AvatarBadge = 'user-fill' | 'heart-fill' | undefined; + +interface APAvatarProps { + author?: ActorProperties; + size?: AvatarSize; + badge?: AvatarBadge; +} + +const APAvatar: React.FC = ({author, size, badge}) => { + let iconSize = 18; + let containerClass = ''; + let imageClass = 'z-10 rounded w-10 h-10'; + const badgeClass = `w-6 h-6 rounded-full absolute -bottom-2 -right-2 border-2 border-white content-box flex items-center justify-center `; + let badgeColor = ''; + + switch (badge) { + case 'user-fill': + badgeColor = ' bg-blue-500'; + break; + case 'heart-fill': + badgeColor = ' bg-red-500'; + break; + } + + switch (size) { + case 'xs': + iconSize = 12; + containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[3px] w-6 h-6'; + imageClass = 'z-10 rounded w-6 h-6'; + break; + case 'sm': + containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-10 h-10'; + break; + case 'lg': + containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-22 h-22'; + break; + default: + containerClass = 'z-10 rounded bg-grey-100 flex items-center justify-center p-[10px] w-10 h-10'; + break; + } + + return ( + <> + {author && author.icon?.url ? ( +
+ + {badge && ( +
+ +
+ )} +
+ ) : ( +
+ +
+ )} + + ); +}; + +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 00000000000..76ea0e4bb2a --- /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 00000000000..93361b1c0a8 --- /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 00000000000..db4ab93ba77 --- /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 00000000000..0d8f72abba8 --- /dev/null +++ b/apps/admin-x-activitypub/src/components/navigation/MainNavigation.tsx @@ -0,0 +1,63 @@ +import MainHeader from './MainHeader'; +import React, {useEffect, useState} from 'react'; +import {Button, Tooltip} from '@tryghost/admin-x-design-system'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; + +interface MainNavigationProps { + title?: string; + page?: string; + onLayoutChange?: (layout: string) => void; +} + +const MainNavigation: React.FC = ({ + title = 'Home', + page = '', + onLayoutChange +}) => { + const {route, updateRoute} = useRouting(); + const mainRoute = route.split('/')[0]; + const [layout, setLayout] = useState('inbox'); + + useEffect(() => { + if (onLayoutChange) { + onLayoutChange(layout); + } + }, [layout, onLayoutChange]); + + return ( + +
+

+ {title} +

+
+
+
+
+ {page === 'home' && +
+ +
+ } +
+
+ ); +}; + +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 00000000000..db0dd424f34 --- /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 00000000000..6b58dc94bd2 --- /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/index.tsx b/apps/admin-x-activitypub/src/index.tsx new file mode 100644 index 00000000000..cb20f2b5899 --- /dev/null +++ b/apps/admin-x-activitypub/src/index.tsx @@ -0,0 +1,6 @@ +import './styles/index.css'; +import App from './App'; + +export { + App as AdminXApp +}; diff --git a/apps/admin-x-activitypub/src/standalone.tsx b/apps/admin-x-activitypub/src/standalone.tsx new file mode 100644 index 00000000000..dd723c833c7 --- /dev/null +++ b/apps/admin-x-activitypub/src/standalone.tsx @@ -0,0 +1,5 @@ +import './styles/index.css'; +import App from './App.tsx'; +import renderStandaloneApp from '@tryghost/admin-x-framework/test/render'; + +renderStandaloneApp(App, {}); diff --git a/apps/admin-x-activitypub/src/styles/index.css b/apps/admin-x-activitypub/src/styles/index.css new file mode 100644 index 00000000000..d9a471ebfb4 --- /dev/null +++ b/apps/admin-x-activitypub/src/styles/index.css @@ -0,0 +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 00000000000..b36554270f0 --- /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 00000000000..2fd6099e337 --- /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/tailwind.config.cjs b/apps/admin-x-activitypub/tailwind.config.cjs new file mode 100644 index 00000000000..c85c4230ddb --- /dev/null +++ b/apps/admin-x-activitypub/tailwind.config.cjs @@ -0,0 +1,6 @@ +const adminXPreset = require('@tryghost/admin-x-design-system/tailwind.cjs'); + +module.exports = { + presets: [adminXPreset('.admin-x-activitypub')], + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}', '../../node_modules/@tryghost/admin-x-design-system/es/**/*.{js,ts,jsx,tsx}'] +}; \ No newline at end of file diff --git a/apps/admin-x-activitypub/test/.eslintrc.cjs b/apps/admin-x-activitypub/test/.eslintrc.cjs new file mode 100644 index 00000000000..42f8e77355a --- /dev/null +++ b/apps/admin-x-activitypub/test/.eslintrc.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/ts-test' + ] +}; diff --git a/apps/admin-x-activitypub/test/acceptance/app.test.ts b/apps/admin-x-activitypub/test/acceptance/app.test.ts new file mode 100644 index 00000000000..90fa60d8428 --- /dev/null +++ b/apps/admin-x-activitypub/test/acceptance/app.test.ts @@ -0,0 +1,10 @@ +import {expect, test} from '@playwright/test'; +// import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance'; + +test.describe('Demo', async () => { + test('Renders the list page', async ({page}) => { + await page.goto('/'); + + 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 00000000000..8e855d1e215 --- /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/utils/get-username.test.tsx b/apps/admin-x-activitypub/test/unit/utils/get-username.test.tsx new file mode 100644 index 00000000000..8ca868b1c9b --- /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 new file mode 100644 index 00000000000..585a4118825 --- /dev/null +++ b/apps/admin-x-activitypub/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "skipLibCheck": true, + "types": ["vite/client", "jest"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "test"] +} diff --git a/apps/admin-x-activitypub/vite.config.mjs b/apps/admin-x-activitypub/vite.config.mjs new file mode 100644 index 00000000000..25210751192 --- /dev/null +++ b/apps/admin-x-activitypub/vite.config.mjs @@ -0,0 +1,18 @@ +import adminXViteConfig from '@tryghost/admin-x-framework/vite'; +import pkg from './package.json'; +import {resolve} from 'path'; + +export default (function viteConfig() { + return adminXViteConfig({ + packageName: pkg.name, + entry: resolve(__dirname, 'src/index.tsx'), + overrides: { + test: { + include: [ + './test/unit/**/*', + './src/**/*.test.ts' + ] + } + } + }); +}); diff --git a/apps/admin-x-demo/.eslintignore b/apps/admin-x-demo/.eslintignore new file mode 100644 index 00000000000..9944eccea22 --- /dev/null +++ b/apps/admin-x-demo/.eslintignore @@ -0,0 +1 @@ +tailwind.config.cjs diff --git a/apps/admin-x-demo/.eslintrc.cjs b/apps/admin-x-demo/.eslintrc.cjs new file mode 100644 index 00000000000..919b0f2cdf6 --- /dev/null +++ b/apps/admin-x-demo/.eslintrc.cjs @@ -0,0 +1,56 @@ +/* eslint-env node */ +module.exports = { + root: true, + extends: [ + 'plugin:ghost/ts', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended' + ], + plugins: [ + 'ghost', + 'react-refresh', + 'tailwindcss' + ], + settings: { + react: { + version: 'detect' + } + }, + rules: { + // sort multiple import lines into alphabetical groups + 'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', { + memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple'] + }], + + // TODO: re-enable this (maybe fixed fast refresh?) + 'react-refresh/only-export-components': 'off', + + // suppress errors for missing 'import React' in JSX files, as we don't need it + 'react/react-in-jsx-scope': 'off', + // ignore prop-types for now + 'react/prop-types': 'off', + + // TODO: re-enable these if deemed useful + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-empty-function': 'off', + + // custom react rules + 'react/jsx-sort-props': ['error', { + reservedFirst: true, + callbacksLast: true, + shorthandLast: true, + locale: 'en' + }], + 'react/button-has-type': 'error', + 'react/no-array-index-key': 'error', + 'react/jsx-key': 'off', + + 'tailwindcss/classnames-order': ['error', {config: 'tailwind.config.cjs'}], + 'tailwindcss/enforces-negative-arbitrary-values': ['warn', {config: 'tailwind.config.cjs'}], + 'tailwindcss/enforces-shorthand': ['warn', {config: 'tailwind.config.cjs'}], + 'tailwindcss/migration-from-tailwind-2': ['warn', {config: 'tailwind.config.cjs'}], + 'tailwindcss/no-arbitrary-value': 'off', + 'tailwindcss/no-custom-classname': 'off', + 'tailwindcss/no-contradicting-classname': ['error', {config: 'tailwind.config.cjs'}] + } +}; diff --git a/apps/admin-x-demo/.gitignore b/apps/admin-x-demo/.gitignore new file mode 100644 index 00000000000..68565785a7f --- /dev/null +++ b/apps/admin-x-demo/.gitignore @@ -0,0 +1,3 @@ +dist +playwright-report +test-results diff --git a/apps/admin-x-demo/index.html b/apps/admin-x-demo/index.html new file mode 100644 index 00000000000..60bd860b4a7 --- /dev/null +++ b/apps/admin-x-demo/index.html @@ -0,0 +1,13 @@ + + + + + + + AdminX Standalone + + +
+ + + diff --git a/apps/admin-x-demo/package.json b/apps/admin-x-demo/package.json new file mode 100644 index 00000000000..a314be47621 --- /dev/null +++ b/apps/admin-x-demo/package.json @@ -0,0 +1,62 @@ +{ + "name": "@tryghost/admin-x-demo", + "version": "0.0.20", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/TryGhost/Ghost/tree/main/apps/admin-x-demo" + }, + "author": "Ghost Foundation", + "files": [ + "LICENSE", + "README.md", + "dist/" + ], + "main": "./dist/admin-x-demo.umd.cjs", + "module": "./dist/admin-x-demo.js", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "dev": "vite build --watch", + "dev:start": "vite", + "build": "tsc && vite build", + "lint": "yarn run lint:code && yarn run lint:test", + "lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src", + "lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test", + "test:unit": "vitest run", + "test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test", + "test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 yarn test:acceptance --headed", + "test:acceptance:full": "ALL_BROWSERS=1 yarn test:acceptance", + "preview": "vite preview" + }, + "devDependencies": { + "@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", + "@types/react-dom": "18.3.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "nx": { + "targets": { + "dev": { + "dependsOn": [ + "^build" + ] + }, + "test:unit": { + "dependsOn": [ + "^build" + ] + }, + "test:acceptance": { + "dependsOn": [ + "^build" + ] + } + } + } +} diff --git a/apps/admin-x-demo/playwright.config.mjs b/apps/admin-x-demo/playwright.config.mjs new file mode 100644 index 00000000000..8fa59553e54 --- /dev/null +++ b/apps/admin-x-demo/playwright.config.mjs @@ -0,0 +1,3 @@ +import {adminXPlaywrightConfig} from '@tryghost/admin-x-framework/playwright'; + +export default adminXPlaywrightConfig(); diff --git a/apps/admin-x-demo/postcss.config.cjs b/apps/admin-x-demo/postcss.config.cjs new file mode 100644 index 00000000000..8799f4acf82 --- /dev/null +++ b/apps/admin-x-demo/postcss.config.cjs @@ -0,0 +1 @@ +module.exports = require('@tryghost/admin-x-design-system/postcss.config.cjs'); diff --git a/apps/admin-x-demo/src/App.tsx b/apps/admin-x-demo/src/App.tsx new file mode 100644 index 00000000000..b39172f6792 --- /dev/null +++ b/apps/admin-x-demo/src/App.tsx @@ -0,0 +1,30 @@ +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'; + +interface AppProps { + framework: TopLevelFrameworkProps; + designSystem: DesignSystemAppProps; +} + +const modals = { + paths: { + 'demo-modal': 'DemoModal' + }, + load: async () => import('./components/modals') +}; + +const App: React.FC = ({framework, designSystem}) => { + return ( + + + + + + + + ); +}; + +export default App; diff --git a/apps/admin-x-demo/src/DetailPage.tsx b/apps/admin-x-demo/src/DetailPage.tsx new file mode 100644 index 00000000000..d93365cda81 --- /dev/null +++ b/apps/admin-x-demo/src/DetailPage.tsx @@ -0,0 +1,147 @@ +import {Avatar, Breadcrumbs, Button, Heading, Page, Toggle, ViewContainer} from '@tryghost/admin-x-design-system'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; + +const DetailPage: React.FC = () => { + const {updateRoute} = useRouting(); + + return ( + { + updateRoute(''); + } + }, + { + label: 'Emerson Vaccaro' + } + ]} + onBack={() => { + updateRoute(''); + }} + /> + } + fullBleedPage={false} + > + + + Emerson Vaccaro +
Colombus, OH
+ + } + primaryAction={ + { + icon: 'ellipsis', + color: 'outline' + } + } + type='page' + > +
+
+ Last seen on 22 June 2023 + Created on 27 Jan 2021 +
+
+ Emails received + 181 +
+
+ Emails opened + 104 +
+
+ Average open rate + 57% +
+
+
+
+
+ Member data +
+
+ Name +
Emerson Vaccaro
+
+
+ Email +
emerson@vaccaro.com
+
+
+ Labels +
+
VIP
+
Inner Circle
+
+
+
+ Notes +
No notes.
+
+
+
+ Newsletters +
+
+ + Daily news +
+
+ + Weekly roundup +
+
+ + The Inner Circle +
+
+ This member cannot receive emails due to permanent failure (bounce). +
+
+
+
+ Subscriptions +
+
+ $5 + Yearly +
+
+ Gold + Renews 21 Jan 2024 +
+
+
+
+
+ Activity +
+
+ Logged in + 13 days ago +
+
+ Subscribed to Daily News + 17 days ago +
+
+ Logged in + 21 days ago +
+
+
+
+
+ ); +}; + +export default DetailPage; diff --git a/apps/admin-x-demo/src/ListPage.tsx b/apps/admin-x-demo/src/ListPage.tsx new file mode 100644 index 00000000000..5432c8f570d --- /dev/null +++ b/apps/admin-x-demo/src/ListPage.tsx @@ -0,0 +1,246 @@ +import {Avatar, Button, ButtonGroup, DynamicTable, DynamicTableColumn, DynamicTableRow, Heading, Hint, Page, SortMenu, Tooltip, ViewContainer, showToast} from '@tryghost/admin-x-design-system'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; +import {useState} from 'react'; + +const ListPage = () => { + const {updateRoute} = useRouting(); + const [view, setView] = useState('list'); + + const dummyActions = [ + , 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-settings/src/admin-x-ds/global/Menu.tsx b/apps/admin-x-design-system/src/global/Menu.tsx similarity index 95% rename from apps/admin-x-settings/src/admin-x-ds/global/Menu.tsx rename to apps/admin-x-design-system/src/global/Menu.tsx index 042f51d479a..c9a99e73721 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/Menu.tsx +++ b/apps/admin-x-design-system/src/global/Menu.tsx @@ -1,6 +1,6 @@ +import React from 'react'; import Button, {ButtonProps, ButtonSize} from './Button'; import Popover, {PopoverPosition} from './Popover'; -import React from 'react'; export type MenuItem = { id: string, @@ -8,7 +8,7 @@ export type MenuItem = { onClick?: () => void } -interface MenuProps { +export interface MenuProps { trigger?: React.ReactNode; triggerButtonProps?: ButtonProps; triggerSize?: ButtonSize; @@ -20,7 +20,7 @@ const Menu: React.FC = ({ trigger, triggerButtonProps, items, - position = 'left' + position = 'start' }) => { if (!trigger) { trigger = : null} + + ))} + + + + ); +}; + +export default SortMenu; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/SortableList.stories.tsx b/apps/admin-x-design-system/src/global/SortableList.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/SortableList.stories.tsx rename to apps/admin-x-design-system/src/global/SortableList.stories.tsx diff --git a/apps/admin-x-design-system/src/global/SortableList.tsx b/apps/admin-x-design-system/src/global/SortableList.tsx new file mode 100644 index 00000000000..43247d0339d --- /dev/null +++ b/apps/admin-x-design-system/src/global/SortableList.tsx @@ -0,0 +1,177 @@ +import {DndContext, DragOverlay, DraggableAttributes, closestCenter} from '@dnd-kit/core'; +import {SortableContext, useSortable, verticalListSortingStrategy} from '@dnd-kit/sortable'; +import {CSS} from '@dnd-kit/utilities'; +import clsx from 'clsx'; +import React, {ElementType, HTMLProps, ReactNode, useState} from 'react'; +import Heading from './Heading'; +import Hint from './Hint'; +import Icon from './Icon'; +import Separator from './Separator'; + +export interface SortableItemContainerProps { + id: string; + setRef?: (element: HTMLElement | null) => void; + isDragging: boolean; + dragHandleAttributes?: DraggableAttributes; + // TODO: figure out a stricter alternative for Function + // eslint-disable-next-line @typescript-eslint/ban-types + dragHandleListeners?: Record; + dragHandleClass?: string; + style?: React.CSSProperties; + children: ReactNode; + separator?: boolean; +} + +export type DragIndicatorProps = Pick & React.HTMLAttributes + +export const DragIndicator: React.FC = ({isDragging, dragHandleAttributes, dragHandleListeners, dragHandleClass, className, ...props}) => ( + +); + +const DefaultContainer: React.FC = ({ + setRef, + isDragging, + style, + separator, + children, + ...props +}) => ( +
+ + {children} +
+); + +const SortableItem: React.FC<{ + id: string + children: ReactNode; + separator?: boolean; + dragHandleClass?: string; + container: (props: SortableItemContainerProps) => ReactNode; +}> = ({id, children, separator, dragHandleClass, container}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition + } = useSortable({id}); + + const style = { + transform: CSS.Transform.toString(transform), + transition + }; + + return container({ + id, + setRef: setNodeRef, + isDragging: false, + separator: separator, + dragHandleClass: dragHandleClass, + dragHandleAttributes: attributes, + dragHandleListeners: listeners, + style, + children + }); +}; + +export interface SortableListProps extends HTMLProps { + title?: string; + titleSeparator?: boolean; + hint?: React.ReactNode; + items: Item[]; + itemSeparator?: boolean; + dragHandleClass?: string; + onMove: (id: string, overId: string) => void; + renderItem: (item: Item) => ReactNode; + container?: (props: SortableItemContainerProps) => ReactNode; + wrapper?: ElementType; + dragOverlayWrapper?: keyof JSX.IntrinsicElements; +} + +/** + * Note: For lists which don't have an ID, you can use `useSortableIndexedList` to give items a consistent index-based ID. + */ +const SortableList = ({ + title, + titleSeparator, + hint, + items, + itemSeparator = true, + dragHandleClass, + onMove, + renderItem, + container = props => , + wrapper: Wrapper = React.Fragment, + dragOverlayWrapper, + ...props +}: SortableListProps) => { + const [draggingId, setDraggingId] = useState(null); + + if (!items.length) { + return <>; + } + + return ( +
+ {title && {title}} +
+ { + onMove(event.active.id as string, event.over?.id as string); + setDraggingId(null); + }} + onDragStart={event => setDraggingId(event.active.id as string)} + > + + + {items.map(item => ( + {renderItem(item)} + ))} + + + + {draggingId ? container({ + id: draggingId, + isDragging: true, + children: renderItem(items.find(({id}) => id === draggingId)!) + }) : null} + + +
+ {hint && + <> + {!itemSeparator && } + {hint} + + } +
+ ); +}; + +export default SortableList; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/StickyFooter.stories.tsx b/apps/admin-x-design-system/src/global/StickyFooter.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/StickyFooter.stories.tsx rename to apps/admin-x-design-system/src/global/StickyFooter.stories.tsx diff --git a/apps/admin-x-settings/src/admin-x-ds/global/StickyFooter.tsx b/apps/admin-x-design-system/src/global/StickyFooter.tsx similarity index 96% rename from apps/admin-x-settings/src/admin-x-ds/global/StickyFooter.tsx rename to apps/admin-x-design-system/src/global/StickyFooter.tsx index ff951baf1ed..9271689d401 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/StickyFooter.tsx +++ b/apps/admin-x-design-system/src/global/StickyFooter.tsx @@ -1,7 +1,7 @@ -import React from 'react'; import clsx from 'clsx'; +import React from 'react'; -interface StickyFooterProps { +export interface StickyFooterProps { shiftY?: string; footerBgColorClass?: string; contentBgColorClass?: string; @@ -68,4 +68,4 @@ const StickyFooter: React.FC = ({ ); }; -export default StickyFooter; \ No newline at end of file +export default StickyFooter; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/TabView.stories.tsx b/apps/admin-x-design-system/src/global/TabView.stories.tsx similarity index 75% rename from apps/admin-x-settings/src/admin-x-ds/global/TabView.stories.tsx rename to apps/admin-x-design-system/src/global/TabView.stories.tsx index 62ab4926946..3315ed778f9 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/TabView.stories.tsx +++ b/apps/admin-x-design-system/src/global/TabView.stories.tsx @@ -29,6 +29,12 @@ const tabs = [ {id: 'tab-6', title: 'Backstreet boys', contents:
Contents three
} ]; +const tabsWithIcons = [ + {id: 'tab-1', title: 'Some items', icon: 'at-sign', contents:
Contents one
}, + {id: 'tab-2', title: 'Lots of items', icon: 'hamburger', contents:
Contents two
}, + {id: 'tab-3', title: 'No items', icon: 'laptop', contents:
Contents three
} +]; + const tabsWithCounters = [ {id: 'tab-1', title: 'Some items', counter: 4, contents:
Contents one
}, {id: 'tab-2', title: 'Lots of items', counter: 12, contents:
Contents two
}, @@ -49,8 +55,21 @@ export const NoBorder: Story = { } }; +export const WithIcon: Story = { + args: { + tabs: tabsWithIcons + } +}; + export const WithCounter: Story = { args: { tabs: tabsWithCounters } }; + +export const WithTopRightContent: Story = { + args: { + tabs: tabs, + topRightContent:

Some content

+ } +}; diff --git a/apps/admin-x-design-system/src/global/TabView.tsx b/apps/admin-x-design-system/src/global/TabView.tsx new file mode 100644 index 00000000000..2e8757b90db --- /dev/null +++ b/apps/admin-x-design-system/src/global/TabView.tsx @@ -0,0 +1,165 @@ +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import clsx from 'clsx'; +import React from 'react'; +import Icon from './Icon'; + +export type Tab = { + id: ID; + title: string; + icon?: string; + counter?: number | null; + tabWrapperClassName?: string; + containerClassName?: string; + + /** + * Optional, so you can just use the tabs to other views + */ + contents?: React.ReactNode; +} + +export type TabWidth = 'narrow' | 'normal' | 'wide'; + +export interface TabButtonProps { + id: ID, + title: string; + onClick?: (e:React.MouseEvent) => void; + border?: boolean; + icon?: string; + counter?: number | null; +} + +export const TabButton: React.FC = ({ + id, + title, + onClick, + border, + icon, + counter +}) => { + return ( + span]:data-[state=active]:text-black', + border && 'border-b-2 border-transparent hover:border-grey-500 data-[state=active]:border-black data-[state=active]:dark:border-white' + )} + id={id} + role='tab' + title={title} + value={id} + onClick={onClick} + > + {icon && } + {title} + {(typeof counter === 'number') && {counter}} + + ); +}; + +export interface TabListProps { + tabs: readonly Tab[]; + width: TabWidth; + handleTabChange?: (e: React.MouseEvent) => void; + border: boolean; + buttonBorder?: boolean; + selectedTab?: ID, + topRightContent?: React.ReactNode +} + +export const TabList: React.FC = ({ + tabs, + width = 'normal', + handleTabChange, + border, + buttonBorder, + topRightContent +}) => { + const containerClasses = clsx( + 'no-scrollbar flex w-full overflow-x-auto', + width === 'narrow' && 'gap-3', + width === 'normal' && 'gap-5', + width === 'wide' && 'gap-7', + border && 'border-b border-grey-300 dark:border-grey-900' + ); + return ( + +
+ {tabs.map(tab => ( +
+ +
+ ))} + {topRightContent !== null ? +
{topRightContent}
: + null + } +
+
+ ); +}; + +export interface TabViewProps { + tabs: readonly Tab[]; + onTabChange: (id: ID) => void; + selectedTab?: ID; + border?: boolean; + buttonBorder?: boolean; + width?: TabWidth; + containerClassName?: string; + topRightContent?: React.ReactNode; + testId?: string; +} + +function TabView({ + testId, + tabs, + onTabChange, + selectedTab, + border = true, + buttonBorder = border, + width = 'normal', + containerClassName, + topRightContent +}: TabViewProps) { + if (tabs.length !== 0 && selectedTab === undefined) { + selectedTab = tabs[0].id; + } + + if (tabs.length === 0) { + return (<>); + } + + const handleTabChange = (e: React.MouseEvent) => { + const newTab = e.currentTarget.id as ID; + onTabChange(newTab); + }; + + return ( + + + {tabs.map((tab) => { + return ( + +
{tab.contents}
+
+ ); + })} +
+ ); +}; + +export default TabView; diff --git a/apps/admin-x-design-system/src/global/Table.stories.tsx b/apps/admin-x-design-system/src/global/Table.stories.tsx new file mode 100644 index 00000000000..bd294e5e6bc --- /dev/null +++ b/apps/admin-x-design-system/src/global/Table.stories.tsx @@ -0,0 +1,246 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {ReactNode} from 'react'; + +import {useSortableIndexedList} from '..'; +import SortableList, {DragIndicator, SortableItemContainerProps} from './SortableList'; +import Table from './Table'; +import TableCell from './TableCell'; +import TableHead from './TableHead'; +import TableRow from './TableRow'; +import * as TableRowStories from './TableRow.stories'; + +const meta = { + title: 'Global / Table', + component: Table, + tags: ['autodocs'] +} satisfies Meta; + +const {/*id,*/ ...tableRowProps} = TableRowStories.HiddenAction.args || {}; + +const tableHeader = ( + <> + Name + Email + +); + +const tableRows = ( + <> + + Jamie Larson + jamie@example.com + + + Jamie Larson + jamie@example.com + + + Jamie Larson + jamie@example.com + + + Jamie Larson + jamie@example.com + + + Jamie Larson + jamie@example.com + + +); + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: tableRows + }, + decorators: [(_story: () => ReactNode) => (
{_story()}
)] +}; + +export const WithHeader: Story = { + args: { + header: tableHeader, + children: tableRows + } +}; + +export const WithPageTitle: Story = { + args: { + pageTitle: 'This is a page title', + children: tableRows + } +}; + +export const WithRowAction: Story = { + args: { + header: tableHeader, + children: tableRows + } +}; + +export const WithHint: Story = { + args: { + header: tableHeader, + children: tableRows, + hint: 'This is a hint', + hintSeparator: true + } +}; + +export const Loading: Story = { + args: { + header: tableHeader, + children: tableRows, + isLoading: true, + hint: 'This is a hint', + hintSeparator: true + } +}; + +// Components for Sortable example + +const SortableContainer: React.FC> = ({setRef, isDragging, style, children, ...props}) => { + const container = ( + + {(props.dragHandleAttributes || isDragging) && + + } + {children} + + ); + + if (isDragging) { + return {container}
; + } else { + return container; + } +}; + +const SortableItem: React.FC<{id: string; item: string}> = ({id, item}) => { + return ( + <> + {id}. + {item} + + ); +}; + +const SortableTable = () => { + const list = useSortableIndexedList({ + items: ['First', 'Second'], + setItems: () => {}, + blank: '', + canAddNewItem: () => false + }); + + return } + items={list.items} + renderItem={item => } + wrapper={Table} + onMove={list.moveItem} + />; +}; + +/** + * Example of combining Table and SortableList to create a sortable table. + * This is a little complex as each type of container/item needs to be overridden + * to end up with the correct table->tbody->tr->td structure. + */ +export const Sortable: Story = { + render: () => +}; + +/** + * Sticky header + */ + +// const complexTableHeader = (sticky: boolean) => ( +// <> +// Member +// Status +// Open rate +// Location +// Created +// Signed up on post +// Newsletter +// Billing Period +// Email sent +// +// ); + +// const complexTableRows = (rows: number) => { +// const data = []; +// for (let i = 0; i < rows; i++) { +// data.push( +// <> +// +// +//
+// {i % 3 === 0 && } +// {i % 3 === 1 && } +// {i % 3 === 2 && } +//
+// {i % 3 === 0 &&
Jamie Larson
} +// {i % 3 === 1 &&
Giana Septimus
} +// {i % 3 === 2 &&
Zaire Bator
} +//
jamie@larson.com
+//
+//
+//
+// Free +// 40% +// London, UK +// 22 June 2023 +// Hiking in the Nordic +// Subscribed +// Monthly +// 1,303 +//
+// +// ); +// } +// return data; +// }; + +// export const HorizontalScroll: Story = { +// args: { +// header: complexTableHeader(false), +// children: complexTableRows(100), +// hint: 'Massive table', +// hintSeparator: true +// } +// }; + +// export const FillContainer: Story = { +// args: { +// fillContainer: true, +// header: complexTableHeader(true), +// children: complexTableRows(50), +// hint: 'Massive table', +// hintSeparator: true +// } +// }; + +// export const PageExample: Story = { +// decorators: [(_story: () => ReactNode) => ( +//
+//
+//

Page title

+//

This example shows how you can create a page with arbitrary content on the top and a large table at the bottom that fills up the remaining space. The table has a sticky header row, a footer that is always visible and scrolling vertically and horizontally (resize the window to see the effect).

+//

The size and positioning of the table is completely controlled by its container. The container must have `relative` position. Use a column flexbox as the main container of the page then set the table container to flex-auto to fill the available horizontal space.

+//
{_story()}
+//
+//
+// )], +// args: { +// fillContainer: true, +// header: complexTableHeader(true), +// children: complexTableRows(50), +// hint: 'The footer of the table sticks to the bottom to stay visible', +// hintSeparator: true, +// paddingXClassName: 'px-10' +// } +// }; \ No newline at end of file diff --git a/apps/admin-x-design-system/src/global/Table.tsx b/apps/admin-x-design-system/src/global/Table.tsx new file mode 100644 index 00000000000..f6c23cca317 --- /dev/null +++ b/apps/admin-x-design-system/src/global/Table.tsx @@ -0,0 +1,198 @@ +import clsx from 'clsx'; +import React from 'react'; +import {PaginationData} from '../hooks/usePagination'; +import Heading from './Heading'; +import Hint from './Hint'; +import {LoadingIndicator} from './LoadingIndicator'; +import Pagination from './Pagination'; +import Separator from './Separator'; +import TableRow from './TableRow'; + +export interface ShowMoreData { + hasMore: boolean; + loadMore: () => void; +} + +export interface TableProps { + /** + * If the table is the primary content on a page (e.g. Members table) then you can set a pagetitle to be consistent + */ + header?: React.ReactNode; + pageTitle?: string; + children?: React.ReactNode; + borderTop?: boolean; + hint?: React.ReactNode; + hintSeparator?: boolean; + className?: string; + isLoading?: boolean; + pagination?: PaginationData; + showMore?: ShowMoreData; + fillContainer?: boolean; + horizontalScroll?: boolean; + paddingXClassName?: string; +} + +const OptionalPagination = ({pagination}: {pagination?: PaginationData}) => { + if (!pagination) { + return null; + } + + return ; +}; + +const OptionalShowMore = ({showMore}: {showMore?: ShowMoreData}) => { + if (!showMore) { + return null; + } else if (!showMore.hasMore) { + return
; + } + + return ( +
+ +
+ ); +}; + +const Table: React.FC = ({ + header, + children, + borderTop, + hint, + hintSeparator, + pageTitle, + className, + pagination, + showMore, + isLoading, + fillContainer = false, + horizontalScroll = false, + paddingXClassName +}) => { + const table = React.useRef(null); + const maxTableHeight = React.useRef(0); + const [tableHeight, setTableHeight] = React.useState(undefined); + + const multiplePages = pagination && pagination.pages && pagination.pages > 1; + + // Observe the height of the table content. This is used to: + // 1) avoid layout jumps when loading a new page of the table + // 2) keep the same table height between pages, cf. https://github.com/TryGhost/Product/issues/3881 + React.useEffect(() => { + if (table.current) { + const resizeObserver = new ResizeObserver((entries) => { + const height = entries[0].target.clientHeight; + setTableHeight(height); + + if (height > maxTableHeight.current) { + maxTableHeight.current = height; + } + }); + + resizeObserver.observe(table.current); + + return () => { + resizeObserver.disconnect(); + }; + } + }, [isLoading, pagination]); + + const loadingStyle = React.useMemo(() => { + if (tableHeight === undefined) { + return { + height: 'auto' + }; + } + + return { + height: maxTableHeight.current + }; + }, [tableHeight]); + + const spaceHeightStyle = React.useMemo(() => { + if (tableHeight === undefined) { + return { + height: 0 + }; + } + + return { + height: maxTableHeight.current - tableHeight + }; + }, [tableHeight]); + + const headerClasses = clsx( + 'h-9 border-b border-grey-200 dark:border-grey-600' + ); + + /** + * To have full-bleed scroll try this: + * - unset width of table + * - set minWidth of table to 100% + * - set side padding of table to 40px + * - unset tableContainer width + * - set minWidth of tableContainer to 100% + * - unset mainContainer width + * - set minWidth of mainContainer to 100% + * - set side margins of outer container to -40px + * - set footer side paddings to 40px + */ + + const tableClasses = clsx( + 'w-full', + fillContainer ? 'min-w-full' : 'w-full', + (borderTop || pageTitle) && 'border-t border-grey-300', + pageTitle ? 'mb-0 mt-14' : 'my-0', + className + ); + + const mainContainerClasses = clsx( + horizontalScroll ? 'overflow-x-auto' : '', + fillContainer ? 'absolute inset-0 min-w-full' : 'w-full' + ); + + const tableContainerClasses = clsx( + fillContainer ? 'max-h-[calc(100%-38px)] w-full overflow-y-auto' : 'w-full', + paddingXClassName + ); + + const footerClasses = clsx( + 'sticky bottom-0 -mt-px bg-white pb-3 dark:bg-black', + paddingXClassName + ); + + return ( + <> +
+ {pageTitle && {pageTitle}} + +
+ + {header && + {header} + } + {!isLoading && + {children} + } + + {multiplePages &&
} +
+
+ + {isLoading &&
} + + {(hint || pagination || showMore) && +
+ {(hintSeparator || pagination) && } +
+ + {hint ?? ' '} + +
+
} +
+ + ); +}; + +export default Table; diff --git a/apps/admin-x-design-system/src/global/TableCell.tsx b/apps/admin-x-design-system/src/global/TableCell.tsx new file mode 100644 index 00000000000..3bb08a5c834 --- /dev/null +++ b/apps/admin-x-design-system/src/global/TableCell.tsx @@ -0,0 +1,36 @@ +import clsx from 'clsx'; +import React, {HTMLProps} from 'react'; + +export interface TableCellProps extends HTMLProps { + padding?: boolean; + align?: 'left' | 'center' | 'right'; + valign?: 'top' | 'center' | 'bottom'; +} + +const TableCell: React.FC = ({ + className, + children, + padding = true, + align = 'left', + valign = 'top', + ...props +}) => { + const tableCellClasses = clsx( + padding ? '!py-3 !pl-0 !pr-6' : '', + (align === 'center' && 'text-center'), + (align === 'right' && 'text-right'), + (valign === 'top' && 'align-top'), + (valign === 'center' && 'align-center'), + (valign === 'bottom' && 'align-bottom'), + props.onClick && 'hover:cursor-pointer', + className + ); + + return ( + + {children} + + ); +}; + +export default TableCell; diff --git a/apps/admin-x-design-system/src/global/TableHead.tsx b/apps/admin-x-design-system/src/global/TableHead.tsx new file mode 100644 index 00000000000..8ccdbbe35da --- /dev/null +++ b/apps/admin-x-design-system/src/global/TableHead.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx'; +import React, {HTMLProps} from 'react'; +import Heading from './Heading'; + +export interface TableHeadProps extends HTMLProps { + sticky?: boolean; +} + +const TableHead: React.FC = ({ + className, + children, + colSpan, + sticky = false, + ...props +}) => { + const tableCellClasses = clsx( + '!py-2 !pl-0 !pr-6 text-left align-top', + sticky && 'sticky top-0 bg-white', + props.onClick && 'hover:cursor-pointer', + className + ); + + return ( + + {children} + + ); +}; + +export default TableHead; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/TableRow.stories.tsx b/apps/admin-x-design-system/src/global/TableRow.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/TableRow.stories.tsx rename to apps/admin-x-design-system/src/global/TableRow.stories.tsx diff --git a/apps/admin-x-design-system/src/global/TableRow.tsx b/apps/admin-x-design-system/src/global/TableRow.tsx new file mode 100644 index 00000000000..7e40a3f5020 --- /dev/null +++ b/apps/admin-x-design-system/src/global/TableRow.tsx @@ -0,0 +1,52 @@ +import clsx from 'clsx'; +import React, {forwardRef} from 'react'; + +export const tableRowHoverBgClasses = 'hover:bg-gradient-to-r hover:from-white hover:to-grey-50 dark:hover:from-black dark:hover:to-grey-950'; + +export interface TableRowProps { + id?: string; + action?: React.ReactNode; + hideActions?: boolean; + className?: string; + style?: React.CSSProperties; + testId?: string; + + /** + * Hidden for the last item in the table + */ + separator?: boolean; + + bgOnHover?: boolean; + onClick?: (e: React.MouseEvent) => void; + children?: React.ReactNode; +} + +const TableRow = forwardRef(function TableRow({id, action, hideActions, className, style, testId, separator, bgOnHover = true, onClick, children}, ref) { + const handleClick = (e: React.MouseEvent) => { + onClick?.(e); + }; + + separator = (separator === undefined) ? true : separator; + const tableRowClasses = clsx( + 'group/table-row', + bgOnHover && tableRowHoverBgClasses, + onClick && 'cursor-pointer', + separator ? 'border-b border-grey-100 last-of-type:border-b-transparent hover:border-grey-200 dark:border-grey-950 dark:hover:border-grey-900' : 'border-y border-none first-of-type:hover:border-t-transparent', + className + ); + + return ( + + {children} + {action && + +
+ {action} +
+ + } + + ); +}); + +export default TableRow; diff --git a/apps/admin-x-design-system/src/global/Toast.stories.tsx b/apps/admin-x-design-system/src/global/Toast.stories.tsx new file mode 100644 index 00000000000..bdb5deb86ba --- /dev/null +++ b/apps/admin-x-design-system/src/global/Toast.stories.tsx @@ -0,0 +1,136 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {ReactNode} from 'react'; + +import Button from './Button'; +import {ShowToastProps, showToast} from './Toast'; + +/** + * This component uses `react-hot-toast` which requires the `` component to be included in the app. + * The design system already does this so you don't have to — just call `showToast()` in any event and it'll work. + */ +const ToastContainer: React.FC = ({...props}) => { + return ( + <> + + + ); +}; + +export default Toast; + +export const showToast = ({ + title, + message, + type = 'neutral', + icon = '', + options = { + position: 'bottom-left', + duration: 5000 + } +}: ShowToastProps): void => { + if (!options.position) { + options.position = 'bottom-left'; + } + + if (type === 'pageError') { + type = 'error'; + options.position = 'top-center'; + options.duration = Infinity; + } + + toast.custom(t => ( + +
+ {title && {title}} + {message && +
{message}
+ } +
+
+ ), + { + ...options + } + ); +}; + +export const dismissAllToasts = (): void => { + toast.dismiss(); +}; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/Tooltip.stories.tsx b/apps/admin-x-design-system/src/global/Tooltip.stories.tsx similarity index 86% rename from apps/admin-x-settings/src/admin-x-ds/global/Tooltip.stories.tsx rename to apps/admin-x-design-system/src/global/Tooltip.stories.tsx index 9711ea27ee5..91f7f43dc61 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/Tooltip.stories.tsx +++ b/apps/admin-x-design-system/src/global/Tooltip.stories.tsx @@ -6,7 +6,12 @@ import Tooltip from './Tooltip'; const meta = { title: 'Global / Tooltip', component: Tooltip, - tags: ['autodocs'] + tags: ['autodocs'], + decorators: [(_story: () => React.ReactNode) => ( +
+ {_story()} +
+ )] } satisfies Meta; export default meta; @@ -31,7 +36,7 @@ export const Left: Story = { args: { content: 'Hello tooltip on the left', children: } diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/MultiSelect.stories.tsx b/apps/admin-x-design-system/src/global/form/MultiSelect.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/form/MultiSelect.stories.tsx rename to apps/admin-x-design-system/src/global/form/MultiSelect.stories.tsx diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/MultiSelect.tsx b/apps/admin-x-design-system/src/global/form/MultiSelect.tsx similarity index 95% rename from apps/admin-x-settings/src/admin-x-ds/global/form/MultiSelect.tsx rename to apps/admin-x-design-system/src/global/form/MultiSelect.tsx index 7ae9ee5dff5..833579a8cfd 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/form/MultiSelect.tsx +++ b/apps/admin-x-design-system/src/global/form/MultiSelect.tsx @@ -1,11 +1,11 @@ -import AsyncCreatableSelect from 'react-select/async-creatable'; +import clsx from 'clsx'; +import React, {useId, useMemo} from 'react'; +import {DropdownIndicatorProps, GroupBase, MultiValue, OptionProps, OptionsOrGroups, Props, default as ReactSelect, components} from 'react-select'; import AsyncSelect from 'react-select/async'; +import AsyncCreatableSelect from 'react-select/async-creatable'; import CreatableSelect from 'react-select/creatable'; import Heading from '../Heading'; import Hint from '../Hint'; -import React, {useId, useMemo} from 'react'; -import clsx from 'clsx'; -import {DropdownIndicatorProps, GroupBase, MultiValue, OptionProps, OptionsOrGroups, Props, default as ReactSelect, components} from 'react-select'; export type MultiSelectColor = 'grey' | 'black' | 'green' | 'pink'; type FieldStyles = 'text' | 'dropdown'; @@ -16,12 +16,12 @@ export type MultiSelectOption = { color?: MultiSelectColor; } -export type LoadOptions = (inputValue: string, callback: (options: OptionsOrGroups>) => void) => void +export type LoadMultiSelectOptions = (inputValue: string, callback: (options: OptionsOrGroups>) => void) => void type MultiSelectOptionProps = { async: true; defaultOptions: boolean | OptionsOrGroups>; - loadOptions: LoadOptions; + loadOptions: LoadMultiSelectOptions; options?: never; } | { async?: false; @@ -30,7 +30,7 @@ type MultiSelectOptionProps = { loadOptions?: never; } -type MultiSelectProps = MultiSelectOptionProps & { +export type MultiSelectProps = MultiSelectOptionProps & { values: MultiValue; title?: string; clearBg?: boolean; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/Radio.stories.tsx b/apps/admin-x-design-system/src/global/form/Radio.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/form/Radio.stories.tsx rename to apps/admin-x-design-system/src/global/form/Radio.stories.tsx diff --git a/apps/admin-x-design-system/src/global/form/Radio.tsx b/apps/admin-x-design-system/src/global/form/Radio.tsx new file mode 100644 index 00000000000..390480a217f --- /dev/null +++ b/apps/admin-x-design-system/src/global/form/Radio.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import Heading from '../Heading'; +import Hint from '../Hint'; +import Separator from '../Separator'; +import * as RadioPrimitive from '@radix-ui/react-radio-group'; + +export interface RadioOption { + value: string; + label: string; + hint?: React.ReactNode; +} + +export interface RadioProps { + id: string; + title?: string; + selectedOption?: string; + options: RadioOption[]; + onSelect: (value: string) => void; + error?:boolean; + hint?: React.ReactNode; + separator?: boolean; +} + +const Radio: React.FC = ({id, title, options, onSelect, error, hint, selectedOption, separator}) => { + return ( + +
+ {title && {title}} + {options.map(option => ( + + ))} + {hint && {hint}} +
+ {(separator || error) && } +
+ ); +}; + +export default Radio; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/Select.stories.tsx b/apps/admin-x-design-system/src/global/form/Select.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/form/Select.stories.tsx rename to apps/admin-x-design-system/src/global/form/Select.stories.tsx diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/Select.tsx b/apps/admin-x-design-system/src/global/form/Select.tsx similarity index 79% rename from apps/admin-x-settings/src/admin-x-ds/global/form/Select.tsx rename to apps/admin-x-design-system/src/global/form/Select.tsx index 14a2cf11b01..2fec4859986 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/form/Select.tsx +++ b/apps/admin-x-design-system/src/global/form/Select.tsx @@ -1,11 +1,11 @@ +import clsx from 'clsx'; +import React, {useId, useMemo, useEffect} from 'react'; +import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, GroupBase, OptionProps, OptionsOrGroups, Props, components} from 'react-select'; import AsyncSelect from 'react-select/async'; +import {useFocusContext} from '../../providers/DesignSystemProvider'; import Heading from '../Heading'; import Hint from '../Hint'; import Icon from '../Icon'; -import React, {useId, useMemo} from 'react'; -import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, GroupBase, OptionProps, OptionsOrGroups, Props, components} from 'react-select'; -import clsx from 'clsx'; -import {useFocusContext} from '../../providers/DesignSystemProvider'; export interface SelectOption { value: string; @@ -32,12 +32,12 @@ export interface SelectControlClasses { clearIndicator?: string; } -export type LoadOptions = (inputValue: string, callback: (options: OptionsOrGroups>) => void) => void +export type LoadSelectOptions = (inputValue: string, callback: (options: OptionsOrGroups>) => void) => void type SelectOptionProps = { async: true; defaultOptions: boolean | OptionsOrGroups>; - loadOptions: LoadOptions; + loadOptions: LoadSelectOptions; options?: never; } | { async?: false; @@ -69,7 +69,7 @@ export type SelectProps = Props & SelectOptionProps & { const DropdownIndicator: React.FC & {clearBg: boolean}> = ({clearBg, ...props}) => ( -
+
); @@ -118,12 +118,35 @@ const Select: React.FC = ({ setFocusState(false); }; + useEffect(() => { + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + // Fix for Safari - if an element in the modal is focused, closing it will jump to + // the bottom of the page because Safari tries to focus the "next" element in the DOM + if (document.activeElement && document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + setFocusState(false); + + // Prevent the event from bubbling up to the window level + event.stopPropagation(); + } + }; + + document.addEventListener('keydown', handleEscapeKey); + + // Clean up the event listener when the modal is closed + return () => { + document.removeEventListener('keydown', handleEscapeKey); + }; + }, [setFocusState]); + let containerClasses = ''; if (!unstyled) { containerClasses = clsx( 'dark:text-white', fullWidth && 'w-full', - disabled && 'opacity-40' + disabled && 'cursor-not-allowed opacity-40' ); } containerClasses = clsx( @@ -134,22 +157,23 @@ const Select: React.FC = ({ const customClasses = { control: clsx( controlClasses?.control, - 'h-9 min-h-[36px] w-full cursor-pointer appearance-none rounded-md border outline-none dark:text-white', + 'h-9 min-h-[36px] w-full appearance-none rounded-lg border outline-none dark:text-white md:h-[38px] md:min-h-[38px]', size === 'xs' ? 'py-0 pr-2 text-xs' : 'py-1 pr-4', clearBg ? '' : 'bg-grey-150 px-3 dark:bg-grey-900', error ? 'border-red' : `border-transparent ${!clearBg && 'hover:bg-grey-100 dark:hover:bg-grey-925'}`, + !disabled && 'cursor-pointer', (title && !clearBg) && 'mt-1.5' ), valueContainer: clsx('mr-1.5 gap-1', controlClasses?.valueContainer), placeHolder: clsx('text-grey-700 dark:text-grey-800', controlClasses?.placeHolder), menu: clsx( - 'z-[300] rounded-b bg-white shadow dark:border dark:border-grey-900 dark:bg-black', + 'z-[300] mt-0.5 overflow-hidden rounded-lg bg-white shadow-lg dark:border dark:border-grey-900 dark:bg-black', size === 'xs' && 'text-xs', controlClasses?.menu ), - option: clsx('px-3 py-[6px] hover:cursor-pointer hover:bg-grey-100 dark:text-white dark:hover:bg-grey-900', controlClasses?.option), + option: clsx('px-3 py-[7px] hover:cursor-pointer hover:bg-grey-100 dark:text-white dark:hover:bg-grey-900', controlClasses?.option), noOptionsMessage: clsx('nowrap p-3 text-grey-600', controlClasses?.noOptionsMessage), - groupHeading: clsx('px-3 py-[6px] text-2xs font-semibold uppercase tracking-wide text-grey-700', controlClasses?.groupHeading), + groupHeading: clsx('px-3 py-[7px] text-2xs font-semibold uppercase tracking-wide text-grey-700', controlClasses?.groupHeading), clearIndicator: clsx('', controlClasses?.clearIndicator) }; @@ -178,6 +202,7 @@ const Select: React.FC = ({ options, placeholder: prompt ? prompt : '', value: selectedOption, + isDisabled: disabled, unstyled: true, onChange: onSelect, onFocus: handleFocus, diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/TextArea.stories.tsx b/apps/admin-x-design-system/src/global/form/TextArea.stories.tsx similarity index 88% rename from apps/admin-x-settings/src/admin-x-ds/global/form/TextArea.stories.tsx rename to apps/admin-x-design-system/src/global/form/TextArea.stories.tsx index b0b912b1314..611bfa14dd3 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/form/TextArea.stories.tsx +++ b/apps/admin-x-design-system/src/global/form/TextArea.stories.tsx @@ -80,15 +80,6 @@ export const ResizeDisabled: Story = { } }; -export const MaxLength: Story = { - args: { - title: 'Description', - placeholder: 'Try to enter more than 80 characters, I dare you...', - value: 'This is a nice text area that only accepts up to 80 characters. Try to add more:', - maxLength: 80 - } -}; - export const Error: Story = { args: { title: 'Description', diff --git a/apps/admin-x-design-system/src/global/form/TextArea.tsx b/apps/admin-x-design-system/src/global/form/TextArea.tsx new file mode 100644 index 00000000000..2f2ad595e32 --- /dev/null +++ b/apps/admin-x-design-system/src/global/form/TextArea.tsx @@ -0,0 +1,110 @@ +import React, {FocusEventHandler, HTMLProps, useId} from 'react'; + +import clsx from 'clsx'; +import {useFocusContext} from '../../providers/DesignSystemProvider'; +import Heading from '../Heading'; +import Hint from '../Hint'; +import * as FormPrimitive from '@radix-ui/react-form'; + +type ResizeOptions = 'both' | 'vertical' | 'horizontal' | 'none'; +type FontStyles = 'sans' | 'mono'; + +export interface TextAreaProps extends HTMLProps { + inputRef?: React.RefObject; + title?: string; + value?: string; + rows?: number; + maxLength?: number; + resize?: ResizeOptions; + error?: boolean; + placeholder?: string; + hint?: React.ReactNode; + fontStyle?: FontStyles; + className?: string; + onChange?: (event: React.ChangeEvent) => void; +} + +const TextArea: React.FC = ({ + inputRef, + title, + value, + rows = 3, + maxLength, + resize = 'none', + error, + placeholder, + hint, + fontStyle = 'sans', + className, + onChange, + onFocus, + onBlur, + ...props +}) => { + const id = useId(); + const {setFocusState} = useFocusContext(); + + const handleFocus: FocusEventHandler = (e) => { + setFocusState(true); + onFocus?.(e); + }; + + const handleBlur: FocusEventHandler = (e) => { + setFocusState(false); + onBlur?.(e); + }; + + let styles = clsx( + 'order-2 rounded-lg border bg-grey-150 px-3 py-2 transition-all dark:bg-grey-900 dark:text-white', + error ? 'border-red bg-white' : 'border-transparent placeholder:text-grey-500 hover:bg-grey-100 focus:border-green focus:bg-white focus:shadow-[0_0_0_2px_rgba(48,207,67,0.25)] dark:placeholder:text-grey-800 dark:hover:bg-grey-925 dark:focus:bg-grey-950', + title && 'mt-1.5', + fontStyle === 'mono' && 'font-mono text-sm', + className + ); + + switch (resize) { + case 'both': + styles += ' resize '; + break; + case 'vertical': + styles += ' resize-y '; + break; + case 'horizontal': + styles += ' resize-x '; + break; + case 'none': + styles += ' resize-none '; + break; + default: + styles += ' resize '; + break; + } + + return ( + +
+ + + + + + {title && {title}} + {hint && {hint}} +
+
+ ); +}; + +export default TextArea; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/TextField.stories.tsx b/apps/admin-x-design-system/src/global/form/TextField.stories.tsx similarity index 98% rename from apps/admin-x-settings/src/admin-x-ds/global/form/TextField.stories.tsx rename to apps/admin-x-design-system/src/global/form/TextField.stories.tsx index 64d7d406b54..47f10bd6e3b 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/form/TextField.stories.tsx +++ b/apps/admin-x-design-system/src/global/form/TextField.stories.tsx @@ -11,7 +11,7 @@ const meta = { component: TextField, tags: ['autodocs'], decorators: [(_story: () => ReactNode) => ( -
+
{_story()}
)], diff --git a/apps/admin-x-design-system/src/global/form/TextField.tsx b/apps/admin-x-design-system/src/global/form/TextField.tsx new file mode 100644 index 00000000000..86b39631c95 --- /dev/null +++ b/apps/admin-x-design-system/src/global/form/TextField.tsx @@ -0,0 +1,149 @@ +import Heading from '../Heading'; +import Hint from '../Hint'; +import React, {FocusEventHandler, useId} from 'react'; +import clsx from 'clsx'; +import {useFocusContext} from '../../providers/DesignSystemProvider'; +import * as FormPrimitive from '@radix-ui/react-form'; + +export type TextFieldProps = React.InputHTMLAttributes & { + inputRef?: React.RefObject; + title?: string; + titleColor?: 'auto' | 'black' | 'grey'; + hideTitle?: boolean; + type?: React.InputHTMLAttributes['type']; + value?: string; + error?: boolean; + placeholder?: string; + rightPlaceholder?: React.ReactNode; + hint?: React.ReactNode; + clearBg?: boolean; + onChange?: (event: React.ChangeEvent) => void; + onBlur?: (event: React.FocusEvent) => void; + className?: string; + maxLength?: number; + containerClassName?: string; + hintClassName?: string; + unstyled?: boolean; + disabled?: boolean; + border?: boolean; + autoFocus?: boolean; +} + +const TextField: React.FC = ({ + type = 'text', + inputRef, + title, + hideTitle, + value, + error, + placeholder, + rightPlaceholder, + hint, + onChange, + onFocus, + onBlur, + clearBg = false, + className = '', + maxLength, + containerClassName = '', + hintClassName = '', + unstyled = false, + disabled, + ...props +}) => { + const id = useId(); + const {setFocusState} = useFocusContext(); + + const handleFocus: FocusEventHandler = (e) => { + onFocus?.(e); + setFocusState(true); + }; + + const handleBlur: FocusEventHandler = (e) => { + onBlur?.(e); + setFocusState(false); + }; + + const fieldContainerClasses = clsx( + 'relative order-2 flex w-full items-center', + (title && !hideTitle) && `mt-1.5` + ); + + const bgClasses = !unstyled && clsx( + 'absolute inset-0 rounded-lg border text-grey-300 transition-colors peer-hover:bg-grey-100 peer-focus:border-green peer-focus:bg-white peer-focus:shadow-[0_0_0_2px_rgba(48,207,67,.25)] dark:peer-hover:bg-grey-925 dark:peer-focus:bg-grey-950', + error ? `border-red bg-white dark:bg-grey-925` : 'border-transparent bg-grey-150 dark:bg-grey-900', + disabled && 'bg-grey-50 peer-hover:bg-grey-50 dark:bg-grey-950 dark:peer-hover:bg-grey-950' + ); + + const textFieldClasses = !unstyled && clsx( + 'peer z-[1] order-2 h-9 w-full bg-transparent px-3 py-1.5 text-sm placeholder:text-grey-500 dark:placeholder:text-grey-700 md:h-[38px] md:py-2 md:text-md', + disabled ? 'cursor-not-allowed text-grey-600 opacity-60 dark:text-grey-800' : 'dark:text-white', + rightPlaceholder ? 'w-0 grow rounded-l-lg' : 'rounded-lg', + className + ); + + const rightPlaceholderClasses = !unstyled && clsx( + 'z-[1] order-3 rounded-r-lg', + (rightPlaceholder ? + ((typeof (rightPlaceholder) === 'string') ? 'flex h-8 items-center py-1 pr-3 text-right text-sm text-grey-500 md:h-9 md:text-base' : 'h-9 pr-1') + : 'pr-2') + ); + + let field = <>; + + const inputField = ; + + field = ( + +
+ + {inputField} + + {!unstyled && !clearBg &&
} + {rightPlaceholder && {rightPlaceholder}} +
+
+ ); + + hintClassName = clsx( + 'order-3', + hintClassName + ); + + containerClassName = clsx( + 'flex flex-col', + containerClassName + ); + + if (title || hint) { + return ( + +
+ {field} + {title && {title}} + {hint && {hint}} +
+
+ ); + } else { + return ( + + {field} + + ); + } +}; + +export default TextField; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/Toggle.stories.tsx b/apps/admin-x-design-system/src/global/form/Toggle.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/form/Toggle.stories.tsx rename to apps/admin-x-design-system/src/global/form/Toggle.stories.tsx diff --git a/apps/admin-x-design-system/src/global/form/Toggle.tsx b/apps/admin-x-design-system/src/global/form/Toggle.tsx new file mode 100644 index 00000000000..cd0eadfc98a --- /dev/null +++ b/apps/admin-x-design-system/src/global/form/Toggle.tsx @@ -0,0 +1,131 @@ +import clsx from 'clsx'; +import React, {useId} from 'react'; +import {Heading6StylesGrey} from '../Heading'; +import Separator from '../Separator'; +import * as TogglePrimitive from '@radix-ui/react-switch'; + +type ToggleSizes = 'sm' | 'md' | 'lg'; +export type ToggleDirections = 'ltr' | 'rtl'; + +export interface ToggleProps { + checked?: boolean; + disabled?: boolean; + name?: string; + error?: boolean; + size?: ToggleSizes; + label?: React.ReactNode; + labelStyle?: 'heading' | 'value'; + labelClasses?: string; + toggleBg?: 'green' | 'black' | 'stripetest'; + separator?: boolean; + direction?: ToggleDirections; + hint?: React.ReactNode; + onChange?: (event: React.ChangeEvent) => void; +} + +const Toggle: React.FC = ({ + size, + direction, + label, + labelStyle = 'value', + labelClasses, + toggleBg = 'black', + hint, + separator, + error, + checked, + disabled, + name, + onChange +}) => { + const id = useId(); + + let sizeStyles = ''; + let thumbSizeStyles = ''; + let labelStyles = ''; + switch (size) { + case 'sm': + sizeStyles = ' h-3 w-5'; + thumbSizeStyles = ' h-2 w-2 data-[state=checked]:translate-x-[10px]'; + labelStyles = 'mt-[-5.5px]'; + break; + + case 'lg': + sizeStyles = ' h-5 w-8'; + thumbSizeStyles = ' h-4 w-4 data-[state=checked]:translate-x-[14px]'; + labelStyles = 'mt-[-1px]'; + break; + + default: + sizeStyles = ' min-w-[28px] h-4 w-7'; + thumbSizeStyles = ' h-3 w-3 data-[state=checked]:translate-x-[14px]'; + labelStyles = 'mt-[-3px]'; + break; + } + + labelStyles = clsx( + labelClasses, + labelStyles + ); + + if (labelStyle === 'heading') { + direction = 'rtl'; + } + + let toggleBgClass; + switch (toggleBg) { + case 'stripetest': + toggleBgClass = 'data-[state=checked]:bg-[#EC6803] dark:data-[state=checked]:bg-[#EC6803]'; + break; + + case 'green': + toggleBgClass = 'data-[state=checked]:bg-green'; + break; + + default: + toggleBgClass = 'data-[state=checked]:bg-black dark:data-[state=checked]:bg-green'; + break; + } + + const handleCheckedChange = (isChecked: boolean) => { + if (onChange) { + const event = { + target: {checked: isChecked} + } as React.ChangeEvent; + onChange(event); + } + }; + + return ( +
+
+ + + + {label && + + } +
+ {(separator || error) && } +
+ ); +}; + +export default Toggle; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/ToggleGroup.stories.tsx b/apps/admin-x-design-system/src/global/form/ToggleGroup.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/form/ToggleGroup.stories.tsx rename to apps/admin-x-design-system/src/global/form/ToggleGroup.stories.tsx diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/ToggleGroup.tsx b/apps/admin-x-design-system/src/global/form/ToggleGroup.tsx similarity index 92% rename from apps/admin-x-settings/src/admin-x-ds/global/form/ToggleGroup.tsx rename to apps/admin-x-design-system/src/global/form/ToggleGroup.tsx index 01258dd8fb4..e1ad3fa2448 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/form/ToggleGroup.tsx +++ b/apps/admin-x-design-system/src/global/form/ToggleGroup.tsx @@ -1,7 +1,7 @@ -import React from 'react'; import clsx from 'clsx'; +import React from 'react'; -interface ToggleGroupProps { +export interface ToggleGroupProps { children?: React.ReactNode; gap?: 'sm' | 'md' | 'lg'; className?: string; @@ -40,4 +40,4 @@ const ToggleGroup: React.FC = ({children, gap = 'md', classNam ); }; -export default ToggleGroup; \ No newline at end of file +export default ToggleGroup; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/URLTextField.stories.tsx b/apps/admin-x-design-system/src/global/form/URLTextField.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/form/URLTextField.stories.tsx rename to apps/admin-x-design-system/src/global/form/URLTextField.stories.tsx diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/URLTextField.tsx b/apps/admin-x-design-system/src/global/form/URLTextField.tsx similarity index 94% rename from apps/admin-x-settings/src/admin-x-ds/global/form/URLTextField.tsx rename to apps/admin-x-design-system/src/global/form/URLTextField.tsx index d25c509e8c1..93985811f55 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/form/URLTextField.tsx +++ b/apps/admin-x-design-system/src/global/form/URLTextField.tsx @@ -1,7 +1,7 @@ import React, {useEffect, useState} from 'react'; -import TextField, {TextFieldProps} from './TextField'; -import validator from 'validator'; +import isEmail from 'validator/es/lib/isEmail'; import {useFocusContext} from '../../providers/DesignSystemProvider'; +import TextField, {TextFieldProps} from './TextField'; export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) => { if (nullable && !value) { @@ -18,7 +18,7 @@ export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) = } // if we have an email address, add the mailto: - if (validator.isEmail(url)) { + if (isEmail(url)) { return {save: `mailto:${url}`, display: `mailto:${url}`}; } @@ -93,6 +93,14 @@ export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) = return {save: url, display: new URL(url, baseUrl).toString()}; }; +export interface URLTextFieldProps extends Omit { + baseUrl?: string; + transformPathWithoutSlash?: boolean; + nullable?: boolean; + value: string | null; + onChange: (value: string | null) => void; +} + /** * A text field that displays and saves relative URLs as absolute relative to a given base URL (probably the site URL). * @@ -102,13 +110,7 @@ export const formatUrl = (value: string, baseUrl?: string, nullable?: boolean) = * - Anchor links are displayed and saved as-is (e.g. `#test`) * - Values that don't look like URLs are displayed and saved as-is (e.g. `test`) */ -const URLTextField: React.FC & { - baseUrl?: string; - transformPathWithoutSlash?: boolean; - nullable?: boolean; - value: string | null; - onChange: (value: string | null) => void; -}> = ({baseUrl, value, transformPathWithoutSlash, nullable, onChange, ...props}) => { +const URLTextField: React.FC = ({baseUrl, value, transformPathWithoutSlash, nullable, onChange, ...props}) => { const [displayedUrl, setDisplayedUrl] = useState(''); const {setFocusState} = useFocusContext(); diff --git a/apps/admin-x-design-system/src/global/layout/AppMenu.tsx b/apps/admin-x-design-system/src/global/layout/AppMenu.tsx new file mode 100644 index 00000000000..01864596aab --- /dev/null +++ b/apps/admin-x-design-system/src/global/layout/AppMenu.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Button from '../Button'; + +const PageMenu: React.FC = () => { + return ( +
+ + ); + } + return posts; +}; + +const examplePostsContent = ( + +
+ {<>{mockPosts()}} +
+
+); + +export const ExampleAlternativeList: Story = { + name: 'Example: Alternative List', + args: { + pageTabs: pageTabs, + showAppMenu: true, + showGlobalActions: true, + children: examplePostsContent + } +}; + +export const ExampleDetailScreen: Story = { + name: 'Example: Detail Page', + args: { + showAppMenu: true, + breadCrumbs: { + alert('Clicked back'); + } + }, + { + label: 'Emerson Vaccaro' + } + ]} + backIcon + />, + showGlobalActions: true, + children: <> + + + Emerson Vaccaro +
Colombus, OH
+ + } + primaryAction={ + { + icon: 'ellipsis', + color: 'outline' + } + } + type='page' + > +
+
+ Last seen on 22 June 2023 + Created on 27 Jan 2021 +
+
+ Emails received + 181 +
+
+ Emails opened + 104 +
+
+ Average open rate + 57% +
+
+
+
+
+ Member data +
+
+ Name +
Emerson Vaccaro
+
+
+ Email +
emerson@vaccaro.com
+
+
+ Labels +
+
VIP
+
Inner Circle
+
+
+
+ Notes +
No notes.
+
+
+
+ Newsletters +
+
+ + Daily news +
+
+ + Weekly roundup +
+
+ + The Inner Circle +
+
+ This member cannot receive emails due to permanent failure (bounce). +
+
+
+
+ Subscriptions +
+
+ $5 + Yearly +
+
+ Gold + Renews 21 Jan 2024 +
+
+
+
+
+ Activity +
+
+ Logged in + 13 days ago +
+
+ Subscribed to Daily News + 17 days ago +
+
+ Logged in + 21 days ago +
+
+
+
+ + } +}; \ No newline at end of file diff --git a/apps/admin-x-design-system/src/global/layout/Page.tsx b/apps/admin-x-design-system/src/global/layout/Page.tsx new file mode 100644 index 00000000000..72e657906d8 --- /dev/null +++ b/apps/admin-x-design-system/src/global/layout/Page.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import {TabList} from '../TabView'; +import clsx from 'clsx'; +import AppMenu from './AppMenu'; +import GlobalActions from './GlobalActions'; +import Button from '../Button'; +import {BreadcrumbsProps} from '../Breadcrumbs'; +import PageHeader from './PageHeader'; + +export interface PageTab { + id: string; + title: string; +} + +export interface CustomGlobalAction { + iconName: string; + onClick?: () => void; +} + +interface PageProps { + mainContainerClassName?: string; + mainClassName?: string; + fullBleedPage?: boolean; + + /** + * The pageToolbar is a WIP part of this component, it's unused ATM in Ghost Admin. + */ + pageToolbarClassName?: string; + fullBleedToolbar?: boolean; + + /** + * TK. Part of the Page Toolbar + */ + showAppMenu?: boolean; + + /** + * Show + */ + showGlobalActions?: boolean; + + /** + * TK. Part of the Page Toolbar + */ + customGlobalActions?: CustomGlobalAction[]; + breadCrumbs?: React.ReactElement; + + /** + * TK. Part of the Page Toolbar + */ + pageTabs?: PageTab[], + + /** + * TK. Part of the Page Toolbar + */ + selectedTab?: string; + + /** + * TK. Part of the Page Toolbar + */ + onTabChange?: (id: string) => void; + + children?: React.ReactNode; +} + +/** + * The page component is the main container in Ghost Admin. It consists of a + * page level toolbar (`pageToolbar` — unused ATM, it's for page level views and + * navigation in the future), and the main content area. + * + * ### Examples + * You can find several examples in the sidebar. If you're building a page for the + * current Admin you can use the ["List in Current Admin"](/story/global-layout-page--example-current-admin-list) + * example as a starting point. The rest of the examples are showing a potential direction for a + * future structure. + */ +const Page: React.FC = ({ + fullBleedPage = true, + mainContainerClassName, + mainClassName, + pageToolbarClassName, + fullBleedToolbar = true, + showAppMenu = false, + showGlobalActions = false, + customGlobalActions, + breadCrumbs, + pageTabs, + selectedTab, + onTabChange, + children +}) => { + const handleTabChange = (e: React.MouseEvent) => { + const newTab = e.currentTarget.id as string; + onTabChange!(newTab); + }; + + if (pageTabs?.length && !selectedTab) { + selectedTab = pageTabs[0].id; + } + + const left: React.ReactNode = ( + (showAppMenu || breadCrumbs || pageTabs?.length) &&
+ {showAppMenu && ( + + )} + {breadCrumbs} + {pageTabs?.length && ( + + )} +
); + + mainClassName = clsx( + 'flex w-full flex-auto flex-col', + mainClassName + ); + + const globalActions = ( + (customGlobalActions?.length || showGlobalActions) && +
+ {(customGlobalActions?.map((action) => { + return ( +
); + + mainContainerClassName = clsx( + 'flex h-[100vh] w-full flex-col overflow-y-auto overflow-x-hidden', + !fullBleedPage && 'mx-auto max-w-7xl', + mainContainerClassName + ); + + pageToolbarClassName = clsx( + 'sticky top-0 z-50 flex h-22 min-h-[92px] w-full items-center justify-between gap-5 bg-white p-8 dark:bg-black', + !fullBleedToolbar && 'mx-auto max-w-7xl', + pageToolbarClassName + ); + + return ( +
+ {(left || globalActions) && + + } +
+ {children} +
+
+ ); +}; + +export default Page; \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/global/layout/PageHeader.stories.tsx b/apps/admin-x-design-system/src/global/layout/PageHeader.stories.tsx similarity index 100% rename from apps/admin-x-settings/src/admin-x-ds/global/layout/PageHeader.stories.tsx rename to apps/admin-x-design-system/src/global/layout/PageHeader.stories.tsx diff --git a/apps/admin-x-settings/src/admin-x-ds/global/layout/PageHeader.tsx b/apps/admin-x-design-system/src/global/layout/PageHeader.tsx similarity index 89% rename from apps/admin-x-settings/src/admin-x-ds/global/layout/PageHeader.tsx rename to apps/admin-x-design-system/src/global/layout/PageHeader.tsx index bfe36f5bf76..8ccbd7745c5 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/layout/PageHeader.tsx +++ b/apps/admin-x-design-system/src/global/layout/PageHeader.tsx @@ -1,7 +1,7 @@ -import React from 'react'; import clsx from 'clsx'; +import React from 'react'; -interface PageHeaderProps { +export interface PageHeaderProps { /** * Use these to specifically place elements on the left | center | right of the header. @@ -28,7 +28,7 @@ const PageHeader: React.FC = ({ children }) => { const containerClasses = clsx( - 'z-50 h-[74px] p-5 px-7', + 'z-50 h-22 min-h-[92px] p-8 px-6 tablet:px-12', !children && 'flex items-center justify-between gap-3', sticky && 'sticky top-0', containerClassName @@ -39,7 +39,7 @@ const PageHeader: React.FC = ({ const leftClasses = clsx( 'flex flex-auto items-center', (right && center) && 'basis-1/3', - ((right && !center) || (!right && center)) && 'basis-1/2' + ((!right && center)) && 'basis-1/2' ); left =
{left}
; } @@ -55,7 +55,7 @@ const PageHeader: React.FC = ({ const rightClasses = clsx( 'flex flex-auto items-center justify-end', (left && center) && 'basis-1/3', - ((left && !center) || (!left && center)) && 'basis-1/2' + ((!left && center)) && 'basis-1/2' ); right =
{right}
; } @@ -74,4 +74,4 @@ const PageHeader: React.FC = ({ ); }; -export default PageHeader; \ No newline at end of file +export default PageHeader; diff --git a/apps/admin-x-design-system/src/global/layout/ViewContainer.stories.tsx b/apps/admin-x-design-system/src/global/layout/ViewContainer.stories.tsx new file mode 100644 index 00000000000..2a002916eb7 --- /dev/null +++ b/apps/admin-x-design-system/src/global/layout/ViewContainer.stories.tsx @@ -0,0 +1,200 @@ +import {useArgs} from '@storybook/preview-api'; +import type {Meta, StoryObj} from '@storybook/react'; + +import ViewContainer, {PrimaryActionProps, ViewTab} from './ViewContainer'; +import Button from '../Button'; +import ButtonGroup from '../ButtonGroup'; + +const meta = { + title: 'Global / Layout / View Container', + component: ViewContainer, + parameters: { + layout: 'fullscreen' + }, + render: function Component(args) { + const [, updateArgs] = useArgs(); + + return { + updateArgs({selectedTab: tab}); + args.onTabChange?.(tab); + }} + />; + }, + argTypes: { + children: { + control: { + type: 'text' + } + } + }, + tags: ['autodocs'], + excludeStories: ['exampleActions'] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const exampleActions = [ + - ); -}; diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/Header.stories.ts b/apps/admin-x-settings/src/admin-x-ds/experimental/Header.stories.ts deleted file mode 100644 index 26f70b8aea0..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/Header.stories.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {Header} from './Header'; -import type {Meta, StoryObj} from '@storybook/react'; - -const meta = { - title: 'Experimental / Header', - component: Header, - // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs - tags: ['autodocs'], - parameters: { - // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout - layout: 'fullscreen' - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const LoggedIn: Story = { - args: { - user: { - name: 'Jane Doe' - } - } -}; - -export const LoggedOut: Story = {}; diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/Header.tsx b/apps/admin-x-settings/src/admin-x-ds/experimental/Header.tsx deleted file mode 100644 index b55ae7c02d6..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/Header.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import './header.css'; -import {ExampleButton} from './ExampleButton'; - -type User = { - name: string; -}; - -interface HeaderProps { - user?: User; - onLogin: () => void; - onLogout: () => void; - onCreateAccount: () => void; -} - -export const Header = ({user, onLogin, onLogout, onCreateAccount}: HeaderProps) => ( -
-
-
- - - - - - - -

Acme

-
-
- {user ? ( - <> - - Welcome, {user.name}! - - - - ) : ( - <> - - - - )} -
-
-
-); diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/Page.stories.ts b/apps/admin-x-settings/src/admin-x-ds/experimental/Page.stories.ts deleted file mode 100644 index 0da02551892..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/Page.stories.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {userEvent, within} from '@storybook/testing-library'; -import type {Meta, StoryObj} from '@storybook/react'; - -import {Page} from './Page'; - -const meta = { - title: 'Experimental / Page', - component: Page, - parameters: { - // More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout - layout: 'fullscreen' - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const LoggedOut: Story = {}; - -// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing -export const LoggedIn: Story = { - play: async ({canvasElement}) => { - const canvas = within(canvasElement); - const loginButton = await canvas.getByRole('button', { - name: /Log in/i - }); - await userEvent.click(loginButton); - } -}; diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/Page.tsx b/apps/admin-x-settings/src/admin-x-ds/experimental/Page.tsx deleted file mode 100644 index b31ddf2ec29..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/Page.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; - -import './page.css'; -import {Header} from './Header'; - -type User = { - name: string; -}; - -export const Page: React.FC = () => { - const [user, setUser] = React.useState(); - - return ( -
-
setUser({name: 'Jane Doe'})} - onLogin={() => setUser({name: 'Jane Doe'})} - onLogout={() => setUser(undefined)} - /> - -
-

Pages in Storybook

-

- We recommend building UIs with a{' '} - - component-driven - {' '} - process starting with atomic components and ending with pages. -

-

- Render pages with mock data. This makes it easy to build and review page states without - needing to navigate to them in your app. Here are some handy patterns for managing page - data in Storybook: -

-
    -
  • - Use a higher-level connected component. Storybook helps you compose such data from the - "args" of child component stories -
  • -
  • - Assemble data in the page component from your services. You can mock these services out - using Storybook. -
  • -
-

- Get a guided tutorial on component-driven development at{' '} - - Storybook tutorials - - . Read more in the{' '} - - docs - - . -

-
- Tip Adjust the width of the canvas with the{' '} - - - - - - Viewports addon in the toolbar -
-
-
- ); -}; diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/Task.stories.tsx b/apps/admin-x-settings/src/admin-x-ds/experimental/Task.stories.tsx deleted file mode 100644 index 3ad8e8ca455..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/Task.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import Task from './Task'; - -const story = { - component: Task, - title: 'Experimental / Task', - tags: ['autodocs'] -}; - -export default story; - -export const Default = { - args: { - task: { - id: '1', - title: 'Test task', - state: 'TASK_INBOX' - } - } -}; - -export const Pinned = { - args: { - task: { - ...Default.args.task, - state: 'TASK_PINNED' - } - } -}; - -export const Archived = { - args: { - task: { - ...Default.args.task, - state: 'TASK_ARCHIVED' - } - } -}; \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/Task.tsx b/apps/admin-x-settings/src/admin-x-ds/experimental/Task.tsx deleted file mode 100644 index 9960ad4aa1d..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/Task.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; - -interface Props { - task: { - id: string, - title: string, - state: string, - } - onArchiveTask: (id: string) => void, - onPinTask: (id: string) => void, -} - -const Task: React.FC = ({task: {id, title, state}, onArchiveTask, onPinTask}) => { - return ( -
- - - - - {state !== 'TASK_ARCHIVED' && ( - - )} -
- ); -}; - -export default Task; \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/Tasklist.stories.tsx b/apps/admin-x-settings/src/admin-x-ds/experimental/Tasklist.stories.tsx deleted file mode 100644 index e07cb3ed48b..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/Tasklist.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import TaskList from './Tasklist'; -import {ReactNode} from 'react'; - -import * as TaskStories from './Task.stories'; - -const story = { - component: TaskList, - title: 'Experimental / Task List', - decorators: [(_story: () => ReactNode) =>
{_story()}
], - tags: ['autodocs'] -}; - -export default story; - -export const Default = { - args: { - tasks: [ - {...TaskStories.Default.args.task, id: '1', title: 'Task 1'}, - {...TaskStories.Default.args.task, id: '2', title: 'Task 2'}, - {...TaskStories.Default.args.task, id: '3', title: 'Task 3'}, - {...TaskStories.Default.args.task, id: '4', title: 'Task 4'} - ] - } -}; - -export const WithPinnedTasks = { - args: { - tasks: [ - ...Default.args.tasks.slice(0, 3), - {id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED'} - ] - } -}; - -export const Loading = { - args: { - tasks: [], - loading: true - } -}; - -export const Empty = { - args: { - ...Loading.args, - loading: false - } -}; diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/Tasklist.tsx b/apps/admin-x-settings/src/admin-x-ds/experimental/Tasklist.tsx deleted file mode 100644 index 3bbae3876a7..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/Tasklist.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import Task from './Task'; - -interface Props { - loading: boolean, - tasks: Array<{ - id: string, - title: string, - state: string, - }>, - onArchiveTask: (id: string) => void, - onPinTask: (id: string) => void, -} - -const TaskList: React.FC = ({loading, tasks, onPinTask, onArchiveTask}) => { - const events = { - onPinTask, - onArchiveTask - }; - - if (loading) { - return
Loading
; - } - - if (tasks.length === 0) { - return
empty
; - } - - return ( -
- {tasks.map(task => ( - - ))} -
- ); -}; - -export default TaskList; \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/code-brackets.svg b/apps/admin-x-settings/src/admin-x-ds/experimental/assets/code-brackets.svg deleted file mode 100644 index 73de9477600..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/code-brackets.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/code-brackets \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/colors.svg b/apps/admin-x-settings/src/admin-x-ds/experimental/assets/colors.svg deleted file mode 100644 index 17d58d516e1..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/colors.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/colors \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/comments.svg b/apps/admin-x-settings/src/admin-x-ds/experimental/assets/comments.svg deleted file mode 100644 index 6493a139f52..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/comments.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/comments \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/direction.svg b/apps/admin-x-settings/src/admin-x-ds/experimental/assets/direction.svg deleted file mode 100644 index 65676ac2722..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/direction.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/direction \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/flow.svg b/apps/admin-x-settings/src/admin-x-ds/experimental/assets/flow.svg deleted file mode 100644 index 8ac27db403c..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/flow.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/flow \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/plugin.svg b/apps/admin-x-settings/src/admin-x-ds/experimental/assets/plugin.svg deleted file mode 100644 index 29e5c690c0a..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/plugin.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/plugin \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/repo.svg b/apps/admin-x-settings/src/admin-x-ds/experimental/assets/repo.svg deleted file mode 100644 index f386ee902c1..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/repo.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/repo \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/stackalt.svg b/apps/admin-x-settings/src/admin-x-ds/experimental/assets/stackalt.svg deleted file mode 100644 index 9b7ad274350..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/assets/stackalt.svg +++ /dev/null @@ -1 +0,0 @@ -illustration/stackalt \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/button.css b/apps/admin-x-settings/src/admin-x-ds/experimental/button.css deleted file mode 100644 index dc91dc76370..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/button.css +++ /dev/null @@ -1,30 +0,0 @@ -.storybook-button { - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-weight: 700; - border: 0; - border-radius: 3em; - cursor: pointer; - display: inline-block; - line-height: 1; -} -.storybook-button--primary { - color: white; - background-color: #1ea7fd; -} -.storybook-button--secondary { - color: #333; - background-color: transparent; - box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; -} -.storybook-button--small { - font-size: 12px; - padding: 10px 16px; -} -.storybook-button--medium { - font-size: 14px; - padding: 11px 20px; -} -.storybook-button--large { - font-size: 16px; - padding: 12px 24px; -} diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/header.css b/apps/admin-x-settings/src/admin-x-ds/experimental/header.css deleted file mode 100644 index 9fb414e5040..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/header.css +++ /dev/null @@ -1,14 +0,0 @@ -.storybook-wrapper { - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - padding: 15px 20px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.storybook-welcome { - color: #333; - font-size: 14px; - margin-right: 10px; -} diff --git a/apps/admin-x-settings/src/admin-x-ds/experimental/page.css b/apps/admin-x-settings/src/admin-x-ds/experimental/page.css deleted file mode 100644 index fb64fe46294..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/experimental/page.css +++ /dev/null @@ -1,69 +0,0 @@ -section { - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-size: 14px; - line-height: 24px; - padding: 48px 20px; - margin: 0 auto; - max-width: 600px; - color: #333; -} - -section h2 { - font-weight: 700; - font-size: 32px; - line-height: 1; - margin: 0 0 4px; - display: inline-block; - vertical-align: top; -} - -section p { - margin: 1em 0; -} - -section a { - text-decoration: none; - color: #1ea7fd; -} - -section ul { - padding-left: 30px; - margin: 1em 0; -} - -section li { - margin-bottom: 8px; -} - -section .tip { - display: inline-block; - border-radius: 1em; - font-size: 11px; - line-height: 12px; - font-weight: 700; - background: #e7fdd8; - color: #66bf3c; - padding: 4px 12px; - margin-right: 10px; - vertical-align: top; -} - -section .tip-wrapper { - font-size: 13px; - line-height: 20px; - margin-top: 40px; - margin-bottom: 40px; -} - -section .tip-wrapper svg { - display: inline-block; - height: 12px; - width: 12px; - margin-right: 4px; - vertical-align: top; - margin-top: 3px; -} - -section .tip-wrapper svg path { - fill: #1ea7fd; -} diff --git a/apps/admin-x-settings/src/admin-x-ds/global/Avatar.tsx b/apps/admin-x-settings/src/admin-x-ds/global/Avatar.tsx deleted file mode 100644 index 6d0a30befa9..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/Avatar.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import {ReactComponent as UserIcon} from '../assets/icons/single-user-fill.svg'; - -type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'; - -interface AvatarProps { - image?: string; - label?: string; - - /** - * Accepts any valid Tailwindcolor e.g. `black`, `green` - */ - labelColor?: string; - - /** - * Accepts any valid CSS value e.g. #ffca03 - */ - bgColor?: string; - size?: AvatarSize; - className?: string; -} - -const Avatar: React.FC = ({image, label, labelColor, bgColor, size, className}) => { - let avatarSize = ''; - let fallbackPosition = ' -mb-2 '; - - switch (size) { - case 'sm': - avatarSize = ' w-7 h-7 text-sm '; - break; - case 'lg': - avatarSize = ' w-12 h-12 text-xl '; - break; - case 'xl': - avatarSize = ' w-16 h-16 text-2xl '; - fallbackPosition = ' -mb-3 '; - break; - default: - avatarSize = ' w-10 h-10 text-md '; - break; - } - - if (image) { - return ( - - ); - } else if (label) { - return ( -
{label}
- ); - } else { - return ( -
- -
- ); - } -}; - -export default Avatar; \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/global/ButtonGroup.tsx b/apps/admin-x-settings/src/admin-x-ds/global/ButtonGroup.tsx deleted file mode 100644 index 1c8e54cd5d8..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/ButtonGroup.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import Button from './Button'; -import React from 'react'; - -import {ButtonProps} from './Button'; - -interface ButtonGroupProps { - buttons: Array; - link?: boolean; - linkWithPadding?: boolean; - className?: string; -} - -const ButtonGroup: React.FC = ({buttons, link, linkWithPadding, className}) => { - return ( -
- {buttons.map(({key, ...props}) => ( -
- ); -}; - -export default ButtonGroup; \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/global/Icon.tsx b/apps/admin-x-settings/src/admin-x-ds/global/Icon.tsx deleted file mode 100644 index 4a082527f9c..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/Icon.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; - -const icons: Record>}> = import.meta.glob('../assets/icons/*.svg', {eager: true}); - -export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number; - -interface IconProps { - name: string; - - /** - * Accepts either predefined sizes or number, in which case the size means the pixel width & height - */ - size?: IconSize; - - /** - * Accepts all colors available in the actual TailwindCSS theme, e.g. `black`, `green-100` - */ - colorClass?: string; - styles?: string; - className?: string; -} - -/** - * Icon guidelines: - * - all icons must be SVG's - * - all icons must have all it's children color value set `currentColor` - * - all strokes must be paths and _NOT_ outlined objects. Stroke width should be set to 1.5px - */ -const Icon: React.FC = ({name, size = 'md', colorClass = '', className = ''}) => { - const {ReactComponent: SvgComponent} = icons[`../assets/icons/${name}.svg`]; - - let styles = ''; - - if (!styles) { - switch (size) { - case 'xs': - styles = 'w-3 h-3'; - break; - case 'sm': - styles = 'w-4 h-4'; - break; - case 'lg': - styles = 'w-8 h-8'; - break; - case 'xl': - styles = 'w-10 h-10'; - break; - - default: - styles = 'w-5 h-5'; - break; - } - } - - styles = clsx( - styles, - colorClass - ); - - if (SvgComponent) { - return ( - - ); - } - return null; -}; - -export default Icon; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/Popover.stories.tsx b/apps/admin-x-settings/src/admin-x-ds/global/Popover.stories.tsx deleted file mode 100644 index d6245ef9f0e..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/Popover.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type {Meta, StoryObj} from '@storybook/react'; - -// import BoilerPlate from './Boilerplate'; -import Button from './Button'; -import Popover from './Popover'; - -const meta = { - title: 'Global / Popover', - component: Popover, - tags: ['autodocs'], - argTypes: { - trigger: { - control: { - type: 'text' - } - } - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - trigger: ( - - {children} - -); - -const SortableItem: React.FC<{ - id: string - children: ReactNode; - separator?: boolean; - dragHandleClass?: string; - container: (props: SortableItemContainerProps) => ReactNode; -}> = ({id, children, separator, dragHandleClass, container}) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition - } = useSortable({id}); - - const style = { - transform: CSS.Transform.toString(transform), - transition - }; - - return container({ - setRef: setNodeRef, - isDragging: false, - separator: separator, - dragHandleClass: dragHandleClass, - dragHandleAttributes: attributes, - dragHandleListeners: listeners, - style, - children - }); -}; - -export interface SortableListProps extends HTMLProps { - title?: string; - titleSeparator?: boolean; - hint?: React.ReactNode; - items: Item[]; - itemSeparator?: boolean; - dragHandleClass?: string; - onMove: (id: string, overId: string) => void; - renderItem: (item: Item) => ReactNode; - container?: (props: SortableItemContainerProps) => ReactNode; -} - -/** - * Note: For lists which don't have an ID, you can use `useSortableIndexedList` to give items a consistent index-based ID. - */ -const SortableList = ({ - title, - titleSeparator, - hint, - items, - itemSeparator = true, - dragHandleClass, - onMove, - renderItem, - container = props => , - ...props -}: SortableListProps) => { - const [draggingId, setDraggingId] = useState(null); - - if (!items.length) { - return <>; - } - - return ( -
- {title && {title}} -
- onMove(event.active.id as string, event.over?.id as string)} - onDragStart={event => setDraggingId(event.active.id as string)} - > - - {items.map(item => ( - {renderItem(item)} - ))} - - - {draggingId ? container({ - isDragging: true, - children: renderItem(items.find(({id}) => id === draggingId)!) - }) : null} - - -
- {hint && - <> - {!itemSeparator && } - {hint} - - } -
- ); -}; - -export default SortableList; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/TabView.tsx b/apps/admin-x-settings/src/admin-x-ds/global/TabView.tsx deleted file mode 100644 index aa959ce572c..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/TabView.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; - -export type Tab = { - id: ID; - title: string; - counter?: number | null; - - /** - * Optional, so you can just use the tabs to other views - */ - contents?: React.ReactNode; -} - -interface TabViewProps { - tabs: readonly Tab[]; - onTabChange: (id: ID) => void; - selectedTab?: ID; - border?: boolean; - width?: 'narrow' | 'normal' | 'wide'; -} - -function TabView({ - tabs, - onTabChange, - selectedTab, - border = true, - width = 'normal' -}: TabViewProps) { - if (tabs.length !== 0 && selectedTab === undefined) { - selectedTab = tabs[0].id; - } - - if (tabs.length === 0) { - return (<>); - } - - const handleTabChange = (e: React.MouseEvent) => { - const newTab = e.currentTarget.id as ID; - onTabChange(newTab); - }; - - const containerClasses = clsx( - 'no-scrollbar flex w-full overflow-x-auto', - width === 'narrow' && 'gap-3', - width === 'normal' && 'gap-5', - width === 'wide' && 'gap-7', - border && 'border-b border-grey-300 dark:border-grey-900' - ); - - return ( -
-
- {tabs.map(tab => ( -
- -
- ))} -
- {tabs.map((tab) => { - return ( - <> - {tab.contents && -
-
{tab.contents}
-
- } - - ); - })} -
- ); -}; - -export default TabView; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/Table.stories.tsx b/apps/admin-x-settings/src/admin-x-ds/global/Table.stories.tsx deleted file mode 100644 index bbadc5d21a0..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/Table.stories.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import {ReactNode} from 'react'; -import type {Meta, StoryObj} from '@storybook/react'; - -import * as TableRowStories from './TableRow.stories'; -import Table from './Table'; -import TableCell from './TableCell'; -import TableHead from './TableHead'; -import TableRow from './TableRow'; - -const meta = { - title: 'Global / Table', - component: Table, - tags: ['autodocs'] -} satisfies Meta; - -const {/*id,*/ ...tableRowProps} = TableRowStories.HiddenAction.args || {}; - -const tableHeader = ( - <> - Name - Email - -); - -const tableRows = ( - <> - - Jamie Larson - jamie@example.com - - - Jamie Larson - jamie@example.com - - - Jamie Larson - jamie@example.com - - - Jamie Larson - jamie@example.com - - - Jamie Larson - jamie@example.com - - -); - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - children: tableRows - }, - decorators: [(_story: () => ReactNode) => (
{_story()}
)] -}; - -export const WithHeader: Story = { - args: { - header: tableHeader, - children: tableRows - } -}; - -export const WithPageTitle: Story = { - args: { - pageTitle: 'This is a page title', - children: tableRows - } -}; - -export const WithRowAction: Story = { - args: { - header: tableHeader, - children: tableRows - } -}; - -export const WithHint: Story = { - args: { - header: tableHeader, - children: tableRows, - hint: 'This is a hint', - hintSeparator: true - } -}; - -export const Loading: Story = { - args: { - header: tableHeader, - children: tableRows, - isLoading: true, - hint: 'This is a hint', - hintSeparator: true - } -}; \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/global/Table.tsx b/apps/admin-x-settings/src/admin-x-ds/global/Table.tsx deleted file mode 100644 index 68db36fa239..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/Table.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import Heading from './Heading'; -import Hint from './Hint'; -import Pagination from './Pagination'; -import React from 'react'; -import Separator from './Separator'; -import TableRow from './TableRow'; -import clsx from 'clsx'; -import {LoadingIndicator} from './LoadingIndicator'; -import {PaginationData} from '../../hooks/usePagination'; - -export interface ShowMoreData { - hasMore: boolean; - loadMore: () => void; -} - -interface TableProps { - /** - * If the table is the primary content on a page (e.g. Members table) then you can set a pagetitle to be consistent - */ - pageTitle?: string; - header?: React.ReactNode; - children?: React.ReactNode; - borderTop?: boolean; - hint?: React.ReactNode; - hintSeparator?: boolean; - className?: string; - isLoading?: boolean; - pagination?: PaginationData; - showMore?: ShowMoreData; -} - -const OptionalPagination = ({pagination}: {pagination?: PaginationData}) => { - if (!pagination) { - return null; - } - - return ; -}; - -const OptionalShowMore = ({showMore}: {showMore?: ShowMoreData}) => { - if (!showMore) { - return null; - } else if (!showMore.hasMore) { - return
; - } - - return ( -
- -
- ); -}; - -const Table: React.FC = ({header, children, borderTop, hint, hintSeparator, pageTitle, className, pagination, showMore, isLoading}) => { - const tableClasses = clsx( - (borderTop || pageTitle) && 'border-t border-grey-300', - 'w-full overflow-x-auto', - pageTitle ? 'mb-0 mt-14' : 'my-0', - className - ); - - const table = React.useRef(null); - const maxTableHeight = React.useRef(0); - const [tableHeight, setTableHeight] = React.useState(undefined); - - const multiplePages = pagination && pagination.pages && pagination.pages > 1; - - // Observe the height of the table content. This is used to: - // 1) avoid layout jumps when loading a new page of the table - // 2) keep the same table height between pages, cf. https://github.com/TryGhost/Product/issues/3881 - React.useEffect(() => { - if (table.current) { - const resizeObserver = new ResizeObserver((entries) => { - const height = entries[0].target.clientHeight; - setTableHeight(height); - - if (height > maxTableHeight.current) { - maxTableHeight.current = height; - } - }); - - resizeObserver.observe(table.current); - - return () => { - resizeObserver.disconnect(); - }; - } - }, [isLoading, pagination]); - - const loadingStyle = React.useMemo(() => { - if (tableHeight === undefined) { - return { - height: 'auto' - }; - } - - return { - height: maxTableHeight.current - }; - }, [tableHeight]); - - const spaceHeightStyle = React.useMemo(() => { - if (tableHeight === undefined) { - return { - height: 0 - }; - } - - return { - height: maxTableHeight.current - tableHeight - }; - }, [tableHeight]); - - return ( - <> -
- {pageTitle && {pageTitle}} - - - {header && - {header} - } - {!isLoading && - {children} - } - - {multiplePages &&
} -
- - {isLoading && } - - {(hint || pagination || showMore) && -
- {(hintSeparator || pagination) && } -
- - {hint ?? ' '} - -
-
} -
- - ); -}; - -export default Table; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/TableCell.tsx b/apps/admin-x-settings/src/admin-x-ds/global/TableCell.tsx deleted file mode 100644 index 0e284261ba4..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/TableCell.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React, {HTMLProps} from 'react'; -import clsx from 'clsx'; - -interface TableCellProps extends HTMLProps { - padding?: boolean; -} - -const TableCell: React.FC = ({className, children, padding = true, ...props}) => { - const tableCellClasses = clsx( - padding ? '!py-3 !pl-0 !pr-6' : '', - 'align-top', - props.onClick && 'hover:cursor-pointer', - className - ); - - return ( - - {children} - - ); -}; - -export default TableCell; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/TableHead.tsx b/apps/admin-x-settings/src/admin-x-ds/global/TableHead.tsx deleted file mode 100644 index 78a6c0e0924..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/TableHead.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Heading from './Heading'; -import React, {HTMLProps} from 'react'; -import clsx from 'clsx'; - -const TableHead: React.FC> = ({className, children, colSpan, ...props}) => { - const tableCellClasses = clsx( - '!py-2 !pl-0 !pr-6 text-left align-top', - props.onClick && 'hover:cursor-pointer', - className - ); - - return ( - - {children} - - ); -}; - -export default TableHead; \ No newline at end of file diff --git a/apps/admin-x-settings/src/admin-x-ds/global/TableRow.tsx b/apps/admin-x-settings/src/admin-x-ds/global/TableRow.tsx deleted file mode 100644 index bf7567ea647..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/TableRow.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import clsx from 'clsx'; - -interface TableRowProps { - id?: string; - action?: React.ReactNode; - hideActions?: boolean; - className?: string; - testId?: string; - - /** - * Hidden for the last item in the table - */ - separator?: boolean; - - bgOnHover?: boolean; - onClick?: (e: React.MouseEvent) => void; - children?: React.ReactNode; -} - -const TableRow: React.FC = ({id, action, hideActions, className, testId, separator, bgOnHover = true, onClick, children}) => { - const handleClick = (e: React.MouseEvent) => { - onClick?.(e); - }; - - separator = (separator === undefined) ? true : separator; - const tableRowClasses = clsx( - 'group/table-row', - bgOnHover && 'hover:bg-gradient-to-r hover:from-white hover:to-grey-50 dark:hover:from-black dark:hover:to-grey-950', - onClick && 'cursor-pointer', - separator ? 'border-b border-grey-100 last-of-type:border-b-transparent hover:border-grey-200 dark:border-grey-950 dark:hover:border-grey-900' : 'border-y border-none first-of-type:hover:border-t-transparent', - className - ); - - return ( - - {children} - {action && - -
- {action} -
- - } - - ); -}; - -export default TableRow; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/Toast.stories.tsx b/apps/admin-x-settings/src/admin-x-ds/global/Toast.stories.tsx deleted file mode 100644 index f806ef499cd..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/Toast.stories.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import {ReactNode} from 'react'; -import type {Meta, StoryObj} from '@storybook/react'; - -import ToastContainer from './ToastContainer'; -import {Toaster} from 'react-hot-toast'; - -const meta = { - title: 'Global / Toast', - component: ToastContainer, - tags: ['autodocs'], - decorators: [(_story: () => ReactNode) => ( - <> - - {_story()} - - )] -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - message: 'Hello notification in a toast' - } -}; - -export const Success: Story = { - args: { - message: 'Hello success message in a toast', - type: 'success' - } -}; - -export const Error: Story = { - args: { - message: 'Hello error message in a toast', - type: 'error' - } -}; - -export const PageError: Story = { - args: { - message: 'This is a page error which should not be automatically dismissed.', - type: 'pageError' - } -}; - -export const Icon: Story = { - args: { - message: 'Custom icon in a toast', - icon: 'user-add' - } -}; - -export const Custom: Story = { - args: { - message: ( -
- And here is one with a longer notification and a link, custom formatting, icon and duration. -
- ), - icon: ( - <> - 👋 - - ), - options: { - duration: 10000 - } - } -}; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/Toast.tsx b/apps/admin-x-settings/src/admin-x-ds/global/Toast.tsx deleted file mode 100644 index 0287abba596..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/Toast.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import Icon from './Icon'; -import React from 'react'; -import clsx from 'clsx'; -import {Toast as HotToast, ToastOptions, toast} from 'react-hot-toast'; - -export type ToastType = 'neutral' | 'success' | 'error' | 'pageError'; - -export interface ShowToastProps { - message?: React.ReactNode; - type?: ToastType; - icon?: React.ReactNode | string; - options?: ToastOptions -} - -interface ToastProps { - t: HotToast; - - /** - * Can be a name of an icon from the icon library or a react component - */ - children?: React.ReactNode; - props?: ShowToastProps; -} - -const Toast: React.FC = ({ - t, - children, - props -}) => { - switch (props?.type) { - case 'success': - props.icon = props.icon || 'check-circle'; - break; - case 'error': - props.icon = props.icon || 'warning'; - break; - } - - const classNames = clsx( - 'z-[90] flex items-start justify-between gap-6 rounded px-4 py-3 text-sm font-medium text-white', - (props?.type === 'success' || props?.type === 'neutral') && 'w-[300px] bg-black dark:bg-grey-950', - props?.type === 'error' && 'w-[300px] bg-red', - props?.options?.position === 'top-center' && 'w-full max-w-[520px] bg-red', - t.visible ? (props?.options?.position === 'top-center' ? 'animate-toaster-top-in' : 'animate-toaster-in') : 'animate-toaster-out' - ); - - return ( -
-
- {props?.icon && (typeof props.icon === 'string' ? -
: props.icon)} - {children} -
- -
- ); -}; - -export default Toast; - -export const showToast = ({ - message, - type = 'neutral', - icon = '', - options = { - position: 'bottom-left', - duration: 5000 - } -}: ShowToastProps): void => { - if (!options.position) { - options.position = 'bottom-left'; - } - - if (type === 'pageError') { - type = 'error'; - options.position = 'top-center'; - options.duration = Infinity; - } - - toast.custom(t => ( - - {message} - - ), - { - ...options - } - ); -}; - -export const dismissAllToasts = (): void => { - toast.dismiss(); -}; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/ToastContainer.tsx b/apps/admin-x-settings/src/admin-x-ds/global/ToastContainer.tsx deleted file mode 100644 index 36166a8a8c2..00000000000 --- a/apps/admin-x-settings/src/admin-x-ds/global/ToastContainer.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Button from './Button'; -import React from 'react'; -import {ShowToastProps, showToast} from './Toast'; - -const ToastContainer: React.FC = ({...props}) => { - return ( - <> - + ); + } else { + return <>; + } +}; + +const MigrationToolsImport: React.FC = () => { + const {updateRoute} = useRouting(); + + const handleImportContent = () => { + NiceModal.show(UniversalImportModal); + }; + + return ( +
+ + } + title='Substack' + onClick={() => updateRoute({isExternal: true, route: '/migrate/substack'})} + /> + + } + title='Medium' + onClick={() => updateRoute({isExternal: true, route: '/migrate/medium'})} + /> + + } + title='Mailchimp' + onClick={() => updateRoute({isExternal: true, route: '/migrate/mailchimp'})} + /> + + } + title='Universal import' + onClick={handleImportContent} + /> +
+ ); +}; + +export default MigrationToolsImport; diff --git a/apps/admin-x-settings/src/components/settings/advanced/migrationtools/UniversalImportModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/migrationtools/UniversalImportModal.tsx new file mode 100644 index 00000000000..737b6378883 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/advanced/migrationtools/UniversalImportModal.tsx @@ -0,0 +1,55 @@ +import NiceModal, {useModal} from '@ebay/nice-modal-react'; +import React, {useState} from 'react'; +import {ConfirmationModal, FileUpload, Modal} from '@tryghost/admin-x-design-system'; +import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import {useImportContent} from '@tryghost/admin-x-framework/api/db'; + +const UniversalImportModal: React.FC = () => { + const modal = useModal(); + const {mutateAsync: importContent} = useImportContent(); + const [uploading, setUploading] = useState(false); + const handleError = useHandleError(); + + return ( + +
+ { + setUploading(true); + try { + await importContent(file); + modal.remove(); + NiceModal.show(ConfirmationModal, { + title: 'Import in progress', + prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it's complete. Usually this only takes a few minutes, but larger imports may take longer.`, + cancelLabel: '', + okLabel: 'Got it', + onOk: confirmModal => confirmModal?.remove(), + formSheet: false + }); + } catch (e) { + handleError(e); + } finally { + setUploading(false); + } + }} + > +
+ {uploading ? 'Uploading...' : <> + Select any JSON or zip file that contains
posts and settings + } +
+
+
+
+ ); +}; + +export default NiceModal.create(UniversalImportModal); diff --git a/apps/admin-x-settings/src/components/settings/email/DefaultRecipients.tsx b/apps/admin-x-settings/src/components/settings/email/DefaultRecipients.tsx index 1e399cdd06a..69bb2c0326f 100644 --- a/apps/admin-x-settings/src/components/settings/email/DefaultRecipients.tsx +++ b/apps/admin-x-settings/src/components/settings/email/DefaultRecipients.tsx @@ -1,14 +1,11 @@ -import MultiSelect, {MultiSelectOption} from '../../../admin-x-ds/global/form/MultiSelect'; import React, {useState} from 'react'; -import Select from '../../../admin-x-ds/global/form/Select'; -import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; -import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent'; +import TopLevelGroup from '../../TopLevelGroup'; import useDefaultRecipientsOptions from './useDefaultRecipientsOptions'; import useSettingGroup from '../../../hooks/useSettingGroup'; +import {MultiSelect, MultiSelectOption, Select, SettingGroupContent, withErrorBoundary} from '@tryghost/admin-x-design-system'; import {MultiValue} from 'react-select'; import {getOptionLabel} from '../../../utils/helpers'; -import {getSettingValues} from '../../../api/settings'; -import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary'; +import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; type RefipientValueArgs = { defaultEmailRecipients: string; @@ -128,7 +125,7 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => { const form = ( + {submitEnabled && -
-
-

- - Unsplash -

-
- - -
-
- {children} -
- - - ); -}; - -export default UnsplashSelector; diff --git a/apps/admin-x-settings/src/unsplash/ui/UnsplashZoomed.tsx b/apps/admin-x-settings/src/unsplash/ui/UnsplashZoomed.tsx deleted file mode 100644 index 88860d3b18a..00000000000 --- a/apps/admin-x-settings/src/unsplash/ui/UnsplashZoomed.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import UnsplashImage, {UnsplashImageProps} from './UnsplashImage'; -import {FC} from 'react'; -import {Photo} from '../UnsplashTypes'; - -interface UnsplashZoomedProps extends Omit { - zoomed: Photo | null; - selectImg: (photo: Photo | null) => void; -} - -const UnsplashZoomed: FC = ({payload, insertImage, selectImg, zoomed}) => { - return ( -
selectImg(null)}> - -
- ); -}; - -export default UnsplashZoomed; diff --git a/apps/admin-x-settings/src/utils/IframeBuffering.tsx b/apps/admin-x-settings/src/utils/IframeBuffering.tsx index 9ca8b52ff44..1f694207e72 100644 --- a/apps/admin-x-settings/src/utils/IframeBuffering.tsx +++ b/apps/admin-x-settings/src/utils/IframeBuffering.tsx @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/react'; import React, {useEffect, useRef, useState} from 'react'; type IframeBufferingProps = { @@ -10,9 +11,23 @@ type IframeBufferingProps = { addDelay?: boolean; }; +function debounce(func: any, wait: number) { // eslint-disable-line + let timeout: NodeJS.Timeout; + + return function executedFunction(...args: any) { // eslint-disable-line + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + const IframeBuffering: React.FC = ({generateContent, className, height, width, parentClassName, testId, addDelay = false}) => { const [visibleIframeIndex, setVisibleIframeIndex] = useState(0); - const iframes = [useRef(null), useRef(null)]; + const iframes = [useRef(null), useRef(null)]; // eslint-disable-line + const [scrollPosition, setScrollPosition] = useState(0); useEffect(() => { const invisibleIframeIndex = visibleIframeIndex === 0 ? 1 : 0; @@ -44,6 +59,36 @@ const IframeBuffering: React.FC = ({generateContent, class // eslint-disable-next-line react-hooks/exhaustive-deps }, [generateContent]); + useEffect(() => { + const iframe = iframes[visibleIframeIndex].current; + + const onScroll = debounce(() => { + setScrollPosition(iframe?.contentWindow?.scrollY || 0); + }, 250); + + iframe?.contentWindow?.addEventListener('scroll', onScroll); + + return () => { + iframe?.contentWindow?.removeEventListener('scroll', onScroll); + }; + }, [visibleIframeIndex, iframes]); + + useEffect(() => { + const iframe = iframes[visibleIframeIndex].current; + + if (iframe) { + // refs https://ghost-foundation.sentry.io/issues/5024564293/ + // Customer reported that code they injected caused Settings to crash. + // According to Sentry this the line that caused the crash. + // We are adding a try catch block to attempt to catch the error for further investigation and prevent the crash. + try { + iframe.contentWindow?.scrollTo(0, scrollPosition); + } catch (e) { + Sentry.captureException(e); + } + } + }, [scrollPosition, visibleIframeIndex, iframes]); + return (
); -}; +}); type ResizableFrameProps = FrameProps & { style: React.CSSProperties, @@ -39,7 +39,7 @@ type ResizableFrameProps = FrameProps & { /** * This iframe has the same height as it contents and mimics a shadow DOM component */ -const ResizableFrame: React.FC = ({children, style, title}) => { +const ResizableFrame = React.forwardRef>(function ResizableFrame({children, style, title}, ref: React.ForwardedRef) { const [iframeStyle, setIframeStyle] = useState(style); const onResize = useCallback((iframeRoot) => { setIframeStyle((current) => { @@ -51,23 +51,25 @@ const ResizableFrame: React.FC = ({children, style, title}) }, []); return ( - + {children} ); -}; +}); -export const CommentsFrame: React.FC = ({children}) => { +type CommentsFrameProps = Record; + +export const CommentsFrame = React.forwardRef>(function CommentsFrame({children}, ref: React.ForwardedRef) { const style = { width: '100%', height: '400px' }; return ( - + {children} ); -}; +}); type PopupFrameProps = FrameProps & { title: string diff --git a/apps/comments-ui/src/components/IFrame.tsx b/apps/comments-ui/src/components/IFrame.tsx index 3dc5e8de0c1..09660ec3f63 100644 --- a/apps/comments-ui/src/components/IFrame.tsx +++ b/apps/comments-ui/src/components/IFrame.tsx @@ -1,10 +1,10 @@ -import {Component} from 'react'; +import {Component, forwardRef} from 'react'; import {createPortal} from 'react-dom'; /** * This is still a class component because it causes issues with the behaviour (DOM recreation and layout glitches) if we switch to a functional component. Feel free to refactor. */ -export default class IFrame extends Component { +class IFrame extends Component { node: any; iframeHtml: any; iframeHead: any; @@ -59,6 +59,9 @@ export default class IFrame extends Component { setNode(node: any) { this.node = node; + if (this.props.innerRef) { + this.props.innerRef.current = node; + } } render() { @@ -71,3 +74,9 @@ export default class IFrame extends Component { ); } } + +const IFrameFC = forwardRef(function IFrameFC(props, ref) { + return \ No newline at end of file + \ No newline at end of file diff --git a/ghost/admin/app/components/gh-context-menu.js b/ghost/admin/app/components/gh-context-menu.js index 8b3de5a54bd..205e488bb1f 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-date-time-picker.hbs b/ghost/admin/app/components/gh-date-time-picker.hbs index 6f2a6bf5879..a634c5f928e 100644 --- a/ghost/admin/app/components/gh-date-time-picker.hbs +++ b/ghost/admin/app/components/gh-date-time-picker.hbs @@ -37,7 +37,7 @@ > {{this.timezone}}
- {{#if this.hasError}} -
{{this.dateError}}{{this.timeError}}
- {{/if}} +{{#if this.hasError}} +
{{this.dateError}}{{this.timeError}}
+{{/if}} diff --git a/ghost/admin/app/components/gh-editor-feature-image.hbs b/ghost/admin/app/components/gh-editor-feature-image.hbs index 411e3eae872..0d934a7d878 100644 --- a/ghost/admin/app/components/gh-editor-feature-image.hbs +++ b/ghost/admin/app/components/gh-editor-feature-image.hbs @@ -1,8 +1,6 @@ {{!-- template-lint-disable no-invalid-interactive --}}
{{!-- template-lint-enable no-invalid-interactive --}} {{svg-jar "eye-closed"}} - {{else}} - - {{svg-jar "feature-image"}} - {{/if}}
{{@alt}} @@ -52,11 +46,11 @@ @imageSrc={{@image}} @saveImage={{fn this.saveImage uploader.setFiles}} /> -
-
+
{{#if this.isEditingAlt}} {{/if}} + + {{#if (and this.tkCount (not this.isEditingAlt))}} +
+ TK +
+ {{/if}}
{{else}} {{!-- no-image state --}} -
+
{{#if this.canDrop}}
Drop to upload feature image
{{else}} diff --git a/ghost/admin/app/components/gh-editor-feature-image.js b/ghost/admin/app/components/gh-editor-feature-image.js index d6b6b6a2884..d621d0de5f3 100644 --- a/ghost/admin/app/components/gh-editor-feature-image.js +++ b/ghost/admin/app/components/gh-editor-feature-image.js @@ -15,14 +15,10 @@ export default class GhEditorFeatureImageComponent extends Component { @service settings; @tracked isEditingAlt = false; - @tracked isHovered = false; @tracked captionInputFocused = false; @tracked showUnsplashSelector = false; @tracked canDrop = false; - - get hideButton() { - return !this.canDrop && !this.isHovered && !this.args.forceButtonDisplay; - } + @tracked tkCount = 0; get caption() { const content = this.args.caption; @@ -39,6 +35,24 @@ export default class GhEditorFeatureImageComponent extends Component { this.args.updateCaption(cleanedHtml); } + @action + registerEditorAPI(API) { + this.editorAPI = API; + } + + @action + focusCaptionEditor() { + if (this.editorAPI) { + this.editorAPI.focusEditor({position: 'bottom'}); + } + } + + @action + handleCaptionBlur() { + this.captionInputFocused = false; + this.args.handleCaptionBlur(); + } + @action setUploadedImage(results) { if (results[0]) { @@ -115,4 +129,12 @@ export default class GhEditorFeatureImageComponent extends Component { this.canDrop = false; setFiles([imageFile]); } + + @action + onTKCountChange(count) { + if (this.args.onTKCountChange) { + this.tkCount = count; + this.args.onTKCountChange(count); + } + } } diff --git a/ghost/admin/app/components/gh-feature-flag.hbs b/ghost/admin/app/components/gh-feature-flag.hbs deleted file mode 100644 index 8b04c56982a..00000000000 --- a/ghost/admin/app/components/gh-feature-flag.hbs +++ /dev/null @@ -1,12 +0,0 @@ - - -{{{yield}}} diff --git a/ghost/admin/app/components/gh-feature-flag.js b/ghost/admin/app/components/gh-feature-flag.js deleted file mode 100644 index f14ce966ae8..00000000000 --- a/ghost/admin/app/components/gh-feature-flag.js +++ /dev/null @@ -1,55 +0,0 @@ -import Component from '@ember/component'; -import classic from 'ember-classic-decorator'; -import {attributeBindings, classNames, tagName} from '@ember-decorators/component'; -import {computed, defineProperty} from '@ember/object'; -import {readOnly} from '@ember/object/computed'; -import {inject as service} from '@ember/service'; - -@classic -@tagName('label') -@classNames('switch') -@attributeBindings('for', 'disabled') -class FeatureFlagComponent extends Component { - @service feature; - - @computed('_disabled') - get disabled() { - if (this._disabled) { - return true; - } - return false; - } - - @computed('_flagValue') - get value() { - return this._flagValue; - } - - set value(value) { - this.set(`feature.${this.flag}`, value); - } - - @computed('flag') - get for() { - return `labs-${this.flag}`; - } - - @computed('flag') - get name() { - return `labs[${this.flag}]`; - } - - get testKey() { - return `labs-${this.flag}`; - } - - init() { - super.init(...arguments); - - defineProperty(this, '_flagValue', readOnly(`feature.${this.flag}`), function () { - return this.get(`feature.${this.flag}`); - }); - } -} - -export default FeatureFlagComponent; diff --git a/ghost/admin/app/components/gh-file-upload.hbs b/ghost/admin/app/components/gh-file-upload.hbs deleted file mode 100644 index 6f17a4b7c3b..00000000000 --- a/ghost/admin/app/components/gh-file-upload.hbs +++ /dev/null @@ -1,4 +0,0 @@ - - diff --git a/ghost/admin/app/components/gh-file-upload.js b/ghost/admin/app/components/gh-file-upload.js deleted file mode 100644 index a4450a677fe..00000000000 --- a/ghost/admin/app/components/gh-file-upload.js +++ /dev/null @@ -1,38 +0,0 @@ -import Component from '@ember/component'; -import classic from 'ember-classic-decorator'; -import {action} from '@ember/object'; - -@classic -export default class GhFileUpload extends Component { - _file = null; - acceptEncoding = null; - uploadButtonText = 'Text'; - uploadButtonDisabled = true; - shouldResetForm = true; - - // closure actions - onUpload() {} - - onAdd() {} - - @action - upload() { - if (!this.uploadButtonDisabled && this._file) { - this.onUpload(this._file); - } - - // Prevent double post by disabling the button. - this.set('uploadButtonDisabled', true); - - // Reset form - if (this.shouldResetForm) { - this.element.closest('form').reset(); - } - } - - change(event) { - this.set('uploadButtonDisabled', false); - this.onAdd(); - this._file = event.target.files[0]; - } -} diff --git a/ghost/admin/app/components/gh-file-uploader.hbs b/ghost/admin/app/components/gh-file-uploader.hbs deleted file mode 100644 index 714dbb55442..00000000000 --- a/ghost/admin/app/components/gh-file-uploader.hbs +++ /dev/null @@ -1,22 +0,0 @@ -{{#if this.file}} - {{!-- Upload in progress! --}} - {{#if this.failureMessage}} -
- {{this.failureMessage}} - - Try again - -
- {{else}} -
-
-
- {{/if}} - -{{else}} -
- -
{{this.labelText}}
-
-
-{{/if}} diff --git a/ghost/admin/app/components/gh-file-uploader.js b/ghost/admin/app/components/gh-file-uploader.js deleted file mode 100644 index bfd5684f8c4..00000000000 --- a/ghost/admin/app/components/gh-file-uploader.js +++ /dev/null @@ -1,272 +0,0 @@ -import Component from '@ember/component'; -import {GENERIC_ERROR_MESSAGE} from 'ghost-admin/services/notifications'; -import { - UnsupportedMediaTypeError, - isRequestEntityTooLargeError, - isUnsupportedMediaTypeError, - isVersionMismatchError -} from 'ghost-admin/services/ajax'; -import {computed} from '@ember/object'; -import {htmlSafe} from '@ember/template'; -import {isBlank} from '@ember/utils'; -import {isArray as isEmberArray} from '@ember/array'; -import {run} from '@ember/runloop'; -import {inject as service} from '@ember/service'; - -const DEFAULTS = { - accept: ['text/csv'], - extensions: ['csv'] -}; - -export default Component.extend({ - ajax: service(), - eventBus: service(), - notifications: service(), - - tagName: 'section', - classNames: ['gh-image-uploader'], - classNameBindings: ['dragClass'], - - labelText: 'Select or drag-and-drop a file', - url: null, - paramName: 'file', - accept: null, - extensions: null, - validate: null, - - file: null, - response: null, - - dragClass: null, - failureMessage: null, - uploadPercentage: 0, - - // Allowed actions - fileSelected: () => {}, - uploadStarted: () => {}, - uploadFinished: () => {}, - uploadSuccess: () => {}, - uploadFailed: () => {}, - - formData: computed('file', function () { - let paramName = this.paramName; - let file = this.file; - let formData = new FormData(); - - formData.append(paramName, file); - - return formData; - }), - - progressStyle: computed('uploadPercentage', function () { - let percentage = this.uploadPercentage; - let width = ''; - - if (percentage > 0) { - width = `${percentage}%`; - } else { - width = '0'; - } - - return htmlSafe(`width: ${width}`); - }), - - // we can optionally listen to a named event bus channel so that the upload - // process can be triggered externally - init() { - this._super(...arguments); - let listenTo = this.listenTo; - - this.accept = this.accept || DEFAULTS.accept; - this.extensions = this.extensions || DEFAULTS.extensions; - - this._uploadEventHandler = function (file) { - if (file) { - this.set('file', file); - } - this.send('upload'); - }; - - if (listenTo) { - this.eventBus.subscribe(`${listenTo}:upload`, this, this._uploadEventHandler); - } - }, - - didReceiveAttrs() { - this._super(...arguments); - let accept = this.accept; - let extensions = this.extensions; - - this._accept = (!isBlank(accept) && !isEmberArray(accept)) ? accept.split(',') : accept; - this._extensions = (!isBlank(extensions) && !isEmberArray(extensions)) ? extensions.split(',') : extensions; - }, - - willDestroyElement() { - let listenTo = this.listenTo; - - this._super(...arguments); - - if (listenTo) { - this.eventBus.unsubscribe(`${listenTo}:upload`, this, this._uploadEventHandler); - } - }, - - actions: { - fileSelected(fileList, resetInput) { - let [file] = Array.from(fileList); - let validationResult = this._validate(file); - - this.set('file', file); - this.fileSelected(file); - - if (validationResult === true) { - run.schedule('actions', this, function () { - this.generateRequest(); - - if (resetInput) { - resetInput(); - } - }); - } else { - this._uploadFailed(validationResult); - - if (resetInput) { - resetInput(); - } - } - }, - - upload() { - if (this.file) { - this.generateRequest(); - } - }, - - reset() { - this.set('file', null); - this.set('uploadPercentage', 0); - this.set('failureMessage', null); - }, - - retry() { - this.send('reset'); - this.send('fileSelected', ...arguments); - } - }, - - dragOver(event) { - if (!event.dataTransfer) { - return; - } - - // this is needed to work around inconsistencies with dropping files - // from Chrome's downloads bar - if (navigator.userAgent.indexOf('Chrome') > -1) { - let eA = event.dataTransfer.effectAllowed; - event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy'; - } - - event.stopPropagation(); - event.preventDefault(); - - this.set('dragClass', '-drag-over'); - }, - - dragLeave(event) { - event.preventDefault(); - this.set('dragClass', null); - }, - - drop(event) { - event.preventDefault(); - this.set('dragClass', null); - if (event.dataTransfer.files) { - this.send('fileSelected', event.dataTransfer.files); - } - }, - - generateRequest() { - let ajax = this.ajax; - let formData = this.formData; - let url = this.url; - - this.uploadStarted(); - - ajax.post(url, { - data: formData, - processData: false, - contentType: false, - dataType: 'text', - xhr: () => { - let xhr = new window.XMLHttpRequest(); - - xhr.upload.addEventListener('progress', (event) => { - this._uploadProgress(event); - }, false); - - return xhr; - } - }).then((response) => { - this._uploadSuccess(JSON.parse(response)); - }).catch((error) => { - this._uploadFailed(error); - }).finally(() => { - this.uploadFinished(); - }); - }, - - _uploadProgress(event) { - if (event.lengthComputable && !this.isDestroyed && !this.isDestroying) { - run(() => { - let percentage = Math.round((event.loaded / event.total) * 100); - this.set('uploadPercentage', percentage); - }); - } - }, - - _uploadSuccess(response) { - this.uploadSuccess(response); - this.send('reset'); - }, - - _uploadFailed(error) { - let message; - - if (isVersionMismatchError(error)) { - this.notifications.showAPIError(error); - } - - if (isUnsupportedMediaTypeError(error)) { - message = 'The file type you uploaded is not supported.'; - } else if (isRequestEntityTooLargeError(error)) { - message = 'The file you uploaded was larger than the maximum file size your server allows.'; - } else if (error.payload && error.payload.errors && !isBlank(error.payload.errors[0].message)) { - message = htmlSafe(error.payload.errors[0].message); - } else { - console.error(error); // eslint-disable-line - message = GENERIC_ERROR_MESSAGE; - } - - this.set('failureMessage', message); - this.uploadFailed(error); - }, - - _validate(file) { - if (this.validate) { - return this.validate(file); - } else { - return this._defaultValidator(file); - } - }, - - _defaultValidator(file) { - let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); - let extensions = this._extensions; - - if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) { - return new UnsupportedMediaTypeError(); - } - - return true; - } -}); diff --git a/ghost/admin/app/components/gh-font-selector.hbs b/ghost/admin/app/components/gh-font-selector.hbs deleted file mode 100644 index a1e1631537c..00000000000 --- a/ghost/admin/app/components/gh-font-selector.hbs +++ /dev/null @@ -1,16 +0,0 @@ - -
-
-

{{option.name}}

-
-
-
\ No newline at end of file diff --git a/ghost/admin/app/components/gh-font-selector.js b/ghost/admin/app/components/gh-font-selector.js deleted file mode 100644 index 93999d6c3ca..00000000000 --- a/ghost/admin/app/components/gh-font-selector.js +++ /dev/null @@ -1,25 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; - -export default class GhFontSelector extends Component { - get options() { - return [{ - name: 'Elegant serif', - description: 'Beautiful lines with great readability', - value: 'serif' - }, { - name: 'Clean sans-serif', - description: 'A more minimal style with sharp lines', - value: 'sans_serif' - }]; - } - - get selectedOption() { - return this.options.find(o => o.value === this.args.selected); - } - - @action - selectOption(option) { - this.args.onChange(option.value); - } -} diff --git a/ghost/admin/app/components/gh-koenig-editor-lexical.hbs b/ghost/admin/app/components/gh-koenig-editor-lexical.hbs index f75af6debaa..cc425e88e09 100644 --- a/ghost/admin/app/components/gh-koenig-editor-lexical.hbs +++ b/ghost/admin/app/components/gh-koenig-editor-lexical.hbs @@ -16,17 +16,28 @@ @updateAlt={{@setFeatureImageAlt}} @caption={{@featureImageCaption}} @updateCaption={{@setFeatureImageCaption}} + @handleCaptionBlur={{@handleFeatureImageCaptionBlur}} @forceButtonDisplay={{or (not @title) (eq @title "(Untitled)") this.titleIsHovered this.titleIsFocused}} @isHidden={{or (not @cardOptions.post.showTitleAndFeatureImage) false}} + @onTKCountChange={{@updateFeatureImageTkCount}} /> -
+
{{#if (and (not @cardOptions.post.showTitleAndFeatureImage) (not @featureImage))}} {{svg-jar "eye-closed"}} {{/if}} + {{#if @titleHasTk}} +
+ TK +
+ {{/if}} + + + {{#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 0d1822b2495..be28f73a5f7 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; @@ -44,10 +51,15 @@ export default class GhKoenigEditorReactComponent extends Component { // mouseup/click event can occur outside of the initially clicked card, in // which case we don't want to then "re-focus" the editor and cause unexpected // selection changes - const clickedOnDecorator = (event.target.closest('[data-lexical-decorator]') !== null) || event.target.hasAttribute('data-lexical-decorator'); - const clickedOnSlashMenu = (event.target.closest('[data-kg-slash-menu]') !== null) || event.target.hasAttribute('data-kg-slash-menu'); + let skipFocus = false; + for (const elem of (event.path || event.composedPath())) { + if (elem.matches?.('[data-lexical-decorator], [data-kg-slash-menu]')) { + skipFocus = true; + break; + } + } - if (clickedOnDecorator || clickedOnSlashMenu) { + if (skipFocus) { this.skipFocusEditor = true; } } @@ -101,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 (key === 'Enter') { + event.preventDefault(); + this.excerptElement?.focus(); + } - if (!editorAPI) { - return; + 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; + if ((key === 'ArrowUp' || key === 'ArrowLeft') && !event.shiftKey) { + const couldLeaveTitle = !value || selectionStart === 0; + + if (couldLeaveTitle) { + event.preventDefault(); + this.focusTitle(); + } + } + + const {editorAPI} = this; const couldLeaveTitle = !value || selectionStart === value.length; - const arrowLeavingTitle = ['ArrowDown', 'ArrowRight'].includes(key) && couldLeaveTitle; + const arrowLeavingTitle = (key === 'ArrowRight' || key === 'ArrowDown') && couldLeaveTitle; - if (key === 'Enter' || key === 'Tab' || arrowLeavingTitle) { + if (key === 'Enter' || (key === 'Tab' && !event.shiftKey) || arrowLeavingTitle) { event.preventDefault(); if (key === 'Enter' && !editorAPI.editorIsEmpty()) { @@ -130,6 +223,8 @@ export default class GhKoenigEditorReactComponent extends Component { } } + // move cursor to the editor on + // Body actions ------------------------------------------------------------ @action @@ -138,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-label-input.hbs b/ghost/admin/app/components/gh-member-label-input.hbs index fed32b75035..b75832dff41 100644 --- a/ghost/admin/app/components/gh-member-label-input.hbs +++ b/ghost/admin/app/components/gh-member-label-input.hbs @@ -19,7 +19,7 @@
{{label.name}} diff --git a/ghost/admin/app/components/gh-member-settings-form.hbs b/ghost/admin/app/components/gh-member-settings-form.hbs index a4da152d918..c6a3234c0a0 100644 --- a/ghost/admin/app/components/gh-member-settings-form.hbs +++ b/ghost/admin/app/components/gh-member-settings-form.hbs @@ -111,122 +111,104 @@
{{#each tier.subscriptions as |sub index|}} -
-
-
- {{sub.price.currencySymbol}} - {{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")}} - Cancelled - {{else if sub.cancel_at_period_end}} - Cancelled - {{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"))}} +
+

+ {{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}} - {{sub.price.nickname}} + Active {{/if}} - {{/if}} - - {{#if (eq sub.status "canceled")}} - Ended {{sub.validUntil}} - {{else if sub.cancel_at_period_end}} - Has access until {{sub.validUntil}} - {{else if sub.compExpiry}} - Expires {{sub.compExpiry}} - {{else if sub.trialUntil}} - Ends {{sub.trialUntil}} - {{else}} - Renews {{sub.validUntil}} - {{/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 467a240a24f..bcd9193bf50 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,38 +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 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') : '-', - 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); @@ -134,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 32926e99f8a..e7563464caa 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-members-no-members.js b/ghost/admin/app/components/gh-members-no-members.js index 1db29001314..42692f186ca 100644 --- a/ghost/admin/app/components/gh-members-no-members.js +++ b/ghost/admin/app/components/gh-members-no-members.js @@ -8,6 +8,7 @@ export default class GhMembersNoMembersComponent extends Component { @service store; @service notifications; @service settings; + @service membersCountCache; @action addYourself() { @@ -34,10 +35,13 @@ export default class GhMembersNoMembersComponent extends Component { this.notifications.showNotification('Member added', { - description: 'You\'ve successfully added yourself as a member.' + description: 'You\'ve added yourself as a member.' } ); + // force update the member count; this otherwise only updates every minute + yield this.membersCountCache.count({}); + return member; } catch (error) { if (error) { diff --git a/ghost/admin/app/components/gh-members-segment-count.js b/ghost/admin/app/components/gh-members-segment-count.js index 6ca4240d6f5..e1c7b991e50 100644 --- a/ghost/admin/app/components/gh-members-segment-count.js +++ b/ghost/admin/app/components/gh-members-segment-count.js @@ -6,6 +6,7 @@ import {tracked} from '@glimmer/tracking'; export default class GhMembersSegmentCountComponent extends Component { @service store; @service session; + @service membersCountCache; @tracked total = 0; @tracked segmentTotal = 0; @@ -17,9 +18,7 @@ export default class GhMembersSegmentCountComponent extends Component { this.fetchSegmentTotalTask.perform(); const filter = this.args.enforcedFilter || undefined; - - const members = yield this.store.query('member', {limit: 1, filter}); - this.total = members.meta.pagination.total; + this.total = yield this.membersCountCache.count({filter}); } @task({group: 'fetchTasks'}) diff --git a/ghost/admin/app/components/gh-membership-tiers-alpha.hbs b/ghost/admin/app/components/gh-membership-tiers-alpha.hbs deleted file mode 100644 index 041f3a73f59..00000000000 --- a/ghost/admin/app/components/gh-membership-tiers-alpha.hbs +++ /dev/null @@ -1,55 +0,0 @@ -
    - -
    -
    - - {{#if type.name}}{{type.name}}{{else}}Unknown type{{/if}} - -
    -
    -
    -
    - {{#if this.isEmptyList}} -
    -

    You have no {{this.selectedType.value}} tiers.

    -
    - {{/if}} - - {{#each this.tiers as |tier|}} - - {{/each}} - {{#if (eq this.type "active" )}} - - {{/if}} -
    - -{{#if this.showTierModal}} - -{{/if}} diff --git a/ghost/admin/app/components/gh-membership-tiers-alpha.js b/ghost/admin/app/components/gh-membership-tiers-alpha.js deleted file mode 100644 index c58703b8d28..00000000000 --- a/ghost/admin/app/components/gh-membership-tiers-alpha.js +++ /dev/null @@ -1,97 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject} from 'ghost-admin/decorators/inject'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -const TYPES = [{ - name: 'Active', - value: 'active' -},{ - name: 'Archived', - value: 'archived' -}]; - -export default class extends Component { - @service membersUtils; - @service ghostPaths; - @service ajax; - @service store; - - @inject config; - - @tracked showTierModal = false; - @tracked tierModel = null; - @tracked type = 'active'; - - get tiers() { - return this.args.tiers.filter((tier) => { - if (this.type === 'active') { - return !!tier.active; - } else if (this.type === 'archived') { - return !tier.active; - } - - return true; - }); - } - - get availableTypes() { - return TYPES; - } - - get selectedType() { - return this.type ? TYPES.find((d) => { - return this.type === d.value; - }) : TYPES[0]; - } - - get isEmptyList() { - return this.tiers.length === 0; - } - - @action - onTypeChange(type) { - this.type = type.value; - } - - @action - async openEditTier(tier) { - this.tierModel = tier; - this.showTierModal = true; - } - - @action - async onUnarchive() { - this.type = 'active'; - this.args.updatePortalPreview(); - this.reloadTiers(); - } - - @action - async onArchive() { - this.args.updatePortalPreview(); - this.reloadTiers(); - } - - reloadTiers() { - // Reload the cached tiers in membersutils - this.membersUtils.reload(); - } - - @action - async openNewTier() { - this.tierModel = this.store.createRecord('tier'); - this.showTierModal = true; - } - - @action - closeTierModal() { - this.showTierModal = false; - } - - @action - confirmTierSave() { - this.args.confirmTierSave(); - } -} diff --git a/ghost/admin/app/components/gh-migrate-iframe.js b/ghost/admin/app/components/gh-migrate-iframe.js index be54474df00..b1e76dd354d 100644 --- a/ghost/admin/app/components/gh-migrate-iframe.js +++ b/ghost/admin/app/components/gh-migrate-iframe.js @@ -63,7 +63,8 @@ export default class GhMigrateIframe extends Component { apiUrl: this.migrate.apiUrl, apiToken: theToken, darkMode: this.feature.nightShift, - stripe: this.migrate.isStripeConnected + stripe: this.migrate.isStripeConnected, + ghostVersion: this.migrate.ghostVersion } }, new URL(this.migrate.getIframeURL()).origin); } diff --git a/ghost/admin/app/components/gh-nav-menu.hbs b/ghost/admin/app/components/gh-nav-menu.hbs index d5a1b85c621..f7d7fd67cbc 100644 --- a/ghost/admin/app/components/gh-nav-menu.hbs +++ b/ghost/admin/app/components/gh-nav-menu.hbs @@ -1,7 +1,3 @@ -