diff --git a/.github/workflows/test-app.yml b/.github/workflows/test-app.yml index 7b2ea7b..f90a4ce 100644 --- a/.github/workflows/test-app.yml +++ b/.github/workflows/test-app.yml @@ -6,6 +6,32 @@ on: - "frontend/**" jobs: + lint: + name: Lint Frontend + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: ./frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + + - name: Run TypeScript check + run: npx tsc --noEmit + build: name: Build Image Without Push runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 0b3d393..5c5a6b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ All frontend commands run from `frontend/`: cd frontend npm run dev # Start Next.js dev server npm run build # Production build -npm run lint # ESLint (next lint) +npm run lint # ESLint 9 (flat config) ``` Docker-based dev environment (requires mise): @@ -87,18 +87,48 @@ Frontend uses standalone Next.js output for Docker. Build args for map config ar **Context Typing**: All React contexts must be fully typed: ```typescript -import { Context, createContext } from 'react' -import { MyContextValue } from '@/types' +import { createContext } from 'react' +import type { Context } from 'react' +import type { MyContextValue } from '@/types' export const MyContext: Context = createContext({ // ... default values with proper types }) ``` -**Component Props**: Explicitly type all component props, preferring imported types from `@/types`: +**Component Props**: Always define a separate type for component props with the naming pattern `ComponentNameProps`: ```typescript -import { MyComponentProps } from '@/types' -export const MyComponent = ({ prop1, prop2 }: MyComponentProps) => { ... } +export type MyComponentProps = { + title: string + count: number + onSubmit?: () => void +} + +export function MyComponent({ title, count, onSubmit }: MyComponentProps) { + // ... +} +``` + +For shared component props, import from `@/types`: +```typescript +import type { MyComponentProps } from '@/types' + +export function MyComponent({ prop1, prop2 }: MyComponentProps) { + // ... +} +``` + +**Component Declarations**: Use regular function declarations instead of arrow functions: +```typescript +// Preferred +export function MyComponent({ prop1, prop2 }: MyComponentProps) { + // ... +} + +// Avoid +export const MyComponent = ({ prop1, prop2 }: MyComponentProps) => { + // ... +} ``` **Null Safety**: Use optional chaining and nullish coalescing: @@ -114,8 +144,37 @@ const handleChange = (event: React.ChangeEvent) => { ... } **Form Data**: Use `z.infer` for React Hook Form + Zod type extraction +**Type Imports**: Always use separate `import type` statements for type-only imports. This is enforced by both `verbatimModuleSyntax` in tsconfig and `@typescript-eslint/consistent-type-imports` ESLint rule: +```typescript +// Correct — separate import type statement +import { useModals } from '@mantine/modals' +import type { ContextModalProps } from '@mantine/modals' + +// Wrong — inline type keyword +import { type ContextModalProps, useModals } from '@mantine/modals' + +// Wrong — type imported as value +import { ContextModalProps, useModals } from '@mantine/modals' +``` + +**Explicit `any` is Forbidden**: Never use explicit `any` types in the codebase: +- Use proper type definitions from `@/types` or create new ones +- For complex types, use `unknown` and narrow with type guards, or define proper interfaces +- For truly dynamic data, use `Record` instead of `any` +- Third-party library types should use their exported types or be properly typed +- Enforced by ESLint `@typescript-eslint/no-explicit-any` rule + **When Adding New Code**: 1. Run `npx tsc --noEmit` to verify types before committing -2. Never use `any` unless absolutely necessary (e.g., complex third-party types) -3. Import shared types from `@/types` rather than defining inline -4. Add return types to exported functions and React components +2. Run `npm run lint` to check ESLint rules +3. NEVER use explicit `any` - see "Explicit `any` is Forbidden" section above +4. Import shared types from `@/types` using `import type` syntax +5. Add return types to exported functions and React components + +## Linting + +**ESLint 9** with flat config (`eslint.config.mjs`). Key rules: +- `@typescript-eslint/no-explicit-any: error` — no `any` types +- `@typescript-eslint/consistent-type-imports: error` — enforce `import type` on separate lines +- `@typescript-eslint/no-unused-vars: warn` — unused variables (prefix with `_` to ignore) +- `react-hooks/exhaustive-deps` — complete dependency arrays in hooks diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json deleted file mode 100644 index 002bb31..0000000 --- a/frontend/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "rules": { - "react/jsx-key": [1, { "checkFragmentShorthand": true }] - } -} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..022f6dc --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,36 @@ +import nextConfig from "eslint-config-next" +import nextCoreWebVitals from "eslint-config-next/core-web-vitals" +import nextTypescript from "eslint-config-next/typescript" + +const eslintConfig = [ + ...nextConfig, + ...nextCoreWebVitals, + ...nextTypescript, + { + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + }, + ], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + }, + }, + { + ignores: [".next/", "node_modules/", "out/", "temp/"], + }, +] + +export default eslintConfig diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1d0c1ca..02fe077 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,7 +33,7 @@ "@types/qs": "^6.9.15", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "eslint": "^9", + "eslint": "^9.39.2", "eslint-config-next": "^16.1.1", "postcss": "^8.4.33", "postcss-preset-mantine": "^1.13.0", diff --git a/frontend/package.json b/frontend/package.json index bd77898..c309de9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "type-check": "tsc --noEmit", + "validate": "npm run type-check && npm run lint", "api": " cd ../backend && npm run dev" }, "dependencies": { @@ -36,7 +39,7 @@ "@types/qs": "^6.9.15", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "eslint": "^9", + "eslint": "^9.39.2", "eslint-config-next": "^16.1.1", "postcss": "^8.4.33", "postcss-preset-mantine": "^1.13.0", diff --git a/frontend/src/components/AddButton/index.tsx b/frontend/src/components/AddButton/index.tsx index 762413a..1aa3874 100644 --- a/frontend/src/components/AddButton/index.tsx +++ b/frontend/src/components/AddButton/index.tsx @@ -1,19 +1,24 @@ import { FormContext } from '@/contexts/form'; import { Popover, Button, Center, Box } from '@mantine/core'; import { useModals } from '@mantine/modals'; -import { useCallback, useContext } from 'react'; +import { useContext } from 'react'; +import type { FormData } from '@/types'; -export const AddButton: React.FC<{ style?: React.CSSProperties }> = ({ style = { +export type AddButtonProps = { + style?: React.CSSProperties +} + +export function AddButton({ style = { position: 'absolute', zIndex: 1, bottom: '3rem', left: '50%', transform: 'translateX(-50%)', -} }) => { +} }: AddButtonProps) { const modals = useModals() const { data, setData } = useContext(FormContext) const { addMode, setAddMode } = useContext(FormContext) - const onClick = useCallback((data) => { + const onClick = (data: FormData) => { if (Object.keys(data).length == 0) { setData({}) } @@ -30,7 +35,7 @@ export const AddButton: React.FC<{ style?: React.CSSProperties }> = ({ style = { }, } ) - }, []) + } return (
diff --git a/frontend/src/components/EmailForm/index.tsx b/frontend/src/components/EmailForm/index.tsx index cb13ce1..fcb1059 100644 --- a/frontend/src/components/EmailForm/index.tsx +++ b/frontend/src/components/EmailForm/index.tsx @@ -1,6 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { Button, Center, Group, Image, Overlay, Stack, Text, TextInput, Textarea, Title } from '@mantine/core' -import Link from 'next/link' +import { Button, Center, Group, Overlay, Stack, Text, TextInput, Textarea, Title } from '@mantine/core' import { useState } from 'react' import { useForm } from 'react-hook-form' import { useMedia } from 'react-use' @@ -19,9 +18,9 @@ const formSchema = z.object({ text: z.string().min(1, 'Сообщение обязательно').max(999, 'Слишком длинное сообщение'), }) -export const EmailForm = () => { +export function EmailForm() { const isMobile = useMedia('(max-width: 1024px)', false) - const { handleSubmit, control, register, formState } = useForm>({ + const { handleSubmit, register, formState } = useForm>({ mode: 'onChange', resolver: zodResolver(formSchema), defaultValues: { @@ -35,7 +34,7 @@ export const EmailForm = () => { const onSubmit = async (data: z.infer) => { setText(states.fetch) - let dataFormatted = JSON.stringify(data) + const dataFormatted = JSON.stringify(data) await fetch( `/api/indexFeedback`, diff --git a/frontend/src/components/Gallery/index.tsx b/frontend/src/components/Gallery/index.tsx index 0ab7440..4de239b 100644 --- a/frontend/src/components/Gallery/index.tsx +++ b/frontend/src/components/Gallery/index.tsx @@ -5,8 +5,9 @@ import { useDisclosure } from '@mantine/hooks' import { useState } from 'react' import NextImage from 'next/image' import { useMedia } from 'react-use' +import type { GalleryImage } from '@/types' -const PrevButton = () => { +function PrevButton() { const swiper = useSwiper() return ( @@ -25,7 +26,11 @@ const PrevButton = () => { ) } -export const Gallery = ({ galleryImages }) => { +export type GalleryProps = { + galleryImages: GalleryImage[] +} + +export function Gallery({ galleryImages }: GalleryProps) { const [imageOpened, { toggle: close, open }] = useDisclosure() const [image, setImage] = useState(0) const isMobile = useMedia('(max-width: 576px)', false) @@ -39,12 +44,12 @@ export const Gallery = ({ galleryImages }) => { slidesOffsetBefore={isMobile ? 10 : 68 / 2} className={s.swiper} centeredSlides={isMobile} - onClick={(swiper, e) => { + onClick={(swiper) => { setImage((swiper.clickedIndex) % galleryImages.length) open() }} > - {galleryImages.map((x, i) => ( + {galleryImages.map((x) => ( diff --git a/frontend/src/components/IdeaModal/index.tsx b/frontend/src/components/IdeaModal/index.tsx index 8d8c661..7beb6b1 100644 --- a/frontend/src/components/IdeaModal/index.tsx +++ b/frontend/src/components/IdeaModal/index.tsx @@ -1,16 +1,18 @@ import { FormContext } from '@/contexts/form' import { Text, Stack, Button, Title, Center, Textarea, Tooltip } from '@mantine/core' -import { ContextModalProps, useModals } from '@mantine/modals' +import { useModals } from '@mantine/modals' +import type { ContextModalProps } from '@mantine/modals' import { useRouter } from 'next/router' -import { useCallback, useContext, useEffect, useState } from 'react' +import { useContext, useEffect, useState } from 'react' import { useForm, Controller } from 'react-hook-form' import { z } from 'zod' import { zodResolver } from "@hookform/resolvers/zod" import { useSWRConfig } from 'swr' import buttonStyles from '@/styles/button.module.css' +import type { IdeaModalDefaultValues } from '@/types' export type IdeaModalProps = { - defaultValues?: { [key: string]: any } + defaultValues?: IdeaModalDefaultValues } const states = { @@ -28,7 +30,7 @@ const formSchema = z.object({ }, { message: 'Добавьте точку' }), }) -export const IdeaModal = ({ context, id: modalId, innerProps }: ContextModalProps) => { +export function IdeaModal({ id: modalId, innerProps }: ContextModalProps) { const { mutate } = useSWRConfig() const modals = useModals() const formContext = useContext(FormContext) @@ -42,7 +44,6 @@ export const IdeaModal = ({ context, id: modalId, innerProps }: ContextModalProp } }) const [text, setText] = useState(states.start) - const [coordReq, setCoordReq] = useState(false) const onSubmit = async (data: z.infer) => { const { coords } = formContext.data @@ -119,17 +120,14 @@ export const IdeaModal = ({ context, id: modalId, innerProps }: ContextModalProp }) } - const onClickCoords = useCallback( - () => { - formContext.setData({ - ...formContext.data, - ...getValues(), - }) - modals.closeModal(modalId) - formContext.setAddMode(true) - }, - [formContext.data] - ) + const onClickCoords = () => { + formContext.setData({ + ...formContext.data, + ...getValues(), + }) + modals.closeModal(modalId) + formContext.setAddMode(true) + } // close modal on route const router = useRouter() @@ -137,7 +135,7 @@ export const IdeaModal = ({ context, id: modalId, innerProps }: ContextModalProp if (router.pathname == '/') { modals.closeAll() } - }, [router.pathname]) + }, [router.pathname, modals]) return (
= ({ isMobile }) => { - const { data, error, isLoading, mutate } = useSWR( +export function IndexBest() { + const { data, error, isLoading } = useSWR( `/api/submissions/best`, (url) => fetch( url, @@ -58,7 +59,7 @@ export const IndexBest: React.FC<{ isMobile: boolean }> = ({ isMobile }) => { className={s.masonry} columnClassName={s.masonryCol} > - {data.map(x => ( + {data.map((x: BestSubmission) => ( <Title order={2} - ta={isMobile ? 'center' : null} + ta={isMobile ? 'center' : undefined} > ГРАФИК ПРОЕКТА @@ -246,7 +246,7 @@ export function IndexPage() { // pl={isMobile ? 8 : 19} style={{ width: '100%', - aspectRatio: !isMobile && '1240 / 762', + aspectRatio: !isMobile ? '1240 / 762' : undefined, backgroundSize: isMobile ? 'auto' : 'contain', }} > @@ -266,7 +266,7 @@ export function IndexPage() { order={2} c={'primary'} mb={24} - ta={isTablet ? 'center' : null} + ta={isTablet ? 'center' : undefined} w={'100%'} style={{ fontSize: isMobile ? undefined : '42px', @@ -369,7 +369,7 @@ export function IndexPage() { zIndex: 0, overflow: 'visible', }} - pb={!isTablet && 100} + pb={!isTablet ? 100 : undefined} > {/* {!isTablet && (
ЧТО ВЫ ХОТИТЕ <br /> ИЗМЕНИТЬ? @@ -479,4 +479,4 @@ export function IndexPage() { ) -} \ No newline at end of file +} diff --git a/frontend/src/components/Map/index.tsx b/frontend/src/components/Map/index.tsx index 67b78d7..605c3e8 100644 --- a/frontend/src/components/Map/index.tsx +++ b/frontend/src/components/Map/index.tsx @@ -1,11 +1,13 @@ -import React, { useCallback, useContext } from 'react' -import { GeolocateControl, Layer, Marker, NavigationControl, Source } from 'react-map-gl/mapbox' +import { useContext } from 'react' +import { Layer, Marker, Source } from 'react-map-gl/mapbox' import { useModals } from '@mantine/modals' import { useRouter } from 'next/router' import { FormContext } from '@/contexts/form' import useSWR from 'swr' import { Popover, ScrollArea, Text } from '@mantine/core' import { useMediaQuery } from '@mantine/hooks' +import type { MapClickEvent } from '@/types' +import type { Submission } from '@/types/submission' import 'mapbox-gl/dist/mapbox-gl.css' @@ -18,7 +20,7 @@ type MapProps = { } } -export const Map: React.FC = ({ initialCoords }) => { +export function Map({ initialCoords }: MapProps) { const { data, error, isLoading } = useSWR( `/api/collections/features/records?perPage=1000`, (url) => fetch( @@ -32,7 +34,7 @@ export const Map: React.FC = ({ initialCoords }) => { const modals = useModals() const { data: formData, setData, addMode, setAddMode } = useContext(FormContext) - const onClick = useCallback((event) => { + const onClick = (event: MapClickEvent) => { if (!addMode) return const { lngLat } = event @@ -57,16 +59,14 @@ export const Map: React.FC = ({ initialCoords }) => { } ) setAddMode(false) - }, - [addMode] - ) + } const isMobile = useMediaQuery('(max-width: 768px)', true, { getInitialValueInEffect: false }) const router = useRouter() const isPreview = Boolean(router.query?.preview) == true - const features = (data?.items ?? []) - .filter(x => x?.feature && JSON.stringify(x?.feature) !== '{}') + const features: Submission[] = (data?.items ?? []) + .filter((x: Submission) => x?.feature && JSON.stringify(x?.feature) !== '{}') return ( { +export type NewsCardProps = { + x: NewsData +} + +export function NewsCard({ x }: NewsCardProps) { return ( { +export type NewsPageProps = { + data: NewsData +} + +export function NewsPage({ data }: NewsPageProps) { return ( diff --git a/frontend/src/components/SubmissionFeed/CommentForm.tsx b/frontend/src/components/SubmissionFeed/CommentForm.tsx index bd26397..91ed233 100644 --- a/frontend/src/components/SubmissionFeed/CommentForm.tsx +++ b/frontend/src/components/SubmissionFeed/CommentForm.tsx @@ -2,13 +2,14 @@ import { zodResolver } from '@hookform/resolvers/zod' import { Stack, Button, Textarea } from '@mantine/core' import { useState } from 'react' import { Controller, useForm } from 'react-hook-form' -import { KeyedMutator } from 'swr' +import type { KeyedMutator } from 'swr' import { z } from 'zod' import s from './index.module.css' +import type { Submission } from '@/types/submission' type CommentFormProps = { id: string - mutate: KeyedMutator + mutate: KeyedMutator } const states = { @@ -21,7 +22,7 @@ const formSchema = z.object({ comment: z.string().min(0, { message: 'Коментарий не может быть пустым' }).max(200, { message: 'Коментарий не может быть больше 200 символов' }), }) -export const CommentForm: React.FC = ({ id, mutate }) => { +export function CommentForm({ id, mutate }: CommentFormProps) { const { handleSubmit, control, reset, } = useForm>({ resolver: zodResolver(formSchema), mode: 'onChange', diff --git a/frontend/src/components/SubmissionFeed/Item.tsx b/frontend/src/components/SubmissionFeed/Item.tsx index ca8f295..c5f7269 100644 --- a/frontend/src/components/SubmissionFeed/Item.tsx +++ b/frontend/src/components/SubmissionFeed/Item.tsx @@ -1,6 +1,6 @@ import { Card, Stack, Text, Group, ScrollArea, ActionIcon } from '@mantine/core' import { useDisclosure } from '@mantine/hooks' -import { Submission } from '.' +import type { Submission } from '.' import { useMap } from 'react-map-gl/maplibre' import { NavbarContext } from '@/contexts/navbar' import { useContext } from 'react' @@ -11,9 +11,9 @@ type ItemProps = { data: Submission } -export const Item: React.FC = ({ data }) => { +export function Item({ data }: ItemProps) { const router = useRouter() - const [opened, { toggle }] = useDisclosure(false) + const [, { toggle }] = useDisclosure(false) const { setDrawer } = useContext(NavbarContext) const { default: map } = useMap() @@ -57,7 +57,7 @@ export const Item: React.FC = ({ data }) => { bg={'white'} onClick={() => { setDrawer(false) - map.flyTo({ + map?.flyTo({ center: data.feature.geometry.coordinates, zoom: 15, }) diff --git a/frontend/src/components/SubmissionFeed/index.tsx b/frontend/src/components/SubmissionFeed/index.tsx index c22a372..dd58680 100644 --- a/frontend/src/components/SubmissionFeed/index.tsx +++ b/frontend/src/components/SubmissionFeed/index.tsx @@ -1,4 +1,4 @@ -import { Stack, ScrollArea, Skeleton, Box, Button, ActionIcon, Group, Text, Alert, Title } from '@mantine/core' +import { Stack, ScrollArea, Skeleton, Box, Button, ActionIcon, Group, Text, Alert } from '@mantine/core' import { useHasMounted } from '@/contexts/hasMounted' import { Item } from './Item' import useSWRInfinite from 'swr/infinite' @@ -9,43 +9,15 @@ import { useRouter } from 'next/router' import { useEffectOnce } from 'react-use' import { AddButton } from '../AddButton' import { useMediaQuery } from '@mantine/hooks' +import type { Feature, Submission, SubmissionResponse } from '@/types/submission' -export type Feature = { - type: 'Feature', - properties: {}, - geometry: { - type: 'Point', - coordinates: [ - number, - number, - ] - } -} - -export type Submission = { - id: string - content: string - feature: Feature - collectionId?: string - collectionName?: string - created?: string - isBanned?: boolean - updated?: string -} - -export type SubmissionResponse = { - page: number - perPage: number - totalPages: number - totalItems: number - items: Submission[] -} +export type { Feature, Submission, SubmissionResponse } -export const SubmissionFeed: React.FC = () => { +export function SubmissionFeed() { const router = useRouter() const hasMounted = useHasMounted() const isMobile = useMediaQuery('(max-width: 768px)', true) - const { data, error, isLoading, mutate, size, setSize } = useSWRInfinite( + const { data, error, isLoading, size, setSize } = useSWRInfinite( (pageIndex, previousPageData) => { if (previousPageData && !previousPageData.hasNextPage) return null return `/api/collections/features/records?page=${pageIndex + 1}` // swr starts at 0, pb at 1 @@ -57,7 +29,7 @@ export const SubmissionFeed: React.FC = () => { } ).then(async res => await res.json()), ) - const dataFlat = (isLoading || error) + const dataFlat = (isLoading || error || !data) ? [] : data .flatMap(x => x.items) @@ -159,13 +131,13 @@ export const SubmissionFeed: React.FC = () => { pb={12} className={s.scrollAreaStack} > - {dataFlat.map((x, i) => ( + {dataFlat.map((x) => ( ))} - {!isLoading && data[0]?.totalItems != dataFlat.length && ( + {!isLoading && data && data[0]?.totalItems != dataFlat.length && (