dorfteich/apps/web/src/pages/PondSettingsPage.tsx
Claude Opus 4.8 699c003d04
All checks were successful
CD / Build and push images (push) Successful in 3m57s
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Auth e2e pack (push) Successful in 3m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
Add pond ZIP + per-page docx/odt export (#65)
Two export paths, both permission-aware (permissions.md):

- `GET /ponds/:id/export/markdown` streams a ZIP of the pond's readable
  pages as Markdown (one `<slug>.md` per page, a `media/` directory,
  wikilinks rewritten to relative `[text](slug.md)` links, image sources to
  `media/<id>.<ext>`). The `reader` guard is "may see the pond"; the service
  filters to the pages the requester may actually read, so a label-restricted
  reader gets only their slice. Media is appended as read streams and pages as
  small strings, so memory stays bounded for a large pond (500-page test).
- `POST /pages/:id/export {format: docx|odt}` enqueues a `markdown → pandoc →
  file` conversion job (the #62 queue): embedded images are inlined as data
  URIs so the sidecar embeds them, wikilinks flatten to text. The client polls
  `GET /jobs/:id` and downloads `GET /jobs/:id/result`.

Frontend: office-export buttons in the page menu (`.docx`/`.odt` run the job
and download the result; PDF is a disabled placeholder for Gotenberg, #67) and
a "Download pond as ZIP" link in pond settings. New `export` i18n namespace
(de+en). Markdown copy/download stay as-is (#30).

Robustness: the pond ZIP skips an attachment whose bytes are missing on disk
(data drift) rather than letting an unhandled read-stream error crash the api;
`FileStorageService.exists` gates inclusion, with a defensive stream error
handler. The per-page export drops an unreadable image the same way.

- shared: EXPORT_FORMATS + pageExportInputSchema; export-markdown transform
  helpers (image/wikilink rewrites, MIME→extension).
- deps: archiver (streaming ZIP; v7 for CommonJS compat), fflate (dev, reads
  ZIPs in tests).
- tests: export-markdown unit + export.service.db (ZIP contents & relative
  links, label-restricted omission, docx job with inlined images, 500-page
  streaming, missing-media skip); e2e export pack (ZIP download; `.docx`
  self-skips without a pandoc sidecar, as in the import pack, #64).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 10:31:19 +02:00

89 lines
3.2 KiB
TypeScript

import type { PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { LabelManager } from '../labels/LabelManager';
import { PhantomPagesView } from '../links/PhantomPagesView';
import { AccessRulesManager } from '../access/AccessRulesManager';
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
import { PondFileManager } from '../files/PondFileManager';
import { apiGet } from '../lib/api';
import { MemberManager } from '../members/MemberManager';
/**
* Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections;
* future pond-level configuration (fonts, etc.) joins it here. Member
* management is Pond-Admin-gated in the api (the MemberManager shows the list
* to any member and hides the controls otherwise); label management needs
* modify rights. The sidebar links here for the pond owner (Site Admins and
* other members can still navigate directly).
*/
export function PondSettingsPage(): React.JSX.Element {
const { t } = useTranslation('labels');
const { t: tLinks } = useTranslation('links');
const { t: tMembers } = useTranslation('members');
const { t: tErrors } = useTranslation('errors');
const { t: tFiles } = useTranslation('files');
const { t: tExport } = useTranslation('export');
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const { user } = useAuth();
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
if (pond.error) return <FormError error={pond.error} />;
if (!pond.data) return <></>;
const canModify = Boolean(user && (user.isSiteAdmin || user.id === pond.data.ownerId));
return (
<div className="pond-settings-page">
<h1>{pond.data.name}</h1>
<section>
<h2>{tMembers('title')}</h2>
<MemberManager pondId={pond.data.id} />
</section>
<AccessRulesManager pondId={pond.data.id} />
<EffectivePermissionsInspector pondId={pond.data.id} />
<section>
<h2>{t('settings.title')}</h2>
{canModify ? (
<LabelManager pondId={pond.data.id} />
) : (
<p className="form-banner form-banner--error" role="alert">
{tErrors('forbidden')}
</p>
)}
</section>
{canModify && (
<section>
<h2>{tLinks('missing.title')}</h2>
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
</section>
)}
{canModify && (
<section>
<h2>{tFiles('manager.title')}</h2>
<PondFileManager pondId={pond.data.id} />
</section>
)}
<section className="pond-export">
<h2>{tExport('pond.heading')}</h2>
<p className="pond-export__hint">{tExport('pond.hint')}</p>
<a
className="button"
href={`/api/v1/ponds/${pond.data.id}/export/markdown`}
download={`${pond.data.slug}.zip`}
>
{tExport('pond.zip')}
</a>
</section>
</div>
);
}