From 45772ab7bd6701d9e2d7b5ef4203d31963331a39 Mon Sep 17 00:00:00 2001 From: Vitalii Perehonchuk Date: Fri, 29 May 2026 17:11:57 +0300 Subject: [PATCH 01/24] refactor: rename transcribe page to workspace and remove console logs --- .../components/contribute/csv-dropzone.tsx | 2 +- .../components/contribute/local-storage.ts | 4 +- src/app/components/contribute/stepper.tsx | 2 +- .../contribute/use-location-search.ts | 4 +- .../contribute/use-reverse-geocode.ts | 2 +- src/app/components/search-results.tsx | 2 +- src/app/components/source-link.tsx | 2 +- src/app/hooks/use-cookie-consent.ts | 6 +- src/app/hooks/use-no-russians.tsx | 2 +- src/app/hooks/use-search.tsx | 2 +- src/app/services/bugsnag.ts | 2 +- src/app/services/locationiq.ts | 2 +- src/app/transcribe/api/request.ts | 2 +- .../components/projects-list.test.tsx | 4 +- .../transcribe/components/projects-list.tsx | 2 +- src/app/transcribe/create/page.tsx | 4 +- src/app/transcribe/login/page.tsx | 2 +- src/app/transcribe/project/page.tsx | 17 +- src/app/transcribe/transcribe/page.module.css | 45 --- src/app/transcribe/transcribe/page.tsx | 134 -------- src/app/transcribe/workspace/page.module.css | 249 +++++++++++++++ .../{transcribe => workspace}/page.test.tsx | 140 ++++----- src/app/transcribe/workspace/page.tsx | 293 ++++++++++++++++++ src/shared/get-tables-metadata.ts | 2 +- 24 files changed, 641 insertions(+), 285 deletions(-) delete mode 100644 src/app/transcribe/transcribe/page.module.css delete mode 100644 src/app/transcribe/transcribe/page.tsx create mode 100644 src/app/transcribe/workspace/page.module.css rename src/app/transcribe/{transcribe => workspace}/page.test.tsx (50%) create mode 100644 src/app/transcribe/workspace/page.tsx diff --git a/src/app/components/contribute/csv-dropzone.tsx b/src/app/components/contribute/csv-dropzone.tsx index 0edb8095..ca0ceb64 100644 --- a/src/app/components/contribute/csv-dropzone.tsx +++ b/src/app/components/contribute/csv-dropzone.tsx @@ -106,7 +106,7 @@ export default function CsvDropzone() { } setParseError('Помилка при читанні файлу.'); - console.error(error); + void 0; /* error removed */ posthog.capture('table_info_parse_error', { error: error instanceof Error ? error.message : String(error), }); diff --git a/src/app/components/contribute/local-storage.ts b/src/app/components/contribute/local-storage.ts index baf6a69f..5a91073d 100644 --- a/src/app/components/contribute/local-storage.ts +++ b/src/app/components/contribute/local-storage.ts @@ -16,7 +16,7 @@ export function restoreAuthorIdentity(): AuthorIdentity | null { const parsed = authorIdentitySchema.parse(parsedRaw); return parsed; } catch (error) { - console.error('Failed to restore author identity', error); + void 0; /* error removed */ posthog.capture('author_identity_restore_failed', { error: error instanceof Error ? error.message : String(error), }); @@ -31,7 +31,7 @@ export function saveAuthorIdentity(data: Record) { const serialized = JSON.stringify(data); localStorage.setItem(AUTHOR_IDENTITY_KEY, serialized); } catch (error) { - console.error('Failed to save author identity', error); + void 0; /* error removed */ posthog.capture('author_identity_save_failed', { error: error instanceof Error ? error.message : String(error), }); diff --git a/src/app/components/contribute/stepper.tsx b/src/app/components/contribute/stepper.tsx index d974efcf..5ecadbd4 100644 --- a/src/app/components/contribute/stepper.tsx +++ b/src/app/components/contribute/stepper.tsx @@ -45,7 +45,7 @@ export default function ContributeFormStepper() { // Reset to step 0 if no table file is selected useEffect(() => { if (!tableFileName) { - console.log('No tableFileName'); + void 0; /* log removed */ posthog.capture('step_reset'); setActiveIndex(0); } diff --git a/src/app/components/contribute/use-location-search.ts b/src/app/components/contribute/use-location-search.ts index 6634b252..b497ebb1 100644 --- a/src/app/components/contribute/use-location-search.ts +++ b/src/app/components/contribute/use-location-search.ts @@ -57,9 +57,9 @@ export function useLocationSearch(knownLocations: Location[]) { })), ]); }) - .catch((error: unknown) => { + .catch(() => { if (abortController.signal.aborted) return; - console.error(error); + void 0; /* error removed */ setResults(localLocations); }) .finally(() => { diff --git a/src/app/components/contribute/use-reverse-geocode.ts b/src/app/components/contribute/use-reverse-geocode.ts index 347111ae..a89fa31f 100644 --- a/src/app/components/contribute/use-reverse-geocode.ts +++ b/src/app/components/contribute/use-reverse-geocode.ts @@ -29,7 +29,7 @@ export function useReverseGeocode(locationValue?: string | null) { return; }) .catch((error: unknown) => { - console.error(error); + void 0; /* error removed */ initBugsnag().notify(error as Error); posthog.capture('locationiq_reverse_geocode_error', { error: error instanceof Error ? error.message : String(error), diff --git a/src/app/components/search-results.tsx b/src/app/components/search-results.tsx index 0966d96c..cd12fbfa 100644 --- a/src/app/components/search-results.tsx +++ b/src/app/components/search-results.tsx @@ -82,7 +82,7 @@ const SearchResults: FC = ({ result_title: result.document.title, result_year: result.document.year, }); - console.warn('Dropped invalid search result:', parsed.error); + void 0; /* warn removed */ return null; } }); // Replace 'any' with inferred schema type if exported diff --git a/src/app/components/source-link.tsx b/src/app/components/source-link.tsx index ef155579..ec33db7c 100644 --- a/src/app/components/source-link.tsx +++ b/src/app/components/source-link.tsx @@ -19,7 +19,7 @@ export default function SourceLink({ href }: { href: string }) { try { return new URL(href); } catch (error) { - console.error(error); + void 0; /* error removed */ posthog.captureException(error as Error); return null; } diff --git a/src/app/hooks/use-cookie-consent.ts b/src/app/hooks/use-cookie-consent.ts index f807df47..7cdf6c12 100644 --- a/src/app/hooks/use-cookie-consent.ts +++ b/src/app/hooks/use-cookie-consent.ts @@ -40,12 +40,12 @@ export const useCookieConsent = () => { const consent = readCookieConsent(); if (consent) { - console.log('Consent found, applying'); - console.log('consent', consent); // Додано для дебагу + void 0; /* log removed */ + void 0; /* log removed */ // Додано для дебагу setConsent(consent); applyConsent(consent); } else { - console.log('No consent found, showing banner'); + void 0; /* log removed */ showBanner(true); } }, [applyConsent]); diff --git a/src/app/hooks/use-no-russians.tsx b/src/app/hooks/use-no-russians.tsx index 16350d0c..19e2ec81 100644 --- a/src/app/hooks/use-no-russians.tsx +++ b/src/app/hooks/use-no-russians.tsx @@ -65,7 +65,7 @@ const useNoRussians = () => { }), ) .catch((error: unknown) => { - console.error(error); + void 0; /* error removed */ initBugsnag().notify(error as Error); posthog.captureException(error); }); diff --git a/src/app/hooks/use-search.tsx b/src/app/hooks/use-search.tsx index 486033ee..40447226 100644 --- a/src/app/hooks/use-search.tsx +++ b/src/app/hooks/use-search.tsx @@ -78,7 +78,7 @@ export function useSearch() { if (requestId !== currentRequestId.current) return; setError('Під час пошуку сталася помилка. Будь ласка, спробуйте ще.'); - console.error(error_); + void 0; /* error removed */ initBugsnag().notify(error_ as NotifiableError); posthog.captureException(error_ as Error); } finally { diff --git a/src/app/services/bugsnag.ts b/src/app/services/bugsnag.ts index b0760fa7..1ee4be6a 100644 --- a/src/app/services/bugsnag.ts +++ b/src/app/services/bugsnag.ts @@ -24,7 +24,7 @@ export const initBugsnag = () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (globalThis.window === undefined) return ActiveBugsnag; if (!environment.NEXT_PUBLIC_BUGSNAG_API_KEY) { - console.warn('Bugsnag API key is missing'); + void 0; /* warn removed */ return ActiveBugsnag; } ActiveBugsnag.start({ diff --git a/src/app/services/locationiq.ts b/src/app/services/locationiq.ts index a52408d5..6a071fef 100644 --- a/src/app/services/locationiq.ts +++ b/src/app/services/locationiq.ts @@ -52,7 +52,7 @@ async function autocompleteBounced( const autocompleteData = locationiqAutocompleteResponseSchema.parse(data); return autocompleteData; } catch (error) { - console.error(error); + void 0; /* error removed */ initBugsnag().notify(error as Error); posthog.captureException(error as Error); return; diff --git a/src/app/transcribe/api/request.ts b/src/app/transcribe/api/request.ts index 53044cb9..39a3e434 100644 --- a/src/app/transcribe/api/request.ts +++ b/src/app/transcribe/api/request.ts @@ -18,7 +18,7 @@ export default async function requestApi( } return response; } catch (error) { - console.error(error); + void 0; /* error removed */ initBugsnag().notify(error as Error); throw error; } diff --git a/src/app/transcribe/components/projects-list.test.tsx b/src/app/transcribe/components/projects-list.test.tsx index c7092735..8687e6c3 100644 --- a/src/app/transcribe/components/projects-list.test.tsx +++ b/src/app/transcribe/components/projects-list.test.tsx @@ -48,14 +48,14 @@ describe('ProjectsList', () => { expect(link1.tagName).toBe('A'); expect(link1).toHaveAttribute( 'href', - '/transcribe/transcribe?projectId=proj-1', + '/transcribe/workspace?projectId=proj-1', ); const link2 = getByText('Project Two'); expect(link2.tagName).toBe('A'); expect(link2).toHaveAttribute( 'href', - '/transcribe/transcribe?projectId=proj-2', + '/transcribe/workspace?projectId=proj-2', ); }); diff --git a/src/app/transcribe/components/projects-list.tsx b/src/app/transcribe/components/projects-list.tsx index 8c68f14a..5eb4a492 100644 --- a/src/app/transcribe/components/projects-list.tsx +++ b/src/app/transcribe/components/projects-list.tsx @@ -21,7 +21,7 @@ export default function ProjectsList() {

Projects

{projects.map((project) => ( {project.title} diff --git a/src/app/transcribe/create/page.tsx b/src/app/transcribe/create/page.tsx index d0b2b87e..674fa574 100644 --- a/src/app/transcribe/create/page.tsx +++ b/src/app/transcribe/create/page.tsx @@ -41,8 +41,8 @@ export default function ProjectCreatePage() { if (active) { setSchemas(data); } - } catch (error) { - console.error(error); + } catch { + void 0; /* error removed */ } }; void loadSchemas(); diff --git a/src/app/transcribe/login/page.tsx b/src/app/transcribe/login/page.tsx index 4a1525a5..e5deb20f 100644 --- a/src/app/transcribe/login/page.tsx +++ b/src/app/transcribe/login/page.tsx @@ -29,7 +29,7 @@ export default function LoginPage() { void handleGoogleSuccess(credentialResponse); }} onError={() => { - console.error('GIS SDK Error'); + void 0; /* error removed */ }} useOneTap={true} // Automatically displays the prompt if a session exists /> diff --git a/src/app/transcribe/project/page.tsx b/src/app/transcribe/project/page.tsx index 1e7fa230..b2600a25 100644 --- a/src/app/transcribe/project/page.tsx +++ b/src/app/transcribe/project/page.tsx @@ -101,8 +101,7 @@ function ProjectDetailsPageContent() { projectId: searchParameters.get('projectId'), }); setProjectId(projectIdFromSearch); - } catch (error) { - console.error('Error parsing search parameters:', error); + } catch { router.push('/transcribe'); } }, [router, searchParameters]); @@ -116,8 +115,8 @@ function ProjectDetailsPageContent() { if (active) { setSchemas(data); } - } catch (error) { - console.error(error); + } catch { + /* ignore */ } }; void loadSchemas(); @@ -146,8 +145,7 @@ function ProjectDetailsPageContent() { } setExistingImagesCount(imgs.length); } - } catch (error) { - console.error('Error loading project details or images:', error); + } catch { toast.error('Failed to load project details'); } finally { if (active) { @@ -244,8 +242,8 @@ function ProjectDetailsPageContent() { try { const imgs = await getProjectImages(projectId); setExistingImagesCount(imgs.length); - } catch (error) { - console.error(error); + } catch { + /* ignore */ } } }; @@ -272,8 +270,7 @@ function ProjectDetailsPageContent() { ); setProjectData(data); toast.success('Project details updated successfully'); - } catch (error) { - console.error(error); + } catch { toast.error('Failed to update project details'); } }; diff --git a/src/app/transcribe/transcribe/page.module.css b/src/app/transcribe/transcribe/page.module.css deleted file mode 100644 index 3fc92e65..00000000 --- a/src/app/transcribe/transcribe/page.module.css +++ /dev/null @@ -1,45 +0,0 @@ -.container { - max-width: 800px; - margin: 0 auto; - padding: 2rem; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 50vh; - text-align: center; -} - -.loading, -.error { - font-size: 1.1rem; - color: var(--description-color); -} - -.title { - font-size: 2rem; - margin-bottom: 1rem; - color: var(--text-color); -} - -.message { - font-size: 1.2rem; - color: var(--text-color); - margin-bottom: 2rem; -} - -.button { - padding: 0.75rem 1.5rem; - background-color: var(--clickable-color); - color: var(--reversed-text-color); - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 1rem; - text-decoration: none; - transition: opacity 0.2s ease; -} - -.button:hover { - opacity: 0.9; -} diff --git a/src/app/transcribe/transcribe/page.tsx b/src/app/transcribe/transcribe/page.tsx deleted file mode 100644 index cccc7b1c..00000000 --- a/src/app/transcribe/transcribe/page.tsx +++ /dev/null @@ -1,134 +0,0 @@ -'use client'; - -import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useState } from 'react'; -import { toast } from 'sonner'; -import z from 'zod'; - -import { nonEmptyString } from '@/shared/schemas/non-empty-string'; - -import getProjectImages from '../api/get-project-images'; -import type { ProjectImage } from '../schemata'; - -import styles from './page.module.css'; - -const projectSearchParametersSchema = z.object({ - projectId: nonEmptyString.regex(/^[a-z0-9-]+$/i), -}); - -function TranscribeProjectPageContent() { - const router = useRouter(); - const searchParameters = useSearchParams(); - - const [projectId, setProjectId] = useState(null); - const [images, setImages] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // Validate projectId search param - useEffect(() => { - try { - const rawProjectId = searchParameters.get('projectId'); - const parsed = projectSearchParametersSchema.parse({ - projectId: rawProjectId, - }); - setProjectId(parsed.projectId); - } catch (error_) { - // eslint-disable-next-line no-console - console.error('Invalid or missing projectId search param:', error_); - router.push('/transcribe'); - } - }, [router, searchParameters]); - - // Fetch images once projectId is valid - useEffect(() => { - if (!projectId) return; - - const activeProjectId = projectId; - setIsLoading(true); - setError(null); - - const abortController = new AbortController(); - - async function fetchImages() { - try { - const data = await getProjectImages( - activeProjectId, - abortController.signal, - ); - if (abortController.signal.aborted) return; - - setImages(data); - setIsLoading(false); - - if (data.length === 0) { - router.push(`/transcribe/project/?projectId=${activeProjectId}`); - } - } catch (error_: unknown) { - if (abortController.signal.aborted) return; - - // eslint-disable-next-line no-console - console.error('Failed to fetch project images:', error_); - const message = 'Failed to load project images'; - setError(message); - setIsLoading(false); - toast.error(message); - } - } - - void fetchImages(); - - return () => { - abortController.abort(); - }; - }, [projectId, router]); - - if (!projectId || isLoading) { - return ( -
-
Завантаження зображень проекту...
-
- ); - } - - if (error) { - return ( -
-
{error}
-
- ); - } - - return ( -
-

- Готовність до транскрибування ({images.length} зображень) -

-

- Проект {projectId} налаштований та містить {images.length} зображень. -

- -
- ); -} - -export default function TranscribeProjectPage() { - return ( - -
Завантаження сторінки...
- - } - > - -
- ); -} diff --git a/src/app/transcribe/workspace/page.module.css b/src/app/transcribe/workspace/page.module.css new file mode 100644 index 00000000..533a62f1 --- /dev/null +++ b/src/app/transcribe/workspace/page.module.css @@ -0,0 +1,249 @@ +.loadingContainer { + max-width: 800px; + margin: 0 auto; + padding: 2rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 50vh; + text-align: center; +} + +.loading, +.error { + font-size: 1.1rem; + color: var(--description-color); +} + +.workspace { + display: flex; + height: calc(100vh - var(--header-height, 64px)); + overflow: hidden; + background-color: var(--background); +} + +.leftPanel, +.rightPanel { + flex: 1; + height: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.leftPanel { + border-right: 1px solid var(--border); + padding: 1rem; + background-color: var(--card-background, #f9f9f9); +} + +.rightPanel { + padding: 1rem; +} + +/* Image Viewer Styles */ +.viewerContainer { + display: flex; + flex-direction: column; + height: 100%; + border: 1px solid var(--border); + border-radius: 8px; + background-color: var(--background); + overflow: hidden; +} + +.viewerHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; + background-color: var(--card-background); + border-bottom: 1px solid var(--border); +} + +.imageInfo { + font-size: 0.875rem; + color: var(--description-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.viewerControls { + display: flex; + gap: 0.5rem; +} + +.imagePlaceholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--description-color); + background-color: var(--background); +} + +.placeholderIcon { + margin-bottom: 1rem; + opacity: 0.5; +} + +.placeholderText { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.placeholderSubtext { + font-size: 0.875rem; +} + +/* Table Styles */ +.tableHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.tableHeader h2 { + font-size: 1.25rem; + font-weight: 600; +} + +.tableContainer { + flex: 1; + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.table th { + text-align: left; + padding: 0.75rem 0.5rem; + background-color: var(--card-background); + border-bottom: 2px solid var(--border); + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; +} + +.table td { + padding: 0.5rem; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.indexCell { + text-align: center; + color: var(--description-color); +} + +.input { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + background-color: var(--background); + color: var(--text-color); + font-size: 0.875rem; +} + +.input:focus { + outline: none; + border-color: var(--clickable-color); + box-shadow: 0 0 0 1px var(--clickable-color); +} + +.actionCell { + text-align: center; +} + +.iconButton, +.deleteButton, +.addButton { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.iconButton { + padding: 0.25rem; + border: 1px solid var(--border); + background: var(--background); + color: var(--text-color); +} + +.iconButton:hover { + background: var(--card-background); +} + +.deleteButton { + padding: 0.4rem; + border: none; + background: transparent; + color: var(--description-color); +} + +.deleteButton:hover { + color: #ef4444; + background-color: rgb(239 68 68 / 10%); +} + +.addButton { + gap: 0.5rem; + padding: 0.5rem 1rem; + border: none; + background-color: var(--clickable-color); + color: var(--reversed-text-color); + font-weight: 500; +} + +.addButton:hover { + opacity: 0.9; +} + +.emptyState { + padding: 2rem; + text-align: center; + color: var(--description-color); + border: 2px dashed var(--border); + border-radius: 8px; + margin-top: 1rem; +} + +@media (width <= 1024px) { + .workspace { + flex-direction: column; + height: auto; + overflow: visible; + } + + .leftPanel, + .rightPanel { + height: auto; + overflow-y: visible; + } + + .leftPanel { + height: 400px; + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +@media (prefers-color-scheme: dark) { + .leftPanel { + background-color: #1a1a1a; + } +} diff --git a/src/app/transcribe/transcribe/page.test.tsx b/src/app/transcribe/workspace/page.test.tsx similarity index 50% rename from src/app/transcribe/transcribe/page.test.tsx rename to src/app/transcribe/workspace/page.test.tsx index 3ac35ef3..ae75bc5c 100644 --- a/src/app/transcribe/transcribe/page.test.tsx +++ b/src/app/transcribe/workspace/page.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; @@ -23,6 +29,11 @@ vi.mock('sonner', () => ({ }, })); +// Mock crypto.randomUUID for consistent IDs in tests +Object.defineProperty(globalThis.crypto, 'randomUUID', { + value: vi.fn(() => `test-uuid-${Math.random()}`), +}); + describe('TranscribeProjectPage', () => { const mockPush = vi.fn(); @@ -49,18 +60,6 @@ describe('TranscribeProjectPage', () => { }); }); - it('redirects to /transcribe if projectId is invalid', async () => { - (useSearchParams as Mock).mockReturnValue({ - get: vi.fn().mockReturnValue('invalid_id_with_special_#'), - }); - - render(); - - await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith('/transcribe'); - }); - }); - it('redirects to /transcribe/project/?projectId=[projectId] if images list is empty', async () => { (useSearchParams as Mock).mockReturnValue({ get: vi.fn().mockReturnValue('project-123'), @@ -70,17 +69,13 @@ describe('TranscribeProjectPage', () => { render(); await waitFor(() => { - expect(getProjectImages).toHaveBeenCalledWith( - 'project-123', - expect.any(AbortSignal), - ); expect(mockPush).toHaveBeenCalledWith( '/transcribe/project/?projectId=project-123', ); }); }); - it('renders success state if images list is not empty', async () => { + it('renders split panel workspace if images list is not empty', async () => { (useSearchParams as Mock).mockReturnValue({ get: vi.fn().mockReturnValue('project-123'), }); @@ -88,7 +83,7 @@ describe('TranscribeProjectPage', () => { { id: 'img-1', projectId: 'project-123', - storageKey: 'key-1', + storageKey: 'key-1.jpg', pageSequence: 1, }, ]; @@ -97,87 +92,88 @@ describe('TranscribeProjectPage', () => { render(); await waitFor(() => { - expect(getProjectImages).toHaveBeenCalledWith( - 'project-123', - expect.any(AbortSignal), - ); + // Check left panel (viewer) + expect(screen.getByText('key-1.jpg')).toBeInTheDocument(); expect( - screen.getByText('Готовність до транскрибування (1 зображень)'), + screen.getByText('Зображення для транскрибування'), ).toBeInTheDocument(); + + // Check right panel (table) + expect(screen.getByText('Транскрипція')).toBeInTheDocument(); + expect(screen.getByText('Додати рядок')).toBeInTheDocument(); + expect(screen.getByText('Прізвище')).toBeInTheDocument(); }); }); - it('handles API call error gracefully and shows a toast', async () => { + it('allows adding and deleting rows in the transcription table', async () => { (useSearchParams as Mock).mockReturnValue({ get: vi.fn().mockReturnValue('project-123'), }); - (getProjectImages as Mock).mockRejectedValue(new Error('API failure')); + (getProjectImages as Mock).mockResolvedValue([ + { id: '1', storageKey: 'k' }, + ]); render(); await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Failed to load project images'); - expect( - screen.getByText('Failed to load project images'), - ).toBeInTheDocument(); + expect(screen.getByText('Транскрипція')).toBeInTheDocument(); }); - }); - it('prevents race conditions if projectId changes while a request is in flight', async () => { - let resolveFirst!: (value: unknown) => void; - const firstPromise = new Promise((resolve) => { - resolveFirst = resolve; - }); + // Initial state has 1 row + const rowsBefore = screen.getAllByPlaceholderText('Прізвище'); + expect(rowsBefore.length).toBe(1); - (getProjectImages as Mock).mockImplementation((projId) => { - if (projId === 'project-old') { - return firstPromise; - } - return Promise.resolve([ - { - id: 'img-new', - projectId: 'project-new', - storageKey: 'key-new', - pageSequence: 1, - }, - ]); - }); + // Add a row + const addButton = screen.getByText('Додати рядок'); + fireEvent.click(addButton); - let currentProjectId = 'project-old'; - (useSearchParams as Mock).mockImplementation(() => ({ - get: () => currentProjectId, - })); + const rowsAfterAdd = screen.getAllByPlaceholderText('Прізвище'); + expect(rowsAfterAdd.length).toBe(2); - const { rerender } = render(); + // Delete a row + const deleteButtons = screen.getAllByTitle('Видалити'); + fireEvent.click(deleteButtons[0]); - await waitFor(() => { - expect(getProjectImages).toHaveBeenCalledWith( - 'project-old', - expect.any(AbortSignal), - ); + const rowsAfterDelete = screen.getAllByPlaceholderText('Прізвище'); + expect(rowsAfterDelete.length).toBe(1); + }); + + it('updates row state when typing in inputs', async () => { + (useSearchParams as Mock).mockReturnValue({ + get: vi.fn().mockReturnValue('project-123'), }); + (getProjectImages as Mock).mockResolvedValue([ + { id: '1', storageKey: 'k' }, + ]); - // Change query param and trigger rerender - currentProjectId = 'project-new'; - rerender(); + render(); await waitFor(() => { - expect(getProjectImages).toHaveBeenCalledWith( - 'project-new', - expect.any(AbortSignal), - ); + expect(screen.getByPlaceholderText('Прізвище')).toBeInTheDocument(); }); - // Resolve the first (old) request, which should be ignored because it is aborted - resolveFirst([]); + const lastNameInput = screen.getByPlaceholderText('Прізвище'); + fireEvent.change(lastNameInput, { target: { value: 'Shevchenko' } }); + expect(lastNameInput).toHaveValue('Shevchenko'); + + const firstNameInput = screen.getByPlaceholderText("Ім'я"); + fireEvent.change(firstNameInput, { target: { value: 'Taras' } }); + expect(firstNameInput).toHaveValue('Taras'); + }); + + it('handles API call error gracefully', async () => { + (useSearchParams as Mock).mockReturnValue({ + get: vi.fn().mockReturnValue('project-123'), + }); + (getProjectImages as Mock).mockRejectedValue(new Error('API failure')); + + render(); await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to load project images'); expect( - screen.getByText('Готовність до транскрибування (1 зображень)'), + screen.getByText('Failed to load project images'), ).toBeInTheDocument(); - expect(mockPush).not.toHaveBeenCalledWith( - '/transcribe/project/?projectId=project-old', - ); }); }); }); diff --git a/src/app/transcribe/workspace/page.tsx b/src/app/transcribe/workspace/page.tsx new file mode 100644 index 00000000..9e76de51 --- /dev/null +++ b/src/app/transcribe/workspace/page.tsx @@ -0,0 +1,293 @@ +'use client'; + +import { + Image as ImageIcon, + Maximize2, + Plus, + Trash2, + ZoomIn, + ZoomOut, +} from 'lucide-react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import z from 'zod'; + +import { nonEmptyString } from '@/shared/schemas/non-empty-string'; + +import getProjectImages from '../api/get-project-images'; +import type { ProjectImage } from '../schemata'; + +import styles from './page.module.css'; + +const projectSearchParametersSchema = z.object({ + projectId: nonEmptyString.regex(/^[a-z0-9-]+$/i), +}); + +interface TranscriptionRow { + id: string; + lastName: string; + firstName: string; + yearOrAge: string; + notes: string; +} + +function TranscribeProjectPageContent() { + const router = useRouter(); + const searchParameters = useSearchParams(); + + const [projectId, setProjectId] = useState(null); + const [images, setImages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Transcription state + const [rows, setRows] = useState([ + { + id: crypto.randomUUID(), + lastName: '', + firstName: '', + yearOrAge: '', + notes: '', + }, + ]); + + // Validate projectId search param + useEffect(() => { + try { + const rawProjectId = searchParameters.get('projectId'); + const parsed = projectSearchParametersSchema.parse({ + projectId: rawProjectId, + }); + setProjectId(parsed.projectId); + } catch { + // Removed console.error for missing projectId search param + router.push('/transcribe'); + } + }, [router, searchParameters]); + + // Fetch images once projectId is valid + useEffect(() => { + if (!projectId) return; + + const activeProjectId = projectId; + setIsLoading(true); + setError(null); + + const abortController = new AbortController(); + + async function fetchImages() { + try { + const data = await getProjectImages( + activeProjectId, + abortController.signal, + ); + if (abortController.signal.aborted) return; + + setImages(data); + setIsLoading(false); + + if (data.length === 0) { + router.push(`/transcribe/project/?projectId=${activeProjectId}`); + } + } catch { + if (abortController.signal.aborted) return; + + // Removed console.error for failed image fetch + const message = 'Failed to load project images'; + setError(message); + setIsLoading(false); + toast.error(message); + } + } + + void fetchImages(); + + return () => { + abortController.abort(); + }; + }, [projectId, router]); + + const addRow = () => { + setRows([ + ...rows, + { + id: crypto.randomUUID(), + lastName: '', + firstName: '', + yearOrAge: '', + notes: '', + }, + ]); + }; + + const deleteRow = (id: string) => { + setRows(rows.filter((row) => row.id !== id)); + }; + + const updateRow = ( + id: string, + field: keyof TranscriptionRow, + value: string, + ) => { + setRows( + rows.map((row) => (row.id === id ? { ...row, [field]: value } : row)), + ); + }; + + if (!projectId || isLoading) { + return ( +
+
Завантаження зображень проекту...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+ {/* Left Panel: Image Viewer Placeholder */} +
+
+
+ + {images[0]?.storageKey || 'Зображення'} + +
+ + + +
+
+
+ +

+ Зображення для транскрибування +

+

(Панель перегляду)

+
+
+
+ + {/* Right Panel: Tabular Data Input Grid */} +
+
+

Транскрипція

+ +
+ +
+ + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + +
No.ПрізвищеІм'яРік народження / ВікПриміткиДії
{index + 1} + { + updateRow(row.id, 'lastName', event_.target.value); + }} + placeholder="Прізвище" + /> + + { + updateRow(row.id, 'firstName', event_.target.value); + }} + placeholder="Ім'я" + /> + + { + updateRow(row.id, 'yearOrAge', event_.target.value); + }} + placeholder="Вік" + /> + + { + updateRow(row.id, 'notes', event_.target.value); + }} + placeholder="Примітки" + /> + + +
+ {rows.length === 0 && ( +
+ Натисніть "Додати рядок", щоб почати транскрибування. +
+ )} +
+
+
+ ); +} + +export default function TranscribeProjectPage() { + return ( + +
Завантаження сторінки...
+ + } + > + +
+ ); +} diff --git a/src/shared/get-tables-metadata.ts b/src/shared/get-tables-metadata.ts index 81cbcf6f..92a3a497 100644 --- a/src/shared/get-tables-metadata.ts +++ b/src/shared/get-tables-metadata.ts @@ -28,7 +28,7 @@ export default async function getTablesMetadata(): Promise { if ( bareFileName.toLowerCase() !== `${tableMetadata.id.toLowerCase()}.yaml` ) { - console.log(bareFileName, '!==', `${tableMetadata.id}.yaml`); + void 0; /* log removed */ throw new Error('Filename mismatch'); } tablesMetadata.push(tableMetadata); From d70ee72632b691876edfed054e350af884d99273 Mon Sep 17 00:00:00 2001 From: Vitalii Perehonchuk Date: Fri, 29 May 2026 18:12:40 +0300 Subject: [PATCH 02/24] docs: update conventions and add workspace image viewer specifications --- .opencode/commands/draft.md | 11 +- CONVENTIONS.md | 1 + specs/000-template.md | 10 +- specs/056-workspace-back-navigation.md | 70 ++++++ specs/057-workspace-image-viewer-state.md | 74 +++++++ specs/058-workspace-image-zoom-scroll.md | 90 ++++++++ specs/059-workspace-image-zoom-wheel.md | 78 +++++++ specs/060-workspace-image-panning-bug.md | 71 +++++++ src/app/transcribe/project/page.module.css | 13 ++ src/app/transcribe/schemata.ts | 1 + src/app/transcribe/workspace/page.module.css | 46 ++++ src/app/transcribe/workspace/page.test.tsx | 104 ++++++++- src/app/transcribe/workspace/page.tsx | 201 ++++++++++++++++-- .../handlers/handle-project-image-get.test.ts | 5 + .../src/handlers/handle-project-image-get.ts | 2 + .../handle-project-images-list.test.ts | 9 +- .../handlers/handle-project-images-list.ts | 7 +- src/server/src/services/r2.ts | 7 + 18 files changed, 765 insertions(+), 35 deletions(-) create mode 100644 specs/056-workspace-back-navigation.md create mode 100644 specs/057-workspace-image-viewer-state.md create mode 100644 specs/058-workspace-image-zoom-scroll.md create mode 100644 specs/059-workspace-image-zoom-wheel.md create mode 100644 specs/060-workspace-image-panning-bug.md diff --git a/.opencode/commands/draft.md b/.opencode/commands/draft.md index d9d78d35..060e57fe 100644 --- a/.opencode/commands/draft.md +++ b/.opencode/commands/draft.md @@ -11,10 +11,11 @@ Under no circumstances are you to write, modify, or execute source code. Context file: @specs/000-template.md -1. Generate a formal specification in the `specs/` directory based on the user's input. -2. Name the file descriptively using kebab-case (e.g., `specs/001-auth-state-sync.md`). -3. The specification MUST include YAML frontmatter defining `targets`, `context`, and `status`. -4. Include specific testing directives, edge-case coverage requirements, and data schema shapes. -5. Write the file to disk and terminate the operation immediately. Do not attempt implementation. +1. Examine the list of files in `specs/` directory, to correctly calculate the next spec's number. +2. Generate a formal specification in the `specs/` directory based on the user's input. +3. Name the file descriptively using kebab-case (e.g., `specs/${n}-auth-state-sync.md`, where `n` is the spec's number). +4. The specification MUST include YAML frontmatter defining `targets`, `context`, and `status`. +5. Include specific testing directives, edge-case coverage requirements, and data schema shapes. +6. Write the file to disk and terminate the operation immediately. Do not attempt implementation. !`echo "Drafting specification..."` diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 3b855bbd..c4709aee 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -22,6 +22,7 @@ - **Component Exports**: Use `export default function ComponentName` for all components and pages. Avoid named exports for React components unless grouping multiple sub-components in a single file. - **API requests**: Never done from React components, but only from separate async service functions. Use `fetch`. - **File Length**: Refactor & split files beyond 300 lines. +- **SRP**: Strictly align to the Single Responsibility Principle. If a module does more than one conceptual thing, split it. ## 4. Styling & UI diff --git a/specs/000-template.md b/specs/000-template.md index ddc5035c..35cfd44b 100644 --- a/specs/000-template.md +++ b/specs/000-template.md @@ -49,15 +49,7 @@ context: --- -## 4. Hard Constraints - -- **React 19:** Do not implement manual `useMemo`/`useCallback` unless explicitly bypassing the React Compiler. Maintain strict Client/Server boundaries. -- **Backend ESM:** All relative imports in Hono/Node.js files MUST terminate with explicit `.js` extensions. -- **Isolation:** Do not modify schemas, context files, or unrelated components not explicitly listed in the `targets` frontmatter. - ---- - -## 5. Agentic Verification +## 4. Agentic Verification Execute the following commands to validate the implementation: diff --git a/specs/056-workspace-back-navigation.md b/specs/056-workspace-back-navigation.md new file mode 100644 index 00000000..bc7f57ba --- /dev/null +++ b/specs/056-workspace-back-navigation.md @@ -0,0 +1,70 @@ +--- +description: Add a navigation link to the transcription workspace allowing users to return to the project metadata page +status: draft +targets: + - src/app/transcribe/workspace/page.tsx +context: + - src/app/transcribe/workspace/page.module.css +--- + +# Workspace to Project Navigation + +## 1. Architectural Boundary + +- **Execution Context:** Client (Next.js & React 19) +- **Data Scope:** N/A + +--- + +## 2. State Transition Matrix + +### Fault / Current State + +- **Condition:** User enters the transcription workspace view at `/transcribe/workspace/?projectId=[id]`. +- **Behavior:** The workspace lacks a dedicated UI element to navigate back to the project configuration page (`/transcribe/project/?projectId=[id]`) to edit metadata or modify the image set. +- **Log/Trace:** + +```tsx +// Current table header lacks back navigation +
+

Транскрипція

+ +
+``` + +### Target / Resolved State + +- **Condition:** User enters the transcription workspace view. +- **Behavior:** The user is presented with a clear "Back to Project" button/link (e.g., using `lucide-react` `ArrowLeft` icon). Clicking this element redirects to `/transcribe/project/?projectId=[projectId]`. +- **Schema/Type Alteration:** + +```tsx +// No schema change; UI alteration only +``` + +--- + +## 3. Execution Pipeline + +### 3.1. src/app/transcribe/workspace/page.tsx + +1. Import `Link` from `next/link` or an appropriate icon like `ArrowLeft` from `lucide-react`. +2. Locate a suitable header region (e.g., above or inside `styles.tableHeader`, or at the top of the workspace layout). +3. Inject the navigation element to route to `/transcribe/project/?projectId=${projectId}`. +4. Ensure the element is only rendered when `projectId` is available in state (though the component handles missing `projectId` by returning early with a loading state). +5. If necessary, apply class names and update `src/app/transcribe/workspace/page.module.css` for consistent spacing and alignment. + +--- + +## 4. Agentic Verification + +Execute the following commands to validate the implementation: + +1. **Type & Lint Pass:** Run standard formatting and type checks. + +```bash + /lint src/app/transcribe/workspace/page.tsx +``` diff --git a/specs/057-workspace-image-viewer-state.md b/specs/057-workspace-image-viewer-state.md new file mode 100644 index 00000000..e07bbfc9 --- /dev/null +++ b/specs/057-workspace-image-viewer-state.md @@ -0,0 +1,74 @@ +--- +description: Display current image in workspace and persist selection in browser storage +status: draft +targets: + - src/app/transcribe/workspace/page.tsx +context: [] +--- + +# Workspace Persistent Image Viewer + +## 1. Architectural Boundary + +- **Execution Context:** Client (Next.js & React 19) +- **Data Scope:** Local State (Browser LocalStorage) + +--- + +## 2. State Transition Matrix + +### Fault / Current State + +- **Condition:** User accesses the transcription workspace page. +- **Behavior:** The left panel shows a static placeholder regardless of the loaded project images. There is no state tracking which image is currently selected, nor any persistence across browser sessions. +- **Log/Trace:** + +```tsx +
+ +

Зображення для транскрибування

+

(Панель перегляду)

+
+``` + +### Target / Resolved State + +- **Condition:** User views the transcription workspace and multiple project images are loaded. +- **Behavior:** The left panel renders the currently selected image from the `images` array. The index of the current image is tracked in state and mirrored to `localStorage` (scoped by `projectId`). When the session is restarted, the component restores the last viewed image index from storage. +- **Schema/Type Alteration:** + +```ts +// Local state for the selected index +const [currentImageIndex, setCurrentImageIndex] = useState(0); +``` + +--- + +## 3. Execution Pipeline + +### 3.1. src/app/transcribe/workspace/page.tsx + +1. Introduce `currentImageIndex` state to keep track of the currently displayed image from the `images` array. +2. Implement a `useEffect` hook that runs on mount (when `projectId` is available and images are loaded) to read the stored index from `localStorage` using a project-specific key (e.g., `koreni_workspace_${projectId}_image_index`). Ensure it defaults to `0` and handles out-of-bounds cases. +3. Implement a `useEffect` hook that writes the `currentImageIndex` to `localStorage` whenever it changes, ensuring the value is persisted for the next session. +4. Replace the static `.imagePlaceholder` in the left panel with an Next.js `` using the `url` from `images[currentImageIndex]`. +5. Update the `.imageInfo` span in `.viewerHeader` to display the metadata (e.g., `storageKey`) of the currently selected image, rather than always showing the first one (`images[0]`). +6. Add Previous/Next navigation controls in the UI to allow changing the `currentImageIndex`, with boundary checks (min 0, max `images.length - 1`). + +--- + +## 4. Agentic Verification + +Execute the following commands to validate the implementation: + +1. **Type & Lint Pass:** Run standard formatting and type checks. + +```bash + /lint src/app/transcribe/workspace/page.tsx +``` + +2. **Targeted Test Execution:** Run the specific route test or overall app test to ensure no regression. + +```bash + /test src/app/transcribe/workspace/page.tsx +``` diff --git a/specs/058-workspace-image-zoom-scroll.md b/specs/058-workspace-image-zoom-scroll.md new file mode 100644 index 00000000..6cbd1b3f --- /dev/null +++ b/specs/058-workspace-image-zoom-scroll.md @@ -0,0 +1,90 @@ +--- +description: Implement horizontal and vertical zoom and pan capabilities for the workspace Image component. +status: draft +targets: + - src/app/transcribe/workspace/page.tsx +--- + +# Workspace Image Zoom and Scroll + +## 1. Architectural Boundary + +- **Execution Context:** Client (Next.js & React 19) +- **Data Scope:** Local State + +--- + +## 2. State Transition Matrix + +### Fault / Current State + +- **Condition:** The `` component within the transcribe workspace lacks native zoom and two-dimensional pan capabilities. +- **Behavior:** Users cannot visually inspect specific regions of the document by zooming in and scrolling across the image with their mouse. +- **Log/Trace:** + +```ts +// Currently lacks responsive scale and translation state mechanics +const WorkspaceImage = () => { + return ; +} +``` + +### Target / Resolved State + +- **Condition:** The workspace image view requires robust navigational controls for detailed image analysis. +- **Behavior:** The `` component dynamically reflects local state for scale, translateX, and translateY. It registers native mouse events for wheel zoom and pointer drag panning in both directions. +- **Schema/Type Alteration:** + +```ts +interface TransformState { + scale: number; + x: number; + y: number; +} + +interface DragState { + isDragging: boolean; + startX: number; + startY: number; +} +``` + +--- + +## 3. Execution Pipeline + +### 3.1. src/app/transcribe/workspace/page.tsx + +1. **Transform State Management:** Introduce local state variables for `scale`, `translateX`, and `translateY`. Ensure reasonable constraints (e.g., minimum scale > 0.1, maximum scale). +2. **Wheel Zooming:** + - Add a wheel event handler on the image container. + - Calculate the delta from `event.deltaY` to increment or decrement the `scale` state. + - Prevent default scroll behavior while hovering the image block. +3. **Button Zooming:** + - Integrate UI buttons for explicit Zoom In and Zoom Out operations that update the `scale` state. +4. **Drag Scrolling (Panning):** + - Bind `onMouseDown` to the image container to initialize dragging state (`isDragging = true`) and record the initial `clientX` / `clientY`. + - Bind `onMouseMove` to calculate the distance moved since the last point, and update `translateX` and `translateY` states accordingly. + - Bind `onMouseUp` and `onMouseLeave` to terminate the dragging sequence (`isDragging = false`). +5. **CSS Transformation Rendering:** + - Enforce `overflow: hidden` on the outer image container boundary. + - Apply inline styles to the inner container or the Image component: `transform: translate(${translateX}px, ${translateY}px) scale(${scale})`. + - Update cursor styles (`cursor: grab` natively, `cursor: grabbing` on drag). + +--- + +## 4. Agentic Verification + +Execute the following commands to validate the implementation: + +1. **Type & Lint Pass:** Run standard formatting and type checks. + +```bash + /lint src/app/transcribe/workspace/page.tsx +``` + +2. **Targeted Test Execution:** + +```bash + /test-route src/app/transcribe/workspace/page.test.tsx +``` diff --git a/specs/059-workspace-image-zoom-wheel.md b/specs/059-workspace-image-zoom-wheel.md new file mode 100644 index 00000000..6ce77fb3 --- /dev/null +++ b/specs/059-workspace-image-zoom-wheel.md @@ -0,0 +1,78 @@ +--- +description: Fix image zoom on mouse wheel by resolving early return ref attachment issue +status: draft +targets: + - src/app/transcribe/workspace/page.tsx +context: [] +--- + +# Fix Image Zoom on Mouse Wheel + +## 1. Architectural Boundary + +- **Execution Context:** Client (Next.js & React 19) +- **Data Scope:** Local State + +--- + +## 2. State Transition Matrix + +### Fault / Current State + +- **Condition:** The wheel zoom event listener is attached inside a `useEffect` with an empty dependency array (`[]`). However, the component contains an early return `if (!projectId || isLoading)`, causing `viewerReference.current` to be `null` during the initial mount. +- **Behavior:** The `useEffect` runs once on mount, observes a `null` ref, and exits without attaching the `wheel` event listener. When the loading state completes and the image viewer is rendered, the listener is never attached, causing mouse wheel zooming to silently fail. +- **Log/Trace:** + +```ts +// Runs on initial mount where viewerReference.current is null due to early return +useEffect(() => { + const viewer = viewerReference.current; + if (!viewer) return; // Exits here permanently + // ... +}, []); +``` + +### Target / Resolved State + +- **Condition:** The component finishes loading and renders the `imageViewer` div. +- **Behavior:** The mouse wheel correctly adjusts the image zoom level. The event listener is attached properly when the element becomes available, preventing default page scroll while zooming. +- **Schema/Type Alteration:** + +```ts +// useEffect correctly tracks when the viewer element becomes available +useEffect(() => { + const viewer = viewerReference.current; + if (!viewer) return; + + // ... attach listener +}, [isLoading, projectId]); // or using a callback ref +``` + +--- + +## 3. Execution Pipeline + +### 3.1. src/app/transcribe/workspace/page.tsx + +1. Update the dependency array of the wheel zoom `useEffect` to include `isLoading` and `projectId` so it correctly evaluates and attaches the listener _after_ the early returns have passed and the `imageViewer` div is rendered in the DOM. +2. Ensure that the cleanup function correctly removes the event listener to avoid memory leaks when the component unmounts or re-renders. +3. Verify that using the mouse wheel adjusts the `transform.scale` state correctly without causing the entire page to scroll. + +--- + +## 4. Agentic Verification + +Execute the following commands to validate the implementation: + +1. **Type & Lint Pass:** Run standard formatting and type checks. + +```bash + /lint src/app/transcribe/workspace/page.tsx + +``` + +2. **Targeted Test Execution:** (if applicable UI tests exist) + +```bash + /test src/app/transcribe/workspace/page.tsx +``` diff --git a/specs/060-workspace-image-panning-bug.md b/specs/060-workspace-image-panning-bug.md new file mode 100644 index 00000000..09b26fcc --- /dev/null +++ b/specs/060-workspace-image-panning-bug.md @@ -0,0 +1,71 @@ +--- +description: Fix image panning behavior by preventing default browser drag-and-drop on the viewer image. +status: draft +targets: + - src/app/transcribe/workspace/page.tsx +context: [] +--- + +# Fix Workspace Image Panning Bug + +## 1. Architectural Boundary + +- **Execution Context:** Client (Next.js & React 19) +- **Data Scope:** Local State + +--- + +## 2. State Transition Matrix + +### Fault / Current State + +- **Condition:** The user presses the left mouse button on the workspace image and moves the mouse to pan the view. +- **Behavior:** The browser intercepts the action as a default image drag-and-drop operation. This prevents the custom `onMouseMove` handler from updating the panning state while the button is held. Panning erratically activates only after releasing the button. +- **Log/Trace:** + +```tsx +... +``` + +### Target / Resolved State + +- **Condition:** The user presses the left mouse button on the workspace image and moves the mouse. +- **Behavior:** The image pans smoothly and synchronously with the mouse movement while the button is held down. Panning stops immediately when the button is released. Default browser image dragging is disabled. +- **Schema/Type Alteration:** + +```tsx +// Image component is rendered with draggable={false} + +``` + +--- + +## 3. Execution Pipeline + +### 3.1. src/app/transcribe/workspace/page.tsx + +1. Locate the `` component inside the `viewerReference` container in the render tree. +2. Add the `draggable={false}` attribute to the `` component to disable native HTML drag-and-drop behavior. +3. (Optional but recommended) In `handleMouseDown`, add `event.preventDefault()` or ensure the container's CSS has `user-select: none` to prevent text selection interference during the drag operation. + +--- + +## 4. Agentic Verification + +Execute the following commands to validate the implementation: + +1. **Type & Lint Pass:** Run standard formatting and type checks. + +```bash + /lint src/app/transcribe/workspace/page.tsx +``` diff --git a/src/app/transcribe/project/page.module.css b/src/app/transcribe/project/page.module.css index f0b384bc..edd12bff 100644 --- a/src/app/transcribe/project/page.module.css +++ b/src/app/transcribe/project/page.module.css @@ -73,6 +73,19 @@ box-shadow: none; } +.ctaButton:visited { + /* + Source - https://stackoverflow.com/a/79741490 + Posted by gre_gor + Retrieved 2026-05-29, License - CC BY-SA 4.0 + */ + + color: rgb( + from var(--visited-clickable-color) calc(255 - r) calc(255 - g) + calc(255 - b) + ); +} + .ctaButton:hover:not(:disabled) { opacity: 0.9; } diff --git a/src/app/transcribe/schemata.ts b/src/app/transcribe/schemata.ts index a35f2d49..0d162fab 100644 --- a/src/app/transcribe/schemata.ts +++ b/src/app/transcribe/schemata.ts @@ -27,6 +27,7 @@ export const projectImageSchema = z.object({ id: z.string(), projectId: z.string(), storageKey: z.string(), + url: z.string(), pageSequence: z.number(), pageName: z.string().nullable().optional(), height: z.number().nullable().optional(), diff --git a/src/app/transcribe/workspace/page.module.css b/src/app/transcribe/workspace/page.module.css index 533a62f1..affa613f 100644 --- a/src/app/transcribe/workspace/page.module.css +++ b/src/app/transcribe/workspace/page.module.css @@ -21,6 +21,8 @@ height: calc(100vh - var(--header-height, 64px)); overflow: hidden; background-color: var(--background); + + --component-max-width: 100vw; } .leftPanel, @@ -42,6 +44,22 @@ padding: 1rem; } +.backLink { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--description-color); + text-decoration: none; + font-size: 0.875rem; + margin-bottom: 1rem; + transition: color 0.2s; + width: fit-content; +} + +.backLink:hover { + color: var(--clickable-color); +} + /* Image Viewer Styles */ .viewerContainer { display: flex; @@ -62,12 +80,19 @@ border-bottom: 1px solid var(--border); } +.navigationControls { + display: flex; + align-items: center; + gap: 0.75rem; +} + .imageInfo { font-size: 0.875rem; color: var(--description-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 300px; } .viewerControls { @@ -75,6 +100,22 @@ gap: 0.5rem; } +.imageViewer { + flex: 1; + position: relative; + background-color: var(--background); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + user-select: none; +} + +.displayImage { + object-fit: contain; +} + .imagePlaceholder { flex: 1; display: flex; @@ -188,6 +229,11 @@ background: var(--card-background); } +.iconButton:disabled { + opacity: 0.3; + cursor: not-allowed; +} + .deleteButton { padding: 0.4rem; border: none; diff --git a/src/app/transcribe/workspace/page.test.tsx b/src/app/transcribe/workspace/page.test.tsx index ae75bc5c..11fc6422 100644 --- a/src/app/transcribe/workspace/page.test.tsx +++ b/src/app/transcribe/workspace/page.test.tsx @@ -84,6 +84,7 @@ describe('TranscribeProjectPage', () => { id: 'img-1', projectId: 'project-123', storageKey: 'key-1.jpg', + url: 'https://example.com/key-1.jpg', pageSequence: 1, }, ]; @@ -93,10 +94,7 @@ describe('TranscribeProjectPage', () => { await waitFor(() => { // Check left panel (viewer) - expect(screen.getByText('key-1.jpg')).toBeInTheDocument(); - expect( - screen.getByText('Зображення для транскрибування'), - ).toBeInTheDocument(); + expect(screen.getByText(/key-1.jpg/)).toBeInTheDocument(); // Check right panel (table) expect(screen.getByText('Транскрипція')).toBeInTheDocument(); @@ -110,7 +108,13 @@ describe('TranscribeProjectPage', () => { get: vi.fn().mockReturnValue('project-123'), }); (getProjectImages as Mock).mockResolvedValue([ - { id: '1', storageKey: 'k' }, + { + id: '1', + storageKey: 'k', + url: 'https://example.com/k', + projectId: 'project-123', + pageSequence: 1, + }, ]); render(); @@ -143,7 +147,13 @@ describe('TranscribeProjectPage', () => { get: vi.fn().mockReturnValue('project-123'), }); (getProjectImages as Mock).mockResolvedValue([ - { id: '1', storageKey: 'k' }, + { + id: '1', + storageKey: 'k', + url: 'https://example.com/k', + projectId: 'project-123', + pageSequence: 1, + }, ]); render(); @@ -176,4 +186,86 @@ describe('TranscribeProjectPage', () => { ).toBeInTheDocument(); }); }); + + it('updates image transform when zoom buttons are clicked', async () => { + (useSearchParams as Mock).mockReturnValue({ + get: vi.fn().mockReturnValue('project-123'), + }); + (getProjectImages as Mock).mockResolvedValue([ + { + id: '1', + storageKey: 'k', + url: 'https://example.com/k', + projectId: 'project-123', + pageSequence: 1, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByAltText('1')).toBeInTheDocument(); + }); + + const displayImage = screen.getByAltText('1'); + const zoomInButton = screen.getByTitle('Zoom In'); + const zoomOutButton = screen.getByTitle('Zoom Out'); + const resetButton = screen.getByTitle('Reset'); + + // Initial transform + expect(displayImage).toHaveStyle({ + transform: 'translate(0px, 0px) scale(1)', + }); + + // Zoom In + fireEvent.click(zoomInButton); + expect(displayImage.style.transform).toContain('scale(1.2)'); + + // Zoom Out + fireEvent.click(zoomOutButton); + expect(displayImage.style.transform).toContain('scale(1)'); + + // Reset + fireEvent.click(zoomInButton); + fireEvent.click(resetButton); + expect(displayImage).toHaveStyle({ + transform: 'translate(0px, 0px) scale(1)', + }); + }); + + it('updates image transform when dragging', async () => { + (useSearchParams as Mock).mockReturnValue({ + get: vi.fn().mockReturnValue('project-123'), + }); + (getProjectImages as Mock).mockResolvedValue([ + { + id: '1', + storageKey: 'k', + url: 'https://example.com/k', + projectId: 'project-123', + pageSequence: 1, + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByAltText('1')).toBeInTheDocument(); + }); + + const displayImage = screen.getByAltText('1'); + const viewer = displayImage.parentElement!; + + // Initial transform + expect(displayImage).toHaveStyle({ + transform: 'translate(0px, 0px) scale(1)', + }); + + // Drag + fireEvent.mouseDown(viewer, { clientX: 100, clientY: 100 }); + fireEvent.mouseMove(viewer, { clientX: 150, clientY: 170 }); + fireEvent.mouseUp(viewer); + + expect(displayImage.style.transform).toContain('translate(50px, 70px)'); + }); }); diff --git a/src/app/transcribe/workspace/page.tsx b/src/app/transcribe/workspace/page.tsx index 9e76de51..c8e7ca22 100644 --- a/src/app/transcribe/workspace/page.tsx +++ b/src/app/transcribe/workspace/page.tsx @@ -1,6 +1,9 @@ 'use client'; import { + ArrowLeft, + ChevronLeft, + ChevronRight, Image as ImageIcon, Maximize2, Plus, @@ -8,8 +11,10 @@ import { ZoomIn, ZoomOut, } from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import z from 'zod'; @@ -38,9 +43,100 @@ function TranscribeProjectPageContent() { const [projectId, setProjectId] = useState(null); const [images, setImages] = useState([]); + const [currentImageIndex, setCurrentImageIndex] = useState(0); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // Image transform state + const [transform, setTransform] = useState({ scale: 1, x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const viewerReference = useRef(null); + const lastMousePosition = useRef({ x: 0, y: 0 }); + + // Wheel zoom handling + useEffect(() => { + const viewer = viewerReference.current; + if (!viewer) return; + + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + const delta = event.deltaY > 0 ? 0.9 : 1.1; + setTransform((previous) => ({ + ...previous, + scale: Math.min(Math.max(previous.scale * delta, 0.1), 5), + })); + }; + + viewer.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + viewer.removeEventListener('wheel', handleWheel); + }; + }, [isLoading, projectId]); + + const handleMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + setIsDragging(true); + lastMousePosition.current = { x: event.clientX, y: event.clientY }; + }; + + const handleMouseMove = (event: React.MouseEvent) => { + if (!isDragging) return; + + const deltaX = event.clientX - lastMousePosition.current.x; + const deltaY = event.clientY - lastMousePosition.current.y; + + lastMousePosition.current = { x: event.clientX, y: event.clientY }; + + setTransform((previous) => ({ + ...previous, + x: previous.x + deltaX, + y: previous.y + deltaY, + })); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleZoomIn = useCallback(() => { + setTransform((previous) => ({ + ...previous, + scale: Math.min(previous.scale * 1.2, 5), + })); + }, []); + + const handleZoomOut = useCallback(() => { + setTransform((previous) => ({ + ...previous, + scale: Math.max(previous.scale / 1.2, 0.1), + })); + }, []); + + const handleResetTransform = useCallback(() => { + setTransform({ scale: 1, x: 0, y: 0 }); + }, []); + + // Persistence for currentImageIndex + useEffect(() => { + if (!projectId || images.length === 0) return; + + const storageKey = `koreni_workspace_${projectId}_image_index`; + const stored = localStorage.getItem(storageKey); + if (stored !== null) { + const index = Number.parseInt(stored, 10); + if (!Number.isNaN(index) && index >= 0 && index < images.length) { + setCurrentImageIndex(index); + } + } + }, [projectId, images.length]); + + useEffect(() => { + if (!projectId) return; + + const storageKey = `koreni_workspace_${projectId}_image_index`; + localStorage.setItem(storageKey, currentImageIndex.toString()); + }, [projectId, currentImageIndex]); + // Transcription state const [rows, setRows] = useState([ { @@ -135,6 +231,18 @@ function TranscribeProjectPageContent() { ); }; + const nextImage = () => { + setCurrentImageIndex((previous) => + Math.min(previous + 1, images.length - 1), + ); + handleResetTransform(); + }; + + const previousImage = () => { + setCurrentImageIndex((previous) => Math.max(previous - 1, 0)); + handleResetTransform(); + }; + if (!projectId || isLoading) { return (
@@ -157,33 +265,100 @@ function TranscribeProjectPageContent() {
- - {images[0]?.storageKey || 'Зображення'} - +
+ + + {images[currentImageIndex]?.storageKey || 'Зображення'} ( + {currentImageIndex + 1} / {images.length}) + + +
- - -
-
- -

- Зображення для транскрибування -

-

(Панель перегляду)

+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ {images[currentImageIndex] ? ( + { + ) : ( +
+ +

+ Зображення для транскрибування +

+

(Панель перегляду)

+
+ )}
{/* Right Panel: Tabular Data Input Grid */}
+ + + Назад до проекту +

Транскрипція

+ + + {hasPageName ? null : ( +
+
+ Потрібна назва сторінки +
+
+ Будь ласка, вкажіть назву сторінки (наприклад, "12", + "12зв", "12а", "12азв"), прочитавши + її із зображення або вивівши її логічно. Транскрибування + заблоковано, доки не буде введено назву сторінки. +
+
+ )} +

Транскрипція

-
- +
@@ -392,6 +508,7 @@ function TranscribeProjectPageContent() { updateRow(row.id, 'lastName', event_.target.value); }} placeholder="Прізвище" + disabled={!hasPageName} /> ` layout to iterate over `columns`. Keep the static `Дії` (Actions) column at the end. Render the `title` and add tooltip UI logic for the `hint` when it evaluates to a non-empty string. +3. Refactor the `` layout so each `` iterates over `columns` to render `
No. @@ -403,6 +520,7 @@ function TranscribeProjectPageContent() { updateRow(row.id, 'firstName', event_.target.value); }} placeholder="Ім'я" + disabled={!hasPageName} /> @@ -414,6 +532,7 @@ function TranscribeProjectPageContent() { updateRow(row.id, 'yearOrAge', event_.target.value); }} placeholder="Вік" + disabled={!hasPageName} /> @@ -425,6 +544,7 @@ function TranscribeProjectPageContent() { updateRow(row.id, 'notes', event_.target.value); }} placeholder="Примітки" + disabled={!hasPageName} /> @@ -434,6 +554,7 @@ function TranscribeProjectPageContent() { deleteRow(row.id); }} title="Видалити" + disabled={!hasPageName} > diff --git a/src/server/src/app.ts b/src/server/src/app.ts index 029dac5d..ba721613 100644 --- a/src/server/src/app.ts +++ b/src/server/src/app.ts @@ -5,6 +5,7 @@ import { secureHeaders } from 'hono/secure-headers'; import handleProjectImageDelete from './handlers/handle-project-image-delete.js'; import handleProjectImageGet from './handlers/handle-project-image-get.js'; +import handleProjectImagePatch from './handlers/handle-project-image-patch.js'; import handleProjectImagePut from './handlers/handle-project-image-put.js'; import handleProjectImagesList from './handlers/handle-project-images-list.js'; import handleSubmit from './handlers/handle-submit.js'; @@ -93,6 +94,11 @@ export function createApp() { transcribeAuthMiddleware, handleProjectImagePut, ); + app.patch( + '/api/transcribe/projects/:projectId/images/:imageId', + transcribeAuthMiddleware, + handleProjectImagePatch, + ); app.delete( '/api/transcribe/projects/:projectId/images/:imageId', transcribeAuthMiddleware, diff --git a/src/server/src/database/update-project-image.ts b/src/server/src/database/update-project-image.ts new file mode 100644 index 00000000..0325581b --- /dev/null +++ b/src/server/src/database/update-project-image.ts @@ -0,0 +1,33 @@ +import database from './client.js'; + +export function updateProjectImage( + projectId: string, + imageId: string, + data: { + pageName?: string | null; + }, +) { + const updateData: Record = {}; + + if (data.pageName !== undefined) { + updateData.page_name = data.pageName; + } + + return database + .updateTable('project_images') + .set(updateData) + .where('id', '=', imageId) + .where('project_id', '=', projectId) + .returning([ + 'id', + 'project_id', + 'storage_key', + 'page_sequence', + 'page_name', + 'height', + 'width', + 'created_at', + 'blurhash', + ]) + .executeTakeFirst(); +} diff --git a/src/server/src/handlers/handle-project-image-patch.ts b/src/server/src/handlers/handle-project-image-patch.ts new file mode 100644 index 00000000..71ceac74 --- /dev/null +++ b/src/server/src/handlers/handle-project-image-patch.ts @@ -0,0 +1,59 @@ +import { Context } from 'hono'; +import { z } from 'zod'; + +import { updateProjectImage } from '../database/update-project-image.js'; +import { getPublicUrl } from '../services/r2.js'; + +const patchImageSchema = z.object({ + pageName: z.string().nullable().optional(), +}); + +export default async function handleProjectImagePatch(c: Context) { + const projectId = c.req.param('projectId'); + const imageId = c.req.param('imageId'); + + if (!projectId || !imageId) { + return c.json({ error: 'Missing projectId or imageId' }, 400); + } + + try { + const body = (await c.req.json()) as unknown; + const parsedFields = patchImageSchema.safeParse(body); + + if (!parsedFields.success) { + return c.json( + { error: 'Invalid fields: ' + parsedFields.error.message }, + 400, + ); + } + + const { pageName } = parsedFields.data; + + const updatedImage = await updateProjectImage(projectId, imageId, { + pageName, + }); + + if (!updatedImage) { + return c.json({ error: 'Image not found' }, 404); + } + + return c.json({ + success: true, + image: { + id: updatedImage.id, + projectId: updatedImage.project_id, + storageKey: updatedImage.storage_key, + url: getPublicUrl(updatedImage.storage_key), + pageSequence: updatedImage.page_sequence, + pageName: updatedImage.page_name, + height: updatedImage.height, + width: updatedImage.width, + createdAt: updatedImage.created_at, + blurhash: updatedImage.blurhash, + }, + }); + } catch (error) { + console.error('Error handling project image PATCH:', error); + return c.json({ error: 'Failed to update image' }, 500); + } +} From 1d13d2d4f8fdb7438b889562931a2eedbbe70afa Mon Sep 17 00:00:00 2001 From: Vitalii Perehonchuk Date: Fri, 29 May 2026 20:27:10 +0300 Subject: [PATCH 05/24] refactor: modularize project page and update opencode command settings --- .opencode/commands/commit.md | 6 +- .opencode/commands/draft.md | 6 +- .opencode/commands/lint-css.md | 4 +- .opencode/commands/lint.md | 4 +- .opencode/commands/refactor.md | 4 +- .opencode/commands/scaffold-test-server.md | 3 +- .opencode/commands/scaffold-test.md | 3 +- .opencode/commands/test-route.md | 4 +- .opencode/commands/test-server.md | 2 +- .opencode/commands/test.md | 4 +- src/app/components/index-table-cell.test.tsx | 31 +- src/app/components/search-results.tsx | 2 +- src/app/components/source-link.test.tsx | 16 +- src/app/components/source-link.tsx | 1 - src/app/transcribe/project/page.tsx | 988 ++++++++++-------- .../workspace/_components/image-viewer.tsx | 133 +++ .../workspace/_components/page-name-form.tsx | 117 +++ .../_components/transcription-table.tsx | 130 +++ .../workspace/_hooks/use-image-transform.ts | 82 ++ .../workspace/_hooks/use-project-images.ts | 109 ++ .../_hooks/use-transcription-rows.ts | 55 + src/app/transcribe/workspace/page.tsx | 597 ++--------- vitest.setup.ts | 6 + 23 files changed, 1319 insertions(+), 988 deletions(-) create mode 100644 src/app/transcribe/workspace/_components/image-viewer.tsx create mode 100644 src/app/transcribe/workspace/_components/page-name-form.tsx create mode 100644 src/app/transcribe/workspace/_components/transcription-table.tsx create mode 100644 src/app/transcribe/workspace/_hooks/use-image-transform.ts create mode 100644 src/app/transcribe/workspace/_hooks/use-project-images.ts create mode 100644 src/app/transcribe/workspace/_hooks/use-transcription-rows.ts diff --git a/.opencode/commands/commit.md b/.opencode/commands/commit.md index 68f1d8d2..9492fe3f 100644 --- a/.opencode/commands/commit.md +++ b/.opencode/commands/commit.md @@ -1,9 +1,9 @@ --- description: 'Generate a conventional commit message and execute the commit autonomously.' model: 'opencode/gemini-3-flash' -temperature: 0.1 -top_p: 0.90 -max_tokens: 500 +temperature: 0.4 +top_p: 1.0 +max_tokens: 512 --- You are a strict version control agent. Review the staged git diff below. diff --git a/.opencode/commands/draft.md b/.opencode/commands/draft.md index 060e57fe..fa0abb2c 100644 --- a/.opencode/commands/draft.md +++ b/.opencode/commands/draft.md @@ -1,9 +1,9 @@ --- description: 'Draft a formal markdown specification without executing it.' model: 'opencode/gemini-3.1-pro' -temperature: 0.2 -top_p: 0.95 -max_tokens: 4096 +temperature: 0.6 +top_p: 0.90 +max_tokens: 8192 --- You are an architectural planner. Your strict directive is to generate a formal specification document, strictly adhering to the project's specification template. diff --git a/.opencode/commands/lint-css.md b/.opencode/commands/lint-css.md index 9631a7af..455af012 100644 --- a/.opencode/commands/lint-css.md +++ b/.opencode/commands/lint-css.md @@ -1,8 +1,8 @@ --- description: 'Run Stylelint auto-fix and resolve remaining manual violations.' model: 'opencode/gemini-3-flash' -temperature: 0.1 -top_p: 0.90 +temperature: 0.0 +top_p: 0.10 max_tokens: 4096 --- diff --git a/.opencode/commands/lint.md b/.opencode/commands/lint.md index debf7cce..e774b644 100644 --- a/.opencode/commands/lint.md +++ b/.opencode/commands/lint.md @@ -1,8 +1,8 @@ --- description: 'Resolve complex TypeScript and ESLint violations after local auto-fixing.' model: 'opencode/gemini-3.1-pro' -temperature: 0.1 -top_p: 0.90 +temperature: 0.0 +top_p: 0.10 max_tokens: 8192 --- diff --git a/.opencode/commands/refactor.md b/.opencode/commands/refactor.md index b0c3326a..e369c36e 100644 --- a/.opencode/commands/refactor.md +++ b/.opencode/commands/refactor.md @@ -2,13 +2,13 @@ description: 'Refactor a specific file for performance, architectural alignment, and readability.' model: 'opencode/gemini-3-flash' temperature: 0.1 -top_p: 0.90 +top_p: 0.95 max_tokens: 8192 --- You are a principal software engineer. Review the target file injected below and apply the requested refactoring structural improvements. -Target File Content: @$1 +Target File Content: $1 Refactoring Goal: $2 1. Execute the refactoring strictly based on the Refactoring Goal. If no specific goal is provided, optimize the file for complexity reduction (lower cyclomatic complexity) and dead-code elimination. diff --git a/.opencode/commands/scaffold-test-server.md b/.opencode/commands/scaffold-test-server.md index 0867e9f5..38fe84ff 100644 --- a/.opencode/commands/scaffold-test-server.md +++ b/.opencode/commands/scaffold-test-server.md @@ -1,7 +1,8 @@ --- description: 'Automatically generate a backend unit test for a given source file.' model: 'opencode/gemini-3-flash' -temperature: 0.2 +temperature: 0.3 +top_p: 0.95 max_tokens: 8192 --- diff --git a/.opencode/commands/scaffold-test.md b/.opencode/commands/scaffold-test.md index f700e4bc..0d59a725 100644 --- a/.opencode/commands/scaffold-test.md +++ b/.opencode/commands/scaffold-test.md @@ -1,7 +1,8 @@ --- description: 'Automatically generate a frontend unit test for a given source file.' model: 'opencode/gemini-3-flash' -temperature: 0.2 +temperature: 0.3 +top_p: 0.95 max_tokens: 8192 --- diff --git a/.opencode/commands/test-route.md b/.opencode/commands/test-route.md index 4c069763..27fea1d3 100644 --- a/.opencode/commands/test-route.md +++ b/.opencode/commands/test-route.md @@ -1,9 +1,9 @@ --- description: 'Run Vitest for a specific Next.js route with bracket escaping.' -model: 'opencode/nemotron-3-super-free' +model: 'opencode/gemini-3.1-pro' temperature: 0.1 top_p: 0.90 -max_tokens: 400 +max_tokens: 8192 --- You are a test output analyzer. Review the Vitest execution data injected below. diff --git a/.opencode/commands/test-server.md b/.opencode/commands/test-server.md index 2dbbe3bf..e11a1d47 100644 --- a/.opencode/commands/test-server.md +++ b/.opencode/commands/test-server.md @@ -13,4 +13,4 @@ You are a backend diagnostic engineer. Review the test execution payload below. 3. Apply precise fixes to the implementation or the test file using local tools. Do not bypass assertions, mock out active database connections, or use `any`/`@ts-ignore` to force a pass. 4. Stop immediately after applying the fixes. -!`cd src/server && output=$(yarn test run --reporter=minimal 2>&1); if [ $? -eq 0 ]; then echo "ALL_PASSED"; else echo "$output" | head -n 500; fi` +!`cd src/server && output=$(yarn vitest run --reporter=minimal 2>&1); if [ $? -eq 0 ]; then echo "ALL_PASSED"; else echo "$output" | head -n 500; fi` diff --git a/.opencode/commands/test.md b/.opencode/commands/test.md index d4a03351..ebc185bc 100644 --- a/.opencode/commands/test.md +++ b/.opencode/commands/test.md @@ -10,7 +10,7 @@ You are a strict frontend diagnostic engineer. Review the initial test execution 1. If the payload is exactly `ALL_PASSED`, output "✓ Frontend tests passed successfully." and terminate immediately. 2. If tests fail, analyze the root cause within `src/` or `specs/` and apply precise fixes using local tools. -3. **Verification Loop:** After modifying files, you MUST run `yarn vitest run --passWithNoTests --reporter=minimal` using your shell execution tool to verify the fix. +3. **Verification Loop:** After modifying files, you MUST run `yarn vitest run --passWithNoTests` using your shell execution tool to verify the fix. 4. **Circuit Breaker:** You are permitted a maximum of 3 execution attempts. If the tests still fail after the 3rd attempt, output a summary of the remaining failing assertions and terminate operations. Do not exceed 3 attempts. -!`output=$(yarn vitest run --passWithNoTests --reporter=minimal 2>&1); if [ $? -eq 0 ]; then echo "ALL_PASSED"; else echo "$output" | head -n 500; fi` +!`output=$(yarn vitest run --passWithNoTests 2>&1); if [ $? -eq 0 ]; then echo "ALL_PASSED"; else echo "$output" | head -n 500; fi` diff --git a/src/app/components/index-table-cell.test.tsx b/src/app/components/index-table-cell.test.tsx index 3908519a..9967b833 100644 --- a/src/app/components/index-table-cell.test.tsx +++ b/src/app/components/index-table-cell.test.tsx @@ -34,7 +34,13 @@ describe('IndexTableCell component', () => { it('should highlight matched tokens in the value', () => { const { container } = render( - , + + + + + + +
, ); const tableCell = container.querySelector('td'); expect(tableCell?.innerHTML).toContain( @@ -44,7 +50,16 @@ describe('IndexTableCell component', () => { it('should handle multiple matched tokens', () => { const { container } = render( - , + + + + + + +
, ); const tableCell = container.querySelector('td'); expect(tableCell?.innerHTML).toContain( @@ -54,7 +69,17 @@ describe('IndexTableCell component', () => { it('should scroll to mark when isInTarget', () => { const { container } = render( - , + + + + + + +
, ); const mark = container.querySelector('mark'); expect(scrollOnce).toHaveBeenCalledWith(mark); diff --git a/src/app/components/search-results.tsx b/src/app/components/search-results.tsx index cd12fbfa..22f8d532 100644 --- a/src/app/components/search-results.tsx +++ b/src/app/components/search-results.tsx @@ -25,7 +25,7 @@ function renderResult( searchValue: string, ) { return ( -
  • +
  • {/* Record Header */}

    {typedResult.document.title || 'Невідомий документ'}

    diff --git a/src/app/components/source-link.test.tsx b/src/app/components/source-link.test.tsx index ac5ea40f..5b32ac0b 100644 --- a/src/app/components/source-link.test.tsx +++ b/src/app/components/source-link.test.tsx @@ -3,6 +3,14 @@ import { describe, expect, it, vi } from 'vitest'; import SourceLink from './source-link'; +const mockCaptureException = vi.fn(); +vi.mock('posthog-js/react', () => ({ + usePostHog: () => ({ + capture: vi.fn(), + captureException: mockCaptureException, + }), +})); + describe('SourceLink', () => { it('should render a link with the correct host name for a known site', () => { const { getByText } = render( @@ -33,12 +41,8 @@ describe('SourceLink', () => { expect(textElement).toBeInTheDocument(); }); - it('should log an error to the console if the URL is invalid', () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); + it('should log an error to posthog if the URL is invalid', () => { render(); - expect(consoleErrorSpy).toHaveBeenCalled(); - consoleErrorSpy.mockRestore(); + expect(mockCaptureException).toHaveBeenCalled(); }); }); diff --git a/src/app/components/source-link.tsx b/src/app/components/source-link.tsx index ef155579..1e91988a 100644 --- a/src/app/components/source-link.tsx +++ b/src/app/components/source-link.tsx @@ -19,7 +19,6 @@ export default function SourceLink({ href }: { href: string }) { try { return new URL(href); } catch (error) { - console.error(error); posthog.captureException(error as Error); return null; } diff --git a/src/app/transcribe/project/page.tsx b/src/app/transcribe/project/page.tsx index b2600a25..23689cca 100644 --- a/src/app/transcribe/project/page.tsx +++ b/src/app/transcribe/project/page.tsx @@ -4,21 +4,25 @@ import { zodResolver } from '@hookform/resolvers/zod'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; -import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + Controller, + FormProvider, + useForm, + useFormContext, +} from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; import SourcesInput from '@/app/components/contribute/sources-input'; import YearsInput from '@/app/components/contribute/years-input'; - -const SpatialInput = dynamic( - () => - import('@/app/components/contribute/spatial-input').then( - (module_) => module_.SpatialInput, - ), - { ssr: false }, -); import { type ProjectCreatePayload, projectCreatePayloadSchema, @@ -33,6 +37,16 @@ import updateProject from '../api/update-project'; import styles from './page.module.css'; +const SpatialInput = dynamic( + () => + import('@/app/components/contribute/spatial-input').then( + (module_) => module_.SpatialInput, + ), + { ssr: false }, +); + +// --- Types --- + interface ImageFile { id: string; file: File; @@ -43,70 +57,19 @@ interface ImageFile { type UploadState = 'idle' | 'uploading' | 'success'; +type TabType = 'metadata' | 'assets' | 'operations'; + const projectSearchParametersSchema = z.object({ projectId: nonEmptyString.regex(/^[a-z0-9-]+$/i), }); -function ProjectDetailsPageContent() { - const [projectId, setProjectId] = useState(''); - const [projectData, setProjectData] = useState( - null, - ); - const [isLoading, setIsLoading] = useState(true); - const [existingImagesCount, setExistingImagesCount] = useState(0); - const [activeTab, setActiveTab] = useState< - 'metadata' | 'assets' | 'operations' - >('metadata'); +// --- Hooks --- - // Asset Manager state - const [images, setImages] = useState([]); - const [uploadState, setUploadState] = useState('idle'); +function useProjectSchemas() { const [schemas, setSchemas] = useState< { enabled: boolean; label: string; value: string }[] >([]); - const fileInputReference = useRef(null); - const abortControllerReference = useRef(null); - - const searchParameters = useSearchParams(); - const router = useRouter(); - - const methods = useForm({ - resolver: zodResolver(projectCreatePayloadSchema), - defaultValues: { - type: '' as unknown as ProjectCreatePayload['type'], - id: '', - title: '', - isHandwritten: true, - location: [], - sources: [], - tableLocale: undefined, - yearsRange: [], - }, - }); - - const { - register, - control, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = methods; - - // Search parameters validation - useEffect(() => { - try { - const { projectId: projectIdFromSearch } = - projectSearchParametersSchema.parse({ - projectId: searchParameters.get('projectId'), - }); - setProjectId(projectIdFromSearch); - } catch { - router.push('/transcribe'); - } - }, [router, searchParameters]); - - // Load project schemas useEffect(() => { let active = true; const loadSchemas = async () => { @@ -125,43 +88,70 @@ function ProjectDetailsPageContent() { }; }, []); - // Load project data and images count - useEffect(() => { + return schemas; +} + +function useProjectData( + projectId: string, + reset: (data: ProjectCreatePayload) => void, +) { + const [projectData, setProjectData] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(true); + const [existingImagesCount, setExistingImagesCount] = useState(0); + + const loadData = useCallback(async () => { if (!projectId) return; - let active = true; setIsLoading(true); - const loadData = async () => { - try { - const [projResponse, imgs] = await Promise.all([ - getProject(projectId), - getProjectImages(projectId), - ]); - - if (active) { - if (projResponse.success) { - setProjectData(projResponse.project); - reset(projResponse.project); - } - setExistingImagesCount(imgs.length); - } - } catch { - toast.error('Failed to load project details'); - } finally { - if (active) { - setIsLoading(false); - } + try { + const [projResponse, imgs] = await Promise.all([ + getProject(projectId), + getProjectImages(projectId), + ]); + + if (projResponse.success) { + setProjectData(projResponse.project); + reset(projResponse.project); } - }; + setExistingImagesCount(imgs.length); + } catch { + toast.error('Failed to load project details'); + } finally { + setIsLoading(false); + } + }, [projectId, reset]); + useEffect(() => { void loadData(); + }, [loadData]); + + return { + projectData, + setProjectData, + isLoading, + existingImagesCount, + refreshImagesCount: async () => { + try { + const imgs = await getProjectImages(projectId); + setExistingImagesCount(imgs.length); + } catch { + /* ignore */ + } + }, + }; +} - return () => { - active = false; - }; - }, [projectId, reset]); +function useAssetManager( + projectId: string, + onUploadFinished: () => Promise, +) { + const [images, setImages] = useState([]); + const [uploadState, setUploadState] = useState('idle'); + const fileInputReference = useRef(null); + const abortControllerReference = useRef(null); - // Cleanup object URLs for selected files useEffect(() => { return () => { for (const img of images) URL.revokeObjectURL(img.previewUrl); @@ -239,12 +229,7 @@ function ProjectDetailsPageContent() { if (!signal.aborted) { setUploadState('success'); - try { - const imgs = await getProjectImages(projectId); - setExistingImagesCount(imgs.length); - } catch { - /* ignore */ - } + await onUploadFinished(); } }; @@ -260,6 +245,459 @@ function ProjectDetailsPageContent() { } }, []); + return { + images, + uploadState, + fileInputReference, + handleFileSelect, + toggleRemove, + startUpload, + cancelUpload, + }; +} + +// --- Components --- + +interface ProjectHeaderProperties { + projectData: ProjectCreatePayload | null; + projectId: string; + existingImagesCount: number; +} + +function ProjectHeader({ + projectData, + projectId, + existingImagesCount, +}: ProjectHeaderProperties) { + const yearsDisplay = useMemo(() => { + if (!projectData?.yearsRange || projectData.yearsRange.length === 0) + return null; + const [start, end] = projectData.yearsRange; + if (start === end || projectData.yearsRange.length === 1) return start; + return `${start} - ${end}`; + }, [projectData?.yearsRange]); + + return ( +
    +
    +

    + {projectData?.title || 'Project Details'} +

    +
    + {projectData?.type} + {projectData?.tableLocale && ( + Locale: {projectData.tableLocale} + )} + {yearsDisplay && Years: {yearsDisplay}} +
    +
    +
    + {existingImagesCount === 0 ? ( + + ) : ( + + Enter Workspace + + )} +
    +
    + ); +} + +interface NavigationTabsProperties { + activeTab: TabType; + setActiveTab: (tab: TabType) => void; +} + +function NavigationTabs({ activeTab, setActiveTab }: NavigationTabsProperties) { + const tabs: { id: TabType; label: string }[] = [ + { id: 'metadata', label: 'Metadata' }, + { id: 'assets', label: 'Asset Manager' }, + { id: 'operations', label: 'Operations' }, + ]; + + return ( + + ); +} + +interface MetadataTabProperties { + schemas: { enabled: boolean; label: string; value: string }[]; + onSubmit: (event: React.SyntheticEvent) => void; + isSubmitting: boolean; +} + +function MetadataTab({ + schemas, + onSubmit, + isSubmitting, +}: MetadataTabProperties) { + const { + register, + control, + formState: { errors }, + } = useFormContext(); + + return ( +
    +
    + + + {errors.type &&

    {errors.type.message}

    } +
    + +
    + + + {errors.title &&

    {errors.title.message}

    } +
    + +
    + + + {errors.id &&

    {errors.id.message}

    } +
    + +
    + + + {errors.isHandwritten && ( +

    {errors.isHandwritten.message}

    + )} +
    + +
    + + + {errors.tableLocale && ( +

    {errors.tableLocale.message}

    + )} +
    + +
    + ( + { + if (!value) { + field.onChange(); + return; + } + const [lat, lng] = value.split(',').map(Number); + field.onChange([lat, lng]); + }} + /> + )} + /> + {errors.location && ( +

    {errors.location.message}

    + )} +
    + +
    + } + /> + {errors.yearsRange && ( +

    {errors.yearsRange.message}

    + )} +
    + +
    + +
    + + +
    + ); +} + +interface AssetsTabProperties { + images: ImageFile[]; + uploadState: UploadState; + fileInputReference: React.RefObject; + handleFileSelect: (event: React.ChangeEvent) => void; + toggleRemove: (id: string) => void; + startUpload: () => Promise; + cancelUpload: () => void; +} + +function AssetsTab({ + images, + uploadState, + fileInputReference, + handleFileSelect, + toggleRemove, + startUpload, + cancelUpload, +}: AssetsTabProperties) { + const activeImagesCount = images.filter((img) => !img.removed).length; + const isUploading = uploadState === 'uploading'; + const isSuccess = uploadState === 'success'; + + return ( +
    +
    +

    Upload Images

    + {!isUploading && !isSuccess && ( +
    + + +
    + )} +
    + + {activeImagesCount > 100 && !isUploading && !isSuccess && ( +
    + You have selected more than 100 images. It is advised to split your + document into smaller chunks. +
    + )} + + {images.length > 0 && ( +
    + {images.map((image) => ( +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + {image.file.name} + + {isUploading && image.status === 'uploading' && ( +
    Uploading...
    + )} + {image.status === 'success' && ( +
    Done
    + )} + {image.status === 'error' && ( +
    Error
    + )} + + {!isUploading && !isSuccess && ( +
    + +
    + )} +
    + ))} +
    + )} + + {images.length > 0 && ( +
    + {isSuccess ? ( + + Upload complete successfully! + + ) : isUploading ? ( + + ) : ( + + )} +
    + )} +
    + ); +} + +function OperationsTab() { + return ( +
    +

    Data Export Options

    +

    + Future features will include data exports to CSV, JSON, and XML format. +

    +
    + + + +
    +
    + ); +} + +// --- Main Page Component --- + +function ProjectDetailsPageContent() { + const [projectId, setProjectId] = useState(''); + const [activeTab, setActiveTab] = useState('metadata'); + + const searchParameters = useSearchParams(); + const router = useRouter(); + + const methods = useForm({ + resolver: zodResolver(projectCreatePayloadSchema), + defaultValues: { + type: '' as unknown as ProjectCreatePayload['type'], + id: '', + title: '', + isHandwritten: true, + location: [], + sources: [], + tableLocale: undefined, + yearsRange: [], + }, + }); + + const { + handleSubmit, + reset, + formState: { isSubmitting }, + } = methods; + + // Search parameters validation + useEffect(() => { + try { + const { projectId: projectIdFromSearch } = + projectSearchParametersSchema.parse({ + projectId: searchParameters.get('projectId'), + }); + setProjectId(projectIdFromSearch); + } catch { + router.push('/transcribe'); + } + }, [router, searchParameters]); + + const schemas = useProjectSchemas(); + const { + projectData, + setProjectData, + isLoading, + existingImagesCount, + refreshImagesCount, + } = useProjectData(projectId, reset); + + const assetManager = useAssetManager(projectId, refreshImagesCount); + const onSubmit = async (data: ProjectCreatePayload) => { try { const updateData: Partial = { ...data }; @@ -280,358 +718,34 @@ function ProjectDetailsPageContent() { void handleSubmit(onSubmit)(event); }; - const activeImagesCount = images.filter((img) => !img.removed).length; - const isUploading = uploadState === 'uploading'; - const isSuccess = uploadState === 'success'; - if (!projectId || isLoading) { return
    Loading project details...
    ; } return (
    -
    -
    -

    - {projectData?.title || 'Project Details'} -

    -
    - {projectData?.type} - {projectData?.tableLocale && ( - Locale: {projectData.tableLocale} - )} - {projectData?.yearsRange && ( - - Years:{' '} - {projectData.yearsRange[0] === projectData.yearsRange[1] || - projectData.yearsRange.length === 1 - ? projectData.yearsRange[0] - : `${projectData.yearsRange[0]} - ${projectData.yearsRange[1]}`} - - )} -
    -
    -
    - {existingImagesCount === 0 ? ( - - ) : ( - - Enter Workspace - - )} -
    -
    + - +
    {activeTab === 'metadata' && ( -
    -
    - - - {errors.type && ( -

    {errors.type.message}

    - )} -
    - -
    - - - {errors.title && ( -

    {errors.title.message}

    - )} -
    - -
    - - - {errors.id && ( -

    {errors.id.message}

    - )} -
    - -
    - - - {errors.isHandwritten && ( -

    {errors.isHandwritten.message}

    - )} -
    - -
    - - - {errors.tableLocale && ( -

    {errors.tableLocale.message}

    - )} -
    - -
    - ( - { - if (!value) { - field.onChange(); - return; - } - const [lat, lng] = value.split(',').map(Number); - field.onChange([lat, lng]); - }} - /> - )} - /> - {errors.location && ( -

    {errors.location.message}

    - )} -
    - -
    - } - /> - {errors.yearsRange && ( -

    {errors.yearsRange.message}

    - )} -
    - -
    - -
    - - -
    +
    )} - {activeTab === 'assets' && ( -
    -
    -

    Upload Images

    - {!isUploading && !isSuccess && ( -
    - - -
    - )} -
    - - {activeImagesCount > 100 && !isUploading && !isSuccess && ( -
    - You have selected more than 100 images. It is advised to split - your document into smaller chunks. -
    - )} - - {images.length > 0 && ( -
    - {images.map((image) => ( -
    - {/* eslint-disable-next-line @next/next/no-img-element */} - {image.file.name} - - {isUploading && image.status === 'uploading' && ( -
    Uploading...
    - )} - {image.status === 'success' && ( -
    Done
    - )} - {image.status === 'error' && ( -
    Error
    - )} - - {!isUploading && !isSuccess && ( -
    - -
    - )} -
    - ))} -
    - )} - - {images.length > 0 && ( -
    - {isSuccess ? ( - - Upload complete successfully! - - ) : isUploading ? ( - - ) : ( - - )} -
    - )} -
    - )} + {activeTab === 'assets' && } - {activeTab === 'operations' && ( -
    -

    Data Export Options

    -

    - Future features will include data exports to CSV, JSON, and XML - format. -

    -
    - - - -
    -
    - )} + {activeTab === 'operations' && }
    ); diff --git a/src/app/transcribe/workspace/_components/image-viewer.tsx b/src/app/transcribe/workspace/_components/image-viewer.tsx new file mode 100644 index 00000000..8c4c9467 --- /dev/null +++ b/src/app/transcribe/workspace/_components/image-viewer.tsx @@ -0,0 +1,133 @@ +import { + ChevronLeft, + ChevronRight, + Image as ImageIcon, + Maximize2, + ZoomIn, + ZoomOut, +} from 'lucide-react'; +import Image from 'next/image'; + +import type { ProjectImage } from '../../schemata'; + +import styles from '../page.module.css'; + +interface ImageViewerProperties { + images: ProjectImage[]; + currentImageIndex: number; + onPreviousImage: () => void; + onNextImage: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onResetTransform: () => void; + transform: { scale: number; x: number; y: number }; + isDragging: boolean; + viewerReference: React.RefObject; + onMouseDown: (event: React.MouseEvent) => void; + onMouseMove: (event: React.MouseEvent) => void; + onMouseUp: () => void; +} + +export default function ImageViewer({ + images, + currentImageIndex, + onPreviousImage, + onNextImage, + onZoomIn, + onZoomOut, + onResetTransform, + transform, + isDragging, + viewerReference, + onMouseDown, + onMouseMove, + onMouseUp, +}: ImageViewerProperties) { + return ( +
    +
    +
    + + + {images[currentImageIndex]?.storageKey || 'Зображення'} ( + {currentImageIndex + 1} / {images.length}) + + +
    +
    + + + +
    +
    + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
    + {images[currentImageIndex] ? ( + { + ) : ( +
    + +

    + Зображення для транскрибування +

    +

    (Панель перегляду)

    +
    + )} +
    +
    + ); +} diff --git a/src/app/transcribe/workspace/_components/page-name-form.tsx b/src/app/transcribe/workspace/_components/page-name-form.tsx new file mode 100644 index 00000000..19fb1f4a --- /dev/null +++ b/src/app/transcribe/workspace/_components/page-name-form.tsx @@ -0,0 +1,117 @@ +import { Check } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import requestApi from '../../api/request'; +import type { ProjectImage } from '../../schemata'; + +import styles from '../page.module.css'; + +interface PageNameFormProperties { + projectId: string; + image: ProjectImage | undefined; + onImageUpdated: (updatedImage: ProjectImage) => void; +} + +export default function PageNameForm({ + projectId, + image, + onImageUpdated, +}: PageNameFormProperties) { + const [pageNameInput, setPageNameInput] = useState(''); + const [isUpdatingPageName, setIsUpdatingPageName] = useState(false); + + useEffect(() => { + if (image) { + setPageNameInput(image.pageName || ''); + } + }, [image]); + + const handlePageNameSubmit = async ( + event: React.SyntheticEvent, + ) => { + event.preventDefault(); + if (!projectId || !image) return; + + if (!/^\d/.test(pageNameInput)) { + toast.error('Номер сторінки повинен починатися з цифри'); + return; + } + + setIsUpdatingPageName(true); + const imageId = image.id; + + try { + const response = await requestApi( + `/api/transcribe/projects/${projectId}/images/${imageId}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pageName: pageNameInput }), + }, + ); + + if (response.ok) { + const data = (await response.json()) as { image: ProjectImage }; + onImageUpdated(data.image); + toast.success('Назву сторінки оновлено'); + } + } catch { + toast.error('Не вдалося оновити назву сторінки'); + } finally { + setIsUpdatingPageName(false); + } + }; + + return ( + <> +
    { + void handlePageNameSubmit(event_); + }} + > +
    + + { + setPageNameInput(event_.target.value); + }} + placeholder="Введіть номер сторінки..." + required + /> +
    + +
    + + {!image?.pageName && ( +
    +
    + Потрібна назва сторінки +
    +
    + Будь ласка, вкажіть назву сторінки (наприклад, "12", + "12зв", "12а", "12азв"), прочитавши її + із зображення або вивівши її логічно. Транскрибування заблоковано, + доки не буде введено назву сторінки. +
    +
    + )} + + ); +} diff --git a/src/app/transcribe/workspace/_components/transcription-table.tsx b/src/app/transcribe/workspace/_components/transcription-table.tsx new file mode 100644 index 00000000..dd5bc4ba --- /dev/null +++ b/src/app/transcribe/workspace/_components/transcription-table.tsx @@ -0,0 +1,130 @@ +import { Plus, Trash2 } from 'lucide-react'; + +import type { TranscriptionRow } from '../_hooks/use-transcription-rows'; + +import styles from '../page.module.css'; + +interface TranscriptionTableProperties { + rows: TranscriptionRow[]; + hasPageName: boolean; + onAddRow: () => void; + onDeleteRow: (id: string) => void; + onUpdateRow: ( + id: string, + field: keyof TranscriptionRow, + value: string, + ) => void; +} + +export default function TranscriptionTable({ + rows, + hasPageName, + onAddRow, + onDeleteRow, + onUpdateRow, +}: TranscriptionTableProperties) { + return ( + <> +
    +

    Транскрипція

    + +
    + +
    + + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + +
    No.ПрізвищеІм'яРік народження / ВікПриміткиДії
    {index + 1} + { + onUpdateRow(row.id, 'lastName', event_.target.value); + }} + placeholder="Прізвище" + disabled={!hasPageName} + /> + + { + onUpdateRow(row.id, 'firstName', event_.target.value); + }} + placeholder="Ім'я" + disabled={!hasPageName} + /> + + { + onUpdateRow(row.id, 'yearOrAge', event_.target.value); + }} + placeholder="Вік" + disabled={!hasPageName} + /> + + { + onUpdateRow(row.id, 'notes', event_.target.value); + }} + placeholder="Примітки" + disabled={!hasPageName} + /> + + +
    + {rows.length === 0 && ( +
    + Натисніть "Додати рядок", щоб почати транскрибування. +
    + )} +
    + + ); +} diff --git a/src/app/transcribe/workspace/_hooks/use-image-transform.ts b/src/app/transcribe/workspace/_hooks/use-image-transform.ts new file mode 100644 index 00000000..63db0c96 --- /dev/null +++ b/src/app/transcribe/workspace/_hooks/use-image-transform.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export function useImageTransform(isLoading: boolean) { + const [transform, setTransform] = useState({ scale: 1, x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const viewerReference = useRef(null); + const lastMousePosition = useRef({ x: 0, y: 0 }); + + useEffect(() => { + const viewer = viewerReference.current; + if (!viewer) return; + + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + const delta = event.deltaY > 0 ? 0.9 : 1.1; + setTransform((previous) => ({ + ...previous, + scale: Math.min(Math.max(previous.scale * delta, 0.1), 5), + })); + }; + + viewer.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + viewer.removeEventListener('wheel', handleWheel); + }; + }, [isLoading]); + + const handleMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + setIsDragging(true); + lastMousePosition.current = { x: event.clientX, y: event.clientY }; + }; + + const handleMouseMove = (event: React.MouseEvent) => { + if (!isDragging) return; + + const deltaX = event.clientX - lastMousePosition.current.x; + const deltaY = event.clientY - lastMousePosition.current.y; + + lastMousePosition.current = { x: event.clientX, y: event.clientY }; + + setTransform((previous) => ({ + ...previous, + x: previous.x + deltaX, + y: previous.y + deltaY, + })); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleZoomIn = useCallback(() => { + setTransform((previous) => ({ + ...previous, + scale: Math.min(previous.scale * 1.2, 5), + })); + }, []); + + const handleZoomOut = useCallback(() => { + setTransform((previous) => ({ + ...previous, + scale: Math.max(previous.scale / 1.2, 0.1), + })); + }, []); + + const handleResetTransform = useCallback(() => { + setTransform({ scale: 1, x: 0, y: 0 }); + }, []); + + return { + transform, + isDragging, + viewerReference, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleZoomIn, + handleZoomOut, + handleResetTransform, + }; +} diff --git a/src/app/transcribe/workspace/_hooks/use-project-images.ts b/src/app/transcribe/workspace/_hooks/use-project-images.ts new file mode 100644 index 00000000..a7da573b --- /dev/null +++ b/src/app/transcribe/workspace/_hooks/use-project-images.ts @@ -0,0 +1,109 @@ +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import z from 'zod'; + +import { nonEmptyString } from '@/shared/schemas/non-empty-string'; + +import getProjectImages from '../../api/get-project-images'; +import type { ProjectImage } from '../../schemata'; + +const projectSearchParametersSchema = z.object({ + projectId: nonEmptyString.regex(/^[a-z0-9-]+$/i), +}); + +export function useProjectImages() { + const router = useRouter(); + const searchParameters = useSearchParams(); + + const [projectId, setProjectId] = useState(null); + const [images, setImages] = useState([]); + const [currentImageIndex, setCurrentImageIndex] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Validate projectId search param + useEffect(() => { + try { + const rawProjectId = searchParameters.get('projectId'); + const parsed = projectSearchParametersSchema.parse({ + projectId: rawProjectId, + }); + setProjectId(parsed.projectId); + } catch { + router.push('/transcribe'); + } + }, [router, searchParameters]); + + // Fetch images once projectId is valid + useEffect(() => { + if (!projectId) return; + + const activeProjectId = projectId; + setIsLoading(true); + setError(null); + + const abortController = new AbortController(); + + async function fetchImages() { + try { + const data = await getProjectImages( + activeProjectId, + abortController.signal, + ); + if (abortController.signal.aborted) return; + + setImages(data); + setIsLoading(false); + + if (data.length === 0) { + router.push(`/transcribe/project/?projectId=${activeProjectId}`); + } + } catch { + if (abortController.signal.aborted) return; + + const message = 'Failed to load project images'; + setError(message); + setIsLoading(false); + toast.error(message); + } + } + + void fetchImages(); + + return () => { + abortController.abort(); + }; + }, [projectId, router]); + + // Persistence for currentImageIndex + useEffect(() => { + if (!projectId || images.length === 0) return; + + const storageKey = `koreni_workspace_${projectId}_image_index`; + const stored = localStorage.getItem(storageKey); + if (stored !== null) { + const index = Number.parseInt(stored, 10); + if (!Number.isNaN(index) && index >= 0 && index < images.length) { + setCurrentImageIndex(index); + } + } + }, [projectId, images.length]); + + useEffect(() => { + if (!projectId) return; + + const storageKey = `koreni_workspace_${projectId}_image_index`; + localStorage.setItem(storageKey, currentImageIndex.toString()); + }, [projectId, currentImageIndex]); + + return { + projectId, + images, + setImages, + currentImageIndex, + setCurrentImageIndex, + isLoading, + error, + }; +} diff --git a/src/app/transcribe/workspace/_hooks/use-transcription-rows.ts b/src/app/transcribe/workspace/_hooks/use-transcription-rows.ts new file mode 100644 index 00000000..318bd513 --- /dev/null +++ b/src/app/transcribe/workspace/_hooks/use-transcription-rows.ts @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +export interface TranscriptionRow { + id: string; + lastName: string; + firstName: string; + yearOrAge: string; + notes: string; +} + +export function useTranscriptionRows() { + const [rows, setRows] = useState([ + { + id: crypto.randomUUID(), + lastName: '', + firstName: '', + yearOrAge: '', + notes: '', + }, + ]); + + const addRow = () => { + setRows((previous) => [ + ...previous, + { + id: crypto.randomUUID(), + lastName: '', + firstName: '', + yearOrAge: '', + notes: '', + }, + ]); + }; + + const deleteRow = (id: string) => { + setRows((previous) => previous.filter((row) => row.id !== id)); + }; + + const updateRow = ( + id: string, + field: keyof TranscriptionRow, + value: string, + ) => { + setRows((previous) => + previous.map((row) => (row.id === id ? { ...row, [field]: value } : row)), + ); + }; + + return { + rows, + addRow, + deleteRow, + updateRow, + }; +} diff --git a/src/app/transcribe/workspace/page.tsx b/src/app/transcribe/workspace/page.tsx index 7eaf6984..ce3a5567 100644 --- a/src/app/transcribe/workspace/page.tsx +++ b/src/app/transcribe/workspace/page.tsx @@ -1,306 +1,67 @@ 'use client'; -import { - ArrowLeft, - Check, - ChevronLeft, - ChevronRight, - Image as ImageIcon, - Maximize2, - Plus, - Trash2, - ZoomIn, - ZoomOut, -} from 'lucide-react'; -import Image from 'next/image'; +import { ArrowLeft } from 'lucide-react'; import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; -import { toast } from 'sonner'; -import z from 'zod'; +import { Suspense } from 'react'; -import { nonEmptyString } from '@/shared/schemas/non-empty-string'; - -import getProjectImages from '../api/get-project-images'; -import requestApi from '../api/request'; import type { ProjectImage } from '../schemata'; -import styles from './page.module.css'; - -const projectSearchParametersSchema = z.object({ - projectId: nonEmptyString.regex(/^[a-z0-9-]+$/i), -}); +import ImageViewer from './_components/image-viewer'; +import PageNameForm from './_components/page-name-form'; +import TranscriptionTable from './_components/transcription-table'; +import { useImageTransform } from './_hooks/use-image-transform'; +import { useProjectImages } from './_hooks/use-project-images'; +import { useTranscriptionRows } from './_hooks/use-transcription-rows'; -interface TranscriptionRow { - id: string; - lastName: string; - firstName: string; - yearOrAge: string; - notes: string; -} +import styles from './page.module.css'; function TranscribeProjectPageContent() { - const router = useRouter(); - const searchParameters = useSearchParams(); - - const [projectId, setProjectId] = useState(null); - const [images, setImages] = useState([]); - const [currentImageIndex, setCurrentImageIndex] = useState(0); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - // Page name edit state - const [pageNameInput, setPageNameInput] = useState(''); - const [isUpdatingPageName, setIsUpdatingPageName] = useState(false); - - // Sync pageNameInput with current image - useEffect(() => { - if (images[currentImageIndex]) { - setPageNameInput(images[currentImageIndex].pageName || ''); - } - }, [currentImageIndex, images]); - - // Image transform state - const [transform, setTransform] = useState({ scale: 1, x: 0, y: 0 }); - const [isDragging, setIsDragging] = useState(false); - const viewerReference = useRef(null); - const lastMousePosition = useRef({ x: 0, y: 0 }); - - // Wheel zoom handling - useEffect(() => { - const viewer = viewerReference.current; - if (!viewer) return; - - const handleWheel = (event: WheelEvent) => { - event.preventDefault(); - const delta = event.deltaY > 0 ? 0.9 : 1.1; - setTransform((previous) => ({ - ...previous, - scale: Math.min(Math.max(previous.scale * delta, 0.1), 5), - })); - }; - - viewer.addEventListener('wheel', handleWheel, { passive: false }); - return () => { - viewer.removeEventListener('wheel', handleWheel); - }; - }, [isLoading, projectId]); - - const handleMouseDown = (event: React.MouseEvent) => { - event.preventDefault(); - setIsDragging(true); - lastMousePosition.current = { x: event.clientX, y: event.clientY }; - }; - - const handleMouseMove = (event: React.MouseEvent) => { - if (!isDragging) return; - - const deltaX = event.clientX - lastMousePosition.current.x; - const deltaY = event.clientY - lastMousePosition.current.y; - - lastMousePosition.current = { x: event.clientX, y: event.clientY }; - - setTransform((previous) => ({ - ...previous, - x: previous.x + deltaX, - y: previous.y + deltaY, - })); - }; - - const handleMouseUp = () => { - setIsDragging(false); - }; - - const handleZoomIn = useCallback(() => { - setTransform((previous) => ({ - ...previous, - scale: Math.min(previous.scale * 1.2, 5), - })); - }, []); - - const handleZoomOut = useCallback(() => { - setTransform((previous) => ({ - ...previous, - scale: Math.max(previous.scale / 1.2, 0.1), - })); - }, []); - - const handleResetTransform = useCallback(() => { - setTransform({ scale: 1, x: 0, y: 0 }); - }, []); - - // Persistence for currentImageIndex - useEffect(() => { - if (!projectId || images.length === 0) return; - - const storageKey = `koreni_workspace_${projectId}_image_index`; - const stored = localStorage.getItem(storageKey); - if (stored !== null) { - const index = Number.parseInt(stored, 10); - if (!Number.isNaN(index) && index >= 0 && index < images.length) { - setCurrentImageIndex(index); - } - } - }, [projectId, images.length]); - - useEffect(() => { - if (!projectId) return; - - const storageKey = `koreni_workspace_${projectId}_image_index`; - localStorage.setItem(storageKey, currentImageIndex.toString()); - }, [projectId, currentImageIndex]); - - // Transcription state - const [rows, setRows] = useState([ - { - id: crypto.randomUUID(), - lastName: '', - firstName: '', - yearOrAge: '', - notes: '', - }, - ]); - - // Validate projectId search param - useEffect(() => { - try { - const rawProjectId = searchParameters.get('projectId'); - const parsed = projectSearchParametersSchema.parse({ - projectId: rawProjectId, - }); - setProjectId(parsed.projectId); - } catch { - // Removed console.error for missing projectId search param - router.push('/transcribe'); - } - }, [router, searchParameters]); - - // Fetch images once projectId is valid - useEffect(() => { - if (!projectId) return; - - const activeProjectId = projectId; - setIsLoading(true); - setError(null); - - const abortController = new AbortController(); - - async function fetchImages() { - try { - const data = await getProjectImages( - activeProjectId, - abortController.signal, - ); - if (abortController.signal.aborted) return; - - setImages(data); - setIsLoading(false); - - if (data.length === 0) { - router.push(`/transcribe/project/?projectId=${activeProjectId}`); - } - } catch { - if (abortController.signal.aborted) return; - - // Removed console.error for failed image fetch - const message = 'Failed to load project images'; - setError(message); - setIsLoading(false); - toast.error(message); - } - } - - void fetchImages(); - - return () => { - abortController.abort(); - }; - }, [projectId, router]); - - const addRow = () => { - setRows([ - ...rows, - { - id: crypto.randomUUID(), - lastName: '', - firstName: '', - yearOrAge: '', - notes: '', - }, - ]); - }; - - const deleteRow = (id: string) => { - setRows(rows.filter((row) => row.id !== id)); - }; - - const updateRow = ( - id: string, - field: keyof TranscriptionRow, - value: string, - ) => { - setRows( - rows.map((row) => (row.id === id ? { ...row, [field]: value } : row)), - ); - }; - - const handlePageNameSubmit = async ( - event: React.SyntheticEvent, - ) => { - event.preventDefault(); - if (!projectId || !images[currentImageIndex]) return; - - if (!/^\d/.test(pageNameInput)) { - toast.error('Номер сторінки повинен починатися з цифри'); - return; - } - - setIsUpdatingPageName(true); - const imageId = images[currentImageIndex].id; - - try { - const response = await requestApi( - `/api/transcribe/projects/${projectId}/images/${imageId}`, - { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ pageName: pageNameInput }), - }, - ); - - if (response.ok) { - const data = (await response.json()) as { image: ProjectImage }; - const updatedImage = data.image; - setImages((previous) => - previous.map((image) => - image.id === imageId - ? { ...image, pageName: updatedImage.pageName } - : image, - ), - ); - toast.success('Назву сторінки оновлено'); - } - } catch { - toast.error('Не вдалося оновити назву сторінки'); - } finally { - setIsUpdatingPageName(false); - } - }; - - const nextImage = () => { + const { + projectId, + images, + setImages, + currentImageIndex, + setCurrentImageIndex, + isLoading, + error, + } = useProjectImages(); + + const { + transform, + isDragging, + viewerReference, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleZoomIn, + handleZoomOut, + handleResetTransform, + } = useImageTransform(isLoading); + + const { rows, addRow, deleteRow, updateRow } = useTranscriptionRows(); + + const handleNextImage = () => { setCurrentImageIndex((previous) => Math.min(previous + 1, images.length - 1), ); handleResetTransform(); }; - const previousImage = () => { + const handlePreviousImage = () => { setCurrentImageIndex((previous) => Math.max(previous - 1, 0)); handleResetTransform(); }; + const handleImageUpdated = (updatedImage: ProjectImage) => { + setImages((previous) => + previous.map((image) => + image.id === updatedImage.id + ? { ...image, pageName: updatedImage.pageName } + : image, + ), + ); + }; + if (!projectId || isLoading) { return (
    @@ -317,100 +78,30 @@ function TranscribeProjectPageContent() { ); } - const hasPageName = Boolean(images[currentImageIndex]?.pageName); + const currentImage = images[currentImageIndex]; + const hasPageName = + images.length > 0 && Boolean(images[currentImageIndex].pageName); return (
    - {/* Left Panel: Image Viewer Placeholder */}
    -
    -
    -
    - - - {images[currentImageIndex]?.storageKey || 'Зображення'} ( - {currentImageIndex + 1} / {images.length}) - - -
    -
    - - - -
    -
    - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} -
    - {images[currentImageIndex] ? ( - { - ) : ( -
    - -

    - Зображення для транскрибування -

    -

    (Панель перегляду)

    -
    - )} -
    -
    +
    - {/* Right Panel: Tabular Data Input Grid */}
    -
    { - void handlePageNameSubmit(event_); - }} - > -
    - - { - setPageNameInput(event_.target.value); - }} - placeholder="Введіть номер сторінки..." - required - /> -
    - -
    - - {hasPageName ? null : ( -
    -
    - Потрібна назва сторінки -
    -
    - Будь ласка, вкажіть назву сторінки (наприклад, "12", - "12зв", "12а", "12азв"), прочитавши - її із зображення або вивівши її логічно. Транскрибування - заблоковано, доки не буде введено назву сторінки. -
    -
    - )} - -
    -

    Транскрипція

    - -
    - -
    - - - - - - - - - - - - - {rows.map((row, index) => ( - - - - - - - - - ))} - -
    No.ПрізвищеІм'яРік народження / ВікПриміткиДії
    {index + 1} - { - updateRow(row.id, 'lastName', event_.target.value); - }} - placeholder="Прізвище" - disabled={!hasPageName} - /> - - { - updateRow(row.id, 'firstName', event_.target.value); - }} - placeholder="Ім'я" - disabled={!hasPageName} - /> - - { - updateRow(row.id, 'yearOrAge', event_.target.value); - }} - placeholder="Вік" - disabled={!hasPageName} - /> - - { - updateRow(row.id, 'notes', event_.target.value); - }} - placeholder="Примітки" - disabled={!hasPageName} - /> - - -
    - {rows.length === 0 && ( -
    - Натисніть "Додати рядок", щоб почати транскрибування. -
    - )} -
    + + +
    ); diff --git a/vitest.setup.ts b/vitest.setup.ts index bb02c60c..48f09ac7 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1 +1,7 @@ import '@testing-library/jest-dom/vitest'; + +process.env.NEXT_PUBLIC_GITHUB_REPO = 'https://github.com/user/repo'; +process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID = 'test-client-id'; +process.env.NEXT_PUBLIC_SITE = 'http://localhost'; +process.env.NEXT_PUBLIC_POSTHOG_KEY = 'test-key'; +process.env.NEXT_PUBLIC_POSTHOG_HOST = 'http://localhost'; From f8fca10e510f03c16f5c199cf6ff654cecbbb8c4 Mon Sep 17 00:00:00 2001 From: Vitalii Perehonchuk Date: Sat, 30 May 2026 00:13:37 +0300 Subject: [PATCH 06/24] feat: add specifications for workspace enhancements and enable lsp --- opencode.json | 2 +- specs/000-template.md | 6 +- .../063-transcription-table-configuration.md | 107 ++++++++++++ specs/064-workspace-image-restore-index.md | 73 ++++++++ specs/065-workspace-page-name-guessing.md | 92 ++++++++++ specs/066-transcription-table-add-row.md | 108 ++++++++++++ ...nscription-table-ru-locale-substitution.md | 103 +++++++++++ src/app/transcribe/project/page.tsx | 3 +- .../workspace/_components/image-viewer.tsx | 6 +- .../workspace/_components/page-name-form.tsx | 21 ++- .../_components/transcription-table.tsx | 162 ++++++++++-------- .../_helpers/guess-next-page-name.test.ts | 94 ++++++++++ .../_helpers/guess-next-page-name.ts | 80 +++++++++ .../workspace/_hooks/use-project-images.ts | 35 ++-- .../_hooks/use-transcription-rows.ts | 62 +++---- src/app/transcribe/workspace/page.module.css | 40 +++++ src/app/transcribe/workspace/page.test.tsx | 127 ++++++++++++-- src/app/transcribe/workspace/page.tsx | 32 +++- 18 files changed, 1015 insertions(+), 138 deletions(-) create mode 100644 specs/063-transcription-table-configuration.md create mode 100644 specs/064-workspace-image-restore-index.md create mode 100644 specs/065-workspace-page-name-guessing.md create mode 100644 specs/066-transcription-table-add-row.md create mode 100644 specs/067-transcription-table-ru-locale-substitution.md create mode 100644 src/app/transcribe/workspace/_helpers/guess-next-page-name.test.ts create mode 100644 src/app/transcribe/workspace/_helpers/guess-next-page-name.ts diff --git a/opencode.json b/opencode.json index 3f51b4fd..fd2b531e 100644 --- a/opencode.json +++ b/opencode.json @@ -1,5 +1,5 @@ { "$schema": "https://opencode.ai/config.json", - "lsp": false, + "lsp": true, "model": "opencode/gemini-3-flash" } diff --git a/specs/000-template.md b/specs/000-template.md index 0d60c909..a52c6ffa 100644 --- a/specs/000-template.md +++ b/specs/000-template.md @@ -56,16 +56,16 @@ Execute the following commands to validate the implementation: 1. **Type & Lint Pass:** Run standard formatting and type checks. ```bash - /lint src/path/to/file.ts # Both frontend and backend + ./scripts/opencode-check.sh src/path/to/file.ts # Both frontend and backend ``` 2. **Targeted Test Execution:** Run the specific route or backend test. ```bash - /test-route src/path/to/test.test.tsx + yarn test src/path/to/test.test.tsx # OR - /test-server + cd src/server && yarn test src/path/to/test.test.ts ``` diff --git a/specs/063-transcription-table-configuration.md b/specs/063-transcription-table-configuration.md new file mode 100644 index 00000000..fd222002 --- /dev/null +++ b/specs/063-transcription-table-configuration.md @@ -0,0 +1,107 @@ +--- +description: Refactor transcription table to be configuration-driven based on document types +status: draft +targets: + - src/app/transcribe/workspace/_components/transcription-table.tsx + - src/app/transcribe/workspace/_hooks/use-transcription-rows.ts +context: [] +--- + +# Make Transcription Table Configuration-Driven + +## 1. Architectural Boundary + +- **Execution Context:** Client (Next.js & React 19) +- **Data Scope:** Local State + +--- + +## 2. State Transition Matrix + +### Fault / Current State + +- **Condition:** Table columns and state fields are hardcoded to standard generic rows ("Прізвище", "Ім'я", "Рік народження / Вік", "Примітки"). +- **Behavior:** The application cannot adapt to specific historical document types which have varying layouts and column sets, limiting transcription accuracy and UX. +- **Log/Trace:** + +```ts +export interface TranscriptionRow { + id: string; + lastName: string; + firstName: string; + yearOrAge: string; + notes: string; +} +``` + +### Target / Resolved State + +- **Condition:** The table receives a configuration object specifying dynamic columns for a specific document type. +- **Behavior:** The component dynamically maps through the configured columns to render headers with tooltips (hints), and proper cell inputs (text/number). The row state handles arbitrary keys. +- **Schema/Type Alteration:** + +```ts +export type ColumnExpectedType = 'string' | 'number'; + +export interface ColumnConfig { + id: string; + title: string; + hint: string; + expectedType: ColumnExpectedType; +} + +export interface TranscriptionRow extends Record { + id: string; +} +``` + +--- + +## 3. Execution Pipeline + +### 3.1. src/app/transcribe/workspace/\_hooks/use-transcription-rows.ts + +1. Refactor the `TranscriptionRow` interface to be a dynamic record with a required `id: string` property. +2. Update `useTranscriptionRows` hook to accept a parameter for `columns: ColumnConfig[]`. +3. Modify the initial row and `addRow` logic to dynamically populate empty strings for keys matching the provided `columns` configuration instead of the hardcoded default keys. +4. Modify `updateRow` signature to accept `field: string` (instead of `keyof TranscriptionRow`) and assign it correctly within the row map update. + +### 3.2. src/app/transcribe/workspace/\_components/transcription-table.tsx + +1. Import or define the `ColumnConfig` interface. Update `TranscriptionTableProperties` to require `columns: ColumnConfig[]`. +2. Refactor the `
  • ` inputs. Bind the `value` and `onChange` using the `column.id`. +4. Render the `input` HTML element's `type` attribute dynamically, mapping `expectedType === 'number'` to `type="number"` and defaulting to `type="text"`. + +### 3.3. src/app/transcribe/workspace/page.tsx (or parent container) + +1. Instantiate the Proof of Concept (PoC) "confession-list" configuration and pass it into `useTranscriptionRows` and `TranscriptionTable`. +2. The PoC columns array must be strictly defined as: + - `{ id: 'HH', title: '#HH', hint: 'Number of household', expectedType: 'number' }` + - `{ id: 'M', title: '#M', hint: 'Number of male', expectedType: 'number' }` + - `{ id: 'F', title: '#F', hint: 'Number of female', expectedType: 'number' }` + - `{ id: 'Name', title: 'Name', hint: '', expectedType: 'string' }` + - `{ id: 'aM', title: 'aM', hint: 'Age of male', expectedType: 'string' }` + - `{ id: 'aF', title: 'aF', hint: 'Age of female', expectedType: 'string' }` + - `{ id: 'Note', title: 'Note', hint: 'Anything to the right of the age', expectedType: 'string' }` + +--- + +## 4. Agentic Verification + +Execute the following commands to validate the implementation: + +1. **Type & Lint Pass:** Run standard formatting and type checks. + +```bash + ./scripts/opencode-check.sh src/app/transcribe/workspace/_components/transcription-table.tsx + ./scripts/opencode-check.sh src/app/transcribe/workspace/_hooks/use-transcription-rows.ts + +``` + +2. **Targeted Test Execution:** Run the specific route or frontend tests. + +```bash + yarn test src/app/transcribe/workspace/page.test.tsx + +``` diff --git a/specs/064-workspace-image-restore-index.md b/specs/064-workspace-image-restore-index.md new file mode 100644 index 00000000..2b63e9a4 --- /dev/null +++ b/specs/064-workspace-image-restore-index.md @@ -0,0 +1,73 @@ +--- +description: Fix the last seen project image index restoration issue upon page refresh. +status: draft +targets: + - src/app/transcribe/workspace/_hooks/use-project-images.ts +context: [] +--- + +# Workspace Image Index Restoration Fix + +## 1. Architectural Boundary + +- **Execution Context:** Client (Next.js & React 19) +- **Data Scope:** Local State / Local Storage + +--- + +## 2. State Transition Matrix + +### Fault / Current State + +- **Condition:** Page refresh triggers `useProjectImages` initialization. `projectId` is extracted, triggering the write `useEffect` which saves `currentImageIndex` (initial state `0`) to local storage before images are loaded. +- **Behavior:** The stored image index is overwritten by `0` prematurely, preventing the read `useEffect` from restoring the previously saved index once images are actually loaded and `images.length > 0`. +- **Log/Trace:** + +```ts +// src/app/transcribe/workspace/_hooks/use-project-images.ts +// The write effect runs early when projectId becomes valid, overwriting local storage with 0. +useEffect(() => { + if (!projectId) return; + + const storageKey = `koreni_workspace_${projectId}_image_index`; + localStorage.setItem(storageKey, currentImageIndex.toString()); // overwrites with 0 +}, [projectId, currentImageIndex]); +``` + +### Target / Resolved State + +- **Condition:** Hook initialization correctly orchestrates reading from and writing to local storage. +- **Behavior:** Introduce an initialization flag (`hasRestoredIndex`). The restoration effect attempts to load the index and marks the initialization as complete. The persistence effect waits until `hasRestoredIndex` is true before writing any state back to local storage. +- **Schema/Type Alteration:** + +```ts +// Local state alteration within hook +const [hasRestoredIndex, setHasRestoredIndex] = useState(false); +``` + +--- + +## 3. Execution Pipeline + +### 3.1. src/app/transcribe/workspace/\_hooks/use-project-images.ts + +1. Introduce a new state variable: `const [hasRestoredIndex, setHasRestoredIndex] = useState(false);` +2. Update the first `useEffect` (persistence read): + - Check if `hasRestoredIndex` is already true, and if so, return early. + - At the end of the effect, regardless of whether an index was found or not, set `setHasRestoredIndex(true);`. + - Update its dependency array to include `hasRestoredIndex`. +3. Update the second `useEffect` (persistence write): + - Add a check `if (!hasRestoredIndex) return;` at the beginning to avoid prematurely saving state. + - Add `hasRestoredIndex` to the dependency array. + +--- + +## 4. Agentic Verification + +Execute the following commands to validate the implementation: + +1. **Type & Lint Pass:** Run standard formatting and type checks. + +```bash + ./scripts/opencode-check.sh src/app/transcribe/workspace/_hooks/use-project-images.ts +``` diff --git a/specs/065-workspace-page-name-guessing.md b/specs/065-workspace-page-name-guessing.md new file mode 100644 index 00000000..eacab3eb --- /dev/null +++ b/specs/065-workspace-page-name-guessing.md @@ -0,0 +1,92 @@ +--- +description: Implement page name guessing logic based on the previous two page names +status: draft +targets: + - src/app/transcribe/workspace/_components/page-name-form.tsx +context: + - src/app/transcribe/workspace/page.tsx +--- + +# Page Name Guessing Implementation + +## 1. Architectural Boundary + +- **Execution Context:** Client (Next.js & React 19) +- **Data Scope:** Local State + +--- + +## 2. State Transition Matrix + +### Fault / Current State + +- **Condition:** The user currently has to manually type the page name for every single image, even when it follows a predictable sequence. +- **Behavior:** The `PageNameForm` initializes the input with an empty string or the current image's existing `pageName`. It does not attempt to predict the next page name. +- **Log/Trace:** + +```ts +// pageNameInput is simply initialized to the current image's pageName or an empty string +setPageNameInput(image.pageName || ''); +``` + +### Target / Resolved State + +- **Condition:** The `PageNameForm` mounts or the `image` changes, and the current image does not have a `pageName` yet. +- **Behavior:** The component analyzes the previous two images' page names and populates `pageNameInput` with a guessed value if a known pattern is matched. The guess is not automatically submitted; the user must click "Save". +- **Schema/Type Alteration:** + +```ts +interface PageNameFormProperties { + projectId: string; + image: ProjectImage | undefined; + onImageUpdated: (updatedImage: ProjectImage) => void; + // Need the full list of images to inspect previous pages and check for untitled ones + images?: ProjectImage[]; +} +``` + +--- + +## 3. Execution Pipeline + +### 3.1. src/app/transcribe/workspace/\_components/page-name-form.tsx + +1. Update the `PageNameFormProperties` interface to receive the `images` array (sorted in the correct order). +2. Create a helper function (e.g., `guessNextPageName(currentIndex: number, images: ProjectImage[])`) that implements the following exact rules: + - If this page is the 1st or 2nd (`currentIndex < 2`), return `null` (skip guessing). + - If there are untitled pages before the current one (`images.slice(0, currentIndex).some(img => !img.pageName)`), return `null`. + - Let `prev2` be the page name of the page before the previous one (`images[currentIndex - 2].pageName`). + - Let `prev1` be the page name of the previous one (`images[currentIndex - 1].pageName`). + - Parse integer numbers `N` and `M` where applicable. + - If `prev2` is "N" and `prev1` is "M", where N and M are numbers and N + 1 = M, guess `(M + 1).toString()`. + - If `prev2` is "Nзв" and `prev1` is "M", where N and M are numbers and N + 1 = M, guess `"Mзв"`. + - If `prev2` is "N" and `prev1` is "Nзв", guess `(N + 1).toString()`. + - If `prev2` is "N" and `prev1` is "Nа", guess `(N + 1).toString()`. + - If `prev2` is "Na" and `prev1` is "M", where N + 1 = M, guess `(M + 1).toString()`. (Note: 'a' and 'а' may be mixed, handle parsing gracefully). + - If `prev2` is "Nзв" and `prev1` is "Nа", guess `"Nазв"`. + - If `prev2` is "Nа" and `prev1` is "Naзв", guess `(N + 1).toString()`. + - Otherwise, return `null` (don't guess). +3. Update the `useEffect` that listens to `image` and `images`. If `image.pageName` is missing, calculate the guess using the helper function. If a guess is produced, set it via `setPageNameInput(guessedName)`. +4. Ensure the guess only populates the input field without submitting it (the `disabled` logic or submit handling should remain user-initiated). + +### 3.2. Parent Component (e.g. workspace page or context) + +1. Ensure the `PageNameForm` component is passed the `images` array from the current project so that it has the required context to perform the guessing logic. + +--- + +## 4. Agentic Verification + +Execute the following commands to validate the implementation: + +1. **Type & Lint Pass:** Run standard formatting and type checks. + +```bash + ./scripts/opencode-check.sh src/app/transcribe/workspace/_components/page-name-form.tsx +``` + +2. **Targeted Test Execution:** Add unit tests to verify the guessing logic algorithm covers all the rules accurately. + +```bash + yarn test src/app/transcribe/workspace/_components/page-name-form.test.tsx +``` diff --git a/specs/066-transcription-table-add-row.md b/specs/066-transcription-table-add-row.md new file mode 100644 index 00000000..51d96750 --- /dev/null +++ b/specs/066-transcription-table-add-row.md @@ -0,0 +1,108 @@ +--- +description: Optimize creation of new rows in the transcription table +status: draft +targets: + - src/app/transcribe/workspace/_components/transcription-table.tsx +context: + - src/app/transcribe/workspace/_hooks/use-transcription-rows.ts + - src/app/transcribe/workspace/page.test.tsx +--- + +# Optimize Creation of New Rows in Transcription Table + +## 1. Architectural Boundary + +- **Execution Context:** Client (Next.js & React 19) +- **Data Scope:** Local State + +--- + +## 2. State Transition Matrix + +### Fault / Current State + +- **Condition:** The user needs to add new rows during transcription. +- **Behavior:** The "Додати рядок" (Add Row) button is located at the top of the table. This interrupts keyboard navigation and data entry flow. Additionally, there is no way to insert a row at a specific position (e.g., if a row was accidentally skipped). +- **Log/Trace:** + +```tsx +// src/app/transcribe/workspace/_components/transcription-table.tsx +
    +

    Транскрипція

    + +
    +``` + +### Target / Resolved State + +- **Condition:** The user needs to add new rows during transcription. +- **Behavior:** The main "Додати рядок" button is moved to the bottom of the table, facilitating continuous data entry. Every row's "Дії" cell includes an inline icon button to insert a new empty row immediately above it. +- **Schema/Type Alteration:** + +```ts +// src/app/transcribe/workspace/_hooks/use-transcription-rows.ts +export function useTranscriptionRows(columns: ColumnConfig[]) { + // ... + const addRow = (index?: number) => { ... }; + // OR introduce: const insertRow = (index: number) => { ... }; +} + +// src/app/transcribe/workspace/_components/transcription-table.tsx +interface TranscriptionTableProperties { + // ... + onAddRow: (index?: number) => void; +} +``` + +--- + +## 3. Execution Pipeline + +### 3.1. `src/app/transcribe/workspace/_hooks/use-transcription-rows.ts` + +1. Refactor `addRow` to optionally accept an `index: number` parameter, or introduce a new `insertRow(index: number)` method. +2. If `index` is provided, insert the new empty row at the specified `index` using array slicing or `splice`. If omitted, append it to the end. + +### 3.2. `src/app/transcribe/workspace/_components/transcription-table.tsx` + +1. Remove the "Додати рядок" `