diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..d9aecc0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: Format and lint + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: yarn install + + - name: Generate code + run: make gen + + - name: Run check + run: make check diff --git a/.gitignore b/.gitignore index ea43704..b7e757e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ dist-ssr *.sw? yarn.lock + +# code-generated files +src/clients/backend +src/clients/admin + diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 092408a..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,28 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' - -export default tseslint.config( - { ignores: ['dist'] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, -) diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..85f2e1c --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,58 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; +import eslintConfigPrettier from "eslint-config-prettier/flat"; + +export default tseslint.config(eslintConfigPrettier, { + extends: [eslint.configs.recommended, tseslint.configs.recommended], + ignores: ["src/clients/**"], // code-generated + rules: { + "@typescript-eslint/array-type": "error", + "array-callback-return": "error", + "no-await-in-loop": "error", + "no-constructor-return": "error", + "no-inner-declarations": "error", + "no-promise-executor-return": "error", + "no-self-compare": "error", + "no-template-curly-in-string": "error", + "no-unassigned-vars": "error", + "no-unreachable-loop": "error", + "no-use-before-define": "error", + "no-useless-assignment": "error", + "require-atomic-updates": "error", + + // opinionated suggestion enforcements to make TypeScript a sane language + "arrow-body-style": "error", + "block-scoped-var": "error", + camelcase: ["error", { properties: "never" }], // code-generated client structures might not always adhere to camelcase + "consistent-return": "error", + "default-case": "error", + "default-case-last": "error", + "default-param-last": "error", + eqeqeq: "error", + "func-name-matching": "error", + "func-style": ["error", "declaration"], + "init-declarations": ["error", "always"], + "no-array-constructor": "error", + "no-caller": "error", + "no-delete-var": "error", + "no-else-return": "error", + "no-empty": "error", + "no-invalid-this": "error", + "no-label-var": "error", + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-loop-func": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-return-assign": "error", + "no-sequences": "error", + "no-throw-literal": "error", + "no-unneeded-ternary": "error", + "no-useless-rename": "error", + "no-useless-return": "error", + "no-var": "error", + "prefer-const": "error", + "require-await": "error", + // 'sort-imports': 'error' // TODO: add code formatter + }, +}); diff --git a/makefile b/makefile index 307f74a..93a0fbf 100644 --- a/makefile +++ b/makefile @@ -6,6 +6,14 @@ run: build: yarn build +check: + yarn run prettier --check src + yarn eslint src + +fix: + yarn run prettier --write src + yarn eslint --fix src + gen: yarn run openapi-ts -i http://dm2.sao.ru:81/api/openapi.json -o ./src/clients/backend yarn run openapi-ts -i http://dm2.sao.ru:81/admin/api/openapi.json -o ./src/clients/admin diff --git a/package.json b/package.json index 7b4bd59..b060b85 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "openapi-ts": "openapi-ts" + "openapi-ts": "openapi-ts", + "prettier": "prettier" }, "dependencies": { "@hey-api/openapi-ts": "^0.80.10", @@ -20,23 +21,24 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-icons": "^5.5.0", - "react-markdown": "^10.0.1", "react-router-dom": "^7.2.0", - "remark-gfm": "^4.0.1", "serve": "^14.2.4", "tailwindcss": "^4.0.9" }, "devDependencies": { - "@eslint/js": "^9.21.0", + "@eslint/js": "^9.34.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react-swc": "^3.8.0", - "eslint": "^9.21.0", + "eslint": "^9.34.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", - "typescript": "~5.7.2", - "typescript-eslint": "^8.24.1", + "jiti": "^2.5.1", + "prettier": "3.6.2", + "typescript": "^5.9.2", + "typescript-eslint": "^8.41.0", "vite": "^6.2.0" } } diff --git a/src/.gitignore b/src/.gitignore deleted file mode 100644 index 1d760ce..0000000 --- a/src/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -clients/backend -clients/admin diff --git a/src/App.tsx b/src/App.tsx index 34ca67b..3a84019 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; import { Footer } from "./components/ui/footer"; import { HomePage } from "./pages/Home"; import { SearchResultsPage } from "./pages/SearchResults"; @@ -56,7 +56,8 @@ function App() { element={ - } + + } /> diff --git a/src/clients/backend.tsx b/src/clients/backend.tsx index 3e37cdf..f0b1569 100644 --- a/src/clients/backend.tsx +++ b/src/clients/backend.tsx @@ -130,7 +130,7 @@ export class HyperLEDAClient { }, }); - private constructor() { } + private constructor() {} public static getInstance(): HyperLEDAClient { if (!HyperLEDAClient.instance) { @@ -142,7 +142,7 @@ export class HyperLEDAClient { public async query( queryString: string, page: number = 0, - pageSize: number = 25 + pageSize: number = 25, ): Promise { try { const response = await this.axiosInstance.get>( @@ -153,7 +153,7 @@ export class HyperLEDAClient { page: page, page_size: pageSize, }, - } + }, ); return { objects: response.data.data.objects || [], @@ -176,10 +176,9 @@ export class HyperLEDAClient { page_size?: number; }): Promise { try { - const response = await this.axiosInstance.get>( - "/api/v1/query/simple", - { params } - ); + const response = await this.axiosInstance.get< + APIResponse + >("/api/v1/query/simple", { params }); return response.data.data; } catch (error) { console.error("Error in querySimple:", error); @@ -190,19 +189,18 @@ export class HyperLEDAClient { public async queryByPGC( pgcNumbers: number[], page: number = 0, - pageSize: number = 25 + pageSize: number = 25, ): Promise { try { - const response = await this.axiosInstance.get>( - "/api/v1/query/simple", - { - params: { - pgcs: pgcNumbers, - page: page, - page_size: pageSize, - }, - } - ); + const response = await this.axiosInstance.get< + APIResponse + >("/api/v1/query/simple", { + params: { + pgcs: pgcNumbers, + page: page, + page_size: pageSize, + }, + }); return response.data.data; } catch (error) { console.error("Error in queryByPGC:", error); diff --git a/src/components/ui/aladin.tsx b/src/components/ui/aladin.tsx index eedacaf..b1bad85 100644 --- a/src/components/ui/aladin.tsx +++ b/src/components/ui/aladin.tsx @@ -44,13 +44,27 @@ export function AladinViewer({ } }, [ra, dec, fov, survey, target]); - return ( -
- ); + return
; } declare global { interface Window { - A: any; + A: { + aladin: ( + element: HTMLElement, + options?: { + survey?: string; + fov?: number; + showReticle?: boolean; + showZoomControl?: boolean; + showFullscreenControl?: boolean; + showLayersControl?: boolean; + showCooGridControl?: boolean; + }, + ) => { + gotoObject: (target: string) => void; + gotoRaDec: (ra: number, dec: number) => void; + }; + }; } } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 4781feb..317445d 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { ReactElement } from "react"; import classNames from "classnames"; interface ButtonProps { @@ -9,24 +9,18 @@ interface ButtonProps { disabled?: boolean; } -export const Button: React.FC = ({ - children, - onClick, - className, - type = "button", - disabled = false, -}) => { +export function Button(props: ButtonProps): ReactElement { return ( ); -}; +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 907fa83..dbd4068 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { ReactElement } from "react"; import classNames from "classnames"; interface CardProps { @@ -8,35 +8,31 @@ interface CardProps { title?: string; } -export const Card: React.FC = ({ - children, - className, - onClick, - title, -}) => { +export function Card(props: CardProps): ReactElement { return (
- {title &&

{title}

} - {children} + {props.title && ( +

{props.title}

+ )} + {props.children}
); -}; +} interface CardContentProps { children: React.ReactNode; className?: string; } -export const CardContent: React.FC = ({ - children, - className, -}) => { - return
{children}
; -}; +export function CardContent(props: CardContentProps): ReactElement { + return ( +
{props.children}
+ ); +} diff --git a/src/components/ui/common-table.tsx b/src/components/ui/common-table.tsx index b314e04..b8df151 100644 --- a/src/components/ui/common-table.tsx +++ b/src/components/ui/common-table.tsx @@ -2,108 +2,115 @@ import React, { ReactElement } from "react"; import classNames from "classnames"; import { Hint } from "./hint"; +export type CellPrimitive = ReactElement | string | number; + export interface Column { - name: string; - renderCell?: (value: any) => React.ReactNode; - hint?: ReactElement; + name: string; + renderCell?: (value: CellPrimitive) => ReactElement; + hint?: ReactElement; } interface CommonTableProps { - columns: Column[]; - data: Record[]; - className?: string; - tableClassName?: string; - headerClassName?: string; - columnHeaderClassName?: string; - cellClassName?: string; - children?: React.ReactNode; + columns: Column[]; + data: Record[]; + className?: string; + tableClassName?: string; + headerClassName?: string; + columnHeaderClassName?: string; + cellClassName?: string; + children?: React.ReactNode; } -export const CommonTable: React.FC = ({ - columns, - data, - className = "", - tableClassName = "", - headerClassName = "bg-gray-700 border-gray-600", - columnHeaderClassName = "bg-gray-600 text-white", - cellClassName = "text-gray-200", - children, -}) => { - const renderCell = (value: any, column: Column): React.ReactNode => { - if (column.renderCell) { - return column.renderCell(value); - } +export function CommonTable({ + columns, + data, + className = "", + tableClassName = "", + headerClassName = "bg-gray-700 border-gray-600", + columnHeaderClassName = "bg-gray-600 text-white", + cellClassName = "text-gray-200", + children, +}: CommonTableProps): ReactElement { + function renderCell(value: CellPrimitive, column: Column): ReactElement { + if (column.renderCell) { + return column.renderCell(value); + } - if (value === undefined || value === null) { - return
; - } + if (value === undefined || value === null) { + return
; + } - if (React.isValidElement(value)) { - return value; - } + if (React.isValidElement(value)) { + return value; + } - return {String(value)}; - }; + return {String(value)}; + } - return ( -
- {children && ( -
- {children} -
- )} + return ( +
+ {children && ( +
+ {children} +
+ )} -
- - - - {columns.map((column) => ( - - ))} - - +
+
- {column.hint ? ( - - {column.name} - - ) : ( - column.name - )} -
+ + + {columns.map((column) => ( + + ))} + + - - {data.map((row, rowIndex) => ( - - {columns.map((column) => { - const cellValue = row[column.name]; - return ( - - ); - })} - - ))} - -
+ {column.hint ? ( + + {column.name} + + ) : ( + column.name + )} +
- {renderCell(cellValue, column)} -
-
-
- ); -}; + + {data.map((row, rowIndex) => ( + + {columns.map((column) => { + const cellValue = row[column.name]; + return ( + + {renderCell(cellValue, column)} + + ); + })} + + ))} + + +
+
+ ); +} diff --git a/src/components/ui/copy-button.tsx b/src/components/ui/copy-button.tsx index e885d78..b90fab9 100644 --- a/src/components/ui/copy-button.tsx +++ b/src/components/ui/copy-button.tsx @@ -3,36 +3,36 @@ import { Button } from "./button"; import { MdCheck, MdContentCopy } from "react-icons/md"; interface CopyButtonProps { - children: ReactElement; - textToCopy: string; + children: ReactElement; + textToCopy: string; } -export const CopyButton: React.FC = ({ children, textToCopy }) => { - const [copied, setCopied] = useState(false); +export function CopyButton(props: CopyButtonProps): ReactElement { + const [copied, setCopied] = useState(false); - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(textToCopy); - setCopied(true); - setTimeout(() => setCopied(false), 1000); - } catch (err) { - console.error('Failed to copy text: ', err); - } - }; + async function handleCopy() { + try { + await navigator.clipboard.writeText(props.textToCopy); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + } catch (err) { + console.error("Failed to copy text: ", err); + } + } - return ( -
-
{children}
- -
- ); -}; + return ( +
+
{props.children}
+ +
+ ); +} diff --git a/src/components/ui/footer.tsx b/src/components/ui/footer.tsx index c21a4d4..52fe08f 100644 --- a/src/components/ui/footer.tsx +++ b/src/components/ui/footer.tsx @@ -4,44 +4,50 @@ import { Button } from "./button"; import { Link } from "./link"; import { MdKeyboardArrowDown, MdKeyboardArrowUp } from "react-icons/md"; -const footerContent =
-
Information:
-
Old version:
-
+const footerContent = ( +
+
+ Information: +
+
+ Old version: +
+
+); export function Footer() { const [isCollapsed, setIsCollapsed] = useState(true); - const toggleCollapse = () => { + function toggleCollapse() { setIsCollapsed(!isCollapsed); - }; + } return ( <>