first commit
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled

This commit is contained in:
2026-04-22 19:51:20 +07:00
commit 93d1b7c3d3
579 changed files with 99797 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
tailwind.config.cjs
+159
View File
@@ -0,0 +1,159 @@
/* eslint-env node */
const tailwindCssConfig = `${__dirname}/../admin/src/index.css`;
module.exports = {
root: true,
extends: [
'plugin:ghost/ts',
'plugin:react/recommended',
'plugin:react-hooks/recommended'
],
plugins: [
'ghost',
'react-refresh',
'tailwindcss'
],
settings: {
react: {
version: 'detect'
},
tailwindcss: {
config: tailwindCssConfig
}
},
rules: {
// ----------------------
// Rules COPIED from base config, remove these when the config is fixed
// Style Rules
// Require 4 spaces
indent: ['error', 4],
// Require single quotes for strings & properties (allows template literals)
quotes: ['error', 'single', {allowTemplateLiterals: true}],
'quote-props': ['error', 'as-needed'],
// Require semi colons, always at the end of a line
semi: ['error', 'always'],
'semi-style': ['error', 'last'],
// Don't allow dangling commas
'comma-dangle': ['error', 'never'],
// Always require curly braces, and position them at the end and beginning of lines
curly: 'error',
'brace-style': ['error', '1tbs'],
// Don't allow padding inside of blocks
'padded-blocks': ['error', 'never'],
// Require objects to be consistently formatted with newlines
'object-curly-newline': ['error', {consistent: true}],
// Don't allow more than 1 consecutive empty line or an empty 1st line
'no-multiple-empty-lines': ['error', {max: 1, maxBOF: 0}],
// Variables must be camelcase, but properties are not checked
camelcase: ['error', {properties: 'never'}],
// Allow newlines before dots, not after e.g. .then goes on a new line
'dot-location': ['error', 'property'],
// Prefer dot notation over array notation
'dot-notation': ['error'],
// Spacing rules
// Don't allow multiple spaces anywhere
'no-multi-spaces': 'error',
// Anonymous functions have a sape, named functions never do
'space-before-function-paren': ['error', {anonymous: 'always', named: 'never'}],
// Don't put spaces inside of objects or arrays
'object-curly-spacing': ['error', 'never'],
'array-bracket-spacing': ['error', 'never'],
// Allow a max of one space between colons and values
'key-spacing': ['error', {mode: 'strict'}],
// Require spaces before and after keywords like if, else, try, catch etc
'keyword-spacing': 'error',
// No spaces around semis
'semi-spacing': 'error',
// 1 space around arrows
'arrow-spacing': 'error',
// Don't allow spaces inside parenthesis
'space-in-parens': ['error', 'never'],
// Require single spaces either side of operators
'space-unary-ops': 'error',
'space-infix-ops': 'error',
// Best practice rules
// Require === / !==
eqeqeq: ['error', 'always'],
// Don't allow ++ and --
'no-plusplus': ['error', {allowForLoopAfterthoughts: true}],
// Don't allow eval
'no-eval': 'error',
// Throw errors for unnecessary usage of .call or .apply
'no-useless-call': 'error',
// Don't allow console.* calls
'no-console': 'error',
// Prevent [variable shadowing](https://en.wikipedia.org/wiki/Variable_shadowing)
'no-shadow': ['error'],
// Return rules
// Prevent missing return statements in array functions like map & reduce
'array-callback-return': 'error',
'no-constructor-return': 'error',
'no-promise-executor-return': 'error',
// Arrow function styles
// Do not enforce single lines when using arrow functions.
// https://eslint.org/docs/rules/arrow-body-style
'arrow-body-style': 'off',
'arrow-parens': ['error', 'as-needed', {requireForBlockBody: true}],
'implicit-arrow-linebreak': 'error',
'no-confusing-arrow': 'error',
// ----------------------
// Rules NOT COPIED from base config, keep these
// sort multiple import lines into alphabetical groups
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple']
}],
'no-restricted-imports': ['error', {
paths: [{
name: '@tryghost/shade',
message: 'Import from layered subpaths instead (components/primitives/patterns/utils/app/tokens).'
}]
}],
// Enforce kebab-case (lowercase with hyphens) for all filenames
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false],
// TODO: enable this when we have the time to retroactively go and fix the issues
'prefer-const': 'off',
// 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 this because otherwise we're just skirting TS
'@typescript-eslint/no-explicit-any': 'warn',
// 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',
'tailwindcss/enforces-negative-arbitrary-values': 'warn',
'tailwindcss/enforces-shorthand': 'warn',
'tailwindcss/migration-from-tailwind-2': 'warn',
'tailwindcss/no-arbitrary-value': 'off',
'tailwindcss/no-custom-classname': 'off',
'tailwindcss/no-contradicting-classname': 'error'
}
};
+33
View File
@@ -0,0 +1,33 @@
# Admin X Settings
Ghost Admin Settings micro-frontend.
## Pre-requisites
- Run `pnpm` in Ghost monorepo root
## Running the app
### Running the development version
Run `pnpm dev` (in this package folder) to start the development server to test/develop the settings standalone. This will generate a demo site from the `index.html` file which renders the app and makes it available on http://localhost:5173
### Running inside Admin
Run `pnpm dev` from the top-level repo. This starts all frontend apps via Docker backend + host dev servers, and AdminX will automatically rebuild when you make changes.
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `pnpm` to install top-level dependencies.
## Test
- `pnpm lint` - run just eslint
- `pnpm test:acceptance` - runs acceptance tests
- `pnpm test:unit` - runs unit tests
- `pnpm test:acceptance path/to/test` - runs a specific test
- `pnpm test:acceptance:slowmo` - runs acceptance tests in slow motion and headed mode, useful for debugging and developing tests
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Settings - Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
/**
* This is used by vite to resolve node builtins. See resolve.alias in vite.config.js
*/
module.exports = {};
+103
View File
@@ -0,0 +1,103 @@
{
"name": "@tryghost/admin-x-settings",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/TryGhost/Ghost/tree/main/packages/admin-x-settings"
},
"author": "Ghost Foundation",
"files": [
"LICENSE",
"README.md",
"dist/"
],
"main": "./dist/admin-x-settings.umd.cjs",
"module": "./dist/admin-x-settings.js",
"exports": {
".": {
"import": "./dist/admin-x-settings.js",
"require": "./dist/admin-x-settings.umd.cjs",
"types": "./src/index.tsx"
},
"./src/*": {
"import": "./src/*.tsx",
"require": "./src/*.tsx"
}
},
"private": true,
"scripts": {
"dev": "vite build --watch",
"dev:start": "vite",
"build": "tsc && vite build",
"lint": "pnpm run lint:js",
"lint:js": "eslint --ext .js,.ts,.cjs,.tsx --cache src test",
"test": "pnpm test:unit",
"test:unit": "vitest run --config vitest.config.ts",
"test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' playwright test",
"test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 pnpm test:acceptance --headed",
"test:acceptance:full": "ALL_BROWSERS=1 pnpm test:acceptance",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-html": "6.4.11",
"@dnd-kit/sortable": "7.0.2",
"@ebay/nice-modal-react": "1.2.13",
"@sentry/react": "7.120.4",
"@tanstack/react-query": "4.36.1",
"@tryghost/color-utils": "0.2.16",
"@tryghost/i18n": "workspace:*",
"@tryghost/kg-unsplash-selector": "0.3.26",
"@tryghost/limit-service": "1.5.2",
"@tryghost/nql": "0.12.10",
"@tryghost/timezone-data": "0.4.18",
"@uiw/react-codemirror": "4.25.2",
"clsx": "2.1.1",
"lucide-react": "0.577.0",
"mingo": "2.5.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hot-toast": "2.6.0",
"react-select": "5.10.2",
"sonner": "2.0.7",
"validator": "13.12.0"
},
"devDependencies": {
"@playwright/test": "1.59.1",
"@testing-library/jest-dom": "^6",
"@testing-library/react": "14.3.1",
"@tryghost/admin-x-design-system": "workspace:*",
"@tryghost/admin-x-framework": "workspace:*",
"@tryghost/custom-fonts": "1.0.8",
"@tryghost/shade": "workspace:*",
"@types/node": "22.19.17",
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"@types/validator": "13.15.10",
"@vitejs/plugin-react": "4.7.0",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.24",
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"stylelint": "15.11.0",
"tailwindcss": "^4.2.2",
"vite": "5.4.21",
"vite-plugin-css-injected-by-js": "3.5.2",
"vite-plugin-svgr": "3.3.0",
"vitest": "1.6.1"
},
"nx": {
"targets": {
"dev": {
"dependsOn": [
"^build"
]
},
"test:acceptance": {
"dependsOn": [
"^build"
]
}
}
}
}
@@ -0,0 +1,3 @@
import {adminXPlaywrightConfig} from '@tryghost/admin-x-framework/playwright';
export default adminXPlaywrightConfig();
+1
View File
@@ -0,0 +1 @@
module.exports = require('@tryghost/admin-x-design-system/postcss.config.cjs');
+38
View File
@@ -0,0 +1,38 @@
import MainContent from './main-content';
import NiceModal from '@ebay/nice-modal-react';
import SettingsAppProvider, {type UpgradeStatusType} from './components/providers/settings-app-provider';
import SettingsRouter, {loadModals, modalPaths} from './components/providers/settings-router';
import {DesignSystemApp, type DesignSystemAppProps} from '@tryghost/admin-x-design-system';
import {FrameworkProvider, type TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
import {RoutingProvider} from '@tryghost/admin-x-framework/routing';
interface AppProps {
designSystem: DesignSystemAppProps;
upgradeStatus?: UpgradeStatusType;
}
export function App({designSystem, upgradeStatus}: AppProps) {
return (
<SettingsAppProvider upgradeStatus={upgradeStatus}>
{/* NOTE: we need to have an extra NiceModal.Provider here because the one inside DesignSystemApp
is loaded too late for possible modals in RoutingProvider, and it's quite hard to change it at
this point */}
<NiceModal.Provider>
<RoutingProvider basePath='settings' modals={{paths: modalPaths, load: loadModals}}>
<DesignSystemApp className='admin-x-settings' {...designSystem}>
<SettingsRouter />
<MainContent />
</DesignSystemApp>
</RoutingProvider>
</NiceModal.Provider>
</SettingsAppProvider>
);
}
export function StandaloneApp({framework, designSystem, upgradeStatus}: AppProps & {framework: TopLevelFrameworkProps}) {
return (
<FrameworkProvider {...framework}>
<App designSystem={designSystem} upgradeStatus={upgradeStatus} />
</FrameworkProvider>
);
}
+6
View File
@@ -0,0 +1,6 @@
import './styles/index.css';
import {StandaloneApp} from './app.tsx';
export {
StandaloneApp as AdminXApp
};
@@ -0,0 +1,95 @@
import ExitSettingsButton from './components/exit-settings-button';
import Settings from './components/settings';
import Sidebar from './components/sidebar';
import Users from './components/settings/general/users';
import {Heading, confirmIfDirty, topLevelBackdropClasses, useGlobalDirtyState} from '@tryghost/admin-x-design-system';
import {type ReactNode, useEffect} from 'react';
import {canAccessSettings, isEditorUser} from '@tryghost/admin-x-framework/api/users';
import {toast} from 'react-hot-toast';
import {useGlobalData} from './components/providers/global-data-provider';
import {useRouting} from '@tryghost/admin-x-framework/routing';
const EMPTY_KEYWORDS: string[] = [];
const Page: React.FC<{children: ReactNode}> = ({children}) => {
return <>
<div className='fixed top-2 right-0 z-50 m-8 flex justify-end bg-transparent tablet:fixed tablet:top-0' id="done-button-container">
<ExitSettingsButton />
</div>
<div className="fixed top-0 left-0 flex size-full dark:bg-grey-975" id="admin-x-settings-content">
{children}
</div>
</>;
};
const MainContent: React.FC = () => {
const {currentUser} = useGlobalData();
const {loadingModal} = useRouting();
const {isDirty} = useGlobalDirtyState();
const navigateAway = (escLocation: string) => {
window.location.hash = escLocation;
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
// Don't navigate away if a modal is open - let the modal handle ESC
const modalBackdrop = document.getElementById('modal-backdrop');
if (modalBackdrop) {
return;
}
confirmIfDirty(isDirty, () => {
navigateAway('/');
});
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isDirty]);
useEffect(() => {
// resets any toasts that may have been left open on initial load
toast.remove();
}, []);
// Contributors/Authors only see their profile modal (rendered via routing)
// Don't render the main settings content for them
if (!canAccessSettings(currentUser)) {
return null;
}
if (isEditorUser(currentUser)) {
return (
<Page>
<div className='flex-1 overflow-y-auto bg-white dark:bg-grey-975' id="admin-x-settings-scroller">
<div className='mx-auto max-w-5xl px-[5vmin] tablet:mt-16 xl:mt-10'>
<Heading className='mb-[5vmin]'>Settings</Heading>
<Users highlight={false} keywords={EMPTY_KEYWORDS} />
</div>
</div>
</Page>
);
}
return (
<Page>
{loadingModal && <div className={`fixed inset-0 z-40 h-[calc(100vh-55px)] w-[100vw] tablet:h-[100vh] ${topLevelBackdropClasses}`} />}
<div className="fixed inset-x-0 top-0 z-[35] max-w-[calc(100%-16px)] flex-1 basis-[320px] bg-white p-8 tablet:relative tablet:inset-x-auto tablet:top-auto tablet:h-full tablet:overflow-y-scroll tablet:bg-grey-50 tablet:py-0 dark:bg-grey-975 dark:tablet:bg-[#101114]" id="admin-x-settings-sidebar-scroller">
<div className="relative w-full">
<Sidebar />
</div>
</div>
<div className="relative h-full flex-1 overflow-y-scroll bg-white pt-12 tablet:basis-[800px] dark:bg-grey-975 dark:tablet:bg-black" id="admin-x-settings-scroller">
<Settings />
</div>
</Page>
);
};
export default MainContent;
+6
View File
@@ -0,0 +1,6 @@
import './styles/index.css';
import renderStandaloneApp from '@tryghost/admin-x-framework/test/render';
import {StandaloneApp} from './app.tsx';
renderStandaloneApp(StandaloneApp, {
});
+10
View File
@@ -0,0 +1,10 @@
declare module '@tryghost/limit-service'
declare module '@tryghost/nql'
declare module '*.svg' {
// eslint-disable-next-line @typescript-eslint/no-require-imports
import React = require('react');
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+5
View File
@@ -0,0 +1,5 @@
import '@testing-library/jest-dom/vitest';
import {setupShadeMocks} from '@tryghost/admin-x-framework/test/setup';
// Set up common mocks for shade components
setupShadeMocks();
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"composite": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "./types",
"emitDeclarationOnly": true,
"tsBuildInfoFile": "./types/tsconfig.tsbuildinfo",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"]
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["node", "vitest/globals", "@testing-library/jest-dom/vitest"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Path mapping */
"baseUrl": ".",
"paths": {
"@src/*": ["./src/*"],
"@test/*": ["./test/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"erasableSyntaxOnly": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "test"]
}
+38
View File
@@ -0,0 +1,38 @@
import adminXViteConfig from '@tryghost/admin-x-framework/vite';
import pkg from './package.json';
import {resolve} from 'path';
import {createRequire} from 'node:module';
const require = createRequire(import.meta.url);
// https://vitejs.dev/config/
export default (function viteConfig() {
return adminXViteConfig({
packageName: pkg.name,
entry: resolve(__dirname, 'src/index.tsx'),
overrides: {
define: {
'process.env.DEBUG': false // Shim env var utilized by the @tryghost/nql package
},
resolve: {
// Shim node modules utilized by the @tryghost/nql package
alias: {
'@src': resolve(__dirname, 'src'),
'@test': resolve(__dirname, 'test'),
fs: 'node-shim.cjs',
path: 'node-shim.cjs',
util: 'node-shim.cjs',
// @TODO: Remove this when @tryghost/nql is updated
mingo: require.resolve('mingo/dist/mingo.js')
}
},
optimizeDeps: {
include: ['@tryghost/kg-unsplash-selector', '@tryghost/custom-fonts']
}
},
build: {
commonjsOptions: {
include: [/ghost\/custom-fonts/]
}
}
});
});
+3
View File
@@ -0,0 +1,3 @@
import {createVitestConfig} from '@tryghost/admin-x-framework/test/vitest-config';
export default createVitestConfig();