refactor(frontend): split large page components into focused sub-components #75

Closed
opened 2026-03-26 11:53:42 +01:00 by marcel · 8 comments
Owner

Context

Several +page.svelte files have grown large enough to hurt readability. The goal is to decompose each page into focused sub-components co-located next to the page file — not for reuse, but to give each visual block a clear name and a single responsibility.

Affected files (by line count)

File Lines
persons/[id]/+page.svelte 610
+page.svelte (home / document search) 580
admin/+page.svelte 573
conversations/+page.svelte 346
documents/[id]/edit/+page.svelte 319
documents/new/+page.svelte 254
profile/+page.svelte 240
admin/users/[id]/+page.svelte 224
admin/users/new/+page.svelte 191
+layout.svelte 205

Approach

  • Identify visually cohesive blocks within each page (card sections, panels, forms).
  • Extract each block into a PascalCase.svelte file co-located in the same route folder (e.g. routes/persons/[id]/PersonMetadataCard.svelte).
  • The page component passes data as props; sub-components own their own UI and form submissions.
  • Server data loading and form actions stay in +page.server.ts — sub-components do not load their own data.
  • Shared reactive state across sub-components goes into a co-located .svelte.ts module.
  • Reserve src/lib/components/ only for components used in more than one route.

Acceptance criteria

  • Each touched +page.svelte reads as a clear composition of named sub-components with minimal inline markup.
  • No behavior changes — this is a pure structural refactor.
  • npm run check and npm run lint pass after each page.
## Context Several `+page.svelte` files have grown large enough to hurt readability. The goal is to decompose each page into focused sub-components co-located next to the page file — not for reuse, but to give each visual block a clear name and a single responsibility. ## Affected files (by line count) | File | Lines | |---|---| | `persons/[id]/+page.svelte` | 610 | | `+page.svelte` (home / document search) | 580 | | `admin/+page.svelte` | 573 | | `conversations/+page.svelte` | 346 | | `documents/[id]/edit/+page.svelte` | 319 | | `documents/new/+page.svelte` | 254 | | `profile/+page.svelte` | 240 | | `admin/users/[id]/+page.svelte` | 224 | | `admin/users/new/+page.svelte` | 191 | | `+layout.svelte` | 205 | ## Approach - Identify visually cohesive blocks within each page (card sections, panels, forms). - Extract each block into a `PascalCase.svelte` file co-located in the same route folder (e.g. `routes/persons/[id]/PersonMetadataCard.svelte`). - The page component passes data as props; sub-components own their own UI and form submissions. - Server data loading and form actions stay in `+page.server.ts` — sub-components do not load their own data. - Shared reactive state across sub-components goes into a co-located `.svelte.ts` module. - Reserve `src/lib/components/` only for components used in more than one route. ## Acceptance criteria - Each touched `+page.svelte` reads as a clear composition of named sub-components with minimal inline markup. - No behavior changes — this is a pure structural refactor. - `npm run check` and `npm run lint` pass after each page.
Author
Owner

persons/[id]/+page.svelte (610 lines)

Proposed splitroutes/persons/[id]/

New file Responsibility Key state / props
PersonCard.svelte Avatar, view-mode metadata, inline edit form (name/alias/birth/death/notes) receives person, canWrite, form?; binds editMode back to page
PersonMergePanel.svelte Merge target picker + two-step confirm receives person, form?; owns mergeTargetId, showMergeConfirm
CoCorrespondentsList.svelte Frequency-ranked correspondent chips with conversation links receives correspondents (pre-computed array) + personId
PersonDocumentList.svelte Sortable, expand-on-demand document list receives documents, heading, emptyMessage; owns sortDir, showAll

+page.svelte keeps: data derivations (coCorrespondents, yearRange), editMode state (needed for the $effect that closes the form on save), and composes the four components.

Note on PersonDocumentList: sent and received lists are structurally identical (same 60-line <li> template), so one generic component serves both. Only the heading and empty-state text differ — pass those as props.

