Migrate to npm-workspaces monorepo (#294)

* spec update

* update md files

* add plan: monorepo

* feat: add empty apps and packages directories

* Add shared tsconfig.base.json

* feat add workspaces

* feat: move hudozka-writer into apps/

* feat: move migrate-to-pocketbase into apps/

* feat: split package.json for apps/hudozka workspace

* feat: extend tsconfig.base.json in apps/hudozka

* add dockerfile path

* feat: verify Phase 3 monorepo migration of Next app

* feat: scaffold @hudozka/utils package

* feat: add utils barrel export

* feat: extract utilities into hudozka-utils package

* feat: extract hooks into hudozka-hooks package

* Extract Box/Button/Panel/Title/Overlay into @hudozka/ui

* Scope Next ESLint rules to apps/hudozka in root flat config

* Update README and CLAUDE.md for monorepo layout

* disable image optimisation
This commit is contained in:
tmshv authored and GitHub committed 2026-04-25 01:33:06 +03:00
1 parent 0665fa7ae7
commit f874b58dd9
258 files changed
+3793 -214

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: ./
file: ./apps/hudozka/Dockerfile
push: true
platforms: linux/amd64
tags: |
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
run: npm ci
- name: Type check
run: npx tsc --noEmit
run: npx tsc --noEmit -p apps/hudozka/tsconfig.json
build:
name: Build
+33 -19
View File
@@ -4,15 +4,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Website for the Art School of Shlisselburg ([art.shlisselburg.org](https://art.shlisselburg.org)). Content is in Russian. Built with Next.js using the App Router and Strapi headless CMS as the backend. The project has migrated through Express → Angular 1 → React class components → Next.js (Pages Router) → Next.js (App Router); some legacy artifacts remain.
Website for the Art School of Shlisselburg ([art.shlisselburg.org](https://art.shlisselburg.org)). Content is in Russian. Built with Next.js using the App Router and PocketBase as the backend (migrated from Strapi — see `pb/` and `src/remote/pb.ts`). The project has migrated through Express → Angular 1 → React class components → Next.js (Pages Router) → Next.js (App Router); some legacy artifacts remain.
## Commands
From repo root (delegates to `apps/hudozka`):
- `npm run dev` — dev server with Turbo (`next dev --turbo`)
- `npm run build` — production build
- `npm run lint` — ESLint checks
- `npm run lint` — ESLint across the whole monorepo
- `npm run format` — auto-fix lint issues
- `npm test` — run unit tests (Vitest, non-watch mode)
- `npm test` — run unit tests (Vitest) across every workspace
Per-app:
- `npm run dev -w apps/hudozka-writer` — writer UI (Vite)
- `npm run migrate -w apps/migrate-to-pocketbase` — one-off migration script
Node 24 required (see `mise.toml`). Deployed on Vercel.
@@ -22,9 +29,9 @@ Feature branches are named `issue-XXX` where `XXX` is the related GitHub issue n
## CI/CD
- `Dockerfile` — multi-stage Alpine build with standalone Next.js output, pushed to GHCR
- `.github/workflows/build-app.yaml` — builds and pushes Docker image on push to master
- `.github/workflows/test-app.yml` — runs lint, tests, and build on PRs (build runs only after lint and tests pass)
- `apps/hudozka/Dockerfile` — multi-stage Alpine build with standalone Next.js output, pushed to GHCR. Build context is the repo root so the workspace graph is available.
- `.github/workflows/build-app.yaml` — builds and pushes Docker image on push to master (`file: ./apps/hudozka/Dockerfile`, `context: ./`).
- `.github/workflows/test-app.yml` — runs lint, tests, typecheck, and build on PRs.
## Code Style
@@ -48,29 +55,35 @@ When adding or updating packages in `package.json`, do not specify the PATCH ver
## Architecture
**App Router with SSG/ISR:** Pages are server components in the `app/` directory with `export const revalidate = 30`. The root layout (`app/layout.tsx`) fetches menu data and provides it via `MenuContext` through a `Providers` client component. The catch-all route `app/[...slug]/page.tsx` handles all dynamic content pages with `generateStaticParams` and `generateMetadata`.
**App Router with SSG/ISR:** Pages are server components under `src/app/` with `export const revalidate = 30`. The root layout (`src/app/layout.tsx`) fetches menu data and passes it down. The catch-all route `src/app/[...slug]/page.tsx` handles all dynamic content pages with `generateStaticParams` and `generateMetadata`.
**Server/client boundary:** Layout and page components are server components that fetch data. The `App` component (`src/components/App`) is a `"use client"` component that provides the layout chrome (navigation, breadcrumbs, footer). Content is passed as `children` from server pages through the client boundary. Components using hooks (`PageGrid`, `HomeContent`, `PageContent`, `Menu`, `MobileNavigation`, `Wrapper`) are marked `"use client"`.
**Data flow:** Strapi CMS API (`hudozka.tmshv.com`) → `src/remote/api.ts` fetches data via `apiGet()` generic wrapper → `src/remote/factory.ts` normalizes CMS responses into app types (`src/types.ts`) → pages render token-based content blocks. The API layer uses a factory pattern with default-response fallbacks on failure.
**Data flow:** PocketBase API → `src/remote/api.ts` fetches data via `apiGet()` generic wrapper → `src/remote/factory.ts` normalizes responses into app types (`src/types.ts`) → pages render token-based content blocks. The API layer uses a factory pattern with default-response fallbacks on failure. The legacy Strapi integration was removed (see commit "rm strapi"); `src/remote/pb.ts` is the PocketBase client.
**Metadata:** Uses the Next.js Metadata API (`generateMetadata`, `export const metadata`). The `buildMetadata()` helper in `src/lib/meta.ts` bridges the app's `Meta` type to Next.js `Metadata` format.
**Content tokens:** Pages are composed of flexible content blocks (tokens): `text`, `image`, `file`, `html`, `youtube`, `instagram`, `grid`. The `Token` discriminated union is defined in `src/types.ts`. Each token type maps to a renderer component in the `PageContent` client component.
**State management:** Valtio for reactive state (`src/store/`). Theme/accessibility preferences in `theme` store, playback state in `play` store, school contact info and year range in `config` store. Navigation data passed via React Context (`src/context/MenuContext`), provided by the root layout.
**State management:** Valtio for reactive state (`src/store/`). Theme/accessibility preferences in `theme` store, playback state in `play` store.
## Key Directories
- `app/` — App Router pages, layouts, and route handlers (layout, home page, `[...slug]` catch-all, not-found, sitemap, robots, feed)
- `src/components/` — page-level and feature components (folder-per-component with `index.tsx` + `styles.module.css`)
- `src/ui/` — reusable primitives (Box, Button, Picture, Panel, Overlay, Title)
- `src/remote/` — Strapi API integration (`api.ts` for fetching, `factory.ts` for transforms, `types.ts` for CMS response shapes)
- `src/store/` — Valtio stores (theme/accessibility, playback config)
- `src/hooks/` — custom hooks (`useMobile`, `useAccessibility`, `useDarkTheme`, `useReducedMotion`)
- `src/lib/` — pure utility functions (date, string, url, image, file helpers)
- `src/style/` — global CSS with CSS custom properties for theming
- `modules/` — ancillary packages (strapi API client, sync tools, writer); excluded from tsconfig
- `apps/hudozka/src/app/` — App Router pages, layouts, route handlers (catch-all, not-found, sitemap, robots, feed)
- `apps/hudozka/src/components/` — page-level and feature components (folder-per-component + `styles.module.css`)
- `apps/hudozka/src/ui/` — Next/store-coupled UI that stays in the app: `Picture`, `ThemeColor`
- `apps/hudozka/src/remote/` — PocketBase API client (`api.ts`, `factory.ts`, `types.ts`, `pb.ts`)
- `apps/hudozka/src/store/` — Valtio stores (theme/accessibility, playback)
- `apps/hudozka/src/hooks/` — business-coupled hooks: `useDarkTheme`, `useAccessibility`
- `apps/hudozka/src/lib/` — app-only lib (currently just `meta.ts`)
- `apps/hudozka/src/style/` — global CSS with CSS custom properties for theming
- `packages/ui/` — `@hudozka/ui` — reusable primitives (Box, Button, Panel, Title, Overlay)
- `packages/hooks/` — `@hudozka/hooks` — generic React hooks (useToggle, useLockBodyScroll, useMediaQuery, useMobile, useReducedMotion)
- `packages/utils/` — `@hudozka/utils` — pure utilities (array, string, url, math, image, file, date)
- `packages/text/` — `@hudozka/text` — markdown + typograf rendering
- `apps/hudozka-writer/` — Vite-based Tiptap editor
- `apps/migrate-to-pocketbase/` — one-off Strapi → PocketBase migration script
- `pb/` — PocketBase instance (data, migrations)
## Styling
@@ -78,7 +91,7 @@ CSS Modules (`.module.css`) for component-scoped styles. Global theme via CSS cu
## TypeScript
Path alias: `@/*` → `src/*`. Strict mode enabled. Target ES2020. `moduleDetection: "force"` in tsconfig (required because Next.js auto-generates `.next/types/validator.ts` without exports, which breaks `verbatimModuleSyntax`). Use `@/` path alias for all local imports, not `src/`. Legacy `src/` imports exist in the codebase but should not be used in new or modified code.
Path alias: `@/*` → `src/*`. Strict mode enabled. Target ES2020. `moduleDetection: "force"` in tsconfig (required because Next.js auto-generates `.next/types/validator.ts` without exports, which breaks `verbatimModuleSyntax`). Use `@/` path alias for all local imports, not `src/`. Legacy `src/` imports exist in the codebase but should not be used in new or modified code. The `@/*` path alias is only configured in `apps/hudozka/tsconfig.json`; packages under `packages/*` use short relative imports.
`verbatimModuleSyntax` is enabled — use `import type` for type-only imports. When importing both values and types from the same module, use separate `import` and `import type` statements:
@@ -108,3 +121,4 @@ Active modernization is tracked in GitHub issues #222–#238. Key items:
- Two orphaned `.scss` files in `src/style/` (`tape-viewer.scss`, `vertical-image.scss`) — not imported anywhere, no SCSS preprocessor configured.
- Some inline styles in `src/components/App/index.tsx` should be CSS modules.
- `react-use` and `use-media` dependencies are unmaintained; only `useToggle` and `useLockBodyScroll` are used from `react-use`.
+24 -2
View File
@@ -1,9 +1,31 @@
# [ART.SHLISSELBURG.ORG](https://art.shlisselburg.org)
Website for the Art School of Shlisselburg. Built with Next.js and powered by Strapi CMS.
Website for the Art School of Shlisselburg. Built with Next.js and powered by PocketBase (migrated from Strapi).
Font: [«Shadow»](http://mishapanfilov.ru/font_shadow.html)
## Repo layout
This is an npm-workspaces monorepo:
- `apps/hudozka` — main Next.js app (art.shlisselburg.org)
- `apps/hudozka-writer` — Vite-based content editor
- `apps/migrate-to-pocketbase` — one-off migration script (Strapi → PocketBase)
- `packages/ui` — `@hudozka/ui` — shared React primitives (Box, Button, Panel, Title, Overlay)
- `packages/hooks` — `@hudozka/hooks` — generic React hooks
- `packages/utils` — `@hudozka/utils` — pure utilities (array, string, url, math, image, file, date)
- `packages/text` — `@hudozka/text` — markdown + typograf rendering
- `pb/` — PocketBase instance (untouched by the monorepo migration)
Common commands (from repo root):
- `npm run dev` — run the web app dev server
- `npm run build` — production build of the web app
- `npm run lint` — lint the whole monorepo
- `npm test` — run tests across every workspace
- `npm run dev -w apps/hudozka-writer` — run the writer
- `npm run migrate -w apps/migrate-to-pocketbase` — run the migration script
## Development
### Prerequisites
@@ -25,7 +47,7 @@ npm run dev
The development server runs at [http://localhost:3000](http://localhost:3000) with Turbo mode enabled.
## Content Management
Content is fetched from Strapi CMS and rendered using flexible content tokens:
Content is fetched from PocketBase and rendered using flexible content tokens:
- `text` — Markdown with Russian typography (Typograf)
- `image` — Responsive images with blur hash
- `file` — Document downloads
View File
Whitespace-only changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,18 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+15 -12
View File
@@ -6,45 +6,47 @@ FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
# Copy workspace manifests (root + every workspace) to leverage npm ci caching.
COPY package.json package-lock.json ./
COPY apps/hudozka/package.json ./apps/hudozka/
COPY apps/hudozka-writer/package.json ./apps/hudozka-writer/
COPY apps/migrate-to-pocketbase/package.json ./apps/migrate-to-pocketbase/
COPY packages/ ./packages/
RUN npm ci
RUN npm install --os=linux --libc=musl --cpu=x64 sharp
RUN npm install --os=linux --libc=musl --cpu=x64 sharp --workspace apps/hudozka
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/hudozka/node_modules ./apps/hudozka/node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
RUN npm run build -w apps/hudozka
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# sharp is required to be installed in standalone mode for the image optimization to function correctly.
# Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production
# RUN npm install sharp
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/apps/hudozka/public ./apps/hudozka/public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
RUN mkdir -p apps/hudozka/.next
RUN chown nextjs:nodejs apps/hudozka/.next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/hudozka/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/hudozka/.next/static ./apps/hudozka/.next/static
USER nextjs
@@ -54,4 +56,5 @@ ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"
WORKDIR /app/apps/hudozka
CMD ["node", "server.js"]
View File
File renamed without changes.
@@ -1,9 +1,12 @@
import type { NextConfig } from "next"
import path from "node:path"
const config: NextConfig = {
output: "standalone",
outputFileTracingRoot: path.join(process.cwd(), "../../"),
trailingSlash: false,
images: {
unoptimized: true,
remotePatterns: [
{ hostname: "hudozka.shlisselburg.org" },
{ hostname: "images.weserv.nl" },
+43
View File
@@ -0,0 +1,43 @@
{
"name": "hudozka",
"private": true,
"version": "7.0.0",
"type": "module",
"description": "",
"scripts": {
"start": "next start",
"dev": "next dev --turbo",
"build": "next build",
"lint": "eslint .",
"format": "eslint --fix .",
"test": "vitest run"
},
"repository": {
"type": "git",
"url": "https://github.com/tmshv/hudozka"
},
"author": "Roman Timashev",
"license": "ISC",
"dependencies": {
"@hudozka/hooks": "*",
"@hudozka/text": "*",
"@hudozka/ui": "*",
"@hudozka/utils": "*",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-tooltip": "^1.2.0",
"classnames": "^2.5.0",
"dangerously-set-html-content": "^1.1.0",
"next": "^16.2.0",
"pocketbase": "^0.26.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
"sharp": "^0.34.0",
"valtio": "^2.3.0"
},
"overrides": {
"dangerously-set-html-content": {
"react": "$react"
}
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -4,7 +4,7 @@ import { App } from "@/components/App"
import { PageContent } from "@/components/PageContent"
import { MetaBuilder, buildMetadata } from "@/lib/meta"
import { getPageBySlug, getUrls } from "@/remote/api"
import { tail } from "@/lib/array"
import { tail } from "@hudozka/utils"
export const revalidate = 30
export const dynamicParams = true
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,5 @@
import { theme, toggleTheme } from "@/store/theme"
import { Button } from "@/ui/Button"
import { Button } from "@hudozka/ui"
import { HiOutlineEye, HiOutlineEyeOff } from "react-icons/hi"
import { useSnapshot } from "valtio"
@@ -8,16 +8,14 @@ import { Content } from "../Content"
import { Breadcrumbs } from "../Breadcrumbs"
import type { BreadcrumbPart } from "@/types"
import { usePathname } from "next/navigation"
import { useMobile } from "@/hooks/useMobile"
import { useMobile } from "@hudozka/hooks"
import { Navigation } from "@/components/Navigation"
import { Box } from "@/ui/Box"
import { Box, Panel, Title } from "@hudozka/ui"
import { AccessibilityButton } from "../AccessibilityButton"
import { Copyright } from "@/components/Copyright"
import { yearRange } from "@/const"
import { Spacer } from "../Spacer"
import { Author } from "../Author"
import { Title } from "@/ui/Title"
import { Panel } from "@/ui/Panel"
import { Contacts } from "../Contacts"
import dynamic from "next/dynamic"
import Link from "next/link"
File renamed without changes.
@@ -1,8 +1,8 @@
import s from "./styles.module.css"
import type { BreadcrumbPart } from "@/types"
import { Button } from "@/ui/Button"
import { insertBetween } from "@/lib/array"
import { Button } from "@hudozka/ui"
import { insertBetween } from "@hudozka/utils"
export type BreadcrumbsProps = {
style?: React.CSSProperties
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff. Show more