From 5310f6336684f7f2eee5b5ebc318a0e556b5e034 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 03:44:18 +0700 Subject: [PATCH 01/22] fix double submission --- frontend/src/components/SurveyModal/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/SurveyModal/index.tsx b/frontend/src/components/SurveyModal/index.tsx index f5a96ab..67c8b08 100644 --- a/frontend/src/components/SurveyModal/index.tsx +++ b/frontend/src/components/SurveyModal/index.tsx @@ -548,6 +548,8 @@ export function SurveyModal({ innerProps }: ContextModalProps) })), }) + setText(states.fetch) + await fetch(API.surveys, { method: "post", body, From 8bb85785407f04b497ca1acde3f8cf0c0049a0bf Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 03:44:25 +0700 Subject: [PATCH 02/22] pb version bump --- pb/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pb/Dockerfile b/pb/Dockerfile index 5c8bcab..e16780e 100644 --- a/pb/Dockerfile +++ b/pb/Dockerfile @@ -1,6 +1,6 @@ FROM alpine:latest -ARG PB_VERSION=0.36.2 +ARG PB_VERSION=0.36.5 RUN apk add --no-cache \ unzip \ From 2cfdd3ca93010f26453d202f889c4491ab9e32c7 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:10:02 +0700 Subject: [PATCH 03/22] add plan: feature-export-page --- .claude/plans/feature-export-page.md | 246 +++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 .claude/plans/feature-export-page.md diff --git a/.claude/plans/feature-export-page.md b/.claude/plans/feature-export-page.md new file mode 100644 index 0000000..95bd625 --- /dev/null +++ b/.claude/plans/feature-export-page.md @@ -0,0 +1,246 @@ +# Plan: /export page + +## Context + +The project collects citizen feedback via map submissions (`features` collection) and surveys (`surveys` collection). There is no admin UI to export this data. A new `/export` page is needed so a superuser can preview and download both datasets as CSV files. The page must authenticate using the PocketBase JS SDK and only show data to verified superusers. + +--- + +## Files to Create / Modify + +| File | Action | +|-------------------------------------------------------------------|----------| +| `frontend/package.json` | add dep | +| `frontend/src/types/index.ts` | add types| +| `frontend/src/lib/pocketbase.ts` | create | +| `frontend/src/lib/exportCsv.ts` | create | +| `frontend/src/app/(default)/export/page.tsx` | create | +| `frontend/src/app/(default)/export/ExportPageContent.tsx` | create | + +--- + +## Implementation + +### Task 1: Add pocketbase dependency +- [ ] Add `"pocketbase": "^0.22.0"` to `frontend/package.json` `dependencies` +- [ ] Run `npm install` from `frontend/` + +--- + +### Task 2: Add types to `src/types/index.ts` + +Append to the end of the file: + +```typescript +// Export page types +export type FeatureRecord = { + id: string + content: string + feature: { + type: "Feature" + properties: Record + geometry: { type: "Point"; coordinates: [number, number] } + } + isBanned: boolean + created: string + updated: string +} + +export type SurveyAnswerItem = { + id: string + text: string + value: string | string[] | number +} + +export type SurveyRecord = { + id: string + data: SurveyAnswerItem[] + created: string + updated: string +} + +export type FeatureRow = { + id: string + content: string + lng: number + lat: number + created: string +} + +export type ExportAuthState = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string } + | { status: "authenticated" } + +export type ExportDataState = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready"; features: FeatureRow[]; surveys: SurveyRecord[] } +``` + +--- + +### Task 3: Create `src/lib/pocketbase.ts` + +Module-level singleton; only imported by client components. + +```typescript +import PocketBase from "pocketbase" +import type { FeatureRecord, SurveyRecord } from "@/types" + +let pbInstance: PocketBase | null = null + +export function getPocketBase(): PocketBase { + if (!pbInstance) { + pbInstance = new PocketBase(window.location.origin) + } + return pbInstance +} + +export async function loginAsSuperuser(email: string, password: string): Promise { + const pb = getPocketBase() + await pb.collection("_superusers").authWithPassword(email, password) + if (!pb.authStore.isAdmin) { + throw new Error("Authenticated user is not a superuser") + } +} + +export async function fetchAllFeatures(): Promise { + const pb = getPocketBase() + const records = await pb.collection("features").getFullList({ sort: "-created" }) + return records as unknown as FeatureRecord[] +} + +export async function fetchAllSurveys(): Promise { + const pb = getPocketBase() + const records = await pb.collection("surveys").getFullList({ sort: "-created" }) + return records as unknown as SurveyRecord[] +} +``` + +Notes: +- `_superusers` is the PocketBase v0.22+ collection for admin auth; admin tokens bypass all collection `listRule`/`viewRule` restrictions +- `as unknown as T[]` is the canonical strict-mode cast for PocketBase `RecordModel` — avoids `any`, isolated to this module +- Singleton pattern: one `PocketBase` instance per browser session; SDK stores token in `localStorage` + +--- + +### Task 4: Create `src/lib/exportCsv.ts` + +Pure utilities, no React. Reuses `SurveySchemaItem` from `@/types`. + +```typescript +import type { FeatureRow, SurveyRecord } from "@/types" +import type { SurveySchemaItem } from "@/types" + +function escapeCell(value: string): string { + if (value.includes(",") || value.includes('"') || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"` + } + return value +} + +function buildRow(cells: string[]): string { + return cells.map(escapeCell).join(",") +} + +export function featuresToCsv(features: FeatureRow[]): string { + const header = buildRow(["id", "content", "lng", "lat", "created"]) + const rows = features.map((f) => + buildRow([f.id, f.content, String(f.lng), String(f.lat), f.created]) + ) + return [header, ...rows].join("\n") +} + +export function surveysToCsv(surveys: SurveyRecord[], schema: SurveySchemaItem[]): string { + // Build column list; expand selectList questions to one column per list item + const columns: Array<{ key: string; header: string }> = [] + for (const item of schema) { + if (item.type === "selectList" && item.list) { + item.list.forEach((label, i) => { + columns.push({ key: `${item.id}-${i}`, header: `${item.text}: ${label}` }) + }) + } else { + columns.push({ key: item.id, header: item.text }) + } + } + const headerRow = buildRow(["id", "created", ...columns.map((c) => c.header)]) + const dataRows = surveys.map((survey) => { + const answerMap = new Map() + for (const answer of survey.data) { + answerMap.set(answer.id, Array.isArray(answer.value) ? answer.value.join(";") : String(answer.value)) + } + return buildRow([survey.id, survey.created, ...columns.map((col) => answerMap.get(col.key) ?? "")]) + }) + return [headerRow, ...dataRows].join("\n") +} + +export function downloadCsv(csvContent: string, filename: string): void { + const BOM = "\uFEFF" // UTF-8 BOM: required for Cyrillic text in Excel + const blob = new Blob([BOM + csvContent], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} +``` + +Note: `selectList` question `dd7afc2c-…` has 13 items → 13 extra columns in surveys CSV. +Multi-value answers (checkboxes) joined with `;`. + +--- + +### Task 5: Create `src/app/(default)/export/page.tsx` + +Mirrors `(map)/map/page.tsx` exactly (thin server component + Suspense): + +```typescript +import { Center, Loader } from "@mantine/core" +import { Suspense } from "react" +import { ExportPageContent } from "./ExportPageContent" + +export default function ExportPage() { + return ( + }> + + + ) +} +``` + +--- + +### Task 6: Create `src/app/(default)/export/ExportPageContent.tsx` + +Client component. Three internal sub-components + main export. + +**State machine** using `ExportAuthState` and `ExportDataState` discriminated unions. + +**`LoginForm`**: email + password fields using `react-hook-form` (already a dep). Calls `loginAsSuperuser()`, surfaces errors in `Alert`. On success calls `onSuccess` callback. + +**`FeaturesTable`**: Mantine `Table` showing `id, content, lng, lat, created`. Preview: first 10 rows. + +**`SurveysTable`**: Mantine `Table` showing `id, created, answer count`. Full column expansion (46+ columns) is too wide for preview — show a summary "Ответов: N" column instead. + +**`ExportPageContent`**: +- Before auth: renders `LoginForm` +- `handleAuthSuccess`: sets `authenticated` state, then `Promise.all([fetchAllFeatures(), fetchAllSurveys()])`, filters `isBanned` features, transforms to `FeatureRow[]` +- After data loads: two sections (Features, Surveys), each with `Title`, row count, preview `Table`, "Скачать CSV" `Button` +- Coordinate extraction: `r.feature?.geometry?.coordinates?.[0] ?? 0` (defensive for malformed records) + +Layout note: the `(default)` layout wraps children in ``, so **no extra `Container`** needed at the top level — use `Stack py={40}` directly. + +--- + +## Verification + +1. `cd frontend && npm run type-check` — must pass with no errors +2. `npm run check` — Biome lint + format must pass +3. Navigate to `/export` — login form appears +4. Log in with superuser credentials — data loads, preview tables render +5. Click "Скачать CSV" for features — file downloads, open in Excel, verify Cyrillic renders, check `lng, lat` columns, confirm no banned rows +6. Click "Скачать CSV" for surveys — file downloads, verify column headers are question texts, check `selectList` question expands to multiple columns From 6bfa29d513febc977ac0d57b1b011e7e1cca5a58 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:11:07 +0700 Subject: [PATCH 04/22] feat: add pocketbase dependency --- .claude/plans/feature-export-page.md | 4 ++-- frontend/package-lock.json | 7 +++++++ frontend/package.json | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.claude/plans/feature-export-page.md b/.claude/plans/feature-export-page.md index 95bd625..e85dc34 100644 --- a/.claude/plans/feature-export-page.md +++ b/.claude/plans/feature-export-page.md @@ -22,8 +22,8 @@ The project collects citizen feedback via map submissions (`features` collection ## Implementation ### Task 1: Add pocketbase dependency -- [ ] Add `"pocketbase": "^0.22.0"` to `frontend/package.json` `dependencies` -- [ ] Run `npm install` from `frontend/` +- [x] Add `"pocketbase": "^0.22.0"` to `frontend/package.json` `dependencies` +- [x] Run `npm install` from `frontend/` --- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da0889b..790f2a5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "@types/mapbox-gl": "^3.4.1", "mapbox-gl": "^3.19.0", "next": "^16.1.0", + "pocketbase": "^0.22.0", "qs": "^6.12.1", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -2832,6 +2833,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pocketbase": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.22.1.tgz", + "integrity": "sha512-hnzpDcIx5kbkUJXz6IkiTox6R4nb706u4rPSMy2FYCaI2OqBnFxbY0qwWBSqx1ERz/Xi61Uguf17Gb9bWZ8+kw==", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8e6cac7..e55a80a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@types/mapbox-gl": "^3.4.1", "mapbox-gl": "^3.19.0", "next": "^16.1.0", + "pocketbase": "^0.22.0", "qs": "^6.12.1", "react": "^19.2.0", "react-dom": "^19.2.0", From 4b06992dfbcbf278409fafd309a55866390ab99d Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:12:36 +0700 Subject: [PATCH 05/22] feat: add export page types to src/types/index.ts --- .claude/plans/feature-export-page.md | 2 ++ frontend/src/types/index.ts | 47 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/.claude/plans/feature-export-page.md b/.claude/plans/feature-export-page.md index e85dc34..7ee48f6 100644 --- a/.claude/plans/feature-export-page.md +++ b/.claude/plans/feature-export-page.md @@ -80,6 +80,8 @@ export type ExportDataState = | { status: "ready"; features: FeatureRow[]; surveys: SurveyRecord[] } ``` +- [x] Append export page types (FeatureRecord, SurveyAnswerItem, SurveyRecord, FeatureRow, ExportAuthState, ExportDataState) to `frontend/src/types/index.ts` + --- ### Task 3: Create `src/lib/pocketbase.ts` diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a593055..0ef5efe 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -103,3 +103,50 @@ export type BestSubmission = { isPoint?: [number, number] comments: number } + +// Export page types +export type FeatureRecord = { + id: string + content: string + feature: { + type: "Feature" + properties: Record + geometry: { type: "Point"; coordinates: [number, number] } + } + isBanned: boolean + created: string + updated: string +} + +export type SurveyAnswerItem = { + id: string + text: string + value: string | string[] | number +} + +export type SurveyRecord = { + id: string + data: SurveyAnswerItem[] + created: string + updated: string +} + +export type FeatureRow = { + id: string + content: string + lng: number + lat: number + created: string +} + +export type ExportAuthState = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string } + | { status: "authenticated" } + +export type ExportDataState = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready"; features: FeatureRow[]; surveys: SurveyRecord[] } From 5419721926322b8a8ba38e673b218e05c8a0e0cf Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:14:53 +0700 Subject: [PATCH 06/22] feat: create pocketbase client module with superuser auth and data fetching Add frontend/src/lib/pocketbase.ts with browser-singleton PocketBase instance, loginAsSuperuser(), fetchAllFeatures(), and fetchAllSurveys() functions. --- .claude/plans/feature-export-page.md | 2 ++ frontend/src/lib/pocketbase.ts | 31 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 frontend/src/lib/pocketbase.ts diff --git a/.claude/plans/feature-export-page.md b/.claude/plans/feature-export-page.md index 7ee48f6..7dd55f0 100644 --- a/.claude/plans/feature-export-page.md +++ b/.claude/plans/feature-export-page.md @@ -127,6 +127,8 @@ Notes: - `as unknown as T[]` is the canonical strict-mode cast for PocketBase `RecordModel` — avoids `any`, isolated to this module - Singleton pattern: one `PocketBase` instance per browser session; SDK stores token in `localStorage` +- [x] Create `frontend/src/lib/pocketbase.ts` with singleton `getPocketBase()`, `loginAsSuperuser()`, `fetchAllFeatures()`, `fetchAllSurveys()` + --- ### Task 4: Create `src/lib/exportCsv.ts` diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts new file mode 100644 index 0000000..e7e16d8 --- /dev/null +++ b/frontend/src/lib/pocketbase.ts @@ -0,0 +1,31 @@ +import PocketBase from "pocketbase" +import type { FeatureRecord, SurveyRecord } from "@/types" + +let pbInstance: PocketBase | null = null + +export function getPocketBase(): PocketBase { + if (!pbInstance) { + pbInstance = new PocketBase(window.location.origin) + } + return pbInstance +} + +export async function loginAsSuperuser(email: string, password: string): Promise { + const pb = getPocketBase() + await pb.collection("_superusers").authWithPassword(email, password) + if (!pb.authStore.isAdmin) { + throw new Error("Authenticated user is not a superuser") + } +} + +export async function fetchAllFeatures(): Promise { + const pb = getPocketBase() + const records = await pb.collection("features").getFullList({ sort: "-created" }) + return records as unknown as FeatureRecord[] +} + +export async function fetchAllSurveys(): Promise { + const pb = getPocketBase() + const records = await pb.collection("surveys").getFullList({ sort: "-created" }) + return records as unknown as SurveyRecord[] +} From 1b397c0314b704fec284baa704c442396f08ae8b Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:17:02 +0700 Subject: [PATCH 07/22] feat: create exportCsv utility module for features and surveys CSV export --- .claude/plans/feature-export-page.md | 2 ++ frontend/src/lib/exportCsv.ts | 52 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 frontend/src/lib/exportCsv.ts diff --git a/.claude/plans/feature-export-page.md b/.claude/plans/feature-export-page.md index 7dd55f0..065e5f1 100644 --- a/.claude/plans/feature-export-page.md +++ b/.claude/plans/feature-export-page.md @@ -196,6 +196,8 @@ export function downloadCsv(csvContent: string, filename: string): void { Note: `selectList` question `dd7afc2c-…` has 13 items → 13 extra columns in surveys CSV. Multi-value answers (checkboxes) joined with `;`. +- [x] Create `frontend/src/lib/exportCsv.ts` with `featuresToCsv`, `surveysToCsv`, `downloadCsv` + --- ### Task 5: Create `src/app/(default)/export/page.tsx` diff --git a/frontend/src/lib/exportCsv.ts b/frontend/src/lib/exportCsv.ts new file mode 100644 index 0000000..53070f7 --- /dev/null +++ b/frontend/src/lib/exportCsv.ts @@ -0,0 +1,52 @@ +import type { FeatureRow, SurveyRecord, SurveySchemaItem } from "@/types" + +function escapeCell(value: string): string { + if (value.includes(",") || value.includes('"') || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"` + } + return value +} + +function buildRow(cells: string[]): string { + return cells.map(escapeCell).join(",") +} + +export function featuresToCsv(features: FeatureRow[]): string { + const header = buildRow(["id", "content", "lng", "lat", "created"]) + const rows = features.map((f) => buildRow([f.id, f.content, String(f.lng), String(f.lat), f.created])) + return [header, ...rows].join("\n") +} + +export function surveysToCsv(surveys: SurveyRecord[], schema: SurveySchemaItem[]): string { + // Build column list; expand selectList questions to one column per list item + const columns: Array<{ key: string; header: string }> = [] + for (const item of schema) { + if (item.type === "selectList" && item.list) { + item.list.forEach((label, i) => { + columns.push({ key: `${item.id}-${i}`, header: `${item.text}: ${label}` }) + }) + } else { + columns.push({ key: item.id, header: item.text }) + } + } + const headerRow = buildRow(["id", "created", ...columns.map((c) => c.header)]) + const dataRows = surveys.map((survey) => { + const answerMap = new Map() + for (const answer of survey.data) { + answerMap.set(answer.id, Array.isArray(answer.value) ? answer.value.join(";") : String(answer.value)) + } + return buildRow([survey.id, survey.created, ...columns.map((col) => answerMap.get(col.key) ?? "")]) + }) + return [headerRow, ...dataRows].join("\n") +} + +export function downloadCsv(csvContent: string, filename: string): void { + const BOM = "\uFEFF" // UTF-8 BOM: required for Cyrillic text in Excel + const blob = new Blob([BOM + csvContent], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} From 38be45adba6654693cbcf0e2bbb953efbdb0f4f1 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:18:18 +0700 Subject: [PATCH 08/22] feat: create export page server component with Suspense wrapper --- .claude/plans/feature-export-page.md | 2 ++ .../app/(default)/export/ExportPageContent.tsx | 5 +++++ frontend/src/app/(default)/export/page.tsx | 17 +++++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 frontend/src/app/(default)/export/ExportPageContent.tsx create mode 100644 frontend/src/app/(default)/export/page.tsx diff --git a/.claude/plans/feature-export-page.md b/.claude/plans/feature-export-page.md index 065e5f1..a9c1b1a 100644 --- a/.claude/plans/feature-export-page.md +++ b/.claude/plans/feature-export-page.md @@ -202,6 +202,8 @@ Multi-value answers (checkboxes) joined with `;`. ### Task 5: Create `src/app/(default)/export/page.tsx` +- [x] Create `frontend/src/app/(default)/export/page.tsx` as thin server component with Suspense + Loader fallback + Mirrors `(map)/map/page.tsx` exactly (thin server component + Suspense): ```typescript diff --git a/frontend/src/app/(default)/export/ExportPageContent.tsx b/frontend/src/app/(default)/export/ExportPageContent.tsx new file mode 100644 index 0000000..4b51c08 --- /dev/null +++ b/frontend/src/app/(default)/export/ExportPageContent.tsx @@ -0,0 +1,5 @@ +"use client" + +export function ExportPageContent() { + return null +} diff --git a/frontend/src/app/(default)/export/page.tsx b/frontend/src/app/(default)/export/page.tsx new file mode 100644 index 0000000..9e86d0a --- /dev/null +++ b/frontend/src/app/(default)/export/page.tsx @@ -0,0 +1,17 @@ +import { Center, Loader } from "@mantine/core" +import { Suspense } from "react" +import { ExportPageContent } from "./ExportPageContent" + +export default function ExportPage() { + return ( + + + + } + > + + + ) +} From e234ebbf7eafc70af5ab2bcdd529bba22b7e76cc Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:24:04 +0700 Subject: [PATCH 09/22] feat: implement ExportPageContent with login form, preview tables, and CSV download --- .claude/plans/feature-export-page.md | 2 + .../(default)/export/ExportPageContent.tsx | 229 +++++++++++++++++- 2 files changed, 229 insertions(+), 2 deletions(-) diff --git a/.claude/plans/feature-export-page.md b/.claude/plans/feature-export-page.md index a9c1b1a..efac26c 100644 --- a/.claude/plans/feature-export-page.md +++ b/.claude/plans/feature-export-page.md @@ -224,6 +224,8 @@ export default function ExportPage() { ### Task 6: Create `src/app/(default)/export/ExportPageContent.tsx` +- [x] Create `frontend/src/app/(default)/export/ExportPageContent.tsx` with LoginForm, FeaturesTable, SurveysTable, and ExportPageContent components + Client component. Three internal sub-components + main export. **State machine** using `ExportAuthState` and `ExportDataState` discriminated unions. diff --git a/frontend/src/app/(default)/export/ExportPageContent.tsx b/frontend/src/app/(default)/export/ExportPageContent.tsx index 4b51c08..f608a8e 100644 --- a/frontend/src/app/(default)/export/ExportPageContent.tsx +++ b/frontend/src/app/(default)/export/ExportPageContent.tsx @@ -1,5 +1,230 @@ "use client" -export function ExportPageContent() { - return null +import { zodResolver } from "@hookform/resolvers/zod" +import { Alert, Button, Group, Loader, PasswordInput, Stack, Table, Text, TextInput, Title } from "@mantine/core" +import type { ReactElement } from "react" +import { useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { z } from "zod" +import { downloadCsv, featuresToCsv, surveysToCsv } from "@/lib/exportCsv" +import { fetchAllFeatures, fetchAllSurveys, loginAsSuperuser } from "@/lib/pocketbase" +import { surveySchema } from "@/surveySchema" +import type { ExportAuthState, ExportDataState, FeatureRow, SurveyRecord } from "@/types" + +const loginSchema = z.object({ + email: z.string().email("Введите корректный email"), + password: z.string().min(1, "Введите пароль"), +}) + +type LoginFormData = z.infer + +type LoginFormProps = { + onSuccess: () => void +} + +function LoginForm({ onSuccess }: LoginFormProps): ReactElement { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }) + + const onSubmit = async (data: LoginFormData): Promise => { + setLoading(true) + setError(null) + try { + await loginAsSuperuser(data.email, data.password) + onSuccess() + } catch (err) { + setLoading(false) + setError(err instanceof Error ? err.message : "Ошибка входа") + } + } + + return ( + + Вход для суперпользователя + {error && ( + + {error} + + )} +
+ + ( + + )} + /> + ( + + )} + /> + + +
+
+ ) +} + +type FeaturesTableProps = { + features: FeatureRow[] +} + +function FeaturesTable({ features }: FeaturesTableProps): ReactElement { + const preview = features.slice(0, 10) + return ( + + + + id + content + lng + lat + created + + + + {preview.map((row) => ( + + {row.id} + {row.content} + {row.lng} + {row.lat} + {row.created} + + ))} + +
+ ) +} + +type SurveysTableProps = { + surveys: SurveyRecord[] +} + +function SurveysTable({ surveys }: SurveysTableProps): ReactElement { + const preview = surveys.slice(0, 10) + return ( + + + + id + created + Ответов + + + + {preview.map((row) => ( + + {row.id} + {row.created} + {row.data.length} + + ))} + +
+ ) +} + +export function ExportPageContent(): ReactElement { + const [authState, setAuthState] = useState({ status: "idle" }) + const [dataState, setDataState] = useState({ status: "idle" }) + + const handleAuthSuccess = async (): Promise => { + setAuthState({ status: "authenticated" }) + setDataState({ status: "loading" }) + try { + const [rawFeatures, surveys] = await Promise.all([fetchAllFeatures(), fetchAllSurveys()]) + const features: FeatureRow[] = rawFeatures + .filter((r) => !r.isBanned) + .map((r) => ({ + id: r.id, + content: r.content, + lng: r.feature?.geometry?.coordinates?.[0] ?? 0, + lat: r.feature?.geometry?.coordinates?.[1] ?? 0, + created: r.created, + })) + setDataState({ status: "ready", features, surveys }) + } catch (err) { + const message = err instanceof Error ? err.message : "Ошибка загрузки данных" + setDataState({ status: "error", message }) + } + } + + if (authState.status !== "authenticated") { + return ( + + + + ) + } + + if (dataState.status === "loading") { + return ( + + + + ) + } + + if (dataState.status === "error") { + return ( + + + {dataState.message} + + + ) + } + + if (dataState.status !== "ready") { + return + } + + const { features, surveys } = dataState + + const handleDownloadFeatures = (): void => { + downloadCsv(featuresToCsv(features), "features.csv") + } + + const handleDownloadSurveys = (): void => { + downloadCsv(surveysToCsv(surveys, surveySchema), "surveys.csv") + } + + return ( + + + + Предложения на карте + Всего: {features.length} + + + + + + + Опросы + Всего: {surveys.length} + + + + + + ) } From 5f83ab81cd2c046bdd869bc3b972acf5e775cdfe Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:29:38 +0700 Subject: [PATCH 10/22] fix: address code review findings - expandsliderList questions in surveysToCsv (same key pattern as selectList, was producing empty columns for all 23 slider items) - fix downloadCsv to append anchor to DOM before click and defer revokeObjectURL to avoid Firefox download failure - remove redundant isAdmin check in loginAsSuperuser (_superusers collection enforces superuser-only auth) - make FeatureRecord.feature optional to match defensive optional-chaining access at call site - add setLoading(false) in success path of LoginForm.onSubmit before calling onSuccess --- frontend/src/app/(default)/export/ExportPageContent.tsx | 1 + frontend/src/lib/exportCsv.ts | 6 ++++-- frontend/src/lib/pocketbase.ts | 3 --- frontend/src/types/index.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/(default)/export/ExportPageContent.tsx b/frontend/src/app/(default)/export/ExportPageContent.tsx index f608a8e..1293430 100644 --- a/frontend/src/app/(default)/export/ExportPageContent.tsx +++ b/frontend/src/app/(default)/export/ExportPageContent.tsx @@ -38,6 +38,7 @@ function LoginForm({ onSuccess }: LoginFormProps): ReactElement { setError(null) try { await loginAsSuperuser(data.email, data.password) + setLoading(false) onSuccess() } catch (err) { setLoading(false) diff --git a/frontend/src/lib/exportCsv.ts b/frontend/src/lib/exportCsv.ts index 53070f7..a64a713 100644 --- a/frontend/src/lib/exportCsv.ts +++ b/frontend/src/lib/exportCsv.ts @@ -21,7 +21,7 @@ export function surveysToCsv(surveys: SurveyRecord[], schema: SurveySchemaItem[] // Build column list; expand selectList questions to one column per list item const columns: Array<{ key: string; header: string }> = [] for (const item of schema) { - if (item.type === "selectList" && item.list) { + if ((item.type === "selectList" || item.type === "sliderList") && item.list) { item.list.forEach((label, i) => { columns.push({ key: `${item.id}-${i}`, header: `${item.text}: ${label}` }) }) @@ -47,6 +47,8 @@ export function downloadCsv(csvContent: string, filename: string): void { const anchor = document.createElement("a") anchor.href = url anchor.download = filename + document.body.appendChild(anchor) anchor.click() - URL.revokeObjectURL(url) + document.body.removeChild(anchor) + setTimeout(() => URL.revokeObjectURL(url), 100) } diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index e7e16d8..ce8038d 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -13,9 +13,6 @@ export function getPocketBase(): PocketBase { export async function loginAsSuperuser(email: string, password: string): Promise { const pb = getPocketBase() await pb.collection("_superusers").authWithPassword(email, password) - if (!pb.authStore.isAdmin) { - throw new Error("Authenticated user is not a superuser") - } } export async function fetchAllFeatures(): Promise { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0ef5efe..d74b540 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -108,7 +108,7 @@ export type BestSubmission = { export type FeatureRecord = { id: string content: string - feature: { + feature?: { type: "Feature" properties: Record geometry: { type: "Point"; coordinates: [number, number] } From d645194b96351ba5458cad7d5b566bb8e5d26171 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:34:25 +0700 Subject: [PATCH 11/22] fix: address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix CSV escaping to also quote cells containing carriage returns (\r) to prevent row corruption with Windows line endings in user text - Remove unused "loading" and "error" variants from ExportAuthState type since authState only transitions idle → authenticated --- frontend/src/lib/exportCsv.ts | 2 +- frontend/src/types/index.ts | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/exportCsv.ts b/frontend/src/lib/exportCsv.ts index a64a713..4358073 100644 --- a/frontend/src/lib/exportCsv.ts +++ b/frontend/src/lib/exportCsv.ts @@ -1,7 +1,7 @@ import type { FeatureRow, SurveyRecord, SurveySchemaItem } from "@/types" function escapeCell(value: string): string { - if (value.includes(",") || value.includes('"') || value.includes("\n")) { + if (value.includes(",") || value.includes('"') || value.includes("\n") || value.includes("\r")) { return `"${value.replace(/"/g, '""')}"` } return value diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d74b540..42e20e7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -139,11 +139,7 @@ export type FeatureRow = { created: string } -export type ExportAuthState = - | { status: "idle" } - | { status: "loading" } - | { status: "error"; message: string } - | { status: "authenticated" } +export type ExportAuthState = { status: "idle" } | { status: "authenticated" } export type ExportDataState = | { status: "idle" } From c9b4e3a30f686105245c6e6809158cc71da02d8b Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 04:39:29 +0700 Subject: [PATCH 12/22] fix: address code review findings --- frontend/src/app/(default)/export/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/(default)/export/page.tsx b/frontend/src/app/(default)/export/page.tsx index 9e86d0a..cc65555 100644 --- a/frontend/src/app/(default)/export/page.tsx +++ b/frontend/src/app/(default)/export/page.tsx @@ -1,8 +1,9 @@ import { Center, Loader } from "@mantine/core" +import type { ReactElement } from "react" import { Suspense } from "react" import { ExportPageContent } from "./ExportPageContent" -export default function ExportPage() { +export default function ExportPage(): ReactElement { return ( Date: Thu, 26 Feb 2026 05:04:01 +0700 Subject: [PATCH 13/22] fix: address codex review findings --- .gitignore | 3 +++ .../(default)/export/ExportPageContent.tsx | 15 +++++++++---- frontend/src/lib/exportCsv.ts | 21 +++++++++++++++---- frontend/src/types/index.ts | 4 ++-- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index e43b0f9..d52bf0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .DS_Store + +# ralphex progress logs +.ralphex/progress/ diff --git a/frontend/src/app/(default)/export/ExportPageContent.tsx b/frontend/src/app/(default)/export/ExportPageContent.tsx index 1293430..f676246 100644 --- a/frontend/src/app/(default)/export/ExportPageContent.tsx +++ b/frontend/src/app/(default)/export/ExportPageContent.tsx @@ -143,8 +143,7 @@ export function ExportPageContent(): ReactElement { const [authState, setAuthState] = useState({ status: "idle" }) const [dataState, setDataState] = useState({ status: "idle" }) - const handleAuthSuccess = async (): Promise => { - setAuthState({ status: "authenticated" }) + const loadData = async (): Promise => { setDataState({ status: "loading" }) try { const [rawFeatures, surveys] = await Promise.all([fetchAllFeatures(), fetchAllSurveys()]) @@ -153,8 +152,8 @@ export function ExportPageContent(): ReactElement { .map((r) => ({ id: r.id, content: r.content, - lng: r.feature?.geometry?.coordinates?.[0] ?? 0, - lat: r.feature?.geometry?.coordinates?.[1] ?? 0, + lng: r.feature?.geometry?.coordinates?.[0] ?? null, + lat: r.feature?.geometry?.coordinates?.[1] ?? null, created: r.created, })) setDataState({ status: "ready", features, surveys }) @@ -164,6 +163,11 @@ export function ExportPageContent(): ReactElement { } } + const handleAuthSuccess = async (): Promise => { + setAuthState({ status: "authenticated" }) + await loadData() + } + if (authState.status !== "authenticated") { return ( @@ -186,6 +190,9 @@ export function ExportPageContent(): ReactElement { {dataState.message} + ) } diff --git a/frontend/src/lib/exportCsv.ts b/frontend/src/lib/exportCsv.ts index 4358073..74cdf1d 100644 --- a/frontend/src/lib/exportCsv.ts +++ b/frontend/src/lib/exportCsv.ts @@ -1,10 +1,15 @@ import type { FeatureRow, SurveyRecord, SurveySchemaItem } from "@/types" function escapeCell(value: string): string { - if (value.includes(",") || value.includes('"') || value.includes("\n") || value.includes("\r")) { - return `"${value.replace(/"/g, '""')}"` + // Prevent spreadsheet formula injection: prefix dangerous leading characters with a literal quote. + // Numeric values are exempt — they cannot execute formulas and must not be corrupted (e.g. coordinates like -73.9). + const isNumeric = value !== "" && Number.isFinite(Number(value)) + const isFormula = !isNumeric && /^[=+\-@]/.test(value.trimStart()) + const sanitized = isFormula ? `'${value}` : value + if (sanitized.includes(",") || sanitized.includes('"') || sanitized.includes("\n") || sanitized.includes("\r")) { + return `"${sanitized.replace(/"/g, '""')}"` } - return value + return sanitized } function buildRow(cells: string[]): string { @@ -13,7 +18,15 @@ function buildRow(cells: string[]): string { export function featuresToCsv(features: FeatureRow[]): string { const header = buildRow(["id", "content", "lng", "lat", "created"]) - const rows = features.map((f) => buildRow([f.id, f.content, String(f.lng), String(f.lat), f.created])) + const rows = features.map((f) => + buildRow([ + f.id, + f.content, + f.lng !== null ? String(f.lng) : "", + f.lat !== null ? String(f.lat) : "", + f.created, + ]), + ) return [header, ...rows].join("\n") } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 42e20e7..7671a5d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -134,8 +134,8 @@ export type SurveyRecord = { export type FeatureRow = { id: string content: string - lng: number - lat: number + lng: number | null + lat: number | null created: string } From 23ea08eed80a39703658ea57f593b5381bb3eda9 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 05:15:01 +0700 Subject: [PATCH 14/22] fix: address code review findings Restore missing isSuperuser guard in loginAsSuperuser per original plan. --- frontend/src/lib/pocketbase.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index ce8038d..3aa6ce3 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -13,6 +13,9 @@ export function getPocketBase(): PocketBase { export async function loginAsSuperuser(email: string, password: string): Promise { const pb = getPocketBase() await pb.collection("_superusers").authWithPassword(email, password) + if (!pb.authStore.isSuperuser) { + throw new Error("Authenticated user is not a superuser") + } } export async function fetchAllFeatures(): Promise { From 61e5194971807ce5aeccf1844c2ac9675361c860 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 05:20:16 +0700 Subject: [PATCH 15/22] fix: address code review findings - Add server-side filter for banned records in fetchAllFeatures to prevent banned content from being transmitted to the browser - Remove unreachable isSuperuser guard that was dead code after successful _superusers authWithPassword --- frontend/src/lib/pocketbase.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index 3aa6ce3..4acccda 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -13,14 +13,11 @@ export function getPocketBase(): PocketBase { export async function loginAsSuperuser(email: string, password: string): Promise { const pb = getPocketBase() await pb.collection("_superusers").authWithPassword(email, password) - if (!pb.authStore.isSuperuser) { - throw new Error("Authenticated user is not a superuser") - } } export async function fetchAllFeatures(): Promise { const pb = getPocketBase() - const records = await pb.collection("features").getFullList({ sort: "-created" }) + const records = await pb.collection("features").getFullList({ sort: "-created", filter: "isBanned = false" }) return records as unknown as FeatureRecord[] } From c1ca3d819455a42a8cf47fd86785a351ccdc41dc Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 05:26:07 +0700 Subject: [PATCH 16/22] fix: address code review findings - Remove redundant client-side isBanned filter (server-side filter already handles this) - Make FeatureRecord.feature.geometry optional to match actual usage with optional chaining --- .../app/(default)/export/ExportPageContent.tsx | 16 +++++++--------- frontend/src/types/index.ts | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/(default)/export/ExportPageContent.tsx b/frontend/src/app/(default)/export/ExportPageContent.tsx index f676246..292d43b 100644 --- a/frontend/src/app/(default)/export/ExportPageContent.tsx +++ b/frontend/src/app/(default)/export/ExportPageContent.tsx @@ -147,15 +147,13 @@ export function ExportPageContent(): ReactElement { setDataState({ status: "loading" }) try { const [rawFeatures, surveys] = await Promise.all([fetchAllFeatures(), fetchAllSurveys()]) - const features: FeatureRow[] = rawFeatures - .filter((r) => !r.isBanned) - .map((r) => ({ - id: r.id, - content: r.content, - lng: r.feature?.geometry?.coordinates?.[0] ?? null, - lat: r.feature?.geometry?.coordinates?.[1] ?? null, - created: r.created, - })) + const features: FeatureRow[] = rawFeatures.map((r) => ({ + id: r.id, + content: r.content, + lng: r.feature?.geometry?.coordinates?.[0] ?? null, + lat: r.feature?.geometry?.coordinates?.[1] ?? null, + created: r.created, + })) setDataState({ status: "ready", features, surveys }) } catch (err) { const message = err instanceof Error ? err.message : "Ошибка загрузки данных" diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7671a5d..feb9492 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -111,7 +111,7 @@ export type FeatureRecord = { feature?: { type: "Feature" properties: Record - geometry: { type: "Point"; coordinates: [number, number] } + geometry?: { type: "Point"; coordinates?: [number, number] } } isBanned: boolean created: string From ad12d9e11c1d82239e0699759613cc959c59e9fb Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 05:31:38 +0700 Subject: [PATCH 17/22] fix: address code review findings --- frontend/src/lib/exportCsv.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/exportCsv.ts b/frontend/src/lib/exportCsv.ts index 74cdf1d..a6e33ab 100644 --- a/frontend/src/lib/exportCsv.ts +++ b/frontend/src/lib/exportCsv.ts @@ -63,5 +63,5 @@ export function downloadCsv(csvContent: string, filename: string): void { document.body.appendChild(anchor) anchor.click() document.body.removeChild(anchor) - setTimeout(() => URL.revokeObjectURL(url), 100) + setTimeout(() => URL.revokeObjectURL(url), 10000) } From 91c7d829b339cf242f8abeb9dc61853e46cc2882 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 05:39:27 +0700 Subject: [PATCH 18/22] fix: address code review findings - Upgrade pocketbase SDK from 0.22.1 to 0.26.8 (latest) to better match server v0.36.5 - Add "use client" directive to pocketbase.ts to enforce client-only boundary at module level - Add re-auth button in data error state so users can return to login without page refresh --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- .../app/(default)/export/ExportPageContent.tsx | 15 ++++++++++++--- frontend/src/lib/pocketbase.ts | 2 ++ 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 790f2a5..ec9f358 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,7 @@ "@types/mapbox-gl": "^3.4.1", "mapbox-gl": "^3.19.0", "next": "^16.1.0", - "pocketbase": "^0.22.0", + "pocketbase": "^0.26.0", "qs": "^6.12.1", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -2834,9 +2834,9 @@ } }, "node_modules/pocketbase": { - "version": "0.22.1", - "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.22.1.tgz", - "integrity": "sha512-hnzpDcIx5kbkUJXz6IkiTox6R4nb706u4rPSMy2FYCaI2OqBnFxbY0qwWBSqx1ERz/Xi61Uguf17Gb9bWZ8+kw==", + "version": "0.26.8", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.8.tgz", + "integrity": "sha512-aQ/ewvS7ncvAE8wxoW10iAZu6ElgbeFpBhKPnCfvRovNzm2gW8u/sQNPGN6vNgVEagz44kK//C61oKjfa+7Low==", "license": "MIT" }, "node_modules/postcss": { diff --git a/frontend/package.json b/frontend/package.json index e55a80a..0d3e7de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,7 @@ "@types/mapbox-gl": "^3.4.1", "mapbox-gl": "^3.19.0", "next": "^16.1.0", - "pocketbase": "^0.22.0", + "pocketbase": "^0.26.0", "qs": "^6.12.1", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/src/app/(default)/export/ExportPageContent.tsx b/frontend/src/app/(default)/export/ExportPageContent.tsx index 292d43b..6dade95 100644 --- a/frontend/src/app/(default)/export/ExportPageContent.tsx +++ b/frontend/src/app/(default)/export/ExportPageContent.tsx @@ -183,14 +183,23 @@ export function ExportPageContent(): ReactElement { } if (dataState.status === "error") { + const handleReset = (): void => { + setAuthState({ status: "idle" }) + setDataState({ status: "idle" }) + } return ( {dataState.message} - + + + + ) } diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index 4acccda..e8bd0ed 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -1,3 +1,5 @@ +"use client" + import PocketBase from "pocketbase" import type { FeatureRecord, SurveyRecord } from "@/types" From bf8194424a719f67d2b4fd2098a847509aa73537 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 05:39:37 +0700 Subject: [PATCH 19/22] move completed plan: feature-export-page.md --- .claude/plans/{ => completed}/feature-export-page.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .claude/plans/{ => completed}/feature-export-page.md (100%) diff --git a/.claude/plans/feature-export-page.md b/.claude/plans/completed/feature-export-page.md similarity index 100% rename from .claude/plans/feature-export-page.md rename to .claude/plans/completed/feature-export-page.md From 7cb7152772f7556a50b81e145109e448bcd5f8b5 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 14:07:54 +0700 Subject: [PATCH 20/22] login page, csv lib, layout updated --- frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/src/app/(admin)/AdminHeader.tsx | 73 ++++++ .../app/(admin)/export/ExportPageContent.tsx | 167 ++++++++++++ .../{(default) => (admin)}/export/page.tsx | 0 frontend/src/app/(admin)/layout.tsx | 14 + .../app/(admin)/login/LoginPageContent.tsx | 83 ++++++ frontend/src/app/(admin)/login/page.tsx | 5 + .../(default)/export/ExportPageContent.tsx | 245 ------------------ frontend/src/lib/csv.test.ts | 82 ++++++ frontend/src/lib/{exportCsv.ts => csv.ts} | 51 ++-- frontend/src/lib/pocketbase.ts | 4 + frontend/src/types/index.ts | 2 - 13 files changed, 476 insertions(+), 262 deletions(-) create mode 100644 frontend/src/app/(admin)/AdminHeader.tsx create mode 100644 frontend/src/app/(admin)/export/ExportPageContent.tsx rename frontend/src/app/{(default) => (admin)}/export/page.tsx (100%) create mode 100644 frontend/src/app/(admin)/layout.tsx create mode 100644 frontend/src/app/(admin)/login/LoginPageContent.tsx create mode 100644 frontend/src/app/(admin)/login/page.tsx delete mode 100644 frontend/src/app/(default)/export/ExportPageContent.tsx create mode 100644 frontend/src/lib/csv.test.ts rename frontend/src/lib/{exportCsv.ts => csv.ts} (70%) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec9f358..79c75cf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "pocketbase": "^0.26.0", "qs": "^6.12.1", "react": "^19.2.0", + "react-data-grid": "^7.0.0-beta.59", "react-dom": "^19.2.0", "react-hook-form": "^7.50.0", "react-map-gl": "^8.1.0", @@ -3036,6 +3037,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-data-grid": { + "version": "7.0.0-beta.59", + "resolved": "https://registry.npmjs.org/react-data-grid/-/react-data-grid-7.0.0-beta.59.tgz", + "integrity": "sha512-iAp/UYWjfmXYFsyKDtGDMP1IvhwtQSjCP6G/wFEbMNuumWGOEZF8Ut1S2Bp4XxVpOrBkEVKXn+QC3rs14AcB7A==", + "license": "MIT", + "peerDependencies": { + "react": "^19.2", + "react-dom": "^19.2" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0d3e7de..2b44537 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "pocketbase": "^0.26.0", "qs": "^6.12.1", "react": "^19.2.0", + "react-data-grid": "^7.0.0-beta.59", "react-dom": "^19.2.0", "react-hook-form": "^7.50.0", "react-map-gl": "^8.1.0", diff --git a/frontend/src/app/(admin)/AdminHeader.tsx b/frontend/src/app/(admin)/AdminHeader.tsx new file mode 100644 index 0000000..1017381 --- /dev/null +++ b/frontend/src/app/(admin)/AdminHeader.tsx @@ -0,0 +1,73 @@ +"use client" + +import { Avatar, Center, Group, Menu, Text } from "@mantine/core" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { useEffect, useState } from "react" +import { getPocketBase, logoutSuperuser } from "@/lib/pocketbase" + +export function AdminHeader() { + const router = useRouter() + const [email, setEmail] = useState(null) + + useEffect(() => { + const pb = getPocketBase() + if (pb.authStore.isValid) { + const model = pb.authStore.record + const userEmail = model?.email as string | undefined + setEmail(userEmail ?? null) + } + }, []) + + const handleLogout = (): void => { + logoutSuperuser() + router.push("/login") + } + + return ( +
+ + + СОСНОВЫЙ{"\u00A0"}БОР + + {email && ( + + + + {email.charAt(0).toUpperCase()} + + + + {email} + + Выйти + + + + )} + +
+ ) +} diff --git a/frontend/src/app/(admin)/export/ExportPageContent.tsx b/frontend/src/app/(admin)/export/ExportPageContent.tsx new file mode 100644 index 0000000..bd4cf67 --- /dev/null +++ b/frontend/src/app/(admin)/export/ExportPageContent.tsx @@ -0,0 +1,167 @@ +"use client" + +import { Alert, Button, Group, Loader, Stack, Text, Title } from "@mantine/core" +import { useRouter } from "next/navigation" +import type { ReactElement } from "react" +import { useCallback, useEffect, useState } from "react" +import { DataGrid } from "react-data-grid" +import "react-data-grid/lib/styles.css" +import type { CsvColumn } from "@/lib/csv" +import { downloadCsv, featuresToCsv, getFeaturesColumns, getSurveysColumns, surveysToCsv } from "@/lib/csv" +import { fetchAllFeatures, fetchAllSurveys, getPocketBase } from "@/lib/pocketbase" +import { surveySchema } from "@/surveySchema" +import type { ExportDataState, FeatureRow, SurveyRecord } from "@/types" + +type CsvPreviewTableProps = { + columns: CsvColumn[] + rows: Record[] + totalCount: number +} + +function CsvPreviewTable({ columns, rows, totalCount }: CsvPreviewTableProps): ReactElement { + const gridColumns = columns.map((col) => ({ key: col.key, name: col.header })) + return ( + + + Всего строк: {totalCount}. Показано: {rows.length} + + + + ) +} + +function featureToRow(f: FeatureRow): Record { + return { + id: f.id, + content: f.content, + lng: f.lng !== null ? String(f.lng) : "", + lat: f.lat !== null ? String(f.lat) : "", + created: f.created, + } +} + +function surveyToRow(survey: SurveyRecord, columns: CsvColumn[]): Record { + const answerMap = new Map() + for (const answer of survey.data) { + answerMap.set(answer.id, Array.isArray(answer.value) ? answer.value.join(";") : String(answer.value)) + } + const row: Record = {} + for (const col of columns) { + if (col.key === "id") { + row[col.key] = survey.id + } else if (col.key === "created") { + row[col.key] = survey.created + } else { + row[col.key] = answerMap.get(col.key) ?? "" + } + } + return row +} + +export function ExportPageContent(): ReactElement { + const router = useRouter() + const [dataState, setDataState] = useState({ status: "loading" }) + + const loadData = useCallback(async (): Promise => { + setDataState({ status: "loading" }) + try { + const [rawFeatures, surveys] = await Promise.all([fetchAllFeatures(), fetchAllSurveys()]) + const features: FeatureRow[] = rawFeatures.map((r) => ({ + id: r.id, + content: r.content, + lng: r.feature?.geometry?.coordinates?.[0] ?? null, + lat: r.feature?.geometry?.coordinates?.[1] ?? null, + created: r.created, + })) + setDataState({ status: "ready", features, surveys }) + } catch (err) { + const message = err instanceof Error ? err.message : "Ошибка загрузки данных" + setDataState({ status: "error", message }) + } + }, []) + + useEffect(() => { + if (!getPocketBase().authStore.isValid) { + router.push("/login") + return + } + void loadData() + }, [router, loadData]) + + if (dataState.status === "loading") { + return ( + + + + ) + } + + if (dataState.status === "error") { + const handleReset = (): void => { + router.push("/login") + } + return ( + + + {dataState.message} + + + + + + + ) + } + + if (dataState.status !== "ready") { + return + } + + const { features, surveys } = dataState + + const featuresColumns = getFeaturesColumns() + const surveysColumns = getSurveysColumns(surveySchema) + + const handleDownloadFeatures = (): void => { + downloadCsv(featuresToCsv(features), "features.csv") + } + + const handleDownloadSurveys = (): void => { + downloadCsv(surveysToCsv(surveys, surveySchema), "surveys.csv") + } + + return ( + + + + Предложения на карте + + + + + + + Опросы + + surveyToRow(s, surveysColumns))} + totalCount={surveys.length} + /> + + + + ) +} diff --git a/frontend/src/app/(default)/export/page.tsx b/frontend/src/app/(admin)/export/page.tsx similarity index 100% rename from frontend/src/app/(default)/export/page.tsx rename to frontend/src/app/(admin)/export/page.tsx diff --git a/frontend/src/app/(admin)/layout.tsx b/frontend/src/app/(admin)/layout.tsx new file mode 100644 index 0000000..977d15f --- /dev/null +++ b/frontend/src/app/(admin)/layout.tsx @@ -0,0 +1,14 @@ +import { Box } from "@mantine/core" +import type { ReactNode } from "react" +import { AdminHeader } from "./AdminHeader" + +export default function AdminLayout({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + ) +} diff --git a/frontend/src/app/(admin)/login/LoginPageContent.tsx b/frontend/src/app/(admin)/login/LoginPageContent.tsx new file mode 100644 index 0000000..57b619e --- /dev/null +++ b/frontend/src/app/(admin)/login/LoginPageContent.tsx @@ -0,0 +1,83 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { Alert, Button, Center, PasswordInput, Stack, TextInput, Title } from "@mantine/core" +import { useRouter } from "next/navigation" +import type { ReactElement } from "react" +import { useEffect, useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { z } from "zod" +import { getPocketBase, loginAsSuperuser } from "@/lib/pocketbase" + +const loginSchema = z.object({ + email: z.string().email("Введите корректный email"), + password: z.string().min(1, "Введите пароль"), +}) + +type LoginFormData = z.infer + +export function LoginPageContent(): ReactElement { + const router = useRouter() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }) + + useEffect(() => { + if (getPocketBase().authStore.isValid) { + router.push("/export") + } + }, [router]) + + const onSubmit = async (data: LoginFormData): Promise => { + setLoading(true) + setError(null) + try { + await loginAsSuperuser(data.email, data.password) + setLoading(false) + router.push("/export") + } catch (err) { + setLoading(false) + setError(err instanceof Error ? err.message : "Ошибка входа") + } + } + + return ( +
+ + Вход для суперпользователя + {error && ( + + {error} + + )} +
+ + ( + + )} + /> + ( + + )} + /> + + +
+
+
+ ) +} diff --git a/frontend/src/app/(admin)/login/page.tsx b/frontend/src/app/(admin)/login/page.tsx new file mode 100644 index 0000000..eb847fd --- /dev/null +++ b/frontend/src/app/(admin)/login/page.tsx @@ -0,0 +1,5 @@ +import { LoginPageContent } from "./LoginPageContent" + +export default function LoginPage() { + return +} diff --git a/frontend/src/app/(default)/export/ExportPageContent.tsx b/frontend/src/app/(default)/export/ExportPageContent.tsx deleted file mode 100644 index 6dade95..0000000 --- a/frontend/src/app/(default)/export/ExportPageContent.tsx +++ /dev/null @@ -1,245 +0,0 @@ -"use client" - -import { zodResolver } from "@hookform/resolvers/zod" -import { Alert, Button, Group, Loader, PasswordInput, Stack, Table, Text, TextInput, Title } from "@mantine/core" -import type { ReactElement } from "react" -import { useState } from "react" -import { Controller, useForm } from "react-hook-form" -import { z } from "zod" -import { downloadCsv, featuresToCsv, surveysToCsv } from "@/lib/exportCsv" -import { fetchAllFeatures, fetchAllSurveys, loginAsSuperuser } from "@/lib/pocketbase" -import { surveySchema } from "@/surveySchema" -import type { ExportAuthState, ExportDataState, FeatureRow, SurveyRecord } from "@/types" - -const loginSchema = z.object({ - email: z.string().email("Введите корректный email"), - password: z.string().min(1, "Введите пароль"), -}) - -type LoginFormData = z.infer - -type LoginFormProps = { - onSuccess: () => void -} - -function LoginForm({ onSuccess }: LoginFormProps): ReactElement { - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const { - control, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(loginSchema), - }) - - const onSubmit = async (data: LoginFormData): Promise => { - setLoading(true) - setError(null) - try { - await loginAsSuperuser(data.email, data.password) - setLoading(false) - onSuccess() - } catch (err) { - setLoading(false) - setError(err instanceof Error ? err.message : "Ошибка входа") - } - } - - return ( - - Вход для суперпользователя - {error && ( - - {error} - - )} -
- - ( - - )} - /> - ( - - )} - /> - - -
-
- ) -} - -type FeaturesTableProps = { - features: FeatureRow[] -} - -function FeaturesTable({ features }: FeaturesTableProps): ReactElement { - const preview = features.slice(0, 10) - return ( - - - - id - content - lng - lat - created - - - - {preview.map((row) => ( - - {row.id} - {row.content} - {row.lng} - {row.lat} - {row.created} - - ))} - -
- ) -} - -type SurveysTableProps = { - surveys: SurveyRecord[] -} - -function SurveysTable({ surveys }: SurveysTableProps): ReactElement { - const preview = surveys.slice(0, 10) - return ( - - - - id - created - Ответов - - - - {preview.map((row) => ( - - {row.id} - {row.created} - {row.data.length} - - ))} - -
- ) -} - -export function ExportPageContent(): ReactElement { - const [authState, setAuthState] = useState({ status: "idle" }) - const [dataState, setDataState] = useState({ status: "idle" }) - - const loadData = async (): Promise => { - setDataState({ status: "loading" }) - try { - const [rawFeatures, surveys] = await Promise.all([fetchAllFeatures(), fetchAllSurveys()]) - const features: FeatureRow[] = rawFeatures.map((r) => ({ - id: r.id, - content: r.content, - lng: r.feature?.geometry?.coordinates?.[0] ?? null, - lat: r.feature?.geometry?.coordinates?.[1] ?? null, - created: r.created, - })) - setDataState({ status: "ready", features, surveys }) - } catch (err) { - const message = err instanceof Error ? err.message : "Ошибка загрузки данных" - setDataState({ status: "error", message }) - } - } - - const handleAuthSuccess = async (): Promise => { - setAuthState({ status: "authenticated" }) - await loadData() - } - - if (authState.status !== "authenticated") { - return ( - - - - ) - } - - if (dataState.status === "loading") { - return ( - - - - ) - } - - if (dataState.status === "error") { - const handleReset = (): void => { - setAuthState({ status: "idle" }) - setDataState({ status: "idle" }) - } - return ( - - - {dataState.message} - - - - - - - ) - } - - if (dataState.status !== "ready") { - return - } - - const { features, surveys } = dataState - - const handleDownloadFeatures = (): void => { - downloadCsv(featuresToCsv(features), "features.csv") - } - - const handleDownloadSurveys = (): void => { - downloadCsv(surveysToCsv(surveys, surveySchema), "surveys.csv") - } - - return ( - - - - Предложения на карте - Всего: {features.length} - - - - - - - Опросы - Всего: {surveys.length} - - - - - - ) -} diff --git a/frontend/src/lib/csv.test.ts b/frontend/src/lib/csv.test.ts new file mode 100644 index 0000000..fdc5313 --- /dev/null +++ b/frontend/src/lib/csv.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest" +import { escapeCell, featuresToCsv } from "./csv" + +describe("escapeCell", () => { + it("plain string passes through unchanged", () => { + expect(escapeCell("hello")).toBe("hello") + }) + + it("empty string passes through unchanged", () => { + expect(escapeCell("")).toBe("") + }) + + it("comma in value wraps in double quotes", () => { + expect(escapeCell("a,b")).toBe('"a,b"') + }) + + it("double quote in value wraps and doubles quotes", () => { + expect(escapeCell('say "hi"')).toBe('"say ""hi"""') + }) + + it("newline in value wraps in double quotes", () => { + expect(escapeCell("a\nb")).toBe('"a\nb"') + }) + + it("carriage return in value wraps in double quotes", () => { + expect(escapeCell("a\rb")).toBe('"a\rb"') + }) + + it("= at start is prefixed to prevent formula injection", () => { + expect(escapeCell("=CMD()")).toBe("'=CMD()") + }) + + it("@ at start is prefixed to prevent formula injection", () => { + expect(escapeCell("@SUM")).toBe("'@SUM") + }) + + it("- at start with non-numeric value is prefixed", () => { + expect(escapeCell("-abc")).toBe("'-abc") + }) + + it("negative number is not prefixed (numeric values are exempt)", () => { + expect(escapeCell("-73.9857")).toBe("-73.9857") + }) + + it("+1 coerces to number and is not prefixed", () => { + expect(escapeCell("+1")).toBe("+1") + }) + + it("zero passes through unchanged", () => { + expect(escapeCell("0")).toBe("0") + }) + + it("leading whitespace before = triggers formula prefix", () => { + expect(escapeCell(" =CMD()")).toBe("' =CMD()") + }) + + it("formula with comma is prefixed then wrapped", () => { + expect(escapeCell("=a,b")).toBe('"\'=a,b"') + }) +}) + +describe("featuresToCsv", () => { + it("produces correct header row", () => { + const csv = featuresToCsv([]) + const header = csv.split("\n")[0] + expect(header).toBe("id,content,lng,lat,created") + }) + + it("null lng and lat become empty strings", () => { + const csv = featuresToCsv([{ id: "abc", content: "test", lng: null, lat: null, created: "2024-01-01" }]) + const dataRow = csv.split("\n")[1] + expect(dataRow).toBe("abc,test,,,2024-01-01") + }) + + it("numeric coordinates are written as-is", () => { + const csv = featuresToCsv([ + { id: "abc", content: "test", lng: 29.076903, lat: 59.896869, created: "2024-01-01" }, + ]) + const dataRow = csv.split("\n")[1] + expect(dataRow).toBe("abc,test,29.076903,59.896869,2024-01-01") + }) +}) diff --git a/frontend/src/lib/exportCsv.ts b/frontend/src/lib/csv.ts similarity index 70% rename from frontend/src/lib/exportCsv.ts rename to frontend/src/lib/csv.ts index a6e33ab..eb0efbf 100644 --- a/frontend/src/lib/exportCsv.ts +++ b/frontend/src/lib/csv.ts @@ -1,6 +1,35 @@ import type { FeatureRow, SurveyRecord, SurveySchemaItem } from "@/types" -function escapeCell(value: string): string { +export type CsvColumn = { key: string; header: string } + +export function getFeaturesColumns(): CsvColumn[] { + return [ + { key: "id", header: "id" }, + { key: "content", header: "content" }, + { key: "lng", header: "lng" }, + { key: "lat", header: "lat" }, + { key: "created", header: "created" }, + ] +} + +export function getSurveysColumns(schema: SurveySchemaItem[]): CsvColumn[] { + const columns: CsvColumn[] = [ + { key: "id", header: "id" }, + { key: "created", header: "created" }, + ] + for (const item of schema) { + if ((item.type === "selectList" || item.type === "sliderList") && item.list) { + item.list.forEach((label, i) => { + columns.push({ key: `${item.id}-${i}`, header: `${item.text}: ${label}` }) + }) + } else { + columns.push({ key: item.id, header: item.text }) + } + } + return columns +} + +export function escapeCell(value: string): string { // Prevent spreadsheet formula injection: prefix dangerous leading characters with a literal quote. // Numeric values are exempt — they cannot execute formulas and must not be corrupted (e.g. coordinates like -73.9). const isNumeric = value !== "" && Number.isFinite(Number(value)) @@ -17,7 +46,8 @@ function buildRow(cells: string[]): string { } export function featuresToCsv(features: FeatureRow[]): string { - const header = buildRow(["id", "content", "lng", "lat", "created"]) + const columns = getFeaturesColumns() + const header = buildRow(columns.map((c) => c.header)) const rows = features.map((f) => buildRow([ f.id, @@ -31,24 +61,15 @@ export function featuresToCsv(features: FeatureRow[]): string { } export function surveysToCsv(surveys: SurveyRecord[], schema: SurveySchemaItem[]): string { - // Build column list; expand selectList questions to one column per list item - const columns: Array<{ key: string; header: string }> = [] - for (const item of schema) { - if ((item.type === "selectList" || item.type === "sliderList") && item.list) { - item.list.forEach((label, i) => { - columns.push({ key: `${item.id}-${i}`, header: `${item.text}: ${label}` }) - }) - } else { - columns.push({ key: item.id, header: item.text }) - } - } - const headerRow = buildRow(["id", "created", ...columns.map((c) => c.header)]) + const allColumns = getSurveysColumns(schema) + const schemaColumns = allColumns.slice(2) // skip id and created + const headerRow = buildRow(allColumns.map((c) => c.header)) const dataRows = surveys.map((survey) => { const answerMap = new Map() for (const answer of survey.data) { answerMap.set(answer.id, Array.isArray(answer.value) ? answer.value.join(";") : String(answer.value)) } - return buildRow([survey.id, survey.created, ...columns.map((col) => answerMap.get(col.key) ?? "")]) + return buildRow([survey.id, survey.created, ...schemaColumns.map((col) => answerMap.get(col.key) ?? "")]) }) return [headerRow, ...dataRows].join("\n") } diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts index e8bd0ed..e229a45 100644 --- a/frontend/src/lib/pocketbase.ts +++ b/frontend/src/lib/pocketbase.ts @@ -17,6 +17,10 @@ export async function loginAsSuperuser(email: string, password: string): Promise await pb.collection("_superusers").authWithPassword(email, password) } +export function logoutSuperuser(): void { + getPocketBase().authStore.clear() +} + export async function fetchAllFeatures(): Promise { const pb = getPocketBase() const records = await pb.collection("features").getFullList({ sort: "-created", filter: "isBanned = false" }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index feb9492..15d613a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -139,8 +139,6 @@ export type FeatureRow = { created: string } -export type ExportAuthState = { status: "idle" } | { status: "authenticated" } - export type ExportDataState = | { status: "idle" } | { status: "loading" } From 506d965328ed3187d40a81d127bc444571ceef86 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 14:25:11 +0700 Subject: [PATCH 21/22] csv lib udpate; admin update --- frontend/src/app/(admin)/AdminProviders.tsx | 8 ++ frontend/src/app/(admin)/adminTheme.ts | 12 ++ .../app/(admin)/export/ExportPageContent.tsx | 3 +- frontend/src/app/(admin)/layout.tsx | 13 +- frontend/src/lib/csv.test.ts | 24 +--- frontend/src/lib/csv.ts | 62 --------- frontend/src/lib/exportData.test.ts | 126 ++++++++++++++++++ frontend/src/lib/exportData.ts | 63 +++++++++ 8 files changed, 220 insertions(+), 91 deletions(-) create mode 100644 frontend/src/app/(admin)/AdminProviders.tsx create mode 100644 frontend/src/app/(admin)/adminTheme.ts create mode 100644 frontend/src/lib/exportData.test.ts create mode 100644 frontend/src/lib/exportData.ts diff --git a/frontend/src/app/(admin)/AdminProviders.tsx b/frontend/src/app/(admin)/AdminProviders.tsx new file mode 100644 index 0000000..947b9ce --- /dev/null +++ b/frontend/src/app/(admin)/AdminProviders.tsx @@ -0,0 +1,8 @@ +"use client" +import { MantineProvider } from "@mantine/core" +import type { ReactNode } from "react" +import { adminTheme } from "./adminTheme" + +export function AdminProviders({ children }: { children: ReactNode }) { + return {children} +} diff --git a/frontend/src/app/(admin)/adminTheme.ts b/frontend/src/app/(admin)/adminTheme.ts new file mode 100644 index 0000000..036d62c --- /dev/null +++ b/frontend/src/app/(admin)/adminTheme.ts @@ -0,0 +1,12 @@ +import { createTheme } from "@mantine/core" +import { createColorTuple } from "@/theme" + +export const adminTheme = createTheme({ + colors: { + primary: createColorTuple("rgb(233 79 43)"), + secondary: createColorTuple("rgb(155 185 98)"), + third: createColorTuple("rgb(247 236 209)"), + dark: createColorTuple("rgb(4,30,73)"), + black: createColorTuple("#1E1928"), + }, +}) diff --git a/frontend/src/app/(admin)/export/ExportPageContent.tsx b/frontend/src/app/(admin)/export/ExportPageContent.tsx index bd4cf67..9edf039 100644 --- a/frontend/src/app/(admin)/export/ExportPageContent.tsx +++ b/frontend/src/app/(admin)/export/ExportPageContent.tsx @@ -7,7 +7,8 @@ import { useCallback, useEffect, useState } from "react" import { DataGrid } from "react-data-grid" import "react-data-grid/lib/styles.css" import type { CsvColumn } from "@/lib/csv" -import { downloadCsv, featuresToCsv, getFeaturesColumns, getSurveysColumns, surveysToCsv } from "@/lib/csv" +import { downloadCsv } from "@/lib/csv" +import { featuresToCsv, getFeaturesColumns, getSurveysColumns, surveysToCsv } from "@/lib/exportData" import { fetchAllFeatures, fetchAllSurveys, getPocketBase } from "@/lib/pocketbase" import { surveySchema } from "@/surveySchema" import type { ExportDataState, FeatureRow, SurveyRecord } from "@/types" diff --git a/frontend/src/app/(admin)/layout.tsx b/frontend/src/app/(admin)/layout.tsx index 977d15f..245d044 100644 --- a/frontend/src/app/(admin)/layout.tsx +++ b/frontend/src/app/(admin)/layout.tsx @@ -1,14 +1,17 @@ import { Box } from "@mantine/core" import type { ReactNode } from "react" import { AdminHeader } from "./AdminHeader" +import { AdminProviders } from "./AdminProviders" export default function AdminLayout({ children }: { children: ReactNode }) { return ( - - - - {children} + + + + + {children} + - + ) } diff --git a/frontend/src/lib/csv.test.ts b/frontend/src/lib/csv.test.ts index fdc5313..1d4590e 100644 --- a/frontend/src/lib/csv.test.ts +++ b/frontend/src/lib/csv.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { escapeCell, featuresToCsv } from "./csv" +import { escapeCell } from "./csv" describe("escapeCell", () => { it("plain string passes through unchanged", () => { @@ -58,25 +58,3 @@ describe("escapeCell", () => { expect(escapeCell("=a,b")).toBe('"\'=a,b"') }) }) - -describe("featuresToCsv", () => { - it("produces correct header row", () => { - const csv = featuresToCsv([]) - const header = csv.split("\n")[0] - expect(header).toBe("id,content,lng,lat,created") - }) - - it("null lng and lat become empty strings", () => { - const csv = featuresToCsv([{ id: "abc", content: "test", lng: null, lat: null, created: "2024-01-01" }]) - const dataRow = csv.split("\n")[1] - expect(dataRow).toBe("abc,test,,,2024-01-01") - }) - - it("numeric coordinates are written as-is", () => { - const csv = featuresToCsv([ - { id: "abc", content: "test", lng: 29.076903, lat: 59.896869, created: "2024-01-01" }, - ]) - const dataRow = csv.split("\n")[1] - expect(dataRow).toBe("abc,test,29.076903,59.896869,2024-01-01") - }) -}) diff --git a/frontend/src/lib/csv.ts b/frontend/src/lib/csv.ts index eb0efbf..5453594 100644 --- a/frontend/src/lib/csv.ts +++ b/frontend/src/lib/csv.ts @@ -1,34 +1,5 @@ -import type { FeatureRow, SurveyRecord, SurveySchemaItem } from "@/types" - export type CsvColumn = { key: string; header: string } -export function getFeaturesColumns(): CsvColumn[] { - return [ - { key: "id", header: "id" }, - { key: "content", header: "content" }, - { key: "lng", header: "lng" }, - { key: "lat", header: "lat" }, - { key: "created", header: "created" }, - ] -} - -export function getSurveysColumns(schema: SurveySchemaItem[]): CsvColumn[] { - const columns: CsvColumn[] = [ - { key: "id", header: "id" }, - { key: "created", header: "created" }, - ] - for (const item of schema) { - if ((item.type === "selectList" || item.type === "sliderList") && item.list) { - item.list.forEach((label, i) => { - columns.push({ key: `${item.id}-${i}`, header: `${item.text}: ${label}` }) - }) - } else { - columns.push({ key: item.id, header: item.text }) - } - } - return columns -} - export function escapeCell(value: string): string { // Prevent spreadsheet formula injection: prefix dangerous leading characters with a literal quote. // Numeric values are exempt — they cannot execute formulas and must not be corrupted (e.g. coordinates like -73.9). @@ -41,39 +12,6 @@ export function escapeCell(value: string): string { return sanitized } -function buildRow(cells: string[]): string { - return cells.map(escapeCell).join(",") -} - -export function featuresToCsv(features: FeatureRow[]): string { - const columns = getFeaturesColumns() - const header = buildRow(columns.map((c) => c.header)) - const rows = features.map((f) => - buildRow([ - f.id, - f.content, - f.lng !== null ? String(f.lng) : "", - f.lat !== null ? String(f.lat) : "", - f.created, - ]), - ) - return [header, ...rows].join("\n") -} - -export function surveysToCsv(surveys: SurveyRecord[], schema: SurveySchemaItem[]): string { - const allColumns = getSurveysColumns(schema) - const schemaColumns = allColumns.slice(2) // skip id and created - const headerRow = buildRow(allColumns.map((c) => c.header)) - const dataRows = surveys.map((survey) => { - const answerMap = new Map() - for (const answer of survey.data) { - answerMap.set(answer.id, Array.isArray(answer.value) ? answer.value.join(";") : String(answer.value)) - } - return buildRow([survey.id, survey.created, ...schemaColumns.map((col) => answerMap.get(col.key) ?? "")]) - }) - return [headerRow, ...dataRows].join("\n") -} - export function downloadCsv(csvContent: string, filename: string): void { const BOM = "\uFEFF" // UTF-8 BOM: required for Cyrillic text in Excel const blob = new Blob([BOM + csvContent], { type: "text/csv;charset=utf-8;" }) diff --git a/frontend/src/lib/exportData.test.ts b/frontend/src/lib/exportData.test.ts new file mode 100644 index 0000000..3c1f217 --- /dev/null +++ b/frontend/src/lib/exportData.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest" +import type { FeatureRow, SurveyRecord, SurveySchemaItem } from "@/types" +import { featuresToCsv, getFeaturesColumns, getSurveysColumns, surveysToCsv } from "./exportData" + +describe("getFeaturesColumns", () => { + it("returns five expected columns", () => { + const columns = getFeaturesColumns() + expect(columns).toHaveLength(5) + expect(columns.map((c) => c.key)).toEqual(["id", "content", "lng", "lat", "created"]) + }) +}) + +describe("featuresToCsv", () => { + it("produces correct header row", () => { + const csv = featuresToCsv([]) + const header = csv.split("\n")[0] + expect(header).toBe("id,content,lng,lat,created") + }) + + it("null lng and lat become empty strings", () => { + const feature: FeatureRow = { id: "abc", content: "test", lng: null, lat: null, created: "2024-01-01" } + const csv = featuresToCsv([feature]) + const dataRow = csv.split("\n")[1] + expect(dataRow).toBe("abc,test,,,2024-01-01") + }) + + it("numeric coordinates are written as-is", () => { + const feature: FeatureRow = { + id: "abc", + content: "test", + lng: 29.076903, + lat: 59.896869, + created: "2024-01-01", + } + const csv = featuresToCsv([feature]) + const dataRow = csv.split("\n")[1] + expect(dataRow).toBe("abc,test,29.076903,59.896869,2024-01-01") + }) +}) + +describe("getSurveysColumns", () => { + it("starts with id and created columns", () => { + const columns = getSurveysColumns([]) + expect(columns).toHaveLength(2) + expect(columns[0]).toEqual({ key: "id", header: "id" }) + expect(columns[1]).toEqual({ key: "created", header: "created" }) + }) + + it("plain question item adds single column with key = item.id", () => { + const schema: SurveySchemaItem[] = [{ id: "q1", type: "text", text: "Your name" }] + const columns = getSurveysColumns(schema) + expect(columns).toHaveLength(3) + expect(columns[2]).toEqual({ key: "q1", header: "Your name" }) + }) + + it("selectList item with list expands to one column per entry", () => { + const schema: SurveySchemaItem[] = [ + { id: "q2", type: "selectList", text: "Choose", list: ["Option A", "Option B"] }, + ] + const columns = getSurveysColumns(schema) + expect(columns).toHaveLength(4) + expect(columns[2]).toEqual({ key: "q2-0", header: "Choose: Option A" }) + expect(columns[3]).toEqual({ key: "q2-1", header: "Choose: Option B" }) + }) + + it("sliderList item with list expands to one column per entry", () => { + const schema: SurveySchemaItem[] = [{ id: "q3", type: "sliderList", text: "Rate", list: ["Low", "High"] }] + const columns = getSurveysColumns(schema) + expect(columns).toHaveLength(4) + expect(columns[2]).toEqual({ key: "q3-0", header: "Rate: Low" }) + expect(columns[3]).toEqual({ key: "q3-1", header: "Rate: High" }) + }) +}) + +describe("surveysToCsv", () => { + const schema: SurveySchemaItem[] = [ + { id: "q1", type: "text", text: "Name" }, + { id: "q2", type: "selectList", text: "Choice", list: ["A", "B"] }, + ] + + it("header row matches column headers from getSurveysColumns", () => { + const csv = surveysToCsv([], schema) + const header = csv.split("\n")[0] + expect(header).toBe("id,created,Name,Choice: A,Choice: B") + }) + + it("answer values mapped by question id into correct column", () => { + const survey: SurveyRecord = { + id: "s1", + created: "2024-01-01", + updated: "2024-01-01", + data: [ + { id: "q1", text: "Name", value: "Alice" }, + { id: "q2-0", text: "Choice: A", value: "3" }, + { id: "q2-1", text: "Choice: B", value: "5" }, + ], + } + const csv = surveysToCsv([survey], schema) + const dataRow = csv.split("\n")[1] + expect(dataRow).toBe("s1,2024-01-01,Alice,3,5") + }) + + it("missing answer for a column becomes empty string", () => { + const survey: SurveyRecord = { + id: "s2", + created: "2024-01-02", + updated: "2024-01-02", + data: [], + } + const csv = surveysToCsv([survey], schema) + const dataRow = csv.split("\n")[1] + expect(dataRow).toBe("s2,2024-01-02,,,") + }) + + it("array answer values joined with semicolon", () => { + const survey: SurveyRecord = { + id: "s3", + created: "2024-01-03", + updated: "2024-01-03", + data: [{ id: "q1", text: "Name", value: ["Alice", "Bob"] }], + } + const csv = surveysToCsv([survey], schema) + const dataRow = csv.split("\n")[1] + expect(dataRow).toBe("s3,2024-01-03,Alice;Bob,,") + }) +}) diff --git a/frontend/src/lib/exportData.ts b/frontend/src/lib/exportData.ts new file mode 100644 index 0000000..5ef5bc3 --- /dev/null +++ b/frontend/src/lib/exportData.ts @@ -0,0 +1,63 @@ +import type { CsvColumn } from "@/lib/csv" +import { escapeCell } from "@/lib/csv" +import type { FeatureRow, SurveyRecord, SurveySchemaItem } from "@/types" + +function buildRow(cells: string[]): string { + return cells.map(escapeCell).join(",") +} + +export function getFeaturesColumns(): CsvColumn[] { + return [ + { key: "id", header: "id" }, + { key: "content", header: "content" }, + { key: "lng", header: "lng" }, + { key: "lat", header: "lat" }, + { key: "created", header: "created" }, + ] +} + +export function getSurveysColumns(schema: SurveySchemaItem[]): CsvColumn[] { + const columns: CsvColumn[] = [ + { key: "id", header: "id" }, + { key: "created", header: "created" }, + ] + for (const item of schema) { + if ((item.type === "selectList" || item.type === "sliderList") && item.list) { + item.list.forEach((label, i) => { + columns.push({ key: `${item.id}-${i}`, header: `${item.text}: ${label}` }) + }) + } else { + columns.push({ key: item.id, header: item.text }) + } + } + return columns +} + +export function featuresToCsv(features: FeatureRow[]): string { + const columns = getFeaturesColumns() + const header = buildRow(columns.map((c) => c.header)) + const rows = features.map((f) => + buildRow([ + f.id, + f.content, + f.lng !== null ? String(f.lng) : "", + f.lat !== null ? String(f.lat) : "", + f.created, + ]), + ) + return [header, ...rows].join("\n") +} + +export function surveysToCsv(surveys: SurveyRecord[], schema: SurveySchemaItem[]): string { + const allColumns = getSurveysColumns(schema) + const schemaColumns = allColumns.slice(2) // skip id and created + const headerRow = buildRow(allColumns.map((c) => c.header)) + const dataRows = surveys.map((survey) => { + const answerMap = new Map() + for (const answer of survey.data) { + answerMap.set(answer.id, Array.isArray(answer.value) ? answer.value.join(";") : String(answer.value)) + } + return buildRow([survey.id, survey.created, ...schemaColumns.map((col) => answerMap.get(col.key) ?? "")]) + }) + return [headerRow, ...dataRows].join("\n") +} From 98d89fd244ac61b9956930b52348c838275f2439 Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Thu, 26 Feb 2026 15:10:08 +0700 Subject: [PATCH 22/22] layout updated --- frontend/src/app/(admin)/AdminProviders.tsx | 8 -------- frontend/src/app/(admin)/adminTheme.ts | 12 ------------ frontend/src/app/(admin)/layout.tsx | 13 +++++-------- frontend/src/app/(default)/layout.tsx | 11 ++++++++++- frontend/src/app/(map)/layout.tsx | 11 ++++++++++- frontend/src/app/providers.tsx | 20 ++++++-------------- frontend/src/app/public-providers.tsx | 18 ++++++++++++++++++ frontend/src/theme.ts | 16 +++++++++------- 8 files changed, 58 insertions(+), 51 deletions(-) delete mode 100644 frontend/src/app/(admin)/AdminProviders.tsx delete mode 100644 frontend/src/app/(admin)/adminTheme.ts create mode 100644 frontend/src/app/public-providers.tsx diff --git a/frontend/src/app/(admin)/AdminProviders.tsx b/frontend/src/app/(admin)/AdminProviders.tsx deleted file mode 100644 index 947b9ce..0000000 --- a/frontend/src/app/(admin)/AdminProviders.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client" -import { MantineProvider } from "@mantine/core" -import type { ReactNode } from "react" -import { adminTheme } from "./adminTheme" - -export function AdminProviders({ children }: { children: ReactNode }) { - return {children} -} diff --git a/frontend/src/app/(admin)/adminTheme.ts b/frontend/src/app/(admin)/adminTheme.ts deleted file mode 100644 index 036d62c..0000000 --- a/frontend/src/app/(admin)/adminTheme.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createTheme } from "@mantine/core" -import { createColorTuple } from "@/theme" - -export const adminTheme = createTheme({ - colors: { - primary: createColorTuple("rgb(233 79 43)"), - secondary: createColorTuple("rgb(155 185 98)"), - third: createColorTuple("rgb(247 236 209)"), - dark: createColorTuple("rgb(4,30,73)"), - black: createColorTuple("#1E1928"), - }, -}) diff --git a/frontend/src/app/(admin)/layout.tsx b/frontend/src/app/(admin)/layout.tsx index 245d044..977d15f 100644 --- a/frontend/src/app/(admin)/layout.tsx +++ b/frontend/src/app/(admin)/layout.tsx @@ -1,17 +1,14 @@ import { Box } from "@mantine/core" import type { ReactNode } from "react" import { AdminHeader } from "./AdminHeader" -import { AdminProviders } from "./AdminProviders" export default function AdminLayout({ children }: { children: ReactNode }) { return ( - - - - - {children} - + + + + {children} - + ) } diff --git a/frontend/src/app/(default)/layout.tsx b/frontend/src/app/(default)/layout.tsx index 00b0531..66175da 100644 --- a/frontend/src/app/(default)/layout.tsx +++ b/frontend/src/app/(default)/layout.tsx @@ -4,12 +4,13 @@ import { AppShell, Box, Button, Center, Drawer, Flex, Group, Stack, Text } from import { useDisclosure, useMediaQuery } from "@mantine/hooks" import Link from "next/link" import type { MouseEvent } from "react" +import { PublicProviders } from "@/app/public-providers" import { Header } from "@/components/Header" import { useOpenSurveyModal } from "@/hooks/useOpenSurveyModal" import { navButtons, scrollToHash } from "@/lib/navigation" import { appShellStyles, mobileMenu } from "@/theme" -export default function DefaultLayout({ children }: { children: React.ReactNode }) { +function DefaultLayoutContent({ children }: { children: React.ReactNode }) { const [mobileOpened, { toggle: toggleMobile }] = useDisclosure() const isMobile = useMediaQuery("(max-width: 768px)") const openSurveyModal = useOpenSurveyModal() @@ -164,3 +165,11 @@ export default function DefaultLayout({ children }: { children: React.ReactNode ) } + +export default function DefaultLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/frontend/src/app/(map)/layout.tsx b/frontend/src/app/(map)/layout.tsx index 73fd95d..74c8638 100644 --- a/frontend/src/app/(map)/layout.tsx +++ b/frontend/src/app/(map)/layout.tsx @@ -4,13 +4,14 @@ import { AppShell, Button, Stack } from "@mantine/core" import { useDisclosure, useMediaQuery } from "@mantine/hooks" import Link from "next/link" import { Suspense, useContext } from "react" +import { PublicProviders } from "@/app/public-providers" import { Header } from "@/components/Header" import { SubmissionFeed } from "@/components/SubmissionFeed" import { NavbarContext } from "@/contexts/navbar" import { useOpenSurveyModal } from "@/hooks/useOpenSurveyModal" import { navButtons } from "@/lib/navigation" -export default function MapLayout({ children }: { children: React.ReactNode }) { +function MapLayoutContent({ children }: { children: React.ReactNode }) { const { drawer, setDrawer } = useContext(NavbarContext) const isMobile = useMediaQuery("(max-width: 768px)", true) const [mobileOpened, { toggle: toggleMobile }] = useDisclosure() @@ -123,3 +124,11 @@ export default function MapLayout({ children }: { children: React.ReactNode }) { ) } + +export default function MapLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 08bdba6..df26e89 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -1,14 +1,13 @@ "use client" -import { MantineProvider } from "@mantine/core" -import { ModalsProvider } from "@mantine/modals" +import { createTheme, MantineProvider } from "@mantine/core" import { MapProvider } from "react-map-gl/mapbox" import { SWRConfig } from "swr" -import { IdeaModal } from "@/components/IdeaModal" -import { SurveyModal } from "@/components/SurveyModal" import { FormContextProvider } from "@/contexts/form" import { NavbarContextProvider } from "@/contexts/navbar" -import { theme } from "@/theme" +import { themeColors } from "@/theme" + +const globalTheme = createTheme({ colors: themeColors }) export function Providers({ children }: { children: React.ReactNode }) { return ( @@ -24,15 +23,8 @@ export function Providers({ children }: { children: React.ReactNode }) { - - - {children} - + + {children} diff --git a/frontend/src/app/public-providers.tsx b/frontend/src/app/public-providers.tsx new file mode 100644 index 0000000..66b04ef --- /dev/null +++ b/frontend/src/app/public-providers.tsx @@ -0,0 +1,18 @@ +"use client" + +import { MantineProvider } from "@mantine/core" +import { ModalsProvider } from "@mantine/modals" +import type { ReactNode } from "react" +import { IdeaModal } from "@/components/IdeaModal" +import { SurveyModal } from "@/components/SurveyModal" +import { theme } from "@/theme" + +export function PublicProviders({ children }: { children: ReactNode }) { + return ( +
+ + {children} + +
+ ) +} diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index 9e8e0ad..b991787 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -23,14 +23,16 @@ export const createColorTuple = (color: string): MantineColorArray => [ color, ] +export const themeColors = { + primary: createColorTuple("rgb(233 79 43)"), + secondary: createColorTuple("rgb(155 185 98)"), + third: createColorTuple("rgb(247 236 209)"), + dark: createColorTuple("rgb(4,30,73)"), + black: createColorTuple("#1E1928"), +} + export const theme = createTheme({ - colors: { - primary: createColorTuple("rgb(233 79 43)"), - secondary: createColorTuple("rgb(155 185 98)"), - third: createColorTuple("rgb(247 236 209)"), - dark: createColorTuple("rgb(4,30,73)"), - black: createColorTuple("#1E1928"), - }, + colors: themeColors, defaultRadius: 0, headings: { fontWeight: "600",