Result: page drops from 610 → ~40 lines.

## `persons/[id]/+page.svelte` (610 lines) **Proposed split** → `routes/persons/[id]/` | New file | Responsibility | Key state / props | |---|---|---| | `PersonCard.svelte` | Avatar, view-mode metadata, inline edit form (name/alias/birth/death/notes) | receives `person`, `canWrite`, `form?`; binds `editMode` back to page | | `PersonMergePanel.svelte` | Merge target picker + two-step confirm | receives `person`, `form?`; owns `mergeTargetId`, `showMergeConfirm` | | `CoCorrespondentsList.svelte` | Frequency-ranked correspondent chips with conversation links | receives `correspondents` (pre-computed array) + `personId` | | `PersonDocumentList.svelte` | Sortable, expand-on-demand document list | receives `documents`, `heading`, `emptyMessage`; owns `sortDir`, `showAll` | `+page.svelte` keeps: data derivations (`coCorrespondents`, `yearRange`), `editMode` state (needed for the `$effect` that closes the form on save), and composes the four components. **Note on `PersonDocumentList`:** sent and received lists are structurally identical (same 60-line `<li>` template), so one generic component serves both. Only the heading and empty-state text differ — pass those as props. **Result:** page drops from 610 → ~40 lines.
Author
Owner

+page.svelte — home / document search (580 lines)

Proposed splitroutes/

New file Responsibility Key state / props
SearchFilterBar.svelte Search input, advanced-filter toggle, tag/sender/receiver/date filters receives current filter values + initialValues as props; emits filter change via callbacks; owns showAdvanced
DropZone.svelte Drag-and-drop / click-to-upload zone, XHR progress bar, upload result messages receives canWrite; owns all upload state (isDragging, windowDragging, isUploading, uploadProgress, uploadMessages); calls invalidateAll() on success
DocumentList.svelte The <ul> of document rows + empty state receives documents, error, canWrite

+page.svelte keeps: URL-synced filter state (q, from, to, senderId, receiverId, tagNames), triggerSearch / handleTextSearch functions, and the three $effect blocks for syncing state with server data and tag changes.

Why not extract search state too? The $effect sync blocks depend on data.filters (server data) and trigger goto() — that's page-level navigation logic, not UI. Keeping it in the page avoids prop-drilling goto into the filter bar.

Result: page drops from 580 → ~60 lines.

## `+page.svelte` — home / document search (580 lines) **Proposed split** → `routes/` | New file | Responsibility | Key state / props | |---|---|---| | `SearchFilterBar.svelte` | Search input, advanced-filter toggle, tag/sender/receiver/date filters | receives current filter values + `initialValues` as props; emits filter change via callbacks; owns `showAdvanced` | | `DropZone.svelte` | Drag-and-drop / click-to-upload zone, XHR progress bar, upload result messages | receives `canWrite`; owns all upload state (`isDragging`, `windowDragging`, `isUploading`, `uploadProgress`, `uploadMessages`); calls `invalidateAll()` on success | | `DocumentList.svelte` | The `<ul>` of document rows + empty state | receives `documents`, `error`, `canWrite` | `+page.svelte` keeps: URL-synced filter state (`q`, `from`, `to`, `senderId`, `receiverId`, `tagNames`), `triggerSearch` / `handleTextSearch` functions, and the three `$effect` blocks for syncing state with server data and tag changes. **Why not extract search state too?** The `$effect` sync blocks depend on `data.filters` (server data) and trigger `goto()` — that's page-level navigation logic, not UI. Keeping it in the page avoids prop-drilling `goto` into the filter bar. **Result:** page drops from 580 → ~60 lines.
Author
Owner

admin/+page.svelte (573 lines)

Proposed splitroutes/admin/

