diff --git a/.claude/plans/completed/feature-export-page.md b/.claude/plans/completed/feature-export-page.md new file mode 100644 index 0000000..efac26c --- /dev/null +++ b/.claude/plans/completed/feature-export-page.md @@ -0,0 +1,256 @@ +# 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 +- [x] Add `"pocketbase": "^0.22.0"` to `frontend/package.json` `dependencies` +- [x] 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[] } +``` + +- [x] Append export page types (FeatureRecord, SurveyAnswerItem, SurveyRecord, FeatureRow, ExportAuthState, ExportDataState) to `frontend/src/types/index.ts` + +--- + +### 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` + +- [x] Create `frontend/src/lib/pocketbase.ts` with singleton `getPocketBase()`, `loginAsSuperuser()`, `fetchAllFeatures()`, `fetchAllSurveys()` + +--- + +### 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 `;`. + +- [x] Create `frontend/src/lib/exportCsv.ts` with `featuresToCsv`, `surveysToCsv`, `downloadCsv` + +--- + +### 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 +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` + +- [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. + +**`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 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/package-lock.json b/frontend/package-lock.json index da0889b..79c75cf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,8 +15,10 @@ "@types/mapbox-gl": "^3.4.1", "mapbox-gl": "^3.19.0", "next": "^16.1.0", + "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", @@ -2832,6 +2834,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pocketbase": { + "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": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3029,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 8e6cac7..2b44537 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,8 +26,10 @@ "@types/mapbox-gl": "^3.4.1", "mapbox-gl": "^3.19.0", "next": "^16.1.0", + "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..9edf039 --- /dev/null +++ b/frontend/src/app/(admin)/export/ExportPageContent.tsx @@ -0,0 +1,168 @@ +"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 } 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" + +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/(admin)/export/page.tsx b/frontend/src/app/(admin)/export/page.tsx new file mode 100644 index 0000000..cc65555 --- /dev/null +++ b/frontend/src/app/(admin)/export/page.tsx @@ -0,0 +1,18 @@ +import { Center, Loader } from "@mantine/core" +import type { ReactElement } from "react" +import { Suspense } from "react" +import { ExportPageContent } from "./ExportPageContent" + +export default function ExportPage(): ReactElement { + return ( + + + + } + > + + + ) +} 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)/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/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, diff --git a/frontend/src/lib/csv.test.ts b/frontend/src/lib/csv.test.ts new file mode 100644 index 0000000..1d4590e --- /dev/null +++ b/frontend/src/lib/csv.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest" +import { escapeCell } 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"') + }) +}) diff --git a/frontend/src/lib/csv.ts b/frontend/src/lib/csv.ts new file mode 100644 index 0000000..5453594 --- /dev/null +++ b/frontend/src/lib/csv.ts @@ -0,0 +1,26 @@ +export type CsvColumn = { key: string; header: string } + +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)) + 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 sanitized +} + +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 + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + setTimeout(() => URL.revokeObjectURL(url), 10000) +} 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") +} diff --git a/frontend/src/lib/pocketbase.ts b/frontend/src/lib/pocketbase.ts new file mode 100644 index 0000000..e229a45 --- /dev/null +++ b/frontend/src/lib/pocketbase.ts @@ -0,0 +1,34 @@ +"use client" + +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) +} + +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" }) + 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[] +} 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", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a593055..15d613a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -103,3 +103,44 @@ 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 | null + lat: number | null + created: string +} + +export type ExportDataState = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ready"; features: FeatureRow[]; surveys: SurveyRecord[] } 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 \