New file Responsibility Key state / props
UsersTab.svelte User table with delete action receives users
TagsTab.svelte Tag list with inline rename and delete receives tags; owns editingTagId, editingTagName
GroupsTab.svelte Groups table with inline edit + create-group form at the bottom receives groups; owns editingGroupId
SystemTab.svelte Backfill-versions and backfill-file-hashes buttons owns all backfill state; calls fetch internally

+page.svelte keeps: activeTab state, the tab navigation buttons, the global success flash (form?.message), and renders the active tab via {#if activeTab === ...}.

Each tab's state is completely independent — no state crosses tab boundaries, making this a clean split with zero shared reactive state.

Result: page drops from 573 → ~40 lines.

## `admin/+page.svelte` (573 lines) **Proposed split** → `routes/admin/` | New file | Responsibility | Key state / props | |---|---|---| | `UsersTab.svelte` | User table with delete action | receives `users` | | `TagsTab.svelte` | Tag list with inline rename and delete | receives `tags`; owns `editingTagId`, `editingTagName` | | `GroupsTab.svelte` | Groups table with inline edit + create-group form at the bottom | receives `groups`; owns `editingGroupId` | | `SystemTab.svelte` | Backfill-versions and backfill-file-hashes buttons | owns all backfill state; calls fetch internally | `+page.svelte` keeps: `activeTab` state, the tab navigation buttons, the global success flash (`form?.message`), and renders the active tab via `{#if activeTab === ...}`. Each tab's state is completely independent — no state crosses tab boundaries, making this a clean split with zero shared reactive state. **Result:** page drops from 573 → ~40 lines.
Author
Owner

conversations/+page.svelte (346 lines)

Proposed splitroutes/conversations/

New file Responsibility Key state / props
ConversationFilterBar.svelte Person A/B typeaheads, swap button, date range, sort toggle receives filter values + initialValues as props; emits changes via callbacks
ConversationTimeline.svelte Summary bar, chat bubbles, year dividers, "new document" link receives documents, senderId, canWrite; owns enrichedDocuments derivation and yearFrom/yearTo

+page.svelte keeps: all filter state (senderId, receiverId, fromDate, toDate, sortDir), applyFilters, toggleSort, swapPersons, the navigation $effect, and the three render branches (no selection / no results / timeline).

Note on senderId coupling: The timeline needs senderId to determine which bubble aligns right (sender = current user A). Passing it as a prop is clean — no shared store needed.

Result: page drops from 346 → ~70 lines.

## `conversations/+page.svelte` (346 lines) **Proposed split** → `routes/conversations/` | New file | Responsibility | Key state / props | |---|---|---| | `ConversationFilterBar.svelte` | Person A/B typeaheads, swap button, date range, sort toggle | receives filter values + `initialValues` as props; emits changes via callbacks | | `ConversationTimeline.svelte` | Summary bar, chat bubbles, year dividers, "new document" link | receives `documents`, `senderId`, `canWrite`; owns `enrichedDocuments` derivation and `yearFrom`/`yearTo` | `+page.svelte` keeps: all filter state (`senderId`, `receiverId`, `fromDate`, `toDate`, `sortDir`), `applyFilters`, `toggleSort`, `swapPersons`, the navigation `$effect`, and the three render branches (no selection / no results / timeline). **Note on `senderId` coupling:** The timeline needs `senderId` to determine which bubble aligns right (sender = current user A). Passing it as a prop is clean — no shared store needed. **Result:** page drops from 346 → ~70 lines.
Author
Owner

documents/[id]/edit/+page.svelte (319 lines) + documents/new/+page.svelte (254 lines)

These two pages share four identical form sections and a save bar. Grouping them together because the right split produces shared components — a rare reuse opportunity within this refactor.

Proposed shared componentssrc/lib/components/document/

New file Responsibility Used in
WhoWhenSection.svelte Date input (with German format + ISO hidden field), location, sender typeahead, receiver multi-select edit + new
DescriptionSection.svelte Title, archive location, tag input, summary textarea edit + new
TranscriptionSection.svelte Transcription textarea edit + new
FileSectionEdit.svelte Current file display + optional replace input edit only
FileSectionNew.svelte Initial file upload input new only

Proposed page-local components

New file Location Responsibility
SaveBar.svelte routes/documents/[id]/edit/ Sticky bar with delete (two-step confirm) + cancel + save

State ownership:

  • dateDisplay, dateIso, dateDirty → live in WhoWhenSection (self-contained; hidden <input name="documentDate"> is submitted with parent form)
  • tags, senderId, selectedReceivers → declared in the page, passed as bindable props into the sections
  • confirmDelete → lives in SaveBar

Why lib/components/document/? These sections are used in two routes, so they cross the "co-location" threshold. They're not general-purpose enough for the top-level lib/components/, so a document/ subdirectory scopes them clearly.

Also: handleDateInput / germanToIso / isoToGerman are duplicated across edit, new, profile, and admin/users. This is the right moment to consolidate them into $lib/utils/date.ts (which already has formatDate).

Result: edit page 319 → ~40 lines, new page 254 → ~30 lines.

## `documents/[id]/edit/+page.svelte` (319 lines) + `documents/new/+page.svelte` (254 lines) These two pages share four identical form sections and a save bar. Grouping them together because the right split produces **shared components** — a rare reuse opportunity within this refactor. **Proposed shared components** → `src/lib/components/document/` | New file | Responsibility | Used in | |---|---|---| | `WhoWhenSection.svelte` | Date input (with German format + ISO hidden field), location, sender typeahead, receiver multi-select | edit + new | | `DescriptionSection.svelte` | Title, archive location, tag input, summary textarea | edit + new | | `TranscriptionSection.svelte` | Transcription textarea | edit + new | | `FileSectionEdit.svelte` | Current file display + optional replace input | edit only | | `FileSectionNew.svelte` | Initial file upload input | new only | **Proposed page-local components** | New file | Location | Responsibility | |---|---|---| | `SaveBar.svelte` | `routes/documents/[id]/edit/` | Sticky bar with delete (two-step confirm) + cancel + save | **State ownership:** - `dateDisplay`, `dateIso`, `dateDirty` → live in `WhoWhenSection` (self-contained; hidden `<input name="documentDate">` is submitted with parent form) - `tags`, `senderId`, `selectedReceivers` → declared in the page, passed as bindable props into the sections - `confirmDelete` → lives in `SaveBar` **Why `lib/components/document/`?** These sections are used in two routes, so they cross the "co-location" threshold. They're not general-purpose enough for the top-level `lib/components/`, so a `document/` subdirectory scopes them clearly. **Also:** `handleDateInput` / `germanToIso` / `isoToGerman` are duplicated across `edit`, `new`, `profile`, and `admin/users`. This is the right moment to consolidate them into `$lib/utils/date.ts` (which already has `formatDate`). **Result:** edit page 319 → ~40 lines, new page 254 → ~30 lines.
Author
Owner

profile/+page.svelte (240 lines)

Proposed splitroutes/profile/

New file Responsibility Key state / props
PersonalInfoForm.svelte First/last name, birth date (German format), email, contact + save button receives user, form?; owns birthDateDisplay, birthDateIso
PasswordChangeForm.svelte Current / new / confirm password fields + save button receives form?

+page.svelte becomes: back link, page heading, two-column grid, and the two components. No shared state between the two forms.

Note: isoToGerman, germanToIso, handleBirthDateInput are duplicated here from the admin user pages. These should be consolidated into $lib/utils/date.ts as part of this refactor (see document edit comment above).

Result: page drops from 240 → ~25 lines.

## `profile/+page.svelte` (240 lines) **Proposed split** → `routes/profile/` | New file | Responsibility | Key state / props | |---|---|---| | `PersonalInfoForm.svelte` | First/last name, birth date (German format), email, contact + save button | receives `user`, `form?`; owns `birthDateDisplay`, `birthDateIso` | | `PasswordChangeForm.svelte` | Current / new / confirm password fields + save button | receives `form?` | `+page.svelte` becomes: back link, page heading, two-column grid, and the two components. No shared state between the two forms. **Note:** `isoToGerman`, `germanToIso`, `handleBirthDateInput` are duplicated here from the admin user pages. These should be consolidated into `$lib/utils/date.ts` as part of this refactor (see document edit comment above). **Result:** page drops from 240 → ~25 lines.
Author
Owner

admin/users/[id]/+page.svelte (224 lines) + admin/users/new/+page.svelte (191 lines)

Similar situation to the document edit/new pair — both pages share the same form sections. Grouping them.

Proposed shared componentssrc/lib/components/user/

New file Responsibility Used in
UserProfileSection.svelte First/last name, birth date, email, contact fields [id] + new
UserGroupsSection.svelte Group checkboxes [id] + new
UserPasswordSection.svelte New + confirm password fields [id] (optional reset) + new (initial password — slight variation: [id] has no required, new has a separate username/password account section)

Proposed page-local components

New file Location Responsibility
AccountSection.svelte routes/admin/users/new/ Username + initial password (only on new user form)

State ownership: All form fields use plain <input name="..."> with no reactive state except birthDateDisplay/birthDateIso — those live inside UserProfileSection.

Result: [id] page 224 → ~35 lines, new page 191 → ~30 lines.

## `admin/users/[id]/+page.svelte` (224 lines) + `admin/users/new/+page.svelte` (191 lines) Similar situation to the document edit/new pair — both pages share the same form sections. Grouping them. **Proposed shared components** → `src/lib/components/user/` | New file | Responsibility | Used in | |---|---|---| | `UserProfileSection.svelte` | First/last name, birth date, email, contact fields | `[id]` + `new` | | `UserGroupsSection.svelte` | Group checkboxes | `[id]` + `new` | | `UserPasswordSection.svelte` | New + confirm password fields | `[id]` (optional reset) + `new` (initial password — slight variation: `[id]` has no `required`, `new` has a separate username/password account section) | **Proposed page-local components** | New file | Location | Responsibility | |---|---|---| | `AccountSection.svelte` | `routes/admin/users/new/` | Username + initial password (only on new user form) | **State ownership:** All form fields use plain `<input name="...">` with no reactive state except `birthDateDisplay`/`birthDateIso` — those live inside `UserProfileSection`. **Result:** `[id]` page 224 → ~35 lines, `new` page 191 → ~30 lines.
Author
Owner

+layout.svelte (205 lines)

The layout is already relatively focused (it's the global shell), but the user menu dropdown adds 50+ lines of state + markup that reads better as its own unit.

Proposed splitroutes/

New file Responsibility Key state / props
AppNav.svelte Logo + nav links with active-state styling receives isAdmin
UserMenu.svelte Avatar/icon button, dropdown with profile link and logout form, click-outside handler receives user, userInitials; owns userMenuOpen, clickOutside action

+layout.svelte keeps: isAuthPage derivation, the language switcher (3 buttons, ~15 lines — not worth extracting), ThemeToggle, and the outer <header> / <main> scaffold.

Result: layout drops from 205 → ~80 lines.

## `+layout.svelte` (205 lines) The layout is already relatively focused (it's the global shell), but the user menu dropdown adds 50+ lines of state + markup that reads better as its own unit. **Proposed split** → `routes/` | New file | Responsibility | Key state / props | |---|---|---| | `AppNav.svelte` | Logo + nav links with active-state styling | receives `isAdmin` | | `UserMenu.svelte` | Avatar/icon button, dropdown with profile link and logout form, click-outside handler | receives `user`, `userInitials`; owns `userMenuOpen`, `clickOutside` action | `+layout.svelte` keeps: `isAuthPage` derivation, the language switcher (3 buttons, ~15 lines — not worth extracting), `ThemeToggle`, and the outer `<header>` / `<main>` scaffold. **Result:** layout drops from 205 → ~80 lines.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#75