@burdenoff/microfe-theculturepost 2026.726.1 → 2026.802.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/theculturepost/TheCulturePostRoutes.js +1 -1
- package/dist/theculturepost/TheCulturePostRoutes.js.map +1 -1
- package/dist/theculturepost/components/AudioRecorderControl.d.ts +1 -1
- package/dist/theculturepost/components/AudioRecorderControl.d.ts.map +1 -1
- package/dist/theculturepost/components/AudioRecorderControl.js.map +1 -1
- package/dist/theculturepost/components/PendingOralHistoryPanel.d.ts.map +1 -1
- package/dist/theculturepost/components/PendingOralHistoryPanel.js.map +1 -1
- package/dist/theculturepost/components/RecordOralHistoryDialog.js.map +1 -1
- package/dist/theculturepost/context/TheCulturePostContext.js +1 -1
- package/dist/theculturepost/context/TheCulturePostContext.js.map +1 -1
- package/dist/theculturepost/lib/offlineOralHistoryQueue.d.ts.map +1 -1
- package/dist/theculturepost/lib/offlineOralHistoryQueue.js.map +1 -1
- package/dist/theculturepost/pages/archive/ArchiveItemDetailPage.js +1 -1
- package/dist/theculturepost/pages/archive/ArchiveItemDetailPage.js.map +1 -1
- package/dist/theculturepost/pages/archive/CollectionDetailPage.js +1 -1
- package/dist/theculturepost/pages/archive/CollectionDetailPage.js.map +1 -1
- package/dist/theculturepost/pages/discussions/DiscussionDetailPage.js +1 -1
- package/dist/theculturepost/pages/discussions/DiscussionDetailPage.js.map +1 -1
- package/dist/theculturepost/pages/stories/StoryDetailPage.js +1 -1
- package/dist/theculturepost/pages/stories/StoryDetailPage.js.map +1 -1
- package/dist/theculturepost/pages/topics/TopicDetailPage.js +1 -1
- package/dist/theculturepost/pages/topics/TopicDetailPage.js.map +1 -1
- package/package.json +6 -6
|
@@ -3,7 +3,7 @@ import { usePageAnalytics as t } from "./hooks/usePageAnalytics.js";
|
|
|
3
3
|
import { HomePage as n } from "./pages/HomePage.js";
|
|
4
4
|
import { Suspense as r, lazy as i } from "react";
|
|
5
5
|
import { FeatureGate as a } from "@burdenoff/fe-libs/shared/feature-flags";
|
|
6
|
-
import { Navigate as o, Route as s, Routes as c, useLocation as l } from "react-router
|
|
6
|
+
import { Navigate as o, Route as s, Routes as c, useLocation as l } from "react-router";
|
|
7
7
|
import { jsx as u, jsxs as d } from "react/jsx-runtime";
|
|
8
8
|
import { Spinner as f } from "@burdenoff/fe-libs/ui";
|
|
9
9
|
//#region src/theculturepost/TheCulturePostRoutes.tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TheCulturePostRoutes.js","names":[],"sources":["../../src/theculturepost/TheCulturePostRoutes.tsx"],"sourcesContent":["/**\n * TheCulturePost Routes\n * @module theculturepost\n *\n * Domain routes. The editorial home page is eager-loaded (primary landing); all\n * other pages are lazy-loaded. Every route maps to a real `tcp*` backend\n * capability.\n */\n\nimport { type FC, lazy, Suspense, type ReactElement } from 'react';\nimport { Routes, Route, Navigate, useLocation } from 'react-router
|
|
1
|
+
{"version":3,"file":"TheCulturePostRoutes.js","names":[],"sources":["../../src/theculturepost/TheCulturePostRoutes.tsx"],"sourcesContent":["/**\n * TheCulturePost Routes\n * @module theculturepost\n *\n * Domain routes. The editorial home page is eager-loaded (primary landing); all\n * other pages are lazy-loaded. Every route maps to a real `tcp*` backend\n * capability.\n */\n\nimport { type FC, lazy, Suspense, type ReactElement } from 'react';\nimport { Routes, Route, Navigate, useLocation } from 'react-router';\nimport { Spinner } from '@burdenoff/fe-libs/ui';\nimport { FeatureGate } from '@burdenoff/fe-libs/shared/feature-flags';\nimport { useTheCulturePost } from './context/TheCulturePostContext';\nimport { usePageAnalytics } from './hooks/usePageAnalytics';\n\nconst FEATURE = {\n home: 'theculturepost.home',\n overview: 'theculturepost.overview',\n stories: 'theculturepost.stories',\n topics: 'theculturepost.topics',\n timeline: 'theculturepost.timeline',\n discussions: 'theculturepost.discussions',\n archive: 'theculturepost.archive',\n saved: 'theculturepost.saved',\n} as const;\n\nfunction FeatureDisabledFallback(): ReactElement {\n return (\n <div className=\"flex min-h-[400px] flex-col items-center justify-center gap-3 text-text-secondary\">\n <span className=\"text-lg font-semibold text-text-primary\">Feature unavailable</span>\n <span className=\"text-sm\">This page is disabled in your workspace.</span>\n </div>\n );\n}\n\nfunction gated(element: ReactElement, flag: string): ReactElement {\n return (\n <FeatureGate flag={flag} defaultValue fallback={<FeatureDisabledFallback />}>\n {element}\n </FeatureGate>\n );\n}\n\n// Eager — primary landing\nimport { HomePage } from './pages/HomePage';\n\n// Stories\nconst StoriesPage = lazy(() =>\n import('./pages/stories/StoriesPage').then((m) => ({ default: m.StoriesPage }))\n);\nconst StoryDetailPage = lazy(() =>\n import('./pages/stories/StoryDetailPage').then((m) => ({ default: m.StoryDetailPage }))\n);\nconst SavedStoriesPage = lazy(() =>\n import('./pages/stories/SavedStoriesPage').then((m) => ({ default: m.SavedStoriesPage }))\n);\n\n// Topics\nconst TopicsPage = lazy(() =>\n import('./pages/topics/TopicsPage').then((m) => ({ default: m.TopicsPage }))\n);\nconst TopicDetailPage = lazy(() =>\n import('./pages/topics/TopicDetailPage').then((m) => ({ default: m.TopicDetailPage }))\n);\n\n// Discussions\nconst DiscussionsPage = lazy(() =>\n import('./pages/discussions/DiscussionsPage').then((m) => ({ default: m.DiscussionsPage }))\n);\nconst DiscussionDetailPage = lazy(() =>\n import('./pages/discussions/DiscussionDetailPage').then((m) => ({\n default: m.DiscussionDetailPage,\n }))\n);\n\n// Timeline\nconst TimelinePage = lazy(() =>\n import('./pages/timeline/TimelinePage').then((m) => ({ default: m.TimelinePage }))\n);\n\n// Governance / Overview\nconst GovernancePage = lazy(() =>\n import('./pages/governance/GovernancePage').then((m) => ({ default: m.GovernancePage }))\n);\n\n// Archive\nconst ArchivePage = lazy(() =>\n import('./pages/archive/ArchivePage').then((m) => ({ default: m.ArchivePage }))\n);\nconst CollectionDetailPage = lazy(() =>\n import('./pages/archive/CollectionDetailPage').then((m) => ({ default: m.CollectionDetailPage }))\n);\nconst ArchiveItemDetailPage = lazy(() =>\n import('./pages/archive/ArchiveItemDetailPage').then((m) => ({\n default: m.ArchiveItemDetailPage,\n }))\n);\n\nconst PageLoader: FC = () => (\n <div className=\"flex min-h-[400px] items-center justify-center\">\n <Spinner size=\"lg\" />\n </div>\n);\n\nexport const TheCulturePostRoutes: FC = () => {\n const { basePath, isStandalone } = useTheCulturePost();\n const location = useLocation();\n\n usePageAnalytics();\n\n const normalizedBasePath = basePath === '/' ? '' : (basePath ?? '').replace(/\\/$/, '');\n\n // Guard: only render if the current path matches basePath (integrated mode).\n if (normalizedBasePath && !isStandalone) {\n const pathMatches =\n location.pathname === normalizedBasePath ||\n location.pathname.startsWith(normalizedBasePath + '/');\n if (!pathMatches) {\n return null;\n }\n }\n\n return (\n <div className=\"flex min-h-full flex-col\">\n <Suspense fallback={<PageLoader />}>\n <Routes>\n <Route index element={gated(<HomePage />, FEATURE.home)} />\n\n <Route path=\"overview\" element={gated(<GovernancePage />, FEATURE.overview)} />\n\n <Route path=\"stories\" element={gated(<StoriesPage />, FEATURE.stories)} />\n <Route path=\"stories/:id\" element={gated(<StoryDetailPage />, FEATURE.stories)} />\n\n <Route path=\"saved\" element={gated(<SavedStoriesPage />, FEATURE.saved)} />\n\n <Route path=\"topics\" element={gated(<TopicsPage />, FEATURE.topics)} />\n <Route path=\"topics/:id\" element={gated(<TopicDetailPage />, FEATURE.topics)} />\n\n <Route path=\"timeline\" element={gated(<TimelinePage />, FEATURE.timeline)} />\n\n <Route path=\"discussions\" element={gated(<DiscussionsPage />, FEATURE.discussions)} />\n <Route\n path=\"discussions/:id\"\n element={gated(<DiscussionDetailPage />, FEATURE.discussions)}\n />\n\n <Route path=\"archive\" element={gated(<ArchivePage />, FEATURE.archive)} />\n <Route\n path=\"archive/collections/:id\"\n element={gated(<CollectionDetailPage />, FEATURE.archive)}\n />\n <Route\n path=\"archive/items/:id\"\n element={gated(<ArchiveItemDetailPage />, FEATURE.archive)}\n />\n\n <Route path=\"*\" element={<Navigate to=\"\" replace />} />\n </Routes>\n </Suspense>\n </div>\n );\n};\n"],"mappings":";;;;;;;;;AAgBA,IAAM,IAAU;CACd,MAAM;CACN,UAAU;CACV,SAAS;CACT,QAAQ;CACR,UAAU;CACV,aAAa;CACb,SAAS;CACT,OAAO;CACR;AAED,SAAS,IAAwC;AAC/C,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf,CACE,kBAAC,QAAD;GAAM,WAAU;aAA0C;GAA0B,CAAA,EACpF,kBAAC,QAAD;GAAM,WAAU;aAAU;GAA+C,CAAA,CACrE;;;AAIV,SAAS,EAAM,GAAuB,GAA4B;AAChE,QACE,kBAAC,GAAD;EAAmB;EAAM,cAAA;EAAa,UAAU,kBAAC,GAAD,EAA2B,CAAA;YACxE;EACW,CAAA;;AAQlB,IAAM,IAAc,QAClB,OAAO,kCAA+B,MAAM,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,CAChF,EACK,IAAkB,QACtB,OAAO,sCAAmC,MAAM,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,CACxF,EACK,IAAmB,QACvB,OAAO,uCAAoC,MAAM,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,CAC1F,EAGK,IAAa,QACjB,OAAO,gCAA6B,MAAM,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,CAC7E,EACK,IAAkB,QACtB,OAAO,qCAAkC,MAAM,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,CACvF,EAGK,IAAkB,QACtB,OAAO,0CAAuC,MAAM,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAC5F,EACK,IAAuB,QAC3B,OAAO,+CAA4C,MAAM,OAAO,EAC9D,SAAS,EAAE,sBACZ,EAAE,CACJ,EAGK,IAAe,QACnB,OAAO,oCAAiC,MAAM,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,CACnF,EAGK,IAAiB,QACrB,OAAO,wCAAqC,MAAM,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,CACzF,EAGK,IAAc,QAClB,OAAO,kCAA+B,MAAM,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,CAChF,EACK,IAAuB,QAC3B,OAAO,2CAAwC,MAAM,OAAO,EAAE,SAAS,EAAE,sBAAsB,EAAE,CAClG,EACK,IAAwB,QAC5B,OAAO,4CAAyC,MAAM,OAAO,EAC3D,SAAS,EAAE,uBACZ,EAAE,CACJ,EAEK,UACJ,kBAAC,OAAD;CAAK,WAAU;WACb,kBAAC,GAAD,EAAS,MAAK,MAAO,CAAA;CACjB,CAAA,EAGK,UAAiC;CAC5C,IAAM,EAAE,aAAU,oBAAiB,GAAmB,EAChD,IAAW,GAAa;AAE9B,IAAkB;CAElB,IAAM,IAAqB,MAAa,MAAM,MAAM,KAAY,IAAI,QAAQ,OAAO,GAAG;AAYtF,QATI,KAAsB,CAAC,KAIrB,EAFF,EAAS,aAAa,KACtB,EAAS,SAAS,WAAW,IAAqB,IAAI,IAE/C,OAKT,kBAAC,OAAD;EAAK,WAAU;YACb,kBAAC,GAAD;GAAU,UAAU,kBAAC,GAAD,EAAc,CAAA;aAChC,kBAAC,GAAD,EAAA,UAAA;IACE,kBAAC,GAAD;KAAO,OAAA;KAAM,SAAS,EAAM,kBAAC,GAAD,EAAY,CAAA,EAAE,EAAQ,KAAK;KAAI,CAAA;IAE3D,kBAAC,GAAD;KAAO,MAAK;KAAW,SAAS,EAAM,kBAAC,GAAD,EAAkB,CAAA,EAAE,EAAQ,SAAS;KAAI,CAAA;IAE/E,kBAAC,GAAD;KAAO,MAAK;KAAU,SAAS,EAAM,kBAAC,GAAD,EAAe,CAAA,EAAE,EAAQ,QAAQ;KAAI,CAAA;IAC1E,kBAAC,GAAD;KAAO,MAAK;KAAc,SAAS,EAAM,kBAAC,GAAD,EAAmB,CAAA,EAAE,EAAQ,QAAQ;KAAI,CAAA;IAElF,kBAAC,GAAD;KAAO,MAAK;KAAQ,SAAS,EAAM,kBAAC,GAAD,EAAoB,CAAA,EAAE,EAAQ,MAAM;KAAI,CAAA;IAE3E,kBAAC,GAAD;KAAO,MAAK;KAAS,SAAS,EAAM,kBAAC,GAAD,EAAc,CAAA,EAAE,EAAQ,OAAO;KAAI,CAAA;IACvE,kBAAC,GAAD;KAAO,MAAK;KAAa,SAAS,EAAM,kBAAC,GAAD,EAAmB,CAAA,EAAE,EAAQ,OAAO;KAAI,CAAA;IAEhF,kBAAC,GAAD;KAAO,MAAK;KAAW,SAAS,EAAM,kBAAC,GAAD,EAAgB,CAAA,EAAE,EAAQ,SAAS;KAAI,CAAA;IAE7E,kBAAC,GAAD;KAAO,MAAK;KAAc,SAAS,EAAM,kBAAC,GAAD,EAAmB,CAAA,EAAE,EAAQ,YAAY;KAAI,CAAA;IACtF,kBAAC,GAAD;KACE,MAAK;KACL,SAAS,EAAM,kBAAC,GAAD,EAAwB,CAAA,EAAE,EAAQ,YAAY;KAC7D,CAAA;IAEF,kBAAC,GAAD;KAAO,MAAK;KAAU,SAAS,EAAM,kBAAC,GAAD,EAAe,CAAA,EAAE,EAAQ,QAAQ;KAAI,CAAA;IAC1E,kBAAC,GAAD;KACE,MAAK;KACL,SAAS,EAAM,kBAAC,GAAD,EAAwB,CAAA,EAAE,EAAQ,QAAQ;KACzD,CAAA;IACF,kBAAC,GAAD;KACE,MAAK;KACL,SAAS,EAAM,kBAAC,GAAD,EAAyB,CAAA,EAAE,EAAQ,QAAQ;KAC1D,CAAA;IAEF,kBAAC,GAAD;KAAO,MAAK;KAAI,SAAS,kBAAC,GAAD;MAAU,IAAG;MAAG,SAAA;MAAU,CAAA;KAAI,CAAA;IAChD,EAAA,CAAA;GACA,CAAA;EACP,CAAA"}
|
|
@@ -23,6 +23,6 @@ interface AudioRecorderControlProps {
|
|
|
23
23
|
onClear: () => void;
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
}
|
|
26
|
-
export declare function AudioRecorderControl({ value, onRecorded, onClear, disabled }: AudioRecorderControlProps): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
export declare function AudioRecorderControl({ value, onRecorded, onClear, disabled, }: AudioRecorderControlProps): import("react/jsx-runtime").JSX.Element;
|
|
27
27
|
export {};
|
|
28
28
|
//# sourceMappingURL=AudioRecorderControl.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AudioRecorderControl.d.ts","sourceRoot":"","sources":["../../../src/theculturepost/components/AudioRecorderControl.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAQH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,yBAAyB;IACjC,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IAC5B,UAAU,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3C,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAmBD,wBAAgB,oBAAoB,CAAC,
|
|
1
|
+
{"version":3,"file":"AudioRecorderControl.d.ts","sourceRoot":"","sources":["../../../src/theculturepost/components/AudioRecorderControl.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAQH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,yEAAyE;IACzE,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,yBAAyB;IACjC,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IAC5B,UAAU,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC3C,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAmBD,wBAAgB,oBAAoB,CAAC,EACnC,KAAK,EACL,UAAU,EACV,OAAO,EACP,QAAQ,GACT,EAAE,yBAAyB,2CA6I3B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AudioRecorderControl.js","names":[],"sources":["../../../src/theculturepost/components/AudioRecorderControl.tsx"],"sourcesContent":["/**\n * AudioRecorderControl — in-app microphone recording for oral-history capture.\n * @module theculturepost/components\n *\n * Uses the standard `navigator.mediaDevices.getUserMedia` + `MediaRecorder`\n * Web APIs, which work identically inside the Capacitor WebView (once the\n * host app declares `RECORD_AUDIO` / `NSMicrophoneUsageDescription`) and in a\n * desktop browser — no native Capacitor plugin is required for audio-only\n * capture. This is what makes \"record now\" possible on-device for field\n * oral-history collection (BOFF-4128), as an addition to the existing\n * upload-an-existing-file flow in {@link MediaDropzone}.\n */\n\nimport { useEffect, useRef, useState } from 'react';\nimport { Mic, Square, Trash2, AlertCircle } from 'lucide-react';\nimport { Button } from '@burdenoff/fe-libs/ui';\nimport { AudioPlayer } from './AudioPlayer';\nimport { isNativePlatform } from '../utils/nativeBridge';\n\nexport interface RecordedAudio {\n blob: Blob;\n mimeType: string;\n durationSecs: number;\n /** An object URL for local preview — caller must revoke it when done. */\n previewUrl: string;\n}\n\ninterface AudioRecorderControlProps {\n value: RecordedAudio | null;\n onRecorded: (audio: RecordedAudio) => void;\n onClear: () => void;\n disabled?: boolean;\n}\n\n/** Picks the first MIME type the platform's MediaRecorder actually supports. */\nfunction pickSupportedMimeType(): string {\n const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/aac'];\n for (const candidate of candidates) {\n if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(candidate)) {\n return candidate;\n }\n }\n return '';\n}\n\nfunction formatElapsed(seconds: number): string {\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return `${m}:${String(s).padStart(2, '0')}`;\n}\n\nexport function AudioRecorderControl({
|
|
1
|
+
{"version":3,"file":"AudioRecorderControl.js","names":[],"sources":["../../../src/theculturepost/components/AudioRecorderControl.tsx"],"sourcesContent":["/**\n * AudioRecorderControl — in-app microphone recording for oral-history capture.\n * @module theculturepost/components\n *\n * Uses the standard `navigator.mediaDevices.getUserMedia` + `MediaRecorder`\n * Web APIs, which work identically inside the Capacitor WebView (once the\n * host app declares `RECORD_AUDIO` / `NSMicrophoneUsageDescription`) and in a\n * desktop browser — no native Capacitor plugin is required for audio-only\n * capture. This is what makes \"record now\" possible on-device for field\n * oral-history collection (BOFF-4128), as an addition to the existing\n * upload-an-existing-file flow in {@link MediaDropzone}.\n */\n\nimport { useEffect, useRef, useState } from 'react';\nimport { Mic, Square, Trash2, AlertCircle } from 'lucide-react';\nimport { Button } from '@burdenoff/fe-libs/ui';\nimport { AudioPlayer } from './AudioPlayer';\nimport { isNativePlatform } from '../utils/nativeBridge';\n\nexport interface RecordedAudio {\n blob: Blob;\n mimeType: string;\n durationSecs: number;\n /** An object URL for local preview — caller must revoke it when done. */\n previewUrl: string;\n}\n\ninterface AudioRecorderControlProps {\n value: RecordedAudio | null;\n onRecorded: (audio: RecordedAudio) => void;\n onClear: () => void;\n disabled?: boolean;\n}\n\n/** Picks the first MIME type the platform's MediaRecorder actually supports. */\nfunction pickSupportedMimeType(): string {\n const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/aac'];\n for (const candidate of candidates) {\n if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported?.(candidate)) {\n return candidate;\n }\n }\n return '';\n}\n\nfunction formatElapsed(seconds: number): string {\n const m = Math.floor(seconds / 60);\n const s = Math.floor(seconds % 60);\n return `${m}:${String(s).padStart(2, '0')}`;\n}\n\nexport function AudioRecorderControl({\n value,\n onRecorded,\n onClear,\n disabled,\n}: AudioRecorderControlProps) {\n const [recording, setRecording] = useState(false);\n const [elapsed, setElapsed] = useState(0);\n const [error, setError] = useState<string | null>(null);\n\n const mediaRecorderRef = useRef<MediaRecorder | null>(null);\n const chunksRef = useRef<Blob[]>([]);\n const streamRef = useRef<MediaStream | null>(null);\n const startedAtRef = useRef<number>(0);\n const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);\n\n useEffect(\n () => () => {\n // Unmount safety: stop any in-flight recording and release the mic.\n if (timerRef.current) clearInterval(timerRef.current);\n streamRef.current?.getTracks().forEach((track) => track.stop());\n },\n []\n );\n\n const stopTimer = () => {\n if (timerRef.current) {\n clearInterval(timerRef.current);\n timerRef.current = null;\n }\n };\n\n const start = async () => {\n setError(null);\n if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {\n setError('Microphone recording is not supported in this browser.');\n return;\n }\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n streamRef.current = stream;\n const mimeType = pickSupportedMimeType();\n const recorder = mimeType\n ? new MediaRecorder(stream, { mimeType })\n : new MediaRecorder(stream);\n chunksRef.current = [];\n\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunksRef.current.push(event.data);\n };\n\n recorder.onstop = () => {\n stopTimer();\n stream.getTracks().forEach((track) => track.stop());\n const finalMimeType = recorder.mimeType || mimeType || 'audio/webm';\n const blob = new Blob(chunksRef.current, { type: finalMimeType });\n const durationSecs = Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000));\n onRecorded({\n blob,\n mimeType: finalMimeType,\n durationSecs,\n previewUrl: URL.createObjectURL(blob),\n });\n };\n\n mediaRecorderRef.current = recorder;\n startedAtRef.current = Date.now();\n setElapsed(0);\n recorder.start();\n setRecording(true);\n timerRef.current = setInterval(() => {\n setElapsed(Math.round((Date.now() - startedAtRef.current) / 1000));\n }, 500);\n } catch {\n setError(\n 'Microphone access was denied or is unavailable. Check app permissions and try again.'\n );\n }\n };\n\n const stop = () => {\n mediaRecorderRef.current?.stop();\n setRecording(false);\n };\n\n const discard = () => {\n if (value) URL.revokeObjectURL(value.previewUrl);\n onClear();\n setElapsed(0);\n };\n\n if (value) {\n return (\n <div className=\"flex flex-col gap-2\">\n <AudioPlayer\n src={value.previewUrl}\n seed=\"local-recording\"\n durationSecs={value.durationSecs}\n />\n <button\n type=\"button\"\n onClick={discard}\n disabled={disabled}\n className=\"inline-flex w-fit items-center gap-1 text-sm text-text-tertiary hover:text-text-primary disabled:opacity-60\"\n >\n <Trash2 className=\"h-3.5 w-3.5\" /> Discard and record again\n </button>\n </div>\n );\n }\n\n return (\n <div className=\"flex flex-col items-center gap-3 rounded-xl border border-dashed border-border-seam bg-bg-surface px-4 py-6 text-center\">\n {recording ? (\n <>\n <div className=\"flex items-center gap-2 text-status-error-text\">\n <span className=\"relative flex h-3 w-3\">\n <span className=\"absolute inline-flex h-full w-full animate-ping rounded-full bg-status-error-text opacity-75\" />\n <span className=\"relative inline-flex h-3 w-3 rounded-full bg-status-error-text\" />\n </span>\n <span className=\"font-mono text-lg tabular-nums\">{formatElapsed(elapsed)}</span>\n </div>\n <Button type=\"button\" variant=\"outline\" onClick={stop}>\n <Square className=\"mr-1.5 h-4 w-4\" /> Stop recording\n </Button>\n </>\n ) : (\n <>\n <Mic className=\"h-6 w-6 text-text-tertiary\" aria-hidden=\"true\" />\n <p className=\"text-sm text-text-secondary\">\n {isNativePlatform()\n ? 'Record the testimony directly on this device.'\n : 'Record using this device’s microphone.'}\n </p>\n <Button type=\"button\" onClick={start} disabled={disabled}>\n <Mic className=\"mr-1.5 h-4 w-4\" /> Start recording\n </Button>\n </>\n )}\n {error && (\n <p className=\"flex items-center gap-1.5 text-xs text-status-error-text\">\n <AlertCircle className=\"h-3.5 w-3.5 shrink-0\" /> {error}\n </p>\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PendingOralHistoryPanel.d.ts","sourceRoot":"","sources":["../../../src/theculturepost/components/PendingOralHistoryPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,wBAAgB,uBAAuB,
|
|
1
|
+
{"version":3,"file":"PendingOralHistoryPanel.d.ts","sourceRoot":"","sources":["../../../src/theculturepost/components/PendingOralHistoryPanel.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,wBAAgB,uBAAuB,mDAmEtC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PendingOralHistoryPanel.js","names":[],"sources":["../../../src/theculturepost/components/PendingOralHistoryPanel.tsx"],"sourcesContent":["/**\n * PendingOralHistoryPanel — shows oral-history captures still queued offline.\n * @module theculturepost/components\n *\n * Surfaces the {@link usePendingOralHistorySync} queue so a field recorder can\n * see what hasn't reached the archive yet, retry a single capture, or\n * discard one recorded in error. Renders nothing when the queue is empty.\n */\n\nimport { CloudOff, RefreshCw, Trash2, AlertTriangle } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport { usePendingOralHistorySync } from '../hooks';\n\nexport function PendingOralHistoryPanel() {\n const { pending, syncingId, syncOne, syncAll, discard } = usePendingOralHistorySync();\n\n if (pending.length === 0) return null;\n\n return (\n <GlassCard\n treatment=\"plain\"\n className=\"flex flex-col gap-3 border-status-warning-border bg-status-warning-bg/40 p-4\"\n >\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <div className=\"flex items-center gap-2 text-sm font-medium text-text-primary\">\n <CloudOff className=\"h-4 w-4 text-status-warning-text\" aria-hidden=\"true\" />\n {pending.length === 1\n ? '1 recording waiting to sync'\n : `${pending.length} recordings waiting to sync`}\n </div>\n <Button
|
|
1
|
+
{"version":3,"file":"PendingOralHistoryPanel.js","names":[],"sources":["../../../src/theculturepost/components/PendingOralHistoryPanel.tsx"],"sourcesContent":["/**\n * PendingOralHistoryPanel — shows oral-history captures still queued offline.\n * @module theculturepost/components\n *\n * Surfaces the {@link usePendingOralHistorySync} queue so a field recorder can\n * see what hasn't reached the archive yet, retry a single capture, or\n * discard one recorded in error. Renders nothing when the queue is empty.\n */\n\nimport { CloudOff, RefreshCw, Trash2, AlertTriangle } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport { usePendingOralHistorySync } from '../hooks';\n\nexport function PendingOralHistoryPanel() {\n const { pending, syncingId, syncOne, syncAll, discard } = usePendingOralHistorySync();\n\n if (pending.length === 0) return null;\n\n return (\n <GlassCard\n treatment=\"plain\"\n className=\"flex flex-col gap-3 border-status-warning-border bg-status-warning-bg/40 p-4\"\n >\n <div className=\"flex flex-wrap items-center justify-between gap-2\">\n <div className=\"flex items-center gap-2 text-sm font-medium text-text-primary\">\n <CloudOff className=\"h-4 w-4 text-status-warning-text\" aria-hidden=\"true\" />\n {pending.length === 1\n ? '1 recording waiting to sync'\n : `${pending.length} recordings waiting to sync`}\n </div>\n <Button\n size=\"sm\"\n variant=\"outline\"\n onClick={() => void syncAll()}\n disabled={syncingId !== null}\n >\n <RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${syncingId ? 'animate-spin' : ''}`} /> Sync now\n </Button>\n </div>\n\n <ul className=\"flex flex-col gap-2\">\n {pending.map((capture) => (\n <li\n key={capture.id}\n className=\"flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border-seam bg-bg-surface px-3 py-2\"\n >\n <div className=\"min-w-0\">\n <p className=\"truncate text-sm font-medium text-text-primary\">{capture.title}</p>\n {capture.lastError && (\n <p className=\"mt-0.5 flex items-center gap-1 text-xs text-status-error-text\">\n <AlertTriangle className=\"h-3 w-3 shrink-0\" /> {capture.lastError}\n </p>\n )}\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n <Button\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => void syncOne(capture)}\n disabled={syncingId === capture.id}\n >\n <RefreshCw\n className={`mr-1 h-3.5 w-3.5 ${syncingId === capture.id ? 'animate-spin' : ''}`}\n />\n Retry\n </Button>\n <button\n type=\"button\"\n onClick={() => void discard(capture.id)}\n aria-label={`Discard ${capture.title}`}\n className=\"text-text-tertiary hover:text-status-error-text\"\n >\n <Trash2 className=\"h-4 w-4\" />\n </button>\n </div>\n </li>\n ))}\n </ul>\n </GlassCard>\n );\n}\n"],"mappings":";;;;;;AAaA,SAAgB,IAA0B;CACxC,IAAM,EAAE,YAAS,cAAW,YAAS,YAAS,eAAY,GAA2B;AAIrF,QAFI,EAAQ,WAAW,IAAU,OAG/B,kBAAC,GAAD;EACE,WAAU;EACV,WAAU;YAFZ,CAIE,kBAAC,OAAD;GAAK,WAAU;aAAf,CACE,kBAAC,OAAD;IAAK,WAAU;cAAf,CACE,kBAAC,GAAD;KAAU,WAAU;KAAmC,eAAY;KAAS,CAAA,EAC3E,EAAQ,WAAW,IAChB,gCACA,GAAG,EAAQ,OAAO,6BAClB;OACN,kBAAC,GAAD;IACE,MAAK;IACL,SAAQ;IACR,eAAe,KAAK,GAAS;IAC7B,UAAU,MAAc;cAJ1B,CAME,kBAAC,GAAD,EAAW,WAAW,sBAAsB,IAAY,iBAAiB,MAAQ,CAAA,EAAA,YAC1E;MACL;MAEN,kBAAC,MAAD;GAAI,WAAU;aACX,EAAQ,KAAK,MACZ,kBAAC,MAAD;IAEE,WAAU;cAFZ,CAIE,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,KAAD;MAAG,WAAU;gBAAkD,EAAQ;MAAU,CAAA,EAChF,EAAQ,aACP,kBAAC,KAAD;MAAG,WAAU;gBAAb;OACE,kBAAC,GAAD,EAAe,WAAU,oBAAqB,CAAA;;OAAE,EAAQ;OACtD;QAEF;QACN,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,GAAD;MACE,MAAK;MACL,SAAQ;MACR,eAAe,KAAK,EAAQ,EAAQ;MACpC,UAAU,MAAc,EAAQ;gBAJlC,CAME,kBAAC,GAAD,EACE,WAAW,oBAAoB,MAAc,EAAQ,KAAK,iBAAiB,MAC3E,CAAA,EAAA,QAEK;SACT,kBAAC,UAAD;MACE,MAAK;MACL,eAAe,KAAK,EAAQ,EAAQ,GAAG;MACvC,cAAY,WAAW,EAAQ;MAC/B,WAAU;gBAEV,kBAAC,GAAD,EAAQ,WAAU,WAAY,CAAA;MACvB,CAAA,CACL;OACH;MAhCE,EAAQ,GAgCV,CACL;GACC,CAAA,CACK"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RecordOralHistoryDialog.js","names":[],"sources":["../../../src/theculturepost/components/RecordOralHistoryDialog.tsx"],"sourcesContent":["/**\n * RecordOralHistoryDialog — capture an oral history recording.\n * @module theculturepost/components\n *\n * Writes `tcpRecordOralHistory`. The recording (audio/video) is uploaded to\n * wspace-files-svc via the shared {@link MediaDropzone} (drag-drop or browse),\n * and previewed inline with the foundation {@link AudioPlayer}. `narratorName`\n * is a plain string stored directly — the schema has NO narrator portrait/age,\n * so none is ever rendered. All `TcpCreateOralHistoryInput` fields are captured.\n */\n\nimport { useRef, useState } from 'react';\nimport { UploadCloud, Loader2, FileCheck2, X, Mic, ShieldCheck } from 'lucide-react';\nimport {\n Button,\n Input,\n Label,\n Textarea,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport { AudioPlayer } from './AudioPlayer';\nimport { AudioRecorderControl, type RecordedAudio } from './AudioRecorderControl';\nimport { TagInput } from './TagInput';\nimport { Tabs, type TabItem } from './Tabs';\nimport { cn } from '../utils/cn';\nimport {\n uploadImageToFiles,\n useTcpRecordOralHistory,\n useTcpCollections,\n FILE_FOLDERS,\n} from '../hooks';\nimport { enqueuePendingOralHistory } from '../lib/offlineOralHistoryQueue';\nimport { formatEnumLabel } from '../utils/formatters';\nimport { notify, describeError } from '../lib/toast';\nimport type { TcpOralHistory, TcpAccessLevel } from '../types';\n\n// ---------------------------------------------------------------------------\n// MediaDropzone — a shared drag-drop uploader (reused by the archive dialogs).\n// ---------------------------------------------------------------------------\n\n/** A file uploaded to wspace-files-svc: id (persisted) + presigned url + name. */\nexport interface UploadedRef {\n id: string;\n url: string;\n name: string;\n}\n\ninterface MediaDropzoneProps {\n /** Destination folder in wspace-files-svc (see FILE_FOLDERS). */\n folder: string;\n /** HTML accept attribute (e.g. 'audio/*,video/*'). */\n accept: string;\n /** Muted helper line under the drop prompt. */\n hint: string;\n /** Large prompt shown in the empty dropzone. */\n promptLabel: string;\n /** Current uploaded file (controlled so the parent can reset on submit). */\n value: UploadedRef | null;\n /** Called after a successful upload; `file` is provided for metadata probes. */\n onUploaded: (ref: UploadedRef, file: File) => void;\n onClear: () => void;\n /** Max upload size in MB. */\n maxMb?: number;\n disabled?: boolean;\n}\n\n/**\n * A token-only drag-and-drop file uploader with an inline preview: images show\n * a thumbnail, audio/video show the {@link AudioPlayer}, everything else shows a\n * filename chip. Uploads go straight to wspace-files-svc — only the id is kept.\n */\nexport function MediaDropzone({\n folder,\n accept,\n hint,\n promptLabel,\n value,\n onUploaded,\n onClear,\n maxMb = 25,\n disabled = false,\n}: MediaDropzoneProps) {\n const inputRef = useRef<HTMLInputElement | null>(null);\n const [dragging, setDragging] = useState(false);\n const [uploading, setUploading] = useState(false);\n const [previewType, setPreviewType] = useState<'image' | 'audio' | 'file' | null>(null);\n\n // Keep the preview kind in sync when the parent clears the value (e.g. on reset).\n const [trackedValue, setTrackedValue] = useState(value);\n if (trackedValue !== value) {\n setTrackedValue(value);\n if (!value) setPreviewType(null);\n }\n\n const maxBytes = maxMb * 1024 * 1024;\n\n const ingest = async (file: File | undefined) => {\n if (!file || disabled) return;\n if (file.size > maxBytes) {\n notify.error('File too large', `Please choose a file under ${maxMb} MB.`);\n return;\n }\n setUploading(true);\n try {\n const uploaded = await uploadImageToFiles(file, folder);\n const isImage = file.type.startsWith('image/');\n const isAv = file.type.startsWith('audio/') || file.type.startsWith('video/');\n setPreviewType(isImage ? 'image' : isAv ? 'audio' : 'file');\n onUploaded({ id: uploaded.id, url: uploaded.url, name: uploaded.name }, file);\n notify.success('File uploaded', uploaded.name);\n } catch (err) {\n notify.error('Upload failed', describeError(err));\n } finally {\n setUploading(false);\n }\n };\n\n const hiddenInput = (\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n className=\"sr-only\"\n disabled={disabled || uploading}\n onChange={(e) => {\n const file = e.target.files?.[0];\n e.target.value = '';\n void ingest(file);\n }}\n />\n );\n\n if (value) {\n return (\n <div className=\"flex flex-col gap-2\">\n {previewType === 'image' ? (\n <div className=\"overflow-hidden rounded-xl border border-border-seam\">\n <img src={value.url} alt={value.name} className=\"max-h-48 w-full object-cover\" />\n </div>\n ) : previewType === 'audio' ? (\n <AudioPlayer src={value.url} seed={value.id} />\n ) : (\n <div className=\"flex items-center gap-2 rounded-xl border border-border-seam bg-bg-surface px-3 py-2 text-sm\">\n <FileCheck2 className=\"h-4 w-4 shrink-0 text-status-success-text\" />\n <span className=\"truncate text-text-primary\">{value.name}</span>\n </div>\n )}\n <div className=\"flex items-center gap-4\">\n <button\n type=\"button\"\n onClick={() => inputRef.current?.click()}\n disabled={disabled || uploading}\n className=\"text-sm font-medium text-accent hover:underline disabled:opacity-60\"\n >\n {uploading ? 'Uploading…' : 'Replace'}\n </button>\n <button\n type=\"button\"\n onClick={onClear}\n className=\"inline-flex items-center gap-1 text-sm text-text-tertiary hover:text-text-primary\"\n >\n <X className=\"h-3.5 w-3.5\" /> Remove\n </button>\n </div>\n {hiddenInput}\n </div>\n );\n }\n\n return (\n <div\n onDragOver={(e) => {\n e.preventDefault();\n if (!disabled) setDragging(true);\n }}\n onDragLeave={() => setDragging(false)}\n onDrop={(e) => {\n e.preventDefault();\n setDragging(false);\n void ingest(e.dataTransfer.files?.[0]);\n }}\n className={cn(\n 'flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed px-4 py-6 text-center transition-colors',\n dragging\n ? 'border-accent bg-[var(--color-accent-soft)]'\n : 'border-border-seam bg-bg-surface',\n (disabled || uploading) && 'opacity-60'\n )}\n >\n {hiddenInput}\n {uploading ? (\n <Loader2 className=\"h-6 w-6 animate-spin text-text-tertiary\" aria-hidden=\"true\" />\n ) : (\n <UploadCloud className=\"h-6 w-6 text-text-tertiary\" aria-hidden=\"true\" />\n )}\n <p className=\"text-sm text-text-secondary\">{uploading ? 'Uploading…' : promptLabel}</p>\n {!uploading && (\n <button\n type=\"button\"\n onClick={() => inputRef.current?.click()}\n disabled={disabled}\n className=\"inline-flex items-center gap-1.5 rounded-lg bg-action-primary-bg px-3 py-1.5 text-sm font-medium text-action-primary-text hover:opacity-90 disabled:opacity-60\"\n >\n <UploadCloud className=\"h-4 w-4\" /> Browse files\n </button>\n )}\n <p className=\"text-xs text-text-tertiary\">{hint}</p>\n </div>\n );\n}\n\n// ---------------------------------------------------------------------------\n// Access levels + copy\n// ---------------------------------------------------------------------------\n\nconst ACCESS_LEVELS: TcpAccessLevel[] = ['COMMUNITY', 'PUBLIC', 'RESTRICTED', 'EMBARGOED'];\n\nconst ACCESS_HELP: Record<TcpAccessLevel, string> = {\n PUBLIC: 'Visible to everyone.',\n COMMUNITY: 'Available to community members.',\n RESTRICTED: 'Visible to selected groups.',\n EMBARGOED: 'Hidden until a future date.',\n};\n\nconst TITLE_MAX = 160;\nconst NARRATOR_MAX = 120;\nconst LANGUAGE_MAX = 60;\nconst SUMMARY_MAX = 300;\nconst TRANSCRIPT_MAX = 5000;\n\nconst dateToIso = (value: string): string | null => {\n if (!value) return null;\n const d = new Date(value);\n return Number.isNaN(d.getTime()) ? null : d.toISOString();\n};\n\ninterface RecordOralHistoryDialogProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n /** Pre-select a collection. */\n collectionId?: string;\n onCreated?: (oralHistory: TcpOralHistory) => void;\n}\n\nexport function RecordOralHistoryDialog({\n open,\n onOpenChange,\n collectionId,\n onCreated,\n}: RecordOralHistoryDialogProps) {\n const record = useTcpRecordOralHistory();\n const collectionsQuery = useTcpCollections({ limit: 100 });\n\n const [source, setSource] = useState<'record' | 'upload'>('record');\n const [title, setTitle] = useState('');\n const [summary, setSummary] = useState('');\n const [transcript, setTranscript] = useState('');\n const [narratorName, setNarratorName] = useState('');\n const [language, setLanguage] = useState('');\n const [accessLevel, setAccessLevel] = useState<TcpAccessLevel>('COMMUNITY');\n const [pickedCollection, setPickedCollection] = useState(collectionId ?? '');\n const [recording, setRecording] = useState<UploadedRef | null>(null);\n const [liveRecording, setLiveRecording] = useState<RecordedAudio | null>(null);\n const [durationSecs, setDurationSecs] = useState('');\n const [recordedAt, setRecordedAt] = useState('');\n const [tags, setTags] = useState<string[]>([]);\n const [consentGiven, setConsentGiven] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [submitting, setSubmitting] = useState(false);\n\n const reset = () => {\n setSource('record');\n setTitle('');\n setSummary('');\n setTranscript('');\n setNarratorName('');\n setLanguage('');\n setAccessLevel('COMMUNITY');\n setPickedCollection(collectionId ?? '');\n setRecording(null);\n if (liveRecording) URL.revokeObjectURL(liveRecording.previewUrl);\n setLiveRecording(null);\n setDurationSecs('');\n setRecordedAt('');\n setTags([]);\n setConsentGiven(false);\n setError(null);\n };\n\n /** Best-effort: read the recording's real duration to pre-fill the field. */\n const probeDuration = (url: string) => {\n try {\n const probe = new Audio();\n probe.preload = 'metadata';\n probe.onloadedmetadata = () => {\n if (Number.isFinite(probe.duration) && probe.duration > 0) {\n setDurationSecs((prev) => (prev ? prev : String(Math.round(probe.duration))));\n }\n };\n probe.src = url;\n } catch {\n // Non-fatal — the field stays manually editable.\n }\n };\n\n const submit = async () => {\n if (!title.trim()) {\n setError('A title is required.');\n return;\n }\n if (!consentGiven) {\n setError(\"The narrator's consent is required before this recording can be preserved.\");\n return;\n }\n\n const baseInput = {\n title: title.trim(),\n summary: summary.trim() || null,\n transcript: transcript.trim() || null,\n narratorName: narratorName.trim() || null,\n language: language.trim() || null,\n accessLevel,\n collectionId: pickedCollection || null,\n recordedAt: dateToIso(recordedAt),\n tags,\n consentGiven: true as const,\n };\n\n setSubmitting(true);\n setError(null);\n try {\n // A live in-app recording that hasn't been uploaded yet: try to upload\n // it now, but fall back to the offline queue instead of losing the\n // testimony if the device has no connectivity (or the upload fails).\n if (source === 'record' && liveRecording && !recording) {\n const canReachNetwork = typeof navigator === 'undefined' || navigator.onLine;\n if (canReachNetwork) {\n try {\n const file = new File([liveRecording.blob], `oral-history-${Date.now()}.webm`, {\n type: liveRecording.mimeType,\n });\n const uploaded = await uploadImageToFiles(file, FILE_FOLDERS.recordings);\n const created = await record.mutateAsync({\n ...baseInput,\n recordingFileId: uploaded.id,\n durationSecs: durationSecs ? Number(durationSecs) : liveRecording.durationSecs,\n });\n notify.success('Oral history recorded', created.title);\n onOpenChange(false);\n reset();\n onCreated?.(created);\n return;\n } catch {\n // Fall through to the offline queue below — connectivity dropped\n // mid-upload, or the gateway is unreachable.\n }\n }\n\n await enqueuePendingOralHistory({\n id: `oh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n blob: liveRecording.blob,\n mimeType: liveRecording.mimeType,\n fileName: `oral-history-${Date.now()}.webm`,\n title: baseInput.title,\n summary: baseInput.summary,\n transcript: baseInput.transcript,\n narratorName: baseInput.narratorName,\n language: baseInput.language,\n accessLevel: baseInput.accessLevel,\n collectionId: baseInput.collectionId,\n durationSecs: durationSecs ? Number(durationSecs) : liveRecording.durationSecs,\n recordedAt: baseInput.recordedAt,\n tags: baseInput.tags,\n consentGiven: true,\n });\n notify.success(\n 'Saved on this device',\n \"No connection right now — this recording will upload automatically once you're back online.\"\n );\n onOpenChange(false);\n reset();\n return;\n }\n\n const created = await record.mutateAsync({\n ...baseInput,\n recordingFileId: recording?.id ?? null,\n durationSecs: durationSecs ? Number(durationSecs) : null,\n });\n notify.success('Oral history recorded', created.title);\n onOpenChange(false);\n reset();\n onCreated?.(created);\n } catch (err) {\n setError(describeError(err));\n } finally {\n setSubmitting(false);\n }\n };\n\n const SOURCE_TABS: TabItem<'record' | 'upload'>[] = [\n { value: 'record', label: 'Record now', icon: <Mic className=\"h-4 w-4\" /> },\n { value: 'upload', label: 'Upload a file', icon: <UploadCloud className=\"h-4 w-4\" /> },\n ];\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"sm:max-w-2xl\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Record an oral history</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Preserve a first-person account. Attach the recording and a transcript for the archive.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"flex max-h-[64vh] flex-col gap-4 overflow-y-auto py-2\">\n <div className=\"grid gap-1.5\">\n <Label>Recording</Label>\n <Tabs\n items={SOURCE_TABS}\n value={source}\n onChange={(next) => {\n setSource(next);\n setError(null);\n }}\n className=\"mb-1\"\n />\n {source === 'record' ? (\n <AudioRecorderControl\n value={liveRecording}\n onRecorded={(audio) => {\n setLiveRecording(audio);\n setRecording(null);\n setDurationSecs((prev) => (prev ? prev : String(audio.durationSecs)));\n }}\n onClear={() => setLiveRecording(null)}\n disabled={submitting}\n />\n ) : (\n <MediaDropzone\n folder={FILE_FOLDERS.recordings}\n accept=\"audio/*,video/*\"\n promptLabel=\"Drag and drop an audio or video file here\"\n hint=\"Audio or video, up to 25 MB.\"\n value={recording}\n onUploaded={(ref) => {\n setRecording(ref);\n probeDuration(ref.url);\n }}\n onClear={() => setRecording(null)}\n />\n )}\n </div>\n\n <div className=\"grid gap-3 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-title\">\n Title <span className=\"text-status-error-text\">*</span>\n </Label>\n <Input\n id=\"oral-title\"\n value={title}\n maxLength={TITLE_MAX}\n onChange={(e) => setTitle(e.target.value)}\n placeholder=\"e.g. A weaver remembers the looms\"\n />\n <Counter length={title.length} max={TITLE_MAX} />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-narrator\">Narrator</Label>\n <Input\n id=\"oral-narrator\"\n value={narratorName}\n maxLength={NARRATOR_MAX}\n onChange={(e) => setNarratorName(e.target.value)}\n placeholder=\"Full name\"\n />\n </div>\n </div>\n\n <div className=\"grid gap-3 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-language\">Language</Label>\n <Input\n id=\"oral-language\"\n value={language}\n maxLength={LANGUAGE_MAX}\n onChange={(e) => setLanguage(e.target.value)}\n placeholder=\"e.g. Tamil\"\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-duration\">Duration (seconds)</Label>\n <Input\n id=\"oral-duration\"\n type=\"number\"\n min={0}\n value={durationSecs}\n onChange={(e) => setDurationSecs(e.target.value)}\n placeholder=\"Auto-filled from the recording\"\n />\n </div>\n </div>\n\n <div className=\"grid gap-3 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-recorded\">Recorded on</Label>\n <Input\n id=\"oral-recorded\"\n type=\"date\"\n value={recordedAt}\n onChange={(e) => setRecordedAt(e.target.value)}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label>Access level</Label>\n <Select\n value={accessLevel}\n onValueChange={(v) => setAccessLevel(v as TcpAccessLevel)}\n >\n <SelectTrigger aria-label=\"Access level\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {ACCESS_LEVELS.map((a) => (\n <SelectItem key={a} value={a}>\n {formatEnumLabel(a)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <p className=\"text-xs text-text-tertiary\">{ACCESS_HELP[accessLevel]}</p>\n </div>\n </div>\n\n {!collectionId && (\n <div className=\"grid gap-1.5\">\n <Label>Collection</Label>\n <Select value={pickedCollection} onValueChange={setPickedCollection}>\n <SelectTrigger>\n <SelectValue placeholder=\"Add to a collection (optional)\" />\n </SelectTrigger>\n <SelectContent>\n {(collectionsQuery.data ?? []).map((collection) => (\n <SelectItem key={collection.id} value={collection.id}>\n {collection.name}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n )}\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-summary\">Summary</Label>\n <Textarea\n id=\"oral-summary\"\n value={summary}\n maxLength={SUMMARY_MAX}\n onChange={(e) => setSummary(e.target.value)}\n placeholder=\"A short description of what this account covers.\"\n rows={3}\n />\n <Counter length={summary.length} max={SUMMARY_MAX} />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-transcript\">Transcript</Label>\n <Textarea\n id=\"oral-transcript\"\n value={transcript}\n maxLength={TRANSCRIPT_MAX}\n onChange={(e) => setTranscript(e.target.value)}\n placeholder=\"The transcribed account (line breaks are preserved).\"\n rows={5}\n />\n <Counter length={transcript.length} max={TRANSCRIPT_MAX} />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-tags\">Tradition / place tags</Label>\n <TagInput\n id=\"oral-tags\"\n value={tags}\n onChange={setTags}\n placeholder=\"e.g. Tamil diaspora, Chennai…\"\n />\n </div>\n\n <label className=\"flex cursor-pointer items-start gap-2.5 rounded-xl border border-border-seam bg-bg-surface p-3\">\n <input\n type=\"checkbox\"\n checked={consentGiven}\n onChange={(e) => setConsentGiven(e.target.checked)}\n className=\"mt-0.5 h-4 w-4 shrink-0 accent-[var(--color-accent)]\"\n />\n <span className=\"text-sm text-text-secondary\">\n <span className=\"inline-flex items-center gap-1 font-medium text-text-primary\">\n <ShieldCheck className=\"h-3.5 w-3.5\" /> Narrator consent obtained\n </span>{' '}\n — the narrator has agreed to have this testimony recorded and preserved in the archive.\n Required to save.\n </span>\n </label>\n\n {error && <p className=\"text-sm text-status-error-text\">{error}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n Cancel\n </Button>\n <Button onClick={submit} disabled={submitting || record.isPending || !consentGiven}>\n {submitting || record.isPending ? (\n <Loader2 className=\"mr-1.5 h-4 w-4 animate-spin\" />\n ) : (\n <Mic className=\"mr-1.5 h-4 w-4\" />\n )}\n Record oral history\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n\n/** A right-aligned character counter shown under a text field. */\nexport function Counter({ length, max }: { length: number; max: number }) {\n return (\n <p className=\"text-right text-xs tabular-nums text-text-tertiary\">\n {length} / {max}\n </p>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"RecordOralHistoryDialog.js","names":[],"sources":["../../../src/theculturepost/components/RecordOralHistoryDialog.tsx"],"sourcesContent":["/**\n * RecordOralHistoryDialog — capture an oral history recording.\n * @module theculturepost/components\n *\n * Writes `tcpRecordOralHistory`. The recording (audio/video) is uploaded to\n * wspace-files-svc via the shared {@link MediaDropzone} (drag-drop or browse),\n * and previewed inline with the foundation {@link AudioPlayer}. `narratorName`\n * is a plain string stored directly — the schema has NO narrator portrait/age,\n * so none is ever rendered. All `TcpCreateOralHistoryInput` fields are captured.\n */\n\nimport { useRef, useState } from 'react';\nimport { UploadCloud, Loader2, FileCheck2, X, Mic, ShieldCheck } from 'lucide-react';\nimport {\n Button,\n Input,\n Label,\n Textarea,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport { AudioPlayer } from './AudioPlayer';\nimport { AudioRecorderControl, type RecordedAudio } from './AudioRecorderControl';\nimport { TagInput } from './TagInput';\nimport { Tabs, type TabItem } from './Tabs';\nimport { cn } from '../utils/cn';\nimport {\n uploadImageToFiles,\n useTcpRecordOralHistory,\n useTcpCollections,\n FILE_FOLDERS,\n} from '../hooks';\nimport { enqueuePendingOralHistory } from '../lib/offlineOralHistoryQueue';\nimport { formatEnumLabel } from '../utils/formatters';\nimport { notify, describeError } from '../lib/toast';\nimport type { TcpOralHistory, TcpAccessLevel } from '../types';\n\n// ---------------------------------------------------------------------------\n// MediaDropzone — a shared drag-drop uploader (reused by the archive dialogs).\n// ---------------------------------------------------------------------------\n\n/** A file uploaded to wspace-files-svc: id (persisted) + presigned url + name. */\nexport interface UploadedRef {\n id: string;\n url: string;\n name: string;\n}\n\ninterface MediaDropzoneProps {\n /** Destination folder in wspace-files-svc (see FILE_FOLDERS). */\n folder: string;\n /** HTML accept attribute (e.g. 'audio/*,video/*'). */\n accept: string;\n /** Muted helper line under the drop prompt. */\n hint: string;\n /** Large prompt shown in the empty dropzone. */\n promptLabel: string;\n /** Current uploaded file (controlled so the parent can reset on submit). */\n value: UploadedRef | null;\n /** Called after a successful upload; `file` is provided for metadata probes. */\n onUploaded: (ref: UploadedRef, file: File) => void;\n onClear: () => void;\n /** Max upload size in MB. */\n maxMb?: number;\n disabled?: boolean;\n}\n\n/**\n * A token-only drag-and-drop file uploader with an inline preview: images show\n * a thumbnail, audio/video show the {@link AudioPlayer}, everything else shows a\n * filename chip. Uploads go straight to wspace-files-svc — only the id is kept.\n */\nexport function MediaDropzone({\n folder,\n accept,\n hint,\n promptLabel,\n value,\n onUploaded,\n onClear,\n maxMb = 25,\n disabled = false,\n}: MediaDropzoneProps) {\n const inputRef = useRef<HTMLInputElement | null>(null);\n const [dragging, setDragging] = useState(false);\n const [uploading, setUploading] = useState(false);\n const [previewType, setPreviewType] = useState<'image' | 'audio' | 'file' | null>(null);\n\n // Keep the preview kind in sync when the parent clears the value (e.g. on reset).\n const [trackedValue, setTrackedValue] = useState(value);\n if (trackedValue !== value) {\n setTrackedValue(value);\n if (!value) setPreviewType(null);\n }\n\n const maxBytes = maxMb * 1024 * 1024;\n\n const ingest = async (file: File | undefined) => {\n if (!file || disabled) return;\n if (file.size > maxBytes) {\n notify.error('File too large', `Please choose a file under ${maxMb} MB.`);\n return;\n }\n setUploading(true);\n try {\n const uploaded = await uploadImageToFiles(file, folder);\n const isImage = file.type.startsWith('image/');\n const isAv = file.type.startsWith('audio/') || file.type.startsWith('video/');\n setPreviewType(isImage ? 'image' : isAv ? 'audio' : 'file');\n onUploaded({ id: uploaded.id, url: uploaded.url, name: uploaded.name }, file);\n notify.success('File uploaded', uploaded.name);\n } catch (err) {\n notify.error('Upload failed', describeError(err));\n } finally {\n setUploading(false);\n }\n };\n\n const hiddenInput = (\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n className=\"sr-only\"\n disabled={disabled || uploading}\n onChange={(e) => {\n const file = e.target.files?.[0];\n e.target.value = '';\n void ingest(file);\n }}\n />\n );\n\n if (value) {\n return (\n <div className=\"flex flex-col gap-2\">\n {previewType === 'image' ? (\n <div className=\"overflow-hidden rounded-xl border border-border-seam\">\n <img src={value.url} alt={value.name} className=\"max-h-48 w-full object-cover\" />\n </div>\n ) : previewType === 'audio' ? (\n <AudioPlayer src={value.url} seed={value.id} />\n ) : (\n <div className=\"flex items-center gap-2 rounded-xl border border-border-seam bg-bg-surface px-3 py-2 text-sm\">\n <FileCheck2 className=\"h-4 w-4 shrink-0 text-status-success-text\" />\n <span className=\"truncate text-text-primary\">{value.name}</span>\n </div>\n )}\n <div className=\"flex items-center gap-4\">\n <button\n type=\"button\"\n onClick={() => inputRef.current?.click()}\n disabled={disabled || uploading}\n className=\"text-sm font-medium text-accent hover:underline disabled:opacity-60\"\n >\n {uploading ? 'Uploading…' : 'Replace'}\n </button>\n <button\n type=\"button\"\n onClick={onClear}\n className=\"inline-flex items-center gap-1 text-sm text-text-tertiary hover:text-text-primary\"\n >\n <X className=\"h-3.5 w-3.5\" /> Remove\n </button>\n </div>\n {hiddenInput}\n </div>\n );\n }\n\n return (\n <div\n onDragOver={(e) => {\n e.preventDefault();\n if (!disabled) setDragging(true);\n }}\n onDragLeave={() => setDragging(false)}\n onDrop={(e) => {\n e.preventDefault();\n setDragging(false);\n void ingest(e.dataTransfer.files?.[0]);\n }}\n className={cn(\n 'flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed px-4 py-6 text-center transition-colors',\n dragging\n ? 'border-accent bg-[var(--color-accent-soft)]'\n : 'border-border-seam bg-bg-surface',\n (disabled || uploading) && 'opacity-60'\n )}\n >\n {hiddenInput}\n {uploading ? (\n <Loader2 className=\"h-6 w-6 animate-spin text-text-tertiary\" aria-hidden=\"true\" />\n ) : (\n <UploadCloud className=\"h-6 w-6 text-text-tertiary\" aria-hidden=\"true\" />\n )}\n <p className=\"text-sm text-text-secondary\">{uploading ? 'Uploading…' : promptLabel}</p>\n {!uploading && (\n <button\n type=\"button\"\n onClick={() => inputRef.current?.click()}\n disabled={disabled}\n className=\"inline-flex items-center gap-1.5 rounded-lg bg-action-primary-bg px-3 py-1.5 text-sm font-medium text-action-primary-text hover:opacity-90 disabled:opacity-60\"\n >\n <UploadCloud className=\"h-4 w-4\" /> Browse files\n </button>\n )}\n <p className=\"text-xs text-text-tertiary\">{hint}</p>\n </div>\n );\n}\n\n// ---------------------------------------------------------------------------\n// Access levels + copy\n// ---------------------------------------------------------------------------\n\nconst ACCESS_LEVELS: TcpAccessLevel[] = ['COMMUNITY', 'PUBLIC', 'RESTRICTED', 'EMBARGOED'];\n\nconst ACCESS_HELP: Record<TcpAccessLevel, string> = {\n PUBLIC: 'Visible to everyone.',\n COMMUNITY: 'Available to community members.',\n RESTRICTED: 'Visible to selected groups.',\n EMBARGOED: 'Hidden until a future date.',\n};\n\nconst TITLE_MAX = 160;\nconst NARRATOR_MAX = 120;\nconst LANGUAGE_MAX = 60;\nconst SUMMARY_MAX = 300;\nconst TRANSCRIPT_MAX = 5000;\n\nconst dateToIso = (value: string): string | null => {\n if (!value) return null;\n const d = new Date(value);\n return Number.isNaN(d.getTime()) ? null : d.toISOString();\n};\n\ninterface RecordOralHistoryDialogProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n /** Pre-select a collection. */\n collectionId?: string;\n onCreated?: (oralHistory: TcpOralHistory) => void;\n}\n\nexport function RecordOralHistoryDialog({\n open,\n onOpenChange,\n collectionId,\n onCreated,\n}: RecordOralHistoryDialogProps) {\n const record = useTcpRecordOralHistory();\n const collectionsQuery = useTcpCollections({ limit: 100 });\n\n const [source, setSource] = useState<'record' | 'upload'>('record');\n const [title, setTitle] = useState('');\n const [summary, setSummary] = useState('');\n const [transcript, setTranscript] = useState('');\n const [narratorName, setNarratorName] = useState('');\n const [language, setLanguage] = useState('');\n const [accessLevel, setAccessLevel] = useState<TcpAccessLevel>('COMMUNITY');\n const [pickedCollection, setPickedCollection] = useState(collectionId ?? '');\n const [recording, setRecording] = useState<UploadedRef | null>(null);\n const [liveRecording, setLiveRecording] = useState<RecordedAudio | null>(null);\n const [durationSecs, setDurationSecs] = useState('');\n const [recordedAt, setRecordedAt] = useState('');\n const [tags, setTags] = useState<string[]>([]);\n const [consentGiven, setConsentGiven] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [submitting, setSubmitting] = useState(false);\n\n const reset = () => {\n setSource('record');\n setTitle('');\n setSummary('');\n setTranscript('');\n setNarratorName('');\n setLanguage('');\n setAccessLevel('COMMUNITY');\n setPickedCollection(collectionId ?? '');\n setRecording(null);\n if (liveRecording) URL.revokeObjectURL(liveRecording.previewUrl);\n setLiveRecording(null);\n setDurationSecs('');\n setRecordedAt('');\n setTags([]);\n setConsentGiven(false);\n setError(null);\n };\n\n /** Best-effort: read the recording's real duration to pre-fill the field. */\n const probeDuration = (url: string) => {\n try {\n const probe = new Audio();\n probe.preload = 'metadata';\n probe.onloadedmetadata = () => {\n if (Number.isFinite(probe.duration) && probe.duration > 0) {\n setDurationSecs((prev) => (prev ? prev : String(Math.round(probe.duration))));\n }\n };\n probe.src = url;\n } catch {\n // Non-fatal — the field stays manually editable.\n }\n };\n\n const submit = async () => {\n if (!title.trim()) {\n setError('A title is required.');\n return;\n }\n if (!consentGiven) {\n setError(\"The narrator's consent is required before this recording can be preserved.\");\n return;\n }\n\n const baseInput = {\n title: title.trim(),\n summary: summary.trim() || null,\n transcript: transcript.trim() || null,\n narratorName: narratorName.trim() || null,\n language: language.trim() || null,\n accessLevel,\n collectionId: pickedCollection || null,\n recordedAt: dateToIso(recordedAt),\n tags,\n consentGiven: true as const,\n };\n\n setSubmitting(true);\n setError(null);\n try {\n // A live in-app recording that hasn't been uploaded yet: try to upload\n // it now, but fall back to the offline queue instead of losing the\n // testimony if the device has no connectivity (or the upload fails).\n if (source === 'record' && liveRecording && !recording) {\n const canReachNetwork = typeof navigator === 'undefined' || navigator.onLine;\n if (canReachNetwork) {\n try {\n const file = new File([liveRecording.blob], `oral-history-${Date.now()}.webm`, {\n type: liveRecording.mimeType,\n });\n const uploaded = await uploadImageToFiles(file, FILE_FOLDERS.recordings);\n const created = await record.mutateAsync({\n ...baseInput,\n recordingFileId: uploaded.id,\n durationSecs: durationSecs ? Number(durationSecs) : liveRecording.durationSecs,\n });\n notify.success('Oral history recorded', created.title);\n onOpenChange(false);\n reset();\n onCreated?.(created);\n return;\n } catch {\n // Fall through to the offline queue below — connectivity dropped\n // mid-upload, or the gateway is unreachable.\n }\n }\n\n await enqueuePendingOralHistory({\n id: `oh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n blob: liveRecording.blob,\n mimeType: liveRecording.mimeType,\n fileName: `oral-history-${Date.now()}.webm`,\n title: baseInput.title,\n summary: baseInput.summary,\n transcript: baseInput.transcript,\n narratorName: baseInput.narratorName,\n language: baseInput.language,\n accessLevel: baseInput.accessLevel,\n collectionId: baseInput.collectionId,\n durationSecs: durationSecs ? Number(durationSecs) : liveRecording.durationSecs,\n recordedAt: baseInput.recordedAt,\n tags: baseInput.tags,\n consentGiven: true,\n });\n notify.success(\n 'Saved on this device',\n \"No connection right now — this recording will upload automatically once you're back online.\"\n );\n onOpenChange(false);\n reset();\n return;\n }\n\n const created = await record.mutateAsync({\n ...baseInput,\n recordingFileId: recording?.id ?? null,\n durationSecs: durationSecs ? Number(durationSecs) : null,\n });\n notify.success('Oral history recorded', created.title);\n onOpenChange(false);\n reset();\n onCreated?.(created);\n } catch (err) {\n setError(describeError(err));\n } finally {\n setSubmitting(false);\n }\n };\n\n const SOURCE_TABS: TabItem<'record' | 'upload'>[] = [\n { value: 'record', label: 'Record now', icon: <Mic className=\"h-4 w-4\" /> },\n { value: 'upload', label: 'Upload a file', icon: <UploadCloud className=\"h-4 w-4\" /> },\n ];\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"sm:max-w-2xl\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Record an oral history</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Preserve a first-person account. Attach the recording and a transcript for the archive.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"flex max-h-[64vh] flex-col gap-4 overflow-y-auto py-2\">\n <div className=\"grid gap-1.5\">\n <Label>Recording</Label>\n <Tabs\n items={SOURCE_TABS}\n value={source}\n onChange={(next) => {\n setSource(next);\n setError(null);\n }}\n className=\"mb-1\"\n />\n {source === 'record' ? (\n <AudioRecorderControl\n value={liveRecording}\n onRecorded={(audio) => {\n setLiveRecording(audio);\n setRecording(null);\n setDurationSecs((prev) => (prev ? prev : String(audio.durationSecs)));\n }}\n onClear={() => setLiveRecording(null)}\n disabled={submitting}\n />\n ) : (\n <MediaDropzone\n folder={FILE_FOLDERS.recordings}\n accept=\"audio/*,video/*\"\n promptLabel=\"Drag and drop an audio or video file here\"\n hint=\"Audio or video, up to 25 MB.\"\n value={recording}\n onUploaded={(ref) => {\n setRecording(ref);\n probeDuration(ref.url);\n }}\n onClear={() => setRecording(null)}\n />\n )}\n </div>\n\n <div className=\"grid gap-3 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-title\">\n Title <span className=\"text-status-error-text\">*</span>\n </Label>\n <Input\n id=\"oral-title\"\n value={title}\n maxLength={TITLE_MAX}\n onChange={(e) => setTitle(e.target.value)}\n placeholder=\"e.g. A weaver remembers the looms\"\n />\n <Counter length={title.length} max={TITLE_MAX} />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-narrator\">Narrator</Label>\n <Input\n id=\"oral-narrator\"\n value={narratorName}\n maxLength={NARRATOR_MAX}\n onChange={(e) => setNarratorName(e.target.value)}\n placeholder=\"Full name\"\n />\n </div>\n </div>\n\n <div className=\"grid gap-3 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-language\">Language</Label>\n <Input\n id=\"oral-language\"\n value={language}\n maxLength={LANGUAGE_MAX}\n onChange={(e) => setLanguage(e.target.value)}\n placeholder=\"e.g. Tamil\"\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-duration\">Duration (seconds)</Label>\n <Input\n id=\"oral-duration\"\n type=\"number\"\n min={0}\n value={durationSecs}\n onChange={(e) => setDurationSecs(e.target.value)}\n placeholder=\"Auto-filled from the recording\"\n />\n </div>\n </div>\n\n <div className=\"grid gap-3 sm:grid-cols-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-recorded\">Recorded on</Label>\n <Input\n id=\"oral-recorded\"\n type=\"date\"\n value={recordedAt}\n onChange={(e) => setRecordedAt(e.target.value)}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label>Access level</Label>\n <Select\n value={accessLevel}\n onValueChange={(v) => setAccessLevel(v as TcpAccessLevel)}\n >\n <SelectTrigger aria-label=\"Access level\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {ACCESS_LEVELS.map((a) => (\n <SelectItem key={a} value={a}>\n {formatEnumLabel(a)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <p className=\"text-xs text-text-tertiary\">{ACCESS_HELP[accessLevel]}</p>\n </div>\n </div>\n\n {!collectionId && (\n <div className=\"grid gap-1.5\">\n <Label>Collection</Label>\n <Select value={pickedCollection} onValueChange={setPickedCollection}>\n <SelectTrigger>\n <SelectValue placeholder=\"Add to a collection (optional)\" />\n </SelectTrigger>\n <SelectContent>\n {(collectionsQuery.data ?? []).map((collection) => (\n <SelectItem key={collection.id} value={collection.id}>\n {collection.name}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n )}\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-summary\">Summary</Label>\n <Textarea\n id=\"oral-summary\"\n value={summary}\n maxLength={SUMMARY_MAX}\n onChange={(e) => setSummary(e.target.value)}\n placeholder=\"A short description of what this account covers.\"\n rows={3}\n />\n <Counter length={summary.length} max={SUMMARY_MAX} />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-transcript\">Transcript</Label>\n <Textarea\n id=\"oral-transcript\"\n value={transcript}\n maxLength={TRANSCRIPT_MAX}\n onChange={(e) => setTranscript(e.target.value)}\n placeholder=\"The transcribed account (line breaks are preserved).\"\n rows={5}\n />\n <Counter length={transcript.length} max={TRANSCRIPT_MAX} />\n </div>\n\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"oral-tags\">Tradition / place tags</Label>\n <TagInput\n id=\"oral-tags\"\n value={tags}\n onChange={setTags}\n placeholder=\"e.g. Tamil diaspora, Chennai…\"\n />\n </div>\n\n <label className=\"flex cursor-pointer items-start gap-2.5 rounded-xl border border-border-seam bg-bg-surface p-3\">\n <input\n type=\"checkbox\"\n checked={consentGiven}\n onChange={(e) => setConsentGiven(e.target.checked)}\n className=\"mt-0.5 h-4 w-4 shrink-0 accent-[var(--color-accent)]\"\n />\n <span className=\"text-sm text-text-secondary\">\n <span className=\"inline-flex items-center gap-1 font-medium text-text-primary\">\n <ShieldCheck className=\"h-3.5 w-3.5\" /> Narrator consent obtained\n </span>{' '}\n — the narrator has agreed to have this testimony recorded and preserved in the\n archive. Required to save.\n </span>\n </label>\n\n {error && <p className=\"text-sm text-status-error-text\">{error}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n Cancel\n </Button>\n <Button onClick={submit} disabled={submitting || record.isPending || !consentGiven}>\n {submitting || record.isPending ? (\n <Loader2 className=\"mr-1.5 h-4 w-4 animate-spin\" />\n ) : (\n <Mic className=\"mr-1.5 h-4 w-4\" />\n )}\n Record oral history\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n\n/** A right-aligned character counter shown under a text field. */\nexport function Counter({ length, max }: { length: number; max: number }) {\n return (\n <p className=\"text-right text-xs tabular-nums text-text-tertiary\">\n {length} / {max}\n </p>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { joinPath as e } from "../utils/navigation.js";
|
|
2
2
|
import { createContext as t, useCallback as n, useContext as r, useMemo as i } from "react";
|
|
3
|
-
import { MemoryRouter as a, useNavigate as o } from "react-router
|
|
3
|
+
import { MemoryRouter as a, useNavigate as o } from "react-router";
|
|
4
4
|
import { jsx as s } from "react/jsx-runtime";
|
|
5
5
|
//#region src/theculturepost/context/TheCulturePostContext.tsx
|
|
6
6
|
var c = t(null);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TheCulturePostContext.js","names":[],"sources":["../../../src/theculturepost/context/TheCulturePostContext.tsx"],"sourcesContent":["/**\n * TheCulturePost Context Provider\n * @module theculturepost/context\n *\n * Splits standalone mode (wraps its own MemoryRouter + useNavigate) from\n * integrated mode (the host shell supplies `navigate`). Exposes gateway/auth\n * context + relative navigation helpers to every page.\n */\n\nimport type { FC, PropsWithChildren } from 'react';\nimport { createContext, useContext, useMemo, useCallback } from 'react';\nimport { MemoryRouter, useNavigate as useRouterNavigate } from 'react-router
|
|
1
|
+
{"version":3,"file":"TheCulturePostContext.js","names":[],"sources":["../../../src/theculturepost/context/TheCulturePostContext.tsx"],"sourcesContent":["/**\n * TheCulturePost Context Provider\n * @module theculturepost/context\n *\n * Splits standalone mode (wraps its own MemoryRouter + useNavigate) from\n * integrated mode (the host shell supplies `navigate`). Exposes gateway/auth\n * context + relative navigation helpers to every page.\n */\n\nimport type { FC, PropsWithChildren } from 'react';\nimport { createContext, useContext, useMemo, useCallback } from 'react';\nimport { MemoryRouter, useNavigate as useRouterNavigate } from 'react-router';\nimport type { TheCulturePostRootProps } from '../types';\nimport { joinPath } from '../utils/navigation';\n\ninterface TheCulturePostContextValue extends Omit<\n TheCulturePostRootProps,\n 'registerNavItems' | 'registerFooterItems' | 'navigate'\n> {\n isStandalone: boolean;\n /** Shell's navigate function (only available in integrated mode). */\n navigate?: (path: string) => void;\n /** Navigate to a path relative to basePath. */\n navigateTo: (relativePath: string) => void;\n /** Get the full path for a relative path. */\n getPath: (relativePath: string) => string;\n}\n\nconst TheCulturePostContext = createContext<TheCulturePostContextValue | null>(null);\n\nexport function useTheCulturePost(): TheCulturePostContextValue {\n const context = useContext(TheCulturePostContext);\n if (!context) {\n throw new Error('useTheCulturePost must be used within a TheCulturePostProvider');\n }\n return context;\n}\n\ntype ProviderProps = Omit<TheCulturePostRootProps, 'registerNavItems' | 'registerFooterItems'> & {\n isStandalone: boolean;\n};\n\nconst StandaloneProviderInner: FC<PropsWithChildren<ProviderProps>> = ({\n children,\n basePath,\n currentUser,\n tenantId,\n workspaceId,\n apiGatewayUrl,\n authToken,\n}) => {\n const routerNavigate = useRouterNavigate();\n const effectiveBasePath = basePath || '/';\n\n const getPath = useCallback(\n (relativePath: string) => joinPath(effectiveBasePath, relativePath),\n [effectiveBasePath]\n );\n\n const navigateTo = useCallback(\n (relativePath: string) => {\n routerNavigate(joinPath(effectiveBasePath, relativePath));\n },\n [effectiveBasePath, routerNavigate]\n );\n\n const contextValue = useMemo<TheCulturePostContextValue>(\n () => ({\n basePath: effectiveBasePath,\n navigate: undefined,\n currentUser,\n tenantId,\n workspaceId,\n apiGatewayUrl,\n authToken,\n isStandalone: true,\n navigateTo,\n getPath,\n }),\n [\n effectiveBasePath,\n currentUser,\n tenantId,\n workspaceId,\n apiGatewayUrl,\n authToken,\n navigateTo,\n getPath,\n ]\n );\n\n return (\n <TheCulturePostContext.Provider value={contextValue}>{children}</TheCulturePostContext.Provider>\n );\n};\n\nconst IntegratedProviderInner: FC<\n PropsWithChildren<ProviderProps & { navigate: (path: string) => void }>\n> = ({\n children,\n basePath,\n navigate,\n currentUser,\n tenantId,\n workspaceId,\n apiGatewayUrl,\n authToken,\n}) => {\n const effectiveBasePath = basePath || '/';\n\n const getPath = useCallback(\n (relativePath: string) => joinPath(effectiveBasePath, relativePath),\n [effectiveBasePath]\n );\n\n const navigateTo = useCallback(\n (relativePath: string) => {\n navigate(joinPath(effectiveBasePath, relativePath));\n },\n [effectiveBasePath, navigate]\n );\n\n const contextValue = useMemo<TheCulturePostContextValue>(\n () => ({\n basePath: effectiveBasePath,\n navigate,\n currentUser,\n tenantId,\n workspaceId,\n apiGatewayUrl,\n authToken,\n isStandalone: false,\n navigateTo,\n getPath,\n }),\n [\n effectiveBasePath,\n navigate,\n currentUser,\n tenantId,\n workspaceId,\n apiGatewayUrl,\n authToken,\n navigateTo,\n getPath,\n ]\n );\n\n return (\n <TheCulturePostContext.Provider value={contextValue}>{children}</TheCulturePostContext.Provider>\n );\n};\n\nexport const TheCulturePostProvider: FC<PropsWithChildren<TheCulturePostRootProps>> = (props) => {\n const { children, navigate } = props;\n const isStandalone = !navigate;\n\n if (isStandalone) {\n return (\n <MemoryRouter>\n <StandaloneProviderInner {...props} isStandalone={true}>\n {children}\n </StandaloneProviderInner>\n </MemoryRouter>\n );\n }\n\n return (\n <IntegratedProviderInner {...props} navigate={navigate} isStandalone={false}>\n {children}\n </IntegratedProviderInner>\n );\n};\n"],"mappings":";;;;;AA4BA,IAAM,IAAwB,EAAiD,KAAK;AAEpF,SAAgB,IAAgD;CAC9D,IAAM,IAAU,EAAW,EAAsB;AACjD,KAAI,CAAC,EACH,OAAU,MAAM,iEAAiE;AAEnF,QAAO;;AAOT,IAAM,KAAiE,EACrE,aACA,aACA,gBACA,aACA,gBACA,kBACA,mBACI;CACJ,IAAM,IAAiB,GAAmB,EACpC,IAAoB,KAAY,KAEhC,IAAU,GACb,MAAyB,EAAS,GAAmB,EAAa,EACnE,CAAC,EAAkB,CACpB,EAEK,IAAa,GAChB,MAAyB;AACxB,IAAe,EAAS,GAAmB,EAAa,CAAC;IAE3D,CAAC,GAAmB,EAAe,CACpC,EAEK,IAAe,SACZ;EACL,UAAU;EACV,UAAU,KAAA;EACV;EACA;EACA;EACA;EACA;EACA,cAAc;EACd;EACA;EACD,GACD;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACD,CACF;AAED,QACE,kBAAC,EAAsB,UAAvB;EAAgC,OAAO;EAAe;EAA0C,CAAA;GAI9F,KAED,EACH,aACA,aACA,aACA,gBACA,aACA,gBACA,kBACA,mBACI;CACJ,IAAM,IAAoB,KAAY,KAEhC,IAAU,GACb,MAAyB,EAAS,GAAmB,EAAa,EACnE,CAAC,EAAkB,CACpB,EAEK,IAAa,GAChB,MAAyB;AACxB,IAAS,EAAS,GAAmB,EAAa,CAAC;IAErD,CAAC,GAAmB,EAAS,CAC9B,EAEK,IAAe,SACZ;EACL,UAAU;EACV;EACA;EACA;EACA;EACA;EACA;EACA,cAAc;EACd;EACA;EACD,GACD;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACD,CACF;AAED,QACE,kBAAC,EAAsB,UAAvB;EAAgC,OAAO;EAAe;EAA0C,CAAA;GAIvF,KAA0E,MAAU;CAC/F,IAAM,EAAE,aAAU,gBAAa;AAa/B,QAZsB,IAapB,kBAAC,GAAD;EAAyB,GAAI;EAAiB;EAAU,cAAc;EACnE;EACuB,CAAA,GAXxB,kBAAC,GAAD,EAAA,UACE,kBAAC,GAAD;EAAyB,GAAI;EAAO,cAAc;EAC/C;EACuB,CAAA,EACb,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"offlineOralHistoryQueue.d.ts","sourceRoot":"","sources":["../../../src/theculturepost/lib/offlineOralHistoryQueue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,MAAM,WAAW,yBAAyB;IACxC,8EAA8E;IAC9E,EAAE,EAAE,MAAM,CAAC;IACX,mEAAmE;IACnE,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,kFAAkF;IAClF,YAAY,EAAE,IAAI,CAAC;IACnB,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAC;IACjB,iDAAiD;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;
|
|
1
|
+
{"version":3,"file":"offlineOralHistoryQueue.d.ts","sourceRoot":"","sources":["../../../src/theculturepost/lib/offlineOralHistoryQueue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAMH,MAAM,WAAW,yBAAyB;IACxC,8EAA8E;IAC9E,EAAE,EAAE,MAAM,CAAC;IACX,mEAAmE;IACnE,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,kFAAkF;IAClF,YAAY,EAAE,IAAI,CAAC;IACnB,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAC;IACjB,iDAAiD;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAuCD,6EAA6E;AAC7E,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,IAAI,CAAC,yBAAyB,EAAE,UAAU,GAAG,UAAU,GAAG,WAAW,CAAC,GAC9E,OAAO,CAAC,yBAAyB,CAAC,CASpC;AAED,gDAAgD;AAChD,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAGrF;AAED,4EAA4E;AAC5E,wBAAsB,wBAAwB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAExE;AAED,iFAAiF;AACjF,wBAAsB,4BAA4B,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0B3F;AAED,mFAAmF;AACnF,wBAAsB,yBAAyB,IAAI,OAAO,CAAC,MAAM,CAAC,CAEjE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"offlineOralHistoryQueue.js","names":[],"sources":["../../../src/theculturepost/lib/offlineOralHistoryQueue.ts"],"sourcesContent":["/**\n * offlineOralHistoryQueue — an offline-first capture queue for oral-history\n * recordings made in the field (BOFF-4128 native mobile capture).\n * @module theculturepost/lib\n *\n * A narrator's testimony may be recorded with no connectivity — the device\n * mic still works, but `tcpRecordOralHistory` cannot be called and the\n * recording cannot be uploaded to wspace-files-svc. Rather than lose the\n * capture, the raw audio Blob + all form metadata are persisted to IndexedDB\n * (works identically inside the Capacitor WebView and a desktop browser — no\n * native plugin required) and retried automatically once the device is back\n * online, or on demand from the pending-captures panel.\n *\n * IndexedDB (not `localStorage`) is used deliberately: `localStorage` cannot\n * hold a Blob and has a ~5MB string-only quota, both fatal for multi-minute\n * audio recordings.\n */\n\nconst DB_NAME = 'tcp-oral-history-offline-queue';\nconst DB_VERSION = 1;\nconst STORE_NAME = 'pending-captures';\n\nexport interface PendingOralHistoryCapture {\n /** Client-generated id — stable across retries, used as the IndexedDB key. */\n id: string;\n /** The recorded (or picked) audio/video blob, not yet uploaded. */\n blob: Blob;\n mimeType: string;\n fileName: string;\n title: string;\n summary: string | null;\n transcript: string | null;\n narratorName: string | null;\n language: string | null;\n accessLevel: string;\n collectionId: string | null;\n durationSecs: number | null;\n recordedAt: string | null;\n tags: string[];\n /** Always true — the consent gate is enforced before a capture is ever queued. */\n consentGiven: true;\n /** When the capture was made on-device (ISO), independent of `recordedAt`. */\n queuedAt: string;\n /** How many sync attempts have failed so far. */\n attempts: number;\n /** The most recent failure message, if any. */\n lastError: string | null;\n}\n\nfunction openDb(): Promise<IDBDatabase> {\n return new Promise((resolve, reject) => {\n if (typeof indexedDB === 'undefined') {\n reject(new Error('IndexedDB is not available in this environment.'));\n return;\n }\n const request = indexedDB.open(DB_NAME, DB_VERSION);\n request.onupgradeneeded = () => {\n const db = request.result;\n if (!db.objectStoreNames.contains(STORE_NAME)) {\n db.createObjectStore(STORE_NAME, { keyPath: 'id' });\n }\n };\n request.onsuccess = () => resolve(request.result);\n request.onerror = ()
|
|
1
|
+
{"version":3,"file":"offlineOralHistoryQueue.js","names":[],"sources":["../../../src/theculturepost/lib/offlineOralHistoryQueue.ts"],"sourcesContent":["/**\n * offlineOralHistoryQueue — an offline-first capture queue for oral-history\n * recordings made in the field (BOFF-4128 native mobile capture).\n * @module theculturepost/lib\n *\n * A narrator's testimony may be recorded with no connectivity — the device\n * mic still works, but `tcpRecordOralHistory` cannot be called and the\n * recording cannot be uploaded to wspace-files-svc. Rather than lose the\n * capture, the raw audio Blob + all form metadata are persisted to IndexedDB\n * (works identically inside the Capacitor WebView and a desktop browser — no\n * native plugin required) and retried automatically once the device is back\n * online, or on demand from the pending-captures panel.\n *\n * IndexedDB (not `localStorage`) is used deliberately: `localStorage` cannot\n * hold a Blob and has a ~5MB string-only quota, both fatal for multi-minute\n * audio recordings.\n */\n\nconst DB_NAME = 'tcp-oral-history-offline-queue';\nconst DB_VERSION = 1;\nconst STORE_NAME = 'pending-captures';\n\nexport interface PendingOralHistoryCapture {\n /** Client-generated id — stable across retries, used as the IndexedDB key. */\n id: string;\n /** The recorded (or picked) audio/video blob, not yet uploaded. */\n blob: Blob;\n mimeType: string;\n fileName: string;\n title: string;\n summary: string | null;\n transcript: string | null;\n narratorName: string | null;\n language: string | null;\n accessLevel: string;\n collectionId: string | null;\n durationSecs: number | null;\n recordedAt: string | null;\n tags: string[];\n /** Always true — the consent gate is enforced before a capture is ever queued. */\n consentGiven: true;\n /** When the capture was made on-device (ISO), independent of `recordedAt`. */\n queuedAt: string;\n /** How many sync attempts have failed so far. */\n attempts: number;\n /** The most recent failure message, if any. */\n lastError: string | null;\n}\n\nfunction openDb(): Promise<IDBDatabase> {\n return new Promise((resolve, reject) => {\n if (typeof indexedDB === 'undefined') {\n reject(new Error('IndexedDB is not available in this environment.'));\n return;\n }\n const request = indexedDB.open(DB_NAME, DB_VERSION);\n request.onupgradeneeded = () => {\n const db = request.result;\n if (!db.objectStoreNames.contains(STORE_NAME)) {\n db.createObjectStore(STORE_NAME, { keyPath: 'id' });\n }\n };\n request.onsuccess = () => resolve(request.result);\n request.onerror = () =>\n reject(request.error ?? new Error('Failed to open the offline queue database.'));\n });\n}\n\nasync function withStore<T>(\n mode: IDBTransactionMode,\n fn: (store: IDBObjectStore) => IDBRequest<T>\n): Promise<T> {\n const db = await openDb();\n try {\n return await new Promise<T>((resolve, reject) => {\n const tx = db.transaction(STORE_NAME, mode);\n const store = tx.objectStore(STORE_NAME);\n const request = fn(store);\n request.onsuccess = () => resolve(request.result);\n request.onerror = () => reject(request.error ?? new Error('Offline queue operation failed.'));\n });\n } finally {\n db.close();\n }\n}\n\n/** Persist a new capture to the offline queue. Returns the stored record. */\nexport async function enqueuePendingOralHistory(\n capture: Omit<PendingOralHistoryCapture, 'queuedAt' | 'attempts' | 'lastError'>\n): Promise<PendingOralHistoryCapture> {\n const record: PendingOralHistoryCapture = {\n ...capture,\n queuedAt: new Date().toISOString(),\n attempts: 0,\n lastError: null,\n };\n await withStore('readwrite', (store) => store.put(record));\n return record;\n}\n\n/** List every pending capture, oldest first. */\nexport async function listPendingOralHistories(): Promise<PendingOralHistoryCapture[]> {\n const all = await withStore<PendingOralHistoryCapture[]>('readonly', (store) => store.getAll());\n return [...all].sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));\n}\n\n/** Remove a capture once it has been uploaded (or the user discards it). */\nexport async function removePendingOralHistory(id: string): Promise<void> {\n await withStore('readwrite', (store) => store.delete(id));\n}\n\n/** Record a failed sync attempt so the panel can show why it's still pending. */\nexport async function markPendingOralHistoryFailed(id: string, error: string): Promise<void> {\n const db = await openDb();\n try {\n await new Promise<void>((resolve, reject) => {\n const tx = db.transaction(STORE_NAME, 'readwrite');\n const store = tx.objectStore(STORE_NAME);\n const getReq = store.get(id);\n getReq.onsuccess = () => {\n const existing = getReq.result as PendingOralHistoryCapture | undefined;\n if (!existing) {\n resolve();\n return;\n }\n const putReq = store.put({\n ...existing,\n attempts: existing.attempts + 1,\n lastError: error,\n });\n putReq.onsuccess = () => resolve();\n putReq.onerror = () => reject(putReq.error ?? new Error('Failed to record sync failure.'));\n };\n getReq.onerror = () => reject(getReq.error ?? new Error('Failed to load pending capture.'));\n });\n } finally {\n db.close();\n }\n}\n\n/** How many captures are currently queued — used for a lightweight badge count. */\nexport async function countPendingOralHistories(): Promise<number> {\n return withStore<number>('readonly', (store) => store.count());\n}\n"],"mappings":";AAkBA,IAAM,IAAU,kCACV,IAAa,GACb,IAAa;AA6BnB,SAAS,IAA+B;AACtC,QAAO,IAAI,SAAS,GAAS,MAAW;AACtC,MAAI,OAAO,YAAc,KAAa;AACpC,KAAO,gBAAI,MAAM,kDAAkD,CAAC;AACpE;;EAEF,IAAM,IAAU,UAAU,KAAK,GAAS,EAAW;AAQnD,EAPA,EAAQ,wBAAwB;GAC9B,IAAM,IAAK,EAAQ;AACnB,GAAK,EAAG,iBAAiB,SAAS,EAAW,IAC3C,EAAG,kBAAkB,GAAY,EAAE,SAAS,MAAM,CAAC;KAGvD,EAAQ,kBAAkB,EAAQ,EAAQ,OAAO,EACjD,EAAQ,gBACN,EAAO,EAAQ,SAAS,gBAAI,MAAM,6CAA6C,CAAC;GAClF;;AAGJ,eAAe,EACb,GACA,GACY;CACZ,IAAM,IAAK,MAAM,GAAQ;AACzB,KAAI;AACF,SAAO,MAAM,IAAI,SAAY,GAAS,MAAW;GAG/C,IAAM,IAAU,EAFL,EAAG,YAAY,GAAY,EAAK,CAC1B,YAAY,EAAW,CACf;AAEzB,GADA,EAAQ,kBAAkB,EAAQ,EAAQ,OAAO,EACjD,EAAQ,gBAAgB,EAAO,EAAQ,SAAS,gBAAI,MAAM,kCAAkC,CAAC;IAC7F;WACM;AACR,IAAG,OAAO;;;AAKd,eAAsB,EACpB,GACoC;CACpC,IAAM,IAAoC;EACxC,GAAG;EACH,2BAAU,IAAI,MAAM,EAAC,aAAa;EAClC,UAAU;EACV,WAAW;EACZ;AAED,QADA,MAAM,EAAU,cAAc,MAAU,EAAM,IAAI,EAAO,CAAC,EACnD;;AAIT,eAAsB,IAAiE;AAErF,QAAO,CAAC,GADI,MAAM,EAAuC,aAAa,MAAU,EAAM,QAAQ,CAAC,CAChF,CAAC,MAAM,GAAG,MAAM,EAAE,SAAS,cAAc,EAAE,SAAS,CAAC;;AAItE,eAAsB,EAAyB,GAA2B;AACxE,OAAM,EAAU,cAAc,MAAU,EAAM,OAAO,EAAG,CAAC;;AAI3D,eAAsB,EAA6B,GAAY,GAA8B;CAC3F,IAAM,IAAK,MAAM,GAAQ;AACzB,KAAI;AACF,QAAM,IAAI,SAAe,GAAS,MAAW;GAE3C,IAAM,IADK,EAAG,YAAY,GAAY,YAAY,CACjC,YAAY,EAAW,EAClC,IAAS,EAAM,IAAI,EAAG;AAe5B,GAdA,EAAO,kBAAkB;IACvB,IAAM,IAAW,EAAO;AACxB,QAAI,CAAC,GAAU;AACb,QAAS;AACT;;IAEF,IAAM,IAAS,EAAM,IAAI;KACvB,GAAG;KACH,UAAU,EAAS,WAAW;KAC9B,WAAW;KACZ,CAAC;AAEF,IADA,EAAO,kBAAkB,GAAS,EAClC,EAAO,gBAAgB,EAAO,EAAO,SAAS,gBAAI,MAAM,iCAAiC,CAAC;MAE5F,EAAO,gBAAgB,EAAO,EAAO,SAAS,gBAAI,MAAM,kCAAkC,CAAC;IAC3F;WACM;AACR,IAAG,OAAO"}
|
|
@@ -12,7 +12,7 @@ import { DecorativeDivider as p } from "../../components/DecorativeDivider.js";
|
|
|
12
12
|
import { IMAGE_KINDS as m, KIND_ICON as h } from "../../components/kindIcon.js";
|
|
13
13
|
import { createElement as g, useEffect as _, useState as v } from "react";
|
|
14
14
|
import { ArrowLeft as y, CalendarClock as b, Clock as x, Download as S, Globe as C, Landmark as w, Languages as T, Library as E, Loader2 as D, Lock as O, Maximize2 as k, Minus as A, Plus as j, Quote as M, Tag as N, Users as P, X as F } from "lucide-react";
|
|
15
|
-
import { useParams as I } from "react-router
|
|
15
|
+
import { useParams as I } from "react-router";
|
|
16
16
|
import { Fragment as L, jsx as R, jsxs as z } from "react/jsx-runtime";
|
|
17
17
|
import { Button as B, GlassCard as V, Input as H, ResponsiveDialog as U, ResponsiveDialogContent as W, ResponsiveDialogDescription as G, ResponsiveDialogFooter as K, ResponsiveDialogHeader as q, ResponsiveDialogTitle as J } from "@burdenoff/fe-libs/ui";
|
|
18
18
|
//#region src/theculturepost/pages/archive/ArchiveItemDetailPage.tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ArchiveItemDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/archive/ArchiveItemDetailPage.tsx"],"sourcesContent":["/**\n * ArchiveItemDetailPage — a single archived artifact.\n * @module theculturepost/pages/archive\n *\n * Reads `tcpArchiveItem` (with its parent collection). Two columns: a media\n * viewer with CSS zoom + a full-screen lightbox (the schema exposes a SINGLE\n * `file` — never a thumbnail-strip gallery) and a details panel. \"Cite in a\n * story\" opens a story picker and writes an `ARCHIVAL_RECORD` citation via\n * `tcpAddCitation`. There is no bookmark/share (the schema has neither).\n */\n\nimport { createElement, useEffect, useState } from 'react';\nimport {\n ArrowLeft,\n Landmark,\n Globe,\n CalendarClock,\n Languages,\n Download,\n Library,\n Tag,\n Quote,\n Maximize2,\n Plus,\n Minus,\n X,\n Loader2,\n Users,\n Lock,\n Clock,\n type LucideIcon,\n} from 'lucide-react';\nimport {\n Button,\n GlassCard,\n Input,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport { useParams } from 'react-router-dom';\nimport { StatusBadge } from '../../components/StatusBadge';\nimport { DecorativeDivider } from '../../components/DecorativeDivider';\nimport { KIND_ICON, IMAGE_KINDS } from '../../components/kindIcon';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { useTcpArchiveItem, useFileUrl, useTcpStories, useTcpAddCitation } from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { cn } from '../../utils/cn';\nimport { formatDate, formatEnumLabel } from '../../utils/formatters';\nimport { notify, describeError } from '../../lib/toast';\nimport type { TcpArchiveItem, TcpStory, TcpAccessLevel } from '../../types';\n\nconst ACCESS_ICON: Record<TcpAccessLevel, LucideIcon> = {\n PUBLIC: Globe,\n COMMUNITY: Users,\n RESTRICTED: Lock,\n EMBARGOED: Clock,\n};\n\nfunction Shell({ onBack, children }: { onBack: () => void; children: React.ReactNode }) {\n return (\n <div className=\"flex h-full flex-col\">\n <div className=\"flex-1 overflow-auto\">\n <div className=\"mx-auto w-full max-w-6xl p-4 sm:p-6\">\n <button\n type=\"button\"\n onClick={onBack}\n className=\"inline-flex items-center gap-1.5 text-sm font-medium text-text-secondary hover:text-text-primary\"\n >\n <ArrowLeft className=\"h-4 w-4\" aria-hidden=\"true\" /> Archive\n </button>\n <div className=\"mt-4\">{children}</div>\n </div>\n </div>\n </div>\n );\n}\n\nexport function ArchiveItemDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const { data: item, isLoading, isError, refetch } = useTcpArchiveItem(id);\n const [citeOpen, setCiteOpen] = useState(false);\n\n const goBack = () => navigateTo('archive');\n\n if (isLoading) {\n return (\n <Shell onBack={goBack}>\n <LoadingView label=\"Loading artifact…\" />\n </Shell>\n );\n }\n\n if (isError || !item) {\n return (\n <Shell onBack={goBack}>\n <ErrorView\n title=\"Artifact not found\"\n message=\"This artifact may have been archived, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </Shell>\n );\n }\n\n const collection = item.collection;\n const AccessIcon = ACCESS_ICON[item.accessLevel];\n const KindIcon = KIND_ICON[item.kind];\n\n return (\n <Shell onBack={goBack}>\n <div className=\"grid gap-6 lg:grid-cols-[1.3fr_1fr]\">\n <MediaViewer item={item} />\n\n <div className=\"flex min-w-0 flex-col gap-5\">\n <h1 className=\"font-display text-3xl font-semibold leading-tight text-text-primary\">\n {item.title}\n </h1>\n\n <div className=\"flex flex-wrap items-center gap-1.5\">\n <StatusBadge\n status={item.kind}\n icon={createElement(KindIcon, { className: 'h-3 w-3' })}\n />\n <StatusBadge\n status={item.accessLevel}\n icon={createElement(AccessIcon, { className: 'h-3 w-3' })}\n />\n </div>\n\n {item.description && (\n <p className=\"whitespace-pre-wrap text-sm leading-relaxed text-text-secondary\">\n {item.description}\n </p>\n )}\n\n <DecorativeDivider className=\"py-2\" />\n\n <dl className=\"flex flex-col gap-4\">\n {item.provenance && (\n <DetailRow icon={Landmark} label=\"Provenance\">\n {item.provenance}\n </DetailRow>\n )}\n {item.origin && (\n <DetailRow icon={Globe} label=\"Origin\">\n {item.origin}\n </DetailRow>\n )}\n {item.datedAt && (\n <DetailRow icon={CalendarClock} label=\"Date\">\n {formatDate(item.datedAt)}\n </DetailRow>\n )}\n {item.languages.length > 0 && (\n <DetailRow icon={Languages} label=\"Languages\">\n {item.languages.map(formatEnumLabel).join(', ')}\n </DetailRow>\n )}\n {item.tags.length > 0 && (\n <DetailRow icon={Tag} label=\"Tags\">\n <div className=\"flex flex-wrap gap-1.5\">\n {item.tags.map((tag) => (\n <span\n key={tag}\n className=\"rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\"\n >\n {tag}\n </span>\n ))}\n </div>\n </DetailRow>\n )}\n </dl>\n\n {collection && (\n <button\n type=\"button\"\n onClick={() => navigateTo(`archive/collections/${collection.id}`)}\n className=\"inline-flex items-center gap-2 self-start rounded-lg border border-border-seam bg-bg-surface px-3 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken\"\n >\n <Library className=\"h-4 w-4 text-text-tertiary\" aria-hidden=\"true\" />{' '}\n {collection.name}\n </button>\n )}\n\n <Button className=\"w-full sm:w-auto\" onClick={() => setCiteOpen(true)}>\n <Quote className=\"mr-1.5 h-4 w-4\" /> Cite in a story\n </Button>\n </div>\n </div>\n\n <CiteInStoryDialog open={citeOpen} onOpenChange={setCiteOpen} item={item} />\n </Shell>\n );\n}\n\n// ---------------------------------------------------------------------------\n// DetailRow\n// ---------------------------------------------------------------------------\n\nfunction DetailRow({\n icon: Icon,\n label,\n children,\n}: {\n icon: LucideIcon;\n label: string;\n children: React.ReactNode;\n}) {\n return (\n <div className=\"flex items-start gap-3\">\n <Icon className=\"mt-0.5 h-4 w-4 shrink-0 text-accent\" aria-hidden=\"true\" />\n <dt className=\"w-24 shrink-0 text-sm text-text-tertiary\">{label}</dt>\n <dd className=\"min-w-0 flex-1 text-sm text-text-primary\">{children}</dd>\n </div>\n );\n}\n\n// ---------------------------------------------------------------------------\n// MediaViewer — single file only (no gallery), CSS zoom + a full-screen lightbox.\n// ---------------------------------------------------------------------------\n\nfunction MediaViewer({ item }: { item: TcpArchiveItem }) {\n const { data: url } = useFileUrl(item.file?.id);\n const Icon = KIND_ICON[item.kind];\n const [zoom, setZoom] = useState(1);\n const [lightbox, setLightbox] = useState(false);\n const [imgFailed, setImgFailed] = useState(false);\n const showImage = IMAGE_KINDS.has(item.kind) && Boolean(url) && !imgFailed;\n\n useEffect(() => {\n if (!lightbox) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') setLightbox(false);\n };\n window.addEventListener('keydown', onKey);\n return () => window.removeEventListener('keydown', onKey);\n }, [lightbox]);\n\n const zoomIn = () => setZoom((z) => Math.min(4, Math.round((z + 0.5) * 10) / 10));\n const zoomOut = () => setZoom((z) => Math.max(1, Math.round((z - 0.5) * 10) / 10));\n\n return (\n <div className=\"flex flex-col gap-4\">\n <GlassCard treatment=\"plain\" className=\"p-3 sm:p-4\">\n <div\n className={cn(\n 'relative aspect-[4/3] w-full rounded-xl bg-bg-sunken',\n zoom > 1 ? 'overflow-auto' : 'overflow-hidden'\n )}\n >\n {showImage && url ? (\n <img\n src={url}\n alt={item.title}\n onError={() => setImgFailed(true)}\n style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}\n className=\"h-full w-full object-contain transition-transform duration-200\"\n />\n ) : (\n <div className=\"flex h-full w-full flex-col items-center justify-center gap-3 text-text-tertiary\">\n <Icon className=\"h-16 w-16\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium\">{formatEnumLabel(item.kind)}</span>\n </div>\n )}\n\n {showImage && url && (\n <>\n <button\n type=\"button\"\n onClick={() => setLightbox(true)}\n aria-label=\"View full screen\"\n className=\"absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-lg bg-bg-canvas/85 text-text-primary shadow-sm backdrop-blur-sm hover:bg-bg-canvas\"\n >\n <Maximize2 className=\"h-4 w-4\" />\n </button>\n <div className=\"absolute bottom-3 left-3 z-10 flex flex-col overflow-hidden rounded-lg border border-border-seam bg-bg-canvas/85 text-text-primary shadow-sm backdrop-blur-sm\">\n <button\n type=\"button\"\n onClick={zoomIn}\n disabled={zoom >= 4}\n aria-label=\"Zoom in\"\n className=\"flex h-8 w-9 items-center justify-center hover:bg-bg-sunken disabled:opacity-40\"\n >\n <Plus className=\"h-4 w-4\" />\n </button>\n <button\n type=\"button\"\n onClick={() => setZoom(1)}\n aria-label=\"Reset zoom\"\n className=\"border-y border-border-seam px-1 py-1 text-[10px] font-medium tabular-nums hover:bg-bg-sunken\"\n >\n {Math.round(zoom * 100)}%\n </button>\n <button\n type=\"button\"\n onClick={zoomOut}\n disabled={zoom <= 1}\n aria-label=\"Zoom out\"\n className=\"flex h-8 w-9 items-center justify-center hover:bg-bg-sunken disabled:opacity-40\"\n >\n <Minus className=\"h-4 w-4\" />\n </button>\n </div>\n </>\n )}\n </div>\n </GlassCard>\n\n {url && (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n download\n className=\"inline-flex items-center justify-center gap-2 self-start rounded-lg border border-border-seam bg-bg-surface px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken\"\n >\n <Download className=\"h-4 w-4\" /> Download\n </a>\n )}\n\n {lightbox && url && (\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${item.title} — full view`}\n onClick={() => setLightbox(false)}\n className=\"fixed inset-0 z-50 flex items-center justify-center bg-bg-canvas/95 p-4 backdrop-blur-sm\"\n >\n <button\n type=\"button\"\n onClick={() => setLightbox(false)}\n aria-label=\"Close full view\"\n className=\"absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-bg-surface text-text-primary shadow-sm hover:bg-bg-sunken\"\n >\n <X className=\"h-5 w-5\" />\n </button>\n <img\n src={url}\n alt={item.title}\n onClick={(e) => e.stopPropagation()}\n className=\"max-h-full max-w-full rounded-lg object-contain\"\n />\n </div>\n )}\n </div>\n );\n}\n\n// ---------------------------------------------------------------------------\n// CiteInStoryDialog — pick a story and attach this artifact as a citation.\n// ---------------------------------------------------------------------------\n\nfunction CiteInStoryDialog({\n open,\n onOpenChange,\n item,\n}: {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n item: TcpArchiveItem;\n}) {\n const storiesQuery = useTcpStories({ pagination: { limit: 50 } });\n const addCitation = useTcpAddCitation();\n const [query, setQuery] = useState('');\n const [pendingId, setPendingId] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n const stories = storiesQuery.data?.items ?? [];\n const q = query.trim().toLowerCase();\n const filtered = q ? stories.filter((s) => s.title.toLowerCase().includes(q)) : stories;\n\n const cite = async (story: TcpStory) => {\n setPendingId(story.id);\n setError(null);\n try {\n await addCitation.mutateAsync({\n storyId: story.id,\n title: item.title,\n sourceType: 'ARCHIVAL_RECORD',\n provenance: item.provenance ?? null,\n excerpt: item.description ?? null,\n sourceFileId: item.file?.id ?? null,\n });\n notify.success('Cited in a story', story.title);\n onOpenChange(false);\n } catch (err) {\n setError(describeError(err));\n } finally {\n setPendingId(null);\n }\n };\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"sm:max-w-lg\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Cite in a story</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Attach “{item.title}” to a story as an archival-record citation.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"flex flex-col gap-3 py-2\">\n <Input\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"Search stories…\"\n aria-label=\"Search stories\"\n />\n <div className=\"flex max-h-[50vh] flex-col gap-1.5 overflow-y-auto\">\n {storiesQuery.isLoading ? (\n <LoadingView label=\"Loading stories…\" className=\"min-h-[8rem]\" />\n ) : filtered.length === 0 ? (\n <p className=\"py-8 text-center text-sm text-text-tertiary\">\n {stories.length === 0\n ? 'No stories yet — draft a story first.'\n : 'No stories match your search.'}\n </p>\n ) : (\n filtered.map((story) => (\n <button\n key={story.id}\n type=\"button\"\n onClick={() => cite(story)}\n disabled={addCitation.isPending}\n className=\"flex items-center justify-between gap-3 rounded-xl border border-border-seam bg-bg-surface px-3 py-2.5 text-left transition-colors hover:bg-bg-sunken disabled:opacity-60\"\n >\n <span className=\"min-w-0\">\n <span className=\"block truncate text-sm font-medium text-text-primary\">\n {story.title}\n </span>\n <span className=\"mt-0.5 block truncate text-xs text-text-tertiary\">\n {story.topic?.name ?? formatEnumLabel(story.status)}\n </span>\n </span>\n {pendingId === story.id ? (\n <Loader2 className=\"h-4 w-4 shrink-0 animate-spin text-text-tertiary\" />\n ) : (\n <Quote className=\"h-4 w-4 shrink-0 text-text-tertiary\" aria-hidden=\"true\" />\n )}\n </button>\n ))\n )}\n </div>\n {error && <p className=\"text-sm text-status-error-text\">{error}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n Close\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ArchiveItemDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/archive/ArchiveItemDetailPage.tsx"],"sourcesContent":["/**\n * ArchiveItemDetailPage — a single archived artifact.\n * @module theculturepost/pages/archive\n *\n * Reads `tcpArchiveItem` (with its parent collection). Two columns: a media\n * viewer with CSS zoom + a full-screen lightbox (the schema exposes a SINGLE\n * `file` — never a thumbnail-strip gallery) and a details panel. \"Cite in a\n * story\" opens a story picker and writes an `ARCHIVAL_RECORD` citation via\n * `tcpAddCitation`. There is no bookmark/share (the schema has neither).\n */\n\nimport { createElement, useEffect, useState } from 'react';\nimport {\n ArrowLeft,\n Landmark,\n Globe,\n CalendarClock,\n Languages,\n Download,\n Library,\n Tag,\n Quote,\n Maximize2,\n Plus,\n Minus,\n X,\n Loader2,\n Users,\n Lock,\n Clock,\n type LucideIcon,\n} from 'lucide-react';\nimport {\n Button,\n GlassCard,\n Input,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport { useParams } from 'react-router';\nimport { StatusBadge } from '../../components/StatusBadge';\nimport { DecorativeDivider } from '../../components/DecorativeDivider';\nimport { KIND_ICON, IMAGE_KINDS } from '../../components/kindIcon';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { useTcpArchiveItem, useFileUrl, useTcpStories, useTcpAddCitation } from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { cn } from '../../utils/cn';\nimport { formatDate, formatEnumLabel } from '../../utils/formatters';\nimport { notify, describeError } from '../../lib/toast';\nimport type { TcpArchiveItem, TcpStory, TcpAccessLevel } from '../../types';\n\nconst ACCESS_ICON: Record<TcpAccessLevel, LucideIcon> = {\n PUBLIC: Globe,\n COMMUNITY: Users,\n RESTRICTED: Lock,\n EMBARGOED: Clock,\n};\n\nfunction Shell({ onBack, children }: { onBack: () => void; children: React.ReactNode }) {\n return (\n <div className=\"flex h-full flex-col\">\n <div className=\"flex-1 overflow-auto\">\n <div className=\"mx-auto w-full max-w-6xl p-4 sm:p-6\">\n <button\n type=\"button\"\n onClick={onBack}\n className=\"inline-flex items-center gap-1.5 text-sm font-medium text-text-secondary hover:text-text-primary\"\n >\n <ArrowLeft className=\"h-4 w-4\" aria-hidden=\"true\" /> Archive\n </button>\n <div className=\"mt-4\">{children}</div>\n </div>\n </div>\n </div>\n );\n}\n\nexport function ArchiveItemDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const { data: item, isLoading, isError, refetch } = useTcpArchiveItem(id);\n const [citeOpen, setCiteOpen] = useState(false);\n\n const goBack = () => navigateTo('archive');\n\n if (isLoading) {\n return (\n <Shell onBack={goBack}>\n <LoadingView label=\"Loading artifact…\" />\n </Shell>\n );\n }\n\n if (isError || !item) {\n return (\n <Shell onBack={goBack}>\n <ErrorView\n title=\"Artifact not found\"\n message=\"This artifact may have been archived, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </Shell>\n );\n }\n\n const collection = item.collection;\n const AccessIcon = ACCESS_ICON[item.accessLevel];\n const KindIcon = KIND_ICON[item.kind];\n\n return (\n <Shell onBack={goBack}>\n <div className=\"grid gap-6 lg:grid-cols-[1.3fr_1fr]\">\n <MediaViewer item={item} />\n\n <div className=\"flex min-w-0 flex-col gap-5\">\n <h1 className=\"font-display text-3xl font-semibold leading-tight text-text-primary\">\n {item.title}\n </h1>\n\n <div className=\"flex flex-wrap items-center gap-1.5\">\n <StatusBadge\n status={item.kind}\n icon={createElement(KindIcon, { className: 'h-3 w-3' })}\n />\n <StatusBadge\n status={item.accessLevel}\n icon={createElement(AccessIcon, { className: 'h-3 w-3' })}\n />\n </div>\n\n {item.description && (\n <p className=\"whitespace-pre-wrap text-sm leading-relaxed text-text-secondary\">\n {item.description}\n </p>\n )}\n\n <DecorativeDivider className=\"py-2\" />\n\n <dl className=\"flex flex-col gap-4\">\n {item.provenance && (\n <DetailRow icon={Landmark} label=\"Provenance\">\n {item.provenance}\n </DetailRow>\n )}\n {item.origin && (\n <DetailRow icon={Globe} label=\"Origin\">\n {item.origin}\n </DetailRow>\n )}\n {item.datedAt && (\n <DetailRow icon={CalendarClock} label=\"Date\">\n {formatDate(item.datedAt)}\n </DetailRow>\n )}\n {item.languages.length > 0 && (\n <DetailRow icon={Languages} label=\"Languages\">\n {item.languages.map(formatEnumLabel).join(', ')}\n </DetailRow>\n )}\n {item.tags.length > 0 && (\n <DetailRow icon={Tag} label=\"Tags\">\n <div className=\"flex flex-wrap gap-1.5\">\n {item.tags.map((tag) => (\n <span\n key={tag}\n className=\"rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\"\n >\n {tag}\n </span>\n ))}\n </div>\n </DetailRow>\n )}\n </dl>\n\n {collection && (\n <button\n type=\"button\"\n onClick={() => navigateTo(`archive/collections/${collection.id}`)}\n className=\"inline-flex items-center gap-2 self-start rounded-lg border border-border-seam bg-bg-surface px-3 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken\"\n >\n <Library className=\"h-4 w-4 text-text-tertiary\" aria-hidden=\"true\" />{' '}\n {collection.name}\n </button>\n )}\n\n <Button className=\"w-full sm:w-auto\" onClick={() => setCiteOpen(true)}>\n <Quote className=\"mr-1.5 h-4 w-4\" /> Cite in a story\n </Button>\n </div>\n </div>\n\n <CiteInStoryDialog open={citeOpen} onOpenChange={setCiteOpen} item={item} />\n </Shell>\n );\n}\n\n// ---------------------------------------------------------------------------\n// DetailRow\n// ---------------------------------------------------------------------------\n\nfunction DetailRow({\n icon: Icon,\n label,\n children,\n}: {\n icon: LucideIcon;\n label: string;\n children: React.ReactNode;\n}) {\n return (\n <div className=\"flex items-start gap-3\">\n <Icon className=\"mt-0.5 h-4 w-4 shrink-0 text-accent\" aria-hidden=\"true\" />\n <dt className=\"w-24 shrink-0 text-sm text-text-tertiary\">{label}</dt>\n <dd className=\"min-w-0 flex-1 text-sm text-text-primary\">{children}</dd>\n </div>\n );\n}\n\n// ---------------------------------------------------------------------------\n// MediaViewer — single file only (no gallery), CSS zoom + a full-screen lightbox.\n// ---------------------------------------------------------------------------\n\nfunction MediaViewer({ item }: { item: TcpArchiveItem }) {\n const { data: url } = useFileUrl(item.file?.id);\n const Icon = KIND_ICON[item.kind];\n const [zoom, setZoom] = useState(1);\n const [lightbox, setLightbox] = useState(false);\n const [imgFailed, setImgFailed] = useState(false);\n const showImage = IMAGE_KINDS.has(item.kind) && Boolean(url) && !imgFailed;\n\n useEffect(() => {\n if (!lightbox) return;\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') setLightbox(false);\n };\n window.addEventListener('keydown', onKey);\n return () => window.removeEventListener('keydown', onKey);\n }, [lightbox]);\n\n const zoomIn = () => setZoom((z) => Math.min(4, Math.round((z + 0.5) * 10) / 10));\n const zoomOut = () => setZoom((z) => Math.max(1, Math.round((z - 0.5) * 10) / 10));\n\n return (\n <div className=\"flex flex-col gap-4\">\n <GlassCard treatment=\"plain\" className=\"p-3 sm:p-4\">\n <div\n className={cn(\n 'relative aspect-[4/3] w-full rounded-xl bg-bg-sunken',\n zoom > 1 ? 'overflow-auto' : 'overflow-hidden'\n )}\n >\n {showImage && url ? (\n <img\n src={url}\n alt={item.title}\n onError={() => setImgFailed(true)}\n style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}\n className=\"h-full w-full object-contain transition-transform duration-200\"\n />\n ) : (\n <div className=\"flex h-full w-full flex-col items-center justify-center gap-3 text-text-tertiary\">\n <Icon className=\"h-16 w-16\" aria-hidden=\"true\" />\n <span className=\"text-sm font-medium\">{formatEnumLabel(item.kind)}</span>\n </div>\n )}\n\n {showImage && url && (\n <>\n <button\n type=\"button\"\n onClick={() => setLightbox(true)}\n aria-label=\"View full screen\"\n className=\"absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-lg bg-bg-canvas/85 text-text-primary shadow-sm backdrop-blur-sm hover:bg-bg-canvas\"\n >\n <Maximize2 className=\"h-4 w-4\" />\n </button>\n <div className=\"absolute bottom-3 left-3 z-10 flex flex-col overflow-hidden rounded-lg border border-border-seam bg-bg-canvas/85 text-text-primary shadow-sm backdrop-blur-sm\">\n <button\n type=\"button\"\n onClick={zoomIn}\n disabled={zoom >= 4}\n aria-label=\"Zoom in\"\n className=\"flex h-8 w-9 items-center justify-center hover:bg-bg-sunken disabled:opacity-40\"\n >\n <Plus className=\"h-4 w-4\" />\n </button>\n <button\n type=\"button\"\n onClick={() => setZoom(1)}\n aria-label=\"Reset zoom\"\n className=\"border-y border-border-seam px-1 py-1 text-[10px] font-medium tabular-nums hover:bg-bg-sunken\"\n >\n {Math.round(zoom * 100)}%\n </button>\n <button\n type=\"button\"\n onClick={zoomOut}\n disabled={zoom <= 1}\n aria-label=\"Zoom out\"\n className=\"flex h-8 w-9 items-center justify-center hover:bg-bg-sunken disabled:opacity-40\"\n >\n <Minus className=\"h-4 w-4\" />\n </button>\n </div>\n </>\n )}\n </div>\n </GlassCard>\n\n {url && (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n download\n className=\"inline-flex items-center justify-center gap-2 self-start rounded-lg border border-border-seam bg-bg-surface px-4 py-2 text-sm font-medium text-text-primary transition-colors hover:bg-bg-sunken\"\n >\n <Download className=\"h-4 w-4\" /> Download\n </a>\n )}\n\n {lightbox && url && (\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${item.title} — full view`}\n onClick={() => setLightbox(false)}\n className=\"fixed inset-0 z-50 flex items-center justify-center bg-bg-canvas/95 p-4 backdrop-blur-sm\"\n >\n <button\n type=\"button\"\n onClick={() => setLightbox(false)}\n aria-label=\"Close full view\"\n className=\"absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-bg-surface text-text-primary shadow-sm hover:bg-bg-sunken\"\n >\n <X className=\"h-5 w-5\" />\n </button>\n <img\n src={url}\n alt={item.title}\n onClick={(e) => e.stopPropagation()}\n className=\"max-h-full max-w-full rounded-lg object-contain\"\n />\n </div>\n )}\n </div>\n );\n}\n\n// ---------------------------------------------------------------------------\n// CiteInStoryDialog — pick a story and attach this artifact as a citation.\n// ---------------------------------------------------------------------------\n\nfunction CiteInStoryDialog({\n open,\n onOpenChange,\n item,\n}: {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n item: TcpArchiveItem;\n}) {\n const storiesQuery = useTcpStories({ pagination: { limit: 50 } });\n const addCitation = useTcpAddCitation();\n const [query, setQuery] = useState('');\n const [pendingId, setPendingId] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n const stories = storiesQuery.data?.items ?? [];\n const q = query.trim().toLowerCase();\n const filtered = q ? stories.filter((s) => s.title.toLowerCase().includes(q)) : stories;\n\n const cite = async (story: TcpStory) => {\n setPendingId(story.id);\n setError(null);\n try {\n await addCitation.mutateAsync({\n storyId: story.id,\n title: item.title,\n sourceType: 'ARCHIVAL_RECORD',\n provenance: item.provenance ?? null,\n excerpt: item.description ?? null,\n sourceFileId: item.file?.id ?? null,\n });\n notify.success('Cited in a story', story.title);\n onOpenChange(false);\n } catch (err) {\n setError(describeError(err));\n } finally {\n setPendingId(null);\n }\n };\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"sm:max-w-lg\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Cite in a story</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Attach “{item.title}” to a story as an archival-record citation.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"flex flex-col gap-3 py-2\">\n <Input\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"Search stories…\"\n aria-label=\"Search stories\"\n />\n <div className=\"flex max-h-[50vh] flex-col gap-1.5 overflow-y-auto\">\n {storiesQuery.isLoading ? (\n <LoadingView label=\"Loading stories…\" className=\"min-h-[8rem]\" />\n ) : filtered.length === 0 ? (\n <p className=\"py-8 text-center text-sm text-text-tertiary\">\n {stories.length === 0\n ? 'No stories yet — draft a story first.'\n : 'No stories match your search.'}\n </p>\n ) : (\n filtered.map((story) => (\n <button\n key={story.id}\n type=\"button\"\n onClick={() => cite(story)}\n disabled={addCitation.isPending}\n className=\"flex items-center justify-between gap-3 rounded-xl border border-border-seam bg-bg-surface px-3 py-2.5 text-left transition-colors hover:bg-bg-sunken disabled:opacity-60\"\n >\n <span className=\"min-w-0\">\n <span className=\"block truncate text-sm font-medium text-text-primary\">\n {story.title}\n </span>\n <span className=\"mt-0.5 block truncate text-xs text-text-tertiary\">\n {story.topic?.name ?? formatEnumLabel(story.status)}\n </span>\n </span>\n {pendingId === story.id ? (\n <Loader2 className=\"h-4 w-4 shrink-0 animate-spin text-text-tertiary\" />\n ) : (\n <Quote className=\"h-4 w-4 shrink-0 text-text-tertiary\" aria-hidden=\"true\" />\n )}\n </button>\n ))\n )}\n </div>\n {error && <p className=\"text-sm text-status-error-text\">{error}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n Close\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n"],"mappings":"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"}
|
|
@@ -13,7 +13,7 @@ import { CreateArchiveItemDialog as u } from "../../components/CreateArchiveItem
|
|
|
13
13
|
import { OralHistoryPlayerRow as d } from "./ArchivePage.js";
|
|
14
14
|
import { useState as f } from "react";
|
|
15
15
|
import { AudioLines as p, Boxes as m, Mic as h, Plus as g } from "lucide-react";
|
|
16
|
-
import { useParams as _ } from "react-router
|
|
16
|
+
import { useParams as _ } from "react-router";
|
|
17
17
|
import { Fragment as v, jsx as y, jsxs as b } from "react/jsx-runtime";
|
|
18
18
|
import { Button as x, GlassCard as S } from "@burdenoff/fe-libs/ui";
|
|
19
19
|
//#region src/theculturepost/pages/archive/CollectionDetailPage.tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CollectionDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/archive/CollectionDetailPage.tsx"],"sourcesContent":["/**\n * CollectionDetailPage — a single archive collection.\n * @module theculturepost/pages/archive\n *\n * Reads `tcpCollection` (with its items + oral histories). An editorial\n * {@link PageHero} shows the real cover (collections DO carry a cover file), with\n * real counts (itemCount, oralHistories.length). Two panels below list the\n * artifacts and the listenable oral histories. Adding an artifact or recording an\n * oral history is pre-scoped to this collection.\n */\n\nimport { useState } from 'react';\nimport { Boxes, AudioLines, Plus, Mic } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport { useParams } from 'react-router-dom';\nimport { PageHero } from '../../components/PageHero';\nimport { DecorativeDivider } from '../../components/DecorativeDivider';\nimport { ArchiveItemCard } from '../../components/ArchiveItemCard';\nimport { CreateArchiveItemDialog } from '../../components/CreateArchiveItemDialog';\nimport { RecordOralHistoryDialog } from '../../components/RecordOralHistoryDialog';\nimport { EmptyState } from '../../components/EmptyState';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { OralHistoryPlayerRow } from './ArchivePage';\nimport { useTcpCollection } from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { formatNumber } from '../../utils/formatters';\n\nfunction Shell({ children }: { children: React.ReactNode }) {\n return (\n <div className=\"flex h-full flex-col\">\n <div className=\"flex-1 overflow-auto\">\n <div className=\"mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6\">{children}</div>\n </div>\n </div>\n );\n}\n\nexport function CollectionDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const { data: collection, isLoading, isError, refetch } = useTcpCollection(id);\n const [addItemOpen, setAddItemOpen] = useState(false);\n const [recordOpen, setRecordOpen] = useState(false);\n\n const goBack = () => navigateTo('archive');\n\n if (isLoading) {\n return (\n <Shell>\n <LoadingView label=\"Loading collection…\" />\n </Shell>\n );\n }\n\n if (isError || !collection) {\n return (\n <Shell>\n <ErrorView\n title=\"Collection not found\"\n message=\"This collection may have been removed, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </Shell>\n );\n }\n\n const items = collection.items ?? [];\n const oralHistories = collection.oralHistories ?? [];\n\n return (\n <Shell>\n <PageHero\n eyebrow=\"Collection\"\n title={collection.name}\n description={collection.description ?? undefined}\n coverFileId={collection.cover?.id}\n backAction={{ label: 'Archive', onClick: goBack }}\n meta={\n <>\n {collection.tradition && (\n <span className=\"inline-flex items-center gap-1.5 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n {collection.tradition}\n </span>\n )}\n <span className=\"inline-flex items-center gap-1.5 text-sm text-text-tertiary\">\n <Boxes className=\"h-4 w-4\" aria-hidden=\"true\" /> {formatNumber(collection.itemCount)}{' '}\n {collection.itemCount === 1 ? 'item' : 'items'}\n </span>\n <span className=\"inline-flex items-center gap-1.5 text-sm text-text-tertiary\">\n <AudioLines className=\"h-4 w-4\" aria-hidden=\"true\" />{' '}\n {formatNumber(oralHistories.length)}{' '}\n {oralHistories.length === 1 ? 'oral history' : 'oral histories'}\n </span>\n </>\n }\n actions={\n <>\n <Button onClick={() => setAddItemOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Add artifact\n </Button>\n <Button variant=\"outline\" onClick={() => setRecordOpen(true)}>\n <Mic className=\"mr-1.5 h-4 w-4\" /> Record\n </Button>\n </>\n }\n />\n\n <DecorativeDivider className=\"py-2\" />\n\n <div className=\"grid gap-6 lg:grid-cols-2\">\n {/* Items */}\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-4 p-5\">\n <div className=\"flex items-center justify-between gap-3\">\n <h2 className=\"flex items-center gap-2 font-display text-lg font-semibold text-text-primary\">\n <Boxes className=\"h-5 w-5 text-accent\" aria-hidden=\"true\" /> Items\n </h2>\n <Button size=\"sm\" variant=\"outline\" onClick={() => setAddItemOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Add\n </Button>\n </div>\n {items.length === 0 ? (\n <EmptyState\n icon={<Boxes className=\"h-9 w-9\" />}\n title=\"No artifacts in this collection\"\n description=\"Add the first artifact to this collection.\"\n action={\n <Button onClick={() => setAddItemOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Add artifact\n </Button>\n }\n />\n ) : (\n <div className=\"grid gap-4 sm:grid-cols-2\">\n {items.map((item) => (\n <ArchiveItemCard\n key={item.id}\n item={item}\n onOpen={() => navigateTo(`archive/items/${item.id}`)}\n />\n ))}\n </div>\n )}\n </GlassCard>\n\n {/* Oral histories */}\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-4 p-5\">\n <div className=\"flex items-center justify-between gap-3\">\n <h2 className=\"flex items-center gap-2 font-display text-lg font-semibold text-text-primary\">\n <AudioLines className=\"h-5 w-5 text-accent\" aria-hidden=\"true\" /> Oral histories\n </h2>\n <Button size=\"sm\" variant=\"outline\" onClick={() => setRecordOpen(true)}>\n <Mic className=\"mr-1.5 h-4 w-4\" /> Record\n </Button>\n </div>\n {oralHistories.length === 0 ? (\n <EmptyState\n icon={<AudioLines className=\"h-9 w-9\" />}\n title=\"No oral histories yet\"\n description=\"Record a first-person account for this collection.\"\n action={\n <Button onClick={() => setRecordOpen(true)}>\n <Mic className=\"mr-1.5 h-4 w-4\" /> Record\n </Button>\n }\n />\n ) : (\n <div className=\"flex flex-col gap-4\">\n {oralHistories.map((oralHistory) => (\n <OralHistoryPlayerRow key={oralHistory.id} oralHistory={oralHistory} />\n ))}\n </div>\n )}\n </GlassCard>\n </div>\n\n <CreateArchiveItemDialog\n open={addItemOpen}\n onOpenChange={setAddItemOpen}\n collectionId={collection.id}\n />\n <RecordOralHistoryDialog\n open={recordOpen}\n onOpenChange={setRecordOpen}\n collectionId={collection.id}\n />\n </Shell>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA2BA,SAAS,EAAM,EAAE,eAA2C;AAC1D,QACE,kBAAC,OAAD;EAAK,WAAU;YACb,kBAAC,OAAD;GAAK,WAAU;aACb,kBAAC,OAAD;IAAK,WAAU;IAA2D;IAAe,CAAA;GACrF,CAAA;EACF,CAAA;;AAIV,SAAgB,IAAuB;CACrC,IAAM,EAAE,UAAO,GAA2B,EACpC,EAAE,kBAAe,GAAmB,EACpC,EAAE,MAAM,GAAY,cAAW,YAAS,eAAY,EAAiB,EAAG,EACxE,CAAC,GAAa,KAAkB,EAAS,GAAM,EAC/C,CAAC,GAAY,KAAiB,EAAS,GAAM,EAE7C,UAAe,EAAW,UAAU;AAE1C,KAAI,EACF,QACE,kBAAC,GAAD,EAAA,UACE,kBAAC,GAAD,EAAa,OAAM,uBAAwB,CAAA,EACrC,CAAA;AAIZ,KAAI,KAAW,CAAC,EACd,QACE,kBAAC,GAAD,EAAA,UACE,kBAAC,GAAD;EACE,OAAM;EACN,SAAQ;EACR,eAAe,GAAS;EACxB,CAAA,EACI,CAAA;CAIZ,IAAM,IAAQ,EAAW,SAAS,EAAE,EAC9B,IAAgB,EAAW,iBAAiB,EAAE;AAEpD,QACE,kBAAC,GAAD,EAAA,UAAA;EACE,kBAAC,GAAD;GACE,SAAQ;GACR,OAAO,EAAW;GAClB,aAAa,EAAW,eAAe,KAAA;GACvC,aAAa,EAAW,OAAO;GAC/B,YAAY;IAAE,OAAO;IAAW,SAAS;IAAQ;GACjD,MACE,kBAAA,GAAA,EAAA,UAAA;IACG,EAAW,aACV,kBAAC,QAAD;KAAM,WAAU;eACb,EAAW;KACP,CAAA;IAET,kBAAC,QAAD;KAAM,WAAU;eAAhB;MACE,kBAAC,GAAD;OAAO,WAAU;OAAU,eAAY;OAAS,CAAA;;MAAE,EAAa,EAAW,UAAU;MAAE;MACrF,EAAW,cAAc,IAAI,SAAS;MAClC;;IACP,kBAAC,QAAD;KAAM,WAAU;eAAhB;MACE,kBAAC,GAAD;OAAY,WAAU;OAAU,eAAY;OAAS,CAAA;MAAC;MACrD,EAAa,EAAc,OAAO;MAAE;MACpC,EAAc,WAAW,IAAI,iBAAiB;MAC1C;;IACN,EAAA,CAAA;GAEL,SACE,kBAAA,GAAA,EAAA,UAAA,CACE,kBAAC,GAAD;IAAQ,eAAe,EAAe,GAAK;cAA3C,CACE,kBAAC,GAAD,EAAM,WAAU,kBAAmB,CAAA,EAAA,gBAC5B;OACT,kBAAC,GAAD;IAAQ,SAAQ;IAAU,eAAe,EAAc,GAAK;cAA5D,CACE,kBAAC,GAAD,EAAK,WAAU,kBAAmB,CAAA,EAAA,UAC3B;MACR,EAAA,CAAA;GAEL,CAAA;EAEF,kBAAC,GAAD,EAAmB,WAAU,QAAS,CAAA;EAEtC,kBAAC,OAAD;GAAK,WAAU;aAAf,CAEE,kBAAC,GAAD;IAAW,WAAU;IAAQ,WAAU;cAAvC,CACE,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,MAAD;MAAI,WAAU;gBAAd,CACE,kBAAC,GAAD;OAAO,WAAU;OAAsB,eAAY;OAAS,CAAA,EAAA,SACzD;SACL,kBAAC,GAAD;MAAQ,MAAK;MAAK,SAAQ;MAAU,eAAe,EAAe,GAAK;gBAAvE,CACE,kBAAC,GAAD,EAAM,WAAU,kBAAmB,CAAA,EAAA,OAC5B;QACL;QACL,EAAM,WAAW,IAChB,kBAAC,GAAD;KACE,MAAM,kBAAC,GAAD,EAAO,WAAU,WAAY,CAAA;KACnC,OAAM;KACN,aAAY;KACZ,QACE,kBAAC,GAAD;MAAQ,eAAe,EAAe,GAAK;gBAA3C,CACE,kBAAC,GAAD,EAAM,WAAU,kBAAmB,CAAA,EAAA,gBAC5B;;KAEX,CAAA,GAEF,kBAAC,OAAD;KAAK,WAAU;eACZ,EAAM,KAAK,MACV,kBAAC,GAAD;MAEQ;MACN,cAAc,EAAW,iBAAiB,EAAK,KAAK;MACpD,EAHK,EAAK,GAGV,CACF;KACE,CAAA,CAEE;OAGZ,kBAAC,GAAD;IAAW,WAAU;IAAQ,WAAU;cAAvC,CACE,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,MAAD;MAAI,WAAU;gBAAd,CACE,kBAAC,GAAD;OAAY,WAAU;OAAsB,eAAY;OAAS,CAAA,EAAA,kBAC9D;SACL,kBAAC,GAAD;MAAQ,MAAK;MAAK,SAAQ;MAAU,eAAe,EAAc,GAAK;gBAAtE,CACE,kBAAC,GAAD,EAAK,WAAU,kBAAmB,CAAA,EAAA,UAC3B;QACL;QACL,EAAc,WAAW,IACxB,kBAAC,GAAD;KACE,MAAM,kBAAC,GAAD,EAAY,WAAU,WAAY,CAAA;KACxC,OAAM;KACN,aAAY;KACZ,QACE,kBAAC,GAAD;MAAQ,eAAe,EAAc,GAAK;gBAA1C,CACE,kBAAC,GAAD,EAAK,WAAU,kBAAmB,CAAA,EAAA,UAC3B;;KAEX,CAAA,GAEF,kBAAC,OAAD;KAAK,WAAU;eACZ,EAAc,KAAK,MAClB,kBAAC,GAAD,EAAwD,gBAAe,EAA5C,EAAY,GAAgC,CACvE;KACE,CAAA,CAEE;MACR;;EAEN,kBAAC,GAAD;GACE,MAAM;GACN,cAAc;GACd,cAAc,EAAW;GACzB,CAAA;EACF,kBAAC,GAAD;GACE,MAAM;GACN,cAAc;GACd,cAAc,EAAW;GACzB,CAAA;EACI,EAAA,CAAA"}
|
|
1
|
+
{"version":3,"file":"CollectionDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/archive/CollectionDetailPage.tsx"],"sourcesContent":["/**\n * CollectionDetailPage — a single archive collection.\n * @module theculturepost/pages/archive\n *\n * Reads `tcpCollection` (with its items + oral histories). An editorial\n * {@link PageHero} shows the real cover (collections DO carry a cover file), with\n * real counts (itemCount, oralHistories.length). Two panels below list the\n * artifacts and the listenable oral histories. Adding an artifact or recording an\n * oral history is pre-scoped to this collection.\n */\n\nimport { useState } from 'react';\nimport { Boxes, AudioLines, Plus, Mic } from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport { useParams } from 'react-router';\nimport { PageHero } from '../../components/PageHero';\nimport { DecorativeDivider } from '../../components/DecorativeDivider';\nimport { ArchiveItemCard } from '../../components/ArchiveItemCard';\nimport { CreateArchiveItemDialog } from '../../components/CreateArchiveItemDialog';\nimport { RecordOralHistoryDialog } from '../../components/RecordOralHistoryDialog';\nimport { EmptyState } from '../../components/EmptyState';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { OralHistoryPlayerRow } from './ArchivePage';\nimport { useTcpCollection } from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { formatNumber } from '../../utils/formatters';\n\nfunction Shell({ children }: { children: React.ReactNode }) {\n return (\n <div className=\"flex h-full flex-col\">\n <div className=\"flex-1 overflow-auto\">\n <div className=\"mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6\">{children}</div>\n </div>\n </div>\n );\n}\n\nexport function CollectionDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const { data: collection, isLoading, isError, refetch } = useTcpCollection(id);\n const [addItemOpen, setAddItemOpen] = useState(false);\n const [recordOpen, setRecordOpen] = useState(false);\n\n const goBack = () => navigateTo('archive');\n\n if (isLoading) {\n return (\n <Shell>\n <LoadingView label=\"Loading collection…\" />\n </Shell>\n );\n }\n\n if (isError || !collection) {\n return (\n <Shell>\n <ErrorView\n title=\"Collection not found\"\n message=\"This collection may have been removed, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </Shell>\n );\n }\n\n const items = collection.items ?? [];\n const oralHistories = collection.oralHistories ?? [];\n\n return (\n <Shell>\n <PageHero\n eyebrow=\"Collection\"\n title={collection.name}\n description={collection.description ?? undefined}\n coverFileId={collection.cover?.id}\n backAction={{ label: 'Archive', onClick: goBack }}\n meta={\n <>\n {collection.tradition && (\n <span className=\"inline-flex items-center gap-1.5 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n {collection.tradition}\n </span>\n )}\n <span className=\"inline-flex items-center gap-1.5 text-sm text-text-tertiary\">\n <Boxes className=\"h-4 w-4\" aria-hidden=\"true\" /> {formatNumber(collection.itemCount)}{' '}\n {collection.itemCount === 1 ? 'item' : 'items'}\n </span>\n <span className=\"inline-flex items-center gap-1.5 text-sm text-text-tertiary\">\n <AudioLines className=\"h-4 w-4\" aria-hidden=\"true\" />{' '}\n {formatNumber(oralHistories.length)}{' '}\n {oralHistories.length === 1 ? 'oral history' : 'oral histories'}\n </span>\n </>\n }\n actions={\n <>\n <Button onClick={() => setAddItemOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Add artifact\n </Button>\n <Button variant=\"outline\" onClick={() => setRecordOpen(true)}>\n <Mic className=\"mr-1.5 h-4 w-4\" /> Record\n </Button>\n </>\n }\n />\n\n <DecorativeDivider className=\"py-2\" />\n\n <div className=\"grid gap-6 lg:grid-cols-2\">\n {/* Items */}\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-4 p-5\">\n <div className=\"flex items-center justify-between gap-3\">\n <h2 className=\"flex items-center gap-2 font-display text-lg font-semibold text-text-primary\">\n <Boxes className=\"h-5 w-5 text-accent\" aria-hidden=\"true\" /> Items\n </h2>\n <Button size=\"sm\" variant=\"outline\" onClick={() => setAddItemOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Add\n </Button>\n </div>\n {items.length === 0 ? (\n <EmptyState\n icon={<Boxes className=\"h-9 w-9\" />}\n title=\"No artifacts in this collection\"\n description=\"Add the first artifact to this collection.\"\n action={\n <Button onClick={() => setAddItemOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Add artifact\n </Button>\n }\n />\n ) : (\n <div className=\"grid gap-4 sm:grid-cols-2\">\n {items.map((item) => (\n <ArchiveItemCard\n key={item.id}\n item={item}\n onOpen={() => navigateTo(`archive/items/${item.id}`)}\n />\n ))}\n </div>\n )}\n </GlassCard>\n\n {/* Oral histories */}\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-4 p-5\">\n <div className=\"flex items-center justify-between gap-3\">\n <h2 className=\"flex items-center gap-2 font-display text-lg font-semibold text-text-primary\">\n <AudioLines className=\"h-5 w-5 text-accent\" aria-hidden=\"true\" /> Oral histories\n </h2>\n <Button size=\"sm\" variant=\"outline\" onClick={() => setRecordOpen(true)}>\n <Mic className=\"mr-1.5 h-4 w-4\" /> Record\n </Button>\n </div>\n {oralHistories.length === 0 ? (\n <EmptyState\n icon={<AudioLines className=\"h-9 w-9\" />}\n title=\"No oral histories yet\"\n description=\"Record a first-person account for this collection.\"\n action={\n <Button onClick={() => setRecordOpen(true)}>\n <Mic className=\"mr-1.5 h-4 w-4\" /> Record\n </Button>\n }\n />\n ) : (\n <div className=\"flex flex-col gap-4\">\n {oralHistories.map((oralHistory) => (\n <OralHistoryPlayerRow key={oralHistory.id} oralHistory={oralHistory} />\n ))}\n </div>\n )}\n </GlassCard>\n </div>\n\n <CreateArchiveItemDialog\n open={addItemOpen}\n onOpenChange={setAddItemOpen}\n collectionId={collection.id}\n />\n <RecordOralHistoryDialog\n open={recordOpen}\n onOpenChange={setRecordOpen}\n collectionId={collection.id}\n />\n </Shell>\n );\n}\n"],"mappings":"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"}
|
|
@@ -11,7 +11,7 @@ import { impact as d, nativeConfirm as f } from "../../utils/nativeBridge.js";
|
|
|
11
11
|
import { PageLayout as p } from "../../components/PageLayout.js";
|
|
12
12
|
import { ReplyThread as m } from "../../components/ReplyThread.js";
|
|
13
13
|
import { ArrowLeft as h, BookOpen as g, CheckCircle2 as _, Clock as v, ExternalLink as y, Flag as b, HeartHandshake as x, Lock as S, MessagesSquare as C, Quote as w, Trash2 as T, Unlock as E } from "lucide-react";
|
|
14
|
-
import { useParams as D } from "react-router
|
|
14
|
+
import { useParams as D } from "react-router";
|
|
15
15
|
import { Fragment as O, jsx as k, jsxs as A } from "react/jsx-runtime";
|
|
16
16
|
import { Button as j, GlassCard as M } from "@burdenoff/fe-libs/ui";
|
|
17
17
|
//#region src/theculturepost/pages/discussions/DiscussionDetailPage.tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DiscussionDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/discussions/DiscussionDetailPage.tsx"],"sourcesContent":["/**\n * DiscussionDetailPage — a single discussion and its moderated replies.\n * @module theculturepost/pages/discussions\n *\n * Reads `tcpDiscussion`. Writes `tcpLockDiscussion` and, via {@link ReplyThread},\n * `tcpPostReply` / `tcpModerateReply`. When anchored to a story, a link back to\n * the story is shown. The header is an editorial card: display-serif title, a\n * moderation-status + interfaith badge row, tag chips, an \"opened by\" byline and\n * a framed prompt callout.\n */\n\nimport type { ReactNode } from 'react';\nimport { useParams } from 'react-router-dom';\nimport {\n ArrowLeft,\n Lock,\n Unlock,\n HeartHandshake,\n BookOpen,\n MessagesSquare,\n Quote,\n CheckCircle2,\n Clock,\n Flag,\n Trash2,\n ExternalLink,\n} from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport { PageLayout } from '../../components/PageLayout';\nimport { StatusBadge } from '../../components/StatusBadge';\nimport { AuthorLabel } from '../../components/AuthorLabel';\nimport { ReplyThread } from '../../components/ReplyThread';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { useTcpDiscussion, useTcpLockDiscussion } from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { formatNumber, formatRelativeTime } from '../../utils/formatters';\nimport { notify, describeError } from '../../lib/toast';\nimport { impact, nativeConfirm } from '../../utils/nativeBridge';\nimport type { TcpModerationStatus } from '../../types';\n\n/** Leading icon for a moderation-status badge (token colours come from StatusBadge). */\nfunction statusIcon(status: TcpModerationStatus): ReactNode {\n const className = 'h-3 w-3';\n switch (status) {\n case 'APPROVED':\n return <CheckCircle2 className={className} aria-hidden=\"true\" />;\n case 'PENDING':\n return <Clock className={className} aria-hidden=\"true\" />;\n case 'FLAGGED':\n return <Flag className={className} aria-hidden=\"true\" />;\n case 'REMOVED':\n return <Trash2 className={className} aria-hidden=\"true\" />;\n }\n}\n\nexport function DiscussionDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const { data: discussion, isLoading, isError, refetch } = useTcpDiscussion(id);\n const lock = useTcpLockDiscussion();\n\n const backButton = (\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('discussions')}>\n <ArrowLeft className=\"mr-1.5 h-4 w-4\" /> Back\n </Button>\n );\n\n if (isLoading) {\n return (\n <PageLayout title=\"Discussion\" actions={backButton}>\n <LoadingView label=\"Loading discussion…\" />\n </PageLayout>\n );\n }\n\n if (isError || !discussion) {\n return (\n <PageLayout title=\"Discussion\" actions={backButton}>\n <ErrorView\n title=\"Discussion not found\"\n message=\"This discussion may have been removed, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </PageLayout>\n );\n }\n\n const doLock = async () => {\n await impact('light');\n const action = discussion.isLocked ? 'unlock' : 'lock';\n const confirmed = await nativeConfirm({\n title: `${action === 'lock' ? 'Lock' : 'Unlock'} discussion`,\n message: `Are you sure you want to ${action} this discussion?`,\n okButtonTitle: action === 'lock' ? 'Lock' : 'Unlock',\n cancelButtonTitle: 'Cancel',\n });\n if (!confirmed) return;\n try {\n await lock.mutateAsync(discussion.id);\n notify.success(discussion.isLocked ? 'Discussion unlocked' : 'Discussion locked');\n } catch (err) {\n notify.error('Could not update the discussion', describeError(err));\n }\n };\n\n return (\n <PageLayout title=\"Discussion\" actions={backButton}>\n <div className=\"mx-auto flex max-w-3xl flex-col gap-6\">\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-4 p-5 sm:p-6\">\n {/* Title + lock/unlock */}\n <div className=\"flex items-start justify-between gap-3\">\n <h1 className=\"font-display text-2xl font-semibold leading-tight text-text-primary sm:text-3xl\">\n {discussion.title}\n </h1>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={doLock}\n disabled={lock.isPending}\n className=\"shrink-0\"\n >\n {discussion.isLocked ? (\n <>\n <Unlock className=\"mr-1.5 h-4 w-4\" /> Unlock\n </>\n ) : (\n <>\n <Lock className=\"mr-1.5 h-4 w-4\" /> Lock\n </>\n )}\n </Button>\n </div>\n\n {/* Moderation status + interfaith / locked signals, with tags to the right */}\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2\">\n <div className=\"flex flex-wrap items-center gap-1.5\">\n <StatusBadge status={discussion.status} icon={statusIcon(discussion.status)} />\n {discussion.isInterfaith && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-soft)] px-2.5 py-0.5 text-xs font-medium text-text-primary\">\n <HeartHandshake className=\"h-3 w-3\" aria-hidden=\"true\" /> Interfaith\n </span>\n )}\n {discussion.isLocked && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n <Lock className=\"h-3 w-3\" aria-hidden=\"true\" /> Locked\n </span>\n )}\n </div>\n {discussion.tags.length > 0 && (\n <div className=\"flex flex-wrap items-center gap-1.5 sm:ml-auto\">\n {discussion.tags.map((tag) => (\n <span\n key={tag}\n className=\"rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\"\n >\n #{tag}\n </span>\n ))}\n </div>\n )}\n </div>\n\n {/* Byline */}\n <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-text-tertiary\">\n <AuthorLabel actorId={discussion.openedById} prefix=\"Opened by\" />\n <span aria-hidden=\"true\">·</span>\n <span className=\"inline-flex items-center gap-1\">\n <MessagesSquare className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n {formatNumber(discussion.replyCount)}{' '}\n {discussion.replyCount === 1 ? 'reply' : 'replies'}\n </span>\n <span aria-hidden=\"true\">·</span>\n <span>Started {formatRelativeTime(discussion.createdAt)}</span>\n </div>\n\n {/* Prompt callout + linked story */}\n {(discussion.prompt || discussion.storyId) && (\n <div className=\"flex flex-col gap-3 rounded-xl border border-border-seam bg-[var(--color-accent-soft)] p-4 sm:flex-row sm:items-start sm:justify-between\">\n {discussion.prompt ? (\n <div className=\"flex min-w-0 items-start gap-3\">\n <span\n className=\"mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-bg-canvas/70 text-accent\"\n aria-hidden=\"true\"\n >\n <Quote className=\"h-4 w-4\" />\n </span>\n <p className=\"min-w-0 whitespace-pre-wrap text-sm leading-relaxed text-text-primary\">\n {discussion.prompt}\n </p>\n </div>\n ) : (\n <span className=\"text-sm text-text-secondary\">Anchored to a story.</span>\n )}\n {discussion.storyId && (\n <button\n type=\"button\"\n onClick={() => navigateTo(`stories/${discussion.storyId}`)}\n className=\"inline-flex shrink-0 items-center gap-1.5 self-start rounded-lg px-1 text-sm font-medium text-accent hover:underline\"\n >\n <BookOpen className=\"h-4 w-4\" aria-hidden=\"true\" /> View linked story\n <ExternalLink className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n </button>\n )}\n </div>\n )}\n </GlassCard>\n\n <ReplyThread discussionId={discussion.id} isLocked={discussion.isLocked} />\n </div>\n </PageLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAyCA,SAAS,EAAW,GAAwC;CAC1D,IAAM,IAAY;AAClB,SAAQ,GAAR;EACE,KAAK,WACH,QAAO,kBAAC,GAAD;GAAyB;GAAW,eAAY;GAAS,CAAA;EAClE,KAAK,UACH,QAAO,kBAAC,GAAD;GAAkB;GAAW,eAAY;GAAS,CAAA;EAC3D,KAAK,UACH,QAAO,kBAAC,GAAD;GAAiB;GAAW,eAAY;GAAS,CAAA;EAC1D,KAAK,UACH,QAAO,kBAAC,GAAD;GAAmB;GAAW,eAAY;GAAS,CAAA;;;AAIhE,SAAgB,IAAuB;CACrC,IAAM,EAAE,UAAO,GAA2B,EACpC,EAAE,kBAAe,GAAmB,EACpC,EAAE,MAAM,GAAY,cAAW,YAAS,eAAY,EAAiB,EAAG,EACxE,IAAO,GAAsB,EAE7B,IACJ,kBAAC,GAAD;EAAQ,SAAQ;EAAQ,MAAK;EAAK,eAAe,EAAW,cAAc;YAA1E,CACE,kBAAC,GAAD,EAAW,WAAU,kBAAmB,CAAA,EAAA,QACjC;;AAyCX,QAtCI,IAEA,kBAAC,GAAD;EAAY,OAAM;EAAa,SAAS;YACtC,kBAAC,GAAD,EAAa,OAAM,uBAAwB,CAAA;EAChC,CAAA,GAIb,KAAW,CAAC,IAEZ,kBAAC,GAAD;EAAY,OAAM;EAAa,SAAS;YACtC,kBAAC,GAAD;GACE,OAAM;GACN,SAAQ;GACR,eAAe,GAAS;GACxB,CAAA;EACS,CAAA,GAuBf,kBAAC,GAAD;EAAY,OAAM;EAAa,SAAS;YACtC,kBAAC,OAAD;GAAK,WAAU;aAAf,CACE,kBAAC,GAAD;IAAW,WAAU;IAAQ,WAAU;cAAvC;KAEE,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,MAAD;OAAI,WAAU;iBACX,EAAW;OACT,CAAA,EACL,kBAAC,GAAD;OACE,SAAQ;OACR,MAAK;OACL,SA9BG,YAAY;AACzB,cAAM,EAAO,QAAQ;QACrB,IAAM,IAAS,EAAW,WAAW,WAAW;AAC9B,kBAAM,EAAc;SACpC,OAAO,GAAG,MAAW,SAAS,SAAS,SAAS;SAChD,SAAS,4BAA4B,EAAO;SAC5C,eAAe,MAAW,SAAS,SAAS;SAC5C,mBAAmB;SACpB,CAAC,CAEF,KAAI;AAEF,SADA,MAAM,EAAK,YAAY,EAAW,GAAG,EACrC,EAAO,QAAQ,EAAW,WAAW,wBAAwB,oBAAoB;iBAC1E,GAAK;AACZ,WAAO,MAAM,mCAAmC,EAAc,EAAI,CAAC;;;OAiB3D,UAAU,EAAK;OACf,WAAU;iBAET,EAAW,WACV,kBAAA,GAAA,EAAA,UAAA,CACE,kBAAC,GAAD,EAAQ,WAAU,kBAAmB,CAAA,EAAA,UACpC,EAAA,CAAA,GAEH,kBAAA,GAAA,EAAA,UAAA,CACE,kBAAC,GAAD,EAAM,WAAU,kBAAmB,CAAA,EAAA,QAClC,EAAA,CAAA;OAEE,CAAA,CACL;;KAGN,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,OAAD;OAAK,WAAU;iBAAf;QACE,kBAAC,GAAD;SAAa,QAAQ,EAAW;SAAQ,MAAM,EAAW,EAAW,OAAO;SAAI,CAAA;QAC9E,EAAW,gBACV,kBAAC,QAAD;SAAM,WAAU;mBAAhB,CACE,kBAAC,GAAD;UAAgB,WAAU;UAAU,eAAY;UAAS,CAAA,EAAA,cACpD;;QAER,EAAW,YACV,kBAAC,QAAD;SAAM,WAAU;mBAAhB,CACE,kBAAC,GAAD;UAAM,WAAU;UAAU,eAAY;UAAS,CAAA,EAAA,UAC1C;;QAEL;UACL,EAAW,KAAK,SAAS,KACxB,kBAAC,OAAD;OAAK,WAAU;iBACZ,EAAW,KAAK,KAAK,MACpB,kBAAC,QAAD;QAEE,WAAU;kBAFZ,CAGC,KACG,EACG;UAJA,EAIA,CACP;OACE,CAAA,CAEJ;;KAGN,kBAAC,OAAD;MAAK,WAAU;gBAAf;OACE,kBAAC,GAAD;QAAa,SAAS,EAAW;QAAY,QAAO;QAAc,CAAA;OAClE,kBAAC,QAAD;QAAM,eAAY;kBAAO;QAAQ,CAAA;OACjC,kBAAC,QAAD;QAAM,WAAU;kBAAhB;SACE,kBAAC,GAAD;UAAgB,WAAU;UAAc,eAAY;UAAS,CAAA;SAC5D,EAAa,EAAW,WAAW;SAAE;SACrC,EAAW,eAAe,IAAI,UAAU;SACpC;;OACP,kBAAC,QAAD;QAAM,eAAY;kBAAO;QAAQ,CAAA;OACjC,kBAAC,QAAD,EAAA,UAAA,CAAM,YAAS,EAAmB,EAAW,UAAU,CAAQ,EAAA,CAAA;OAC3D;;MAGJ,EAAW,UAAU,EAAW,YAChC,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACG,EAAW,SACV,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,QAAD;QACE,WAAU;QACV,eAAY;kBAEZ,kBAAC,GAAD,EAAO,WAAU,WAAY,CAAA;QACxB,CAAA,EACP,kBAAC,KAAD;QAAG,WAAU;kBACV,EAAW;QACV,CAAA,CACA;WAEN,kBAAC,QAAD;OAAM,WAAU;iBAA8B;OAA2B,CAAA,EAE1E,EAAW,WACV,kBAAC,UAAD;OACE,MAAK;OACL,eAAe,EAAW,WAAW,EAAW,UAAU;OAC1D,WAAU;iBAHZ;QAKE,kBAAC,GAAD;SAAU,WAAU;SAAU,eAAY;SAAS,CAAA;;QACnD,kBAAC,GAAD;SAAc,WAAU;SAAc,eAAY;SAAS,CAAA;QACpD;SAEP;;KAEE;OAEZ,kBAAC,GAAD;IAAa,cAAc,EAAW;IAAI,UAAU,EAAW;IAAY,CAAA,CACvE;;EACK,CAAA"}
|
|
1
|
+
{"version":3,"file":"DiscussionDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/discussions/DiscussionDetailPage.tsx"],"sourcesContent":["/**\n * DiscussionDetailPage — a single discussion and its moderated replies.\n * @module theculturepost/pages/discussions\n *\n * Reads `tcpDiscussion`. Writes `tcpLockDiscussion` and, via {@link ReplyThread},\n * `tcpPostReply` / `tcpModerateReply`. When anchored to a story, a link back to\n * the story is shown. The header is an editorial card: display-serif title, a\n * moderation-status + interfaith badge row, tag chips, an \"opened by\" byline and\n * a framed prompt callout.\n */\n\nimport type { ReactNode } from 'react';\nimport { useParams } from 'react-router';\nimport {\n ArrowLeft,\n Lock,\n Unlock,\n HeartHandshake,\n BookOpen,\n MessagesSquare,\n Quote,\n CheckCircle2,\n Clock,\n Flag,\n Trash2,\n ExternalLink,\n} from 'lucide-react';\nimport { Button, GlassCard } from '@burdenoff/fe-libs/ui';\nimport { PageLayout } from '../../components/PageLayout';\nimport { StatusBadge } from '../../components/StatusBadge';\nimport { AuthorLabel } from '../../components/AuthorLabel';\nimport { ReplyThread } from '../../components/ReplyThread';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport { useTcpDiscussion, useTcpLockDiscussion } from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { formatNumber, formatRelativeTime } from '../../utils/formatters';\nimport { notify, describeError } from '../../lib/toast';\nimport { impact, nativeConfirm } from '../../utils/nativeBridge';\nimport type { TcpModerationStatus } from '../../types';\n\n/** Leading icon for a moderation-status badge (token colours come from StatusBadge). */\nfunction statusIcon(status: TcpModerationStatus): ReactNode {\n const className = 'h-3 w-3';\n switch (status) {\n case 'APPROVED':\n return <CheckCircle2 className={className} aria-hidden=\"true\" />;\n case 'PENDING':\n return <Clock className={className} aria-hidden=\"true\" />;\n case 'FLAGGED':\n return <Flag className={className} aria-hidden=\"true\" />;\n case 'REMOVED':\n return <Trash2 className={className} aria-hidden=\"true\" />;\n }\n}\n\nexport function DiscussionDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const { data: discussion, isLoading, isError, refetch } = useTcpDiscussion(id);\n const lock = useTcpLockDiscussion();\n\n const backButton = (\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('discussions')}>\n <ArrowLeft className=\"mr-1.5 h-4 w-4\" /> Back\n </Button>\n );\n\n if (isLoading) {\n return (\n <PageLayout title=\"Discussion\" actions={backButton}>\n <LoadingView label=\"Loading discussion…\" />\n </PageLayout>\n );\n }\n\n if (isError || !discussion) {\n return (\n <PageLayout title=\"Discussion\" actions={backButton}>\n <ErrorView\n title=\"Discussion not found\"\n message=\"This discussion may have been removed, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </PageLayout>\n );\n }\n\n const doLock = async () => {\n await impact('light');\n const action = discussion.isLocked ? 'unlock' : 'lock';\n const confirmed = await nativeConfirm({\n title: `${action === 'lock' ? 'Lock' : 'Unlock'} discussion`,\n message: `Are you sure you want to ${action} this discussion?`,\n okButtonTitle: action === 'lock' ? 'Lock' : 'Unlock',\n cancelButtonTitle: 'Cancel',\n });\n if (!confirmed) return;\n try {\n await lock.mutateAsync(discussion.id);\n notify.success(discussion.isLocked ? 'Discussion unlocked' : 'Discussion locked');\n } catch (err) {\n notify.error('Could not update the discussion', describeError(err));\n }\n };\n\n return (\n <PageLayout title=\"Discussion\" actions={backButton}>\n <div className=\"mx-auto flex max-w-3xl flex-col gap-6\">\n <GlassCard treatment=\"plain\" className=\"flex flex-col gap-4 p-5 sm:p-6\">\n {/* Title + lock/unlock */}\n <div className=\"flex items-start justify-between gap-3\">\n <h1 className=\"font-display text-2xl font-semibold leading-tight text-text-primary sm:text-3xl\">\n {discussion.title}\n </h1>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={doLock}\n disabled={lock.isPending}\n className=\"shrink-0\"\n >\n {discussion.isLocked ? (\n <>\n <Unlock className=\"mr-1.5 h-4 w-4\" /> Unlock\n </>\n ) : (\n <>\n <Lock className=\"mr-1.5 h-4 w-4\" /> Lock\n </>\n )}\n </Button>\n </div>\n\n {/* Moderation status + interfaith / locked signals, with tags to the right */}\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2\">\n <div className=\"flex flex-wrap items-center gap-1.5\">\n <StatusBadge status={discussion.status} icon={statusIcon(discussion.status)} />\n {discussion.isInterfaith && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-[var(--color-accent-soft)] px-2.5 py-0.5 text-xs font-medium text-text-primary\">\n <HeartHandshake className=\"h-3 w-3\" aria-hidden=\"true\" /> Interfaith\n </span>\n )}\n {discussion.isLocked && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n <Lock className=\"h-3 w-3\" aria-hidden=\"true\" /> Locked\n </span>\n )}\n </div>\n {discussion.tags.length > 0 && (\n <div className=\"flex flex-wrap items-center gap-1.5 sm:ml-auto\">\n {discussion.tags.map((tag) => (\n <span\n key={tag}\n className=\"rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\"\n >\n #{tag}\n </span>\n ))}\n </div>\n )}\n </div>\n\n {/* Byline */}\n <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-text-tertiary\">\n <AuthorLabel actorId={discussion.openedById} prefix=\"Opened by\" />\n <span aria-hidden=\"true\">·</span>\n <span className=\"inline-flex items-center gap-1\">\n <MessagesSquare className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n {formatNumber(discussion.replyCount)}{' '}\n {discussion.replyCount === 1 ? 'reply' : 'replies'}\n </span>\n <span aria-hidden=\"true\">·</span>\n <span>Started {formatRelativeTime(discussion.createdAt)}</span>\n </div>\n\n {/* Prompt callout + linked story */}\n {(discussion.prompt || discussion.storyId) && (\n <div className=\"flex flex-col gap-3 rounded-xl border border-border-seam bg-[var(--color-accent-soft)] p-4 sm:flex-row sm:items-start sm:justify-between\">\n {discussion.prompt ? (\n <div className=\"flex min-w-0 items-start gap-3\">\n <span\n className=\"mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-bg-canvas/70 text-accent\"\n aria-hidden=\"true\"\n >\n <Quote className=\"h-4 w-4\" />\n </span>\n <p className=\"min-w-0 whitespace-pre-wrap text-sm leading-relaxed text-text-primary\">\n {discussion.prompt}\n </p>\n </div>\n ) : (\n <span className=\"text-sm text-text-secondary\">Anchored to a story.</span>\n )}\n {discussion.storyId && (\n <button\n type=\"button\"\n onClick={() => navigateTo(`stories/${discussion.storyId}`)}\n className=\"inline-flex shrink-0 items-center gap-1.5 self-start rounded-lg px-1 text-sm font-medium text-accent hover:underline\"\n >\n <BookOpen className=\"h-4 w-4\" aria-hidden=\"true\" /> View linked story\n <ExternalLink className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />\n </button>\n )}\n </div>\n )}\n </GlassCard>\n\n <ReplyThread discussionId={discussion.id} isLocked={discussion.isLocked} />\n </div>\n </PageLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAyCA,SAAS,EAAW,GAAwC;CAC1D,IAAM,IAAY;AAClB,SAAQ,GAAR;EACE,KAAK,WACH,QAAO,kBAAC,GAAD;GAAyB;GAAW,eAAY;GAAS,CAAA;EAClE,KAAK,UACH,QAAO,kBAAC,GAAD;GAAkB;GAAW,eAAY;GAAS,CAAA;EAC3D,KAAK,UACH,QAAO,kBAAC,GAAD;GAAiB;GAAW,eAAY;GAAS,CAAA;EAC1D,KAAK,UACH,QAAO,kBAAC,GAAD;GAAmB;GAAW,eAAY;GAAS,CAAA;;;AAIhE,SAAgB,IAAuB;CACrC,IAAM,EAAE,UAAO,GAA2B,EACpC,EAAE,kBAAe,GAAmB,EACpC,EAAE,MAAM,GAAY,cAAW,YAAS,eAAY,EAAiB,EAAG,EACxE,IAAO,GAAsB,EAE7B,IACJ,kBAAC,GAAD;EAAQ,SAAQ;EAAQ,MAAK;EAAK,eAAe,EAAW,cAAc;YAA1E,CACE,kBAAC,GAAD,EAAW,WAAU,kBAAmB,CAAA,EAAA,QACjC;;AAyCX,QAtCI,IAEA,kBAAC,GAAD;EAAY,OAAM;EAAa,SAAS;YACtC,kBAAC,GAAD,EAAa,OAAM,uBAAwB,CAAA;EAChC,CAAA,GAIb,KAAW,CAAC,IAEZ,kBAAC,GAAD;EAAY,OAAM;EAAa,SAAS;YACtC,kBAAC,GAAD;GACE,OAAM;GACN,SAAQ;GACR,eAAe,GAAS;GACxB,CAAA;EACS,CAAA,GAuBf,kBAAC,GAAD;EAAY,OAAM;EAAa,SAAS;YACtC,kBAAC,OAAD;GAAK,WAAU;aAAf,CACE,kBAAC,GAAD;IAAW,WAAU;IAAQ,WAAU;cAAvC;KAEE,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,MAAD;OAAI,WAAU;iBACX,EAAW;OACT,CAAA,EACL,kBAAC,GAAD;OACE,SAAQ;OACR,MAAK;OACL,SA9BG,YAAY;AACzB,cAAM,EAAO,QAAQ;QACrB,IAAM,IAAS,EAAW,WAAW,WAAW;AAC9B,kBAAM,EAAc;SACpC,OAAO,GAAG,MAAW,SAAS,SAAS,SAAS;SAChD,SAAS,4BAA4B,EAAO;SAC5C,eAAe,MAAW,SAAS,SAAS;SAC5C,mBAAmB;SACpB,CAAC,CAEF,KAAI;AAEF,SADA,MAAM,EAAK,YAAY,EAAW,GAAG,EACrC,EAAO,QAAQ,EAAW,WAAW,wBAAwB,oBAAoB;iBAC1E,GAAK;AACZ,WAAO,MAAM,mCAAmC,EAAc,EAAI,CAAC;;;OAiB3D,UAAU,EAAK;OACf,WAAU;iBAET,EAAW,WACV,kBAAA,GAAA,EAAA,UAAA,CACE,kBAAC,GAAD,EAAQ,WAAU,kBAAmB,CAAA,EAAA,UACpC,EAAA,CAAA,GAEH,kBAAA,GAAA,EAAA,UAAA,CACE,kBAAC,GAAD,EAAM,WAAU,kBAAmB,CAAA,EAAA,QAClC,EAAA,CAAA;OAEE,CAAA,CACL;;KAGN,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,OAAD;OAAK,WAAU;iBAAf;QACE,kBAAC,GAAD;SAAa,QAAQ,EAAW;SAAQ,MAAM,EAAW,EAAW,OAAO;SAAI,CAAA;QAC9E,EAAW,gBACV,kBAAC,QAAD;SAAM,WAAU;mBAAhB,CACE,kBAAC,GAAD;UAAgB,WAAU;UAAU,eAAY;UAAS,CAAA,EAAA,cACpD;;QAER,EAAW,YACV,kBAAC,QAAD;SAAM,WAAU;mBAAhB,CACE,kBAAC,GAAD;UAAM,WAAU;UAAU,eAAY;UAAS,CAAA,EAAA,UAC1C;;QAEL;UACL,EAAW,KAAK,SAAS,KACxB,kBAAC,OAAD;OAAK,WAAU;iBACZ,EAAW,KAAK,KAAK,MACpB,kBAAC,QAAD;QAEE,WAAU;kBAFZ,CAGC,KACG,EACG;UAJA,EAIA,CACP;OACE,CAAA,CAEJ;;KAGN,kBAAC,OAAD;MAAK,WAAU;gBAAf;OACE,kBAAC,GAAD;QAAa,SAAS,EAAW;QAAY,QAAO;QAAc,CAAA;OAClE,kBAAC,QAAD;QAAM,eAAY;kBAAO;QAAQ,CAAA;OACjC,kBAAC,QAAD;QAAM,WAAU;kBAAhB;SACE,kBAAC,GAAD;UAAgB,WAAU;UAAc,eAAY;UAAS,CAAA;SAC5D,EAAa,EAAW,WAAW;SAAE;SACrC,EAAW,eAAe,IAAI,UAAU;SACpC;;OACP,kBAAC,QAAD;QAAM,eAAY;kBAAO;QAAQ,CAAA;OACjC,kBAAC,QAAD,EAAA,UAAA,CAAM,YAAS,EAAmB,EAAW,UAAU,CAAQ,EAAA,CAAA;OAC3D;;MAGJ,EAAW,UAAU,EAAW,YAChC,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACG,EAAW,SACV,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,QAAD;QACE,WAAU;QACV,eAAY;kBAEZ,kBAAC,GAAD,EAAO,WAAU,WAAY,CAAA;QACxB,CAAA,EACP,kBAAC,KAAD;QAAG,WAAU;kBACV,EAAW;QACV,CAAA,CACA;WAEN,kBAAC,QAAD;OAAM,WAAU;iBAA8B;OAA2B,CAAA,EAE1E,EAAW,WACV,kBAAC,UAAD;OACE,MAAK;OACL,eAAe,EAAW,WAAW,EAAW,UAAU;OAC1D,WAAU;iBAHZ;QAKE,kBAAC,GAAD;SAAU,WAAU;SAAU,eAAY;SAAS,CAAA;;QACnD,kBAAC,GAAD;SAAc,WAAU;SAAc,eAAY;SAAS,CAAA;QACpD;SAEP;;KAEE;OAEZ,kBAAC,GAAD;IAAa,cAAc,EAAW;IAAI,UAAU,EAAW;IAAY,CAAA,CACvE;;EACK,CAAA"}
|
|
@@ -22,7 +22,7 @@ import { CitationList as M, VERIFICATIONS as N, VERIFICATION_META as P, sourceTy
|
|
|
22
22
|
import { AddCitationDialog as ee } from "../../components/AddCitationDialog.js";
|
|
23
23
|
import { Fragment as I, useMemo as L, useState as R } from "react";
|
|
24
24
|
import { Archive as z, ArrowLeft as B, BookOpen as te, Bookmark as ne, ChevronDown as re, Clock as ie, GitBranch as V, Languages as ae, Lightbulb as oe, Lock as se, MessagesSquare as H, Pencil as ce, Plus as U, RotateCcw as le, Scroll as W, Send as G, Share2 as ue, ShieldCheck as K, Sparkles as de, Tag as fe } from "lucide-react";
|
|
25
|
-
import { useParams as pe, useSearchParams as me } from "react-router
|
|
25
|
+
import { useParams as pe, useSearchParams as me } from "react-router";
|
|
26
26
|
import { Fragment as he, jsx as q, jsxs as J } from "react/jsx-runtime";
|
|
27
27
|
import { Button as Y, DropdownMenu as ge, DropdownMenuContent as _e, DropdownMenuItem as ve, DropdownMenuTrigger as ye, GlassCard as X, Input as be, Label as Z, ResponsiveDialog as xe, ResponsiveDialogContent as Se, ResponsiveDialogDescription as Ce, ResponsiveDialogFooter as we, ResponsiveDialogHeader as Te, ResponsiveDialogTitle as Ee, ResponsiveTable as De, Select as Oe, SelectContent as ke, SelectItem as Ae, SelectTrigger as je, SelectValue as Me, Textarea as Ne } from "@burdenoff/fe-libs/ui";
|
|
28
28
|
//#region src/theculturepost/pages/stories/StoryDetailPage.tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"StoryDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/stories/StoryDetailPage.tsx"],"sourcesContent":["/**\n * StoryDetailPage — a single story in two complementary surfaces.\n * @module theculturepost/pages/stories\n *\n * A `?view=` toggle (with a sub-tab control) splits the page into:\n * - READING (tcp-03): a cover-banner hero, serif display title, a drop-cap\n * article body with ornamental section breaks, and a right rail of citations\n * (compact) + linked discussions.\n * - EDITORIAL (tcp-04): a visual lifecycle stepper with validated transitions\n * (Publish / Send back), plus citations as a verifiable-source table with an\n * inline verify control.\n *\n * Reads `tcpStory` (+ citations), `tcpListDiscussions(storyId)`. Writes\n * `tcpTransitionStory`, `tcpUpdateStory`, `tcpVerifyCitation`. Person references\n * are id-only, so we render \"You\" / \"Contributor\".\n */\n\nimport { Fragment, useMemo, useState, type ReactNode } from 'react';\nimport { useParams, useSearchParams } from 'react-router-dom';\nimport {\n ArrowLeft,\n Bookmark,\n BookOpen,\n ChevronDown,\n Clock,\n GitBranch,\n Languages,\n Lightbulb,\n Lock,\n MessagesSquare,\n Pencil,\n Plus,\n RotateCcw,\n Scroll,\n Send,\n Share2,\n ShieldCheck,\n Sparkles,\n Tag,\n Archive as ArchiveIcon,\n type LucideIcon,\n} from 'lucide-react';\nimport {\n Button,\n GlassCard,\n Input,\n Label,\n Textarea,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n ResponsiveTable,\n DropdownMenu,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuItem,\n type ResponsiveTableColumn,\n} from '@burdenoff/fe-libs/ui';\nimport { PageHero } from '../../components/PageHero';\nimport { StatusBadge } from '../../components/StatusBadge';\nimport { Tabs, type TabItem } from '../../components/Tabs';\nimport { LifecycleStepper } from '../../components/LifecycleStepper';\nimport { DecorativeDivider } from '../../components/DecorativeDivider';\nimport { AuthorLabel } from '../../components/AuthorLabel';\nimport {\n CitationList,\n sourceTypeIcon,\n VERIFICATION_META,\n VERIFICATIONS,\n} from '../../components/CitationList';\nimport { AddCitationDialog } from '../../components/AddCitationDialog';\nimport { OpenDiscussionDialog } from '../../components/OpenDiscussionDialog';\nimport { TagInput } from '../../components/TagInput';\nimport { FileUploadField } from '../../components/FileUploadField';\nimport { EmptyState } from '../../components/EmptyState';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport {\n useTcpStory,\n useTcpTransitionStory,\n useTcpUpdateStory,\n useTcpToggleStoryBookmark,\n useTcpTopics,\n useTcpDiscussions,\n useTcpCitations,\n useTcpVerifyCitation,\n FILE_FOLDERS,\n} from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { cn } from '../../utils/cn';\nimport { share, impact, nativeToast } from '../../utils/nativeBridge';\nimport {\n formatDate,\n formatEnumLabel,\n formatNumber,\n formatRelativeTime,\n} from '../../utils/formatters';\nimport { notify, describeError } from '../../lib/toast';\nimport type {\n TcpCitation,\n TcpDiscussion,\n TcpStory,\n TcpStoryFormat,\n TcpStoryStatus,\n TcpVerificationStatus,\n} from '../../types';\n\ntype StoryView = 'reading' | 'editorial';\n\nconst NEXT_STATUSES: Record<TcpStoryStatus, TcpStoryStatus[]> = {\n IDEA: ['DRAFT'],\n DRAFT: ['IN_REVIEW'],\n IN_REVIEW: ['PUBLISHED', 'DRAFT'],\n PUBLISHED: ['ARCHIVED'],\n ARCHIVED: ['PUBLISHED'],\n};\n\ninterface TransitionMetaEntry {\n label: string;\n icon: LucideIcon;\n primary?: boolean;\n}\n\nconst TRANSITION_META: Record<TcpStoryStatus, TransitionMetaEntry> = {\n IDEA: { label: 'Move to Idea', icon: Lightbulb },\n DRAFT: { label: 'Send back to Draft', icon: RotateCcw },\n IN_REVIEW: { label: 'Send to Review', icon: Send },\n PUBLISHED: { label: 'Publish', icon: Send, primary: true },\n ARCHIVED: { label: 'Archive', icon: ArchiveIcon },\n};\n\n/** Semantic-token colour for a verification icon (matches CitationList legend). */\nfunction verificationIconClass(status: TcpVerificationStatus): string {\n switch (VERIFICATION_META[status].accent) {\n case 'success':\n return 'text-status-success-text';\n case 'warning':\n return 'text-status-warning-text';\n case 'destructive':\n return 'text-status-error-text';\n default:\n return 'text-text-tertiary';\n }\n}\n\n// ===========================================================================\n// Page\n// ===========================================================================\n\nexport function StoryDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const [searchParams, setSearchParams] = useSearchParams();\n\n const view: StoryView = searchParams.get('view') === 'editorial' ? 'editorial' : 'reading';\n const setView = (next: StoryView) => {\n setSearchParams(\n (prev) => {\n const params = new URLSearchParams(prev);\n if (next === 'editorial') params.set('view', 'editorial');\n else params.delete('view');\n return params;\n },\n { replace: true }\n );\n };\n\n const { data: story, isLoading, isError, refetch } = useTcpStory(id);\n const toggleBookmark = useTcpToggleStoryBookmark();\n\n const [citationOpen, setCitationOpen] = useState(false);\n const [discussionOpen, setDiscussionOpen] = useState(false);\n const [editOpen, setEditOpen] = useState(false);\n\n const discussionsQuery = useTcpDiscussions({ storyId: id, pagination: { limit: 20 } });\n const linkedDiscussions = discussionsQuery.data?.items ?? [];\n\n if (isLoading) {\n return (\n <div className=\"mx-auto max-w-6xl p-4 sm:p-6\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('stories')}>\n <ArrowLeft className=\"mr-1.5 h-4 w-4\" /> Stories\n </Button>\n <LoadingView label=\"Loading story…\" />\n </div>\n );\n }\n\n if (isError || !story) {\n return (\n <div className=\"mx-auto max-w-6xl p-4 sm:p-6\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('stories')}>\n <ArrowLeft className=\"mr-1.5 h-4 w-4\" /> Stories\n </Button>\n <ErrorView\n title=\"Story not found\"\n message=\"This story may have been archived, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </div>\n );\n }\n\n const heroMeta = (\n <>\n <StatusBadge status={story.format} />\n <StatusBadge status={story.status} />\n {story.topic && (\n <button\n type=\"button\"\n onClick={() => navigateTo(`topics/${story.topic!.id}`)}\n className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:text-text-primary\"\n >\n <Tag className=\"h-3 w-3\" aria-hidden=\"true\" /> {story.topic.name}\n </button>\n )}\n <span className=\"inline-flex items-center gap-1.5 text-xs text-text-tertiary\">\n <AuthorLabel actorId={story.authorId} prefix=\"By\" withIcon />\n </span>\n {story.readingMins > 0 && (\n <span className=\"inline-flex items-center gap-1 text-xs text-text-tertiary\">\n <Clock className=\"h-3.5 w-3.5\" aria-hidden=\"true\" /> {formatNumber(story.readingMins)} min\n read\n </span>\n )}\n {story.languages.length > 0 && (\n <span className=\"inline-flex items-center gap-1 text-xs text-text-tertiary\">\n <Languages className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />{' '}\n {story.languages.map(formatEnumLabel).join(', ')}\n </span>\n )}\n </>\n );\n\n const tabs: TabItem<StoryView>[] = [\n { value: 'reading', label: 'Reading', icon: <BookOpen className=\"h-4 w-4\" /> },\n {\n value: 'editorial',\n label: 'Editorial',\n icon: <GitBranch className=\"h-4 w-4\" />,\n count: story.citations.length,\n },\n ];\n\n return (\n <div className=\"mx-auto flex max-w-6xl flex-col gap-6 p-4 sm:p-6\">\n <PageHero\n eyebrow=\"Story\"\n title={story.title}\n description={story.summary ?? undefined}\n coverFileId={story.cover?.id ?? undefined}\n backAction={{ label: 'Stories', onClick: () => navigateTo('stories') }}\n meta={heroMeta}\n actions={\n <div className=\"flex items-center gap-2\">\n <Button\n variant={story.isBookmarked ? 'default' : 'outline'}\n size=\"sm\"\n onClick={async () => {\n await impact('light');\n try {\n const bookmarked = await toggleBookmark.mutateAsync(story.id);\n await nativeToast(\n bookmarked ? 'Saved to your reading list' : 'Removed from your reading list'\n );\n } catch (error) {\n notify.error(describeError(error));\n }\n }}\n disabled={toggleBookmark.isPending}\n aria-pressed={story.isBookmarked}\n aria-label={story.isBookmarked ? 'Remove from reading list' : 'Save to reading list'}\n data-tour=\"theculturepost-story-bookmark\"\n >\n <Bookmark className={cn('mr-1.5 h-4 w-4', story.isBookmarked && 'fill-current')} />{' '}\n {story.isBookmarked ? 'Saved' : 'Save'}\n </Button>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={async () => {\n await impact('light');\n const ok = await share({\n title: story.title,\n text: story.summary ?? undefined,\n url: window.location.href,\n });\n if (ok) await nativeToast('Link shared');\n }}\n aria-label=\"Share story\"\n >\n <Share2 className=\"mr-1.5 h-4 w-4\" /> Share\n </Button>\n <Button\n variant=\"outline\"\n onClick={() => setEditOpen(true)}\n data-tour=\"theculturepost-story-edit\"\n >\n <Pencil className=\"mr-1.5 h-4 w-4\" /> Edit\n </Button>\n </div>\n }\n />\n\n <div data-tour=\"theculturepost-story-tabs\">\n <Tabs items={tabs} value={view} onChange={setView} />\n </div>\n\n {view === 'reading' ? (\n <ReadingView\n story={story}\n discussions={linkedDiscussions}\n discussionsLoading={discussionsQuery.isLoading}\n onViewCitations={() => setView('editorial')}\n onOpenDiscussion={() => setDiscussionOpen(true)}\n onViewAllDiscussions={() => navigateTo('discussions')}\n onOpenDiscussionItem={(discussion) => navigateTo(`discussions/${discussion.id}`)}\n />\n ) : (\n <EditorialView story={story} onAddCitation={() => setCitationOpen(true)} />\n )}\n\n <AddCitationDialog open={citationOpen} onOpenChange={setCitationOpen} storyId={story.id} />\n <OpenDiscussionDialog\n open={discussionOpen}\n onOpenChange={setDiscussionOpen}\n storyId={story.id}\n onCreated={(discussion) => navigateTo(`discussions/${discussion.id}`)}\n />\n <EditStoryDialog open={editOpen} onOpenChange={setEditOpen} story={story} />\n </div>\n );\n}\n\n// ===========================================================================\n// Reading view (tcp-03)\n// ===========================================================================\n\ninterface ReadingViewProps {\n story: TcpStory;\n discussions: TcpDiscussion[];\n discussionsLoading: boolean;\n onViewCitations: () => void;\n onOpenDiscussion: () => void;\n onViewAllDiscussions: () => void;\n onOpenDiscussionItem: (discussion: TcpDiscussion) => void;\n}\n\nfunction ReadingView({\n story,\n discussions,\n discussionsLoading,\n onViewCitations,\n onOpenDiscussion,\n onViewAllDiscussions,\n onOpenDiscussionItem,\n}: ReadingViewProps) {\n return (\n <div className=\"grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_360px]\">\n {/* Article */}\n <article className=\"min-w-0\">\n <StoryBody body={story.body} />\n\n {story.tags.length > 0 && (\n <div className=\"mt-8 flex flex-wrap gap-1.5 border-t border-border-seam pt-6\">\n {story.tags.map((tag) => (\n <span\n key={tag}\n className=\"rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\"\n >\n #{tag}\n </span>\n ))}\n </div>\n )}\n </article>\n\n {/* Right rail */}\n <aside className=\"flex flex-col gap-6 lg:sticky lg:top-4 lg:self-start\">\n <RailPanel\n title=\"Citations\"\n action={\n <button\n type=\"button\"\n onClick={onViewCitations}\n className=\"text-xs font-medium text-accent hover:underline\"\n >\n View all\n </button>\n }\n >\n <CitationList storyId={story.id} compact />\n </RailPanel>\n\n <RailPanel\n title=\"Linked discussions\"\n action={\n <button\n type=\"button\"\n onClick={onViewAllDiscussions}\n className=\"text-xs font-medium text-accent hover:underline\"\n >\n View all\n </button>\n }\n >\n {discussionsLoading ? (\n <LoadingView label=\"Loading discussions…\" className=\"min-h-[8rem]\" />\n ) : discussions.length === 0 ? (\n <EmptyState\n icon={<MessagesSquare className=\"h-8 w-8\" />}\n title=\"No discussions yet\"\n description=\"Open a discussion to invite perspectives on this story.\"\n action={\n <Button size=\"sm\" variant=\"outline\" onClick={onOpenDiscussion}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Open discussion\n </Button>\n }\n />\n ) : (\n <div className=\"flex flex-col gap-3\">\n {discussions.slice(0, 5).map((discussion) => (\n <LinkedDiscussion\n key={discussion.id}\n discussion={discussion}\n onOpen={() => onOpenDiscussionItem(discussion)}\n />\n ))}\n </div>\n )}\n </RailPanel>\n </aside>\n </div>\n );\n}\n\nfunction RailPanel({\n title,\n action,\n children,\n}: {\n title: string;\n action?: ReactNode;\n children: ReactNode;\n}) {\n return (\n <section>\n <div className=\"mb-3 flex items-center justify-between gap-2\">\n <h2 className=\"font-display text-lg font-semibold text-text-primary\">{title}</h2>\n {action}\n </div>\n {children}\n </section>\n );\n}\n\nfunction StoryBody({ body }: { body?: string | null }) {\n const paragraphs = useMemo(\n () =>\n (body ?? '')\n .split(/\\n{2,}/)\n .map((p) => p.trim())\n .filter(Boolean),\n [body]\n );\n\n if (paragraphs.length === 0) {\n return (\n <p className=\"rounded-xl border border-dashed border-border-seam px-4 py-10 text-center text-sm text-text-tertiary\">\n This story has no body yet. Use Edit to write it.\n </p>\n );\n }\n\n return (\n <div className=\"max-w-prose\">\n {paragraphs.map((paragraph, index) => (\n <Fragment key={index}>\n {index === 1 && <DecorativeDivider />}\n <p\n className={cn(\n 'text-[1.05rem] leading-8 text-text-primary',\n index > 0 && index !== 1 && 'mt-5',\n index === 0 &&\n 'first-letter:float-left first-letter:mr-3 first-letter:mt-1 first-letter:font-display first-letter:text-6xl first-letter:font-semibold first-letter:leading-[0.72] first-letter:text-accent'\n )}\n >\n {paragraph}\n </p>\n </Fragment>\n ))}\n </div>\n );\n}\n\nfunction LinkedDiscussion({\n discussion,\n onOpen,\n}: {\n discussion: TcpDiscussion;\n onOpen: () => void;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onOpen}\n className=\"w-full rounded-xl border border-border-seam bg-bg-surface p-3 text-left transition-colors hover:border-border-strong\"\n >\n <div className=\"flex items-start gap-2.5\">\n <span\n className=\"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-[var(--color-accent-soft)] text-text-primary\"\n aria-hidden=\"true\"\n >\n <MessagesSquare className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium text-text-primary\">{discussion.title}</p>\n {discussion.prompt && (\n <p className=\"mt-0.5 line-clamp-2 text-xs text-text-tertiary\">{discussion.prompt}</p>\n )}\n <div className=\"mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-text-tertiary\">\n <span>\n {formatNumber(discussion.replyCount)}{' '}\n {discussion.replyCount === 1 ? 'reply' : 'replies'}\n </span>\n <span aria-hidden=\"true\">·</span>\n <span>{formatRelativeTime(discussion.createdAt)}</span>\n {discussion.isInterfaith && (\n <StatusBadge\n status=\"Interfaith\"\n icon={<Sparkles className=\"h-3 w-3\" />}\n className=\"ml-0.5\"\n />\n )}\n {discussion.isLocked && (\n <span className=\"inline-flex items-center gap-1 text-text-tertiary\">\n <Lock className=\"h-3 w-3\" aria-hidden=\"true\" /> Locked\n </span>\n )}\n </div>\n </div>\n </div>\n </button>\n );\n}\n\n// ===========================================================================\n// Editorial view (tcp-04)\n// ===========================================================================\n\nfunction EditorialView({ story, onAddCitation }: { story: TcpStory; onAddCitation: () => void }) {\n const transition = useTcpTransitionStory();\n const nextStatuses = NEXT_STATUSES[story.status] ?? [];\n\n const doTransition = async (status: TcpStoryStatus) => {\n try {\n await transition.mutateAsync({ id: story.id, status });\n notify.success('Story updated', `Moved to ${formatEnumLabel(status)}`);\n } catch (err) {\n notify.error('Could not update the story', describeError(err));\n }\n };\n\n return (\n <div className=\"flex flex-col gap-6\">\n {/* Lifecycle */}\n <GlassCard treatment=\"plain\" className=\"p-5 sm:p-6\">\n <div className=\"flex items-center gap-2.5\">\n <span\n className=\"grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[var(--color-accent-soft)] text-text-primary\"\n aria-hidden=\"true\"\n >\n <GitBranch className=\"h-5 w-5\" />\n </span>\n <h2 className=\"font-display text-xl font-semibold text-text-primary\">\n Editorial lifecycle\n </h2>\n </div>\n\n <LifecycleStepper status={story.status} className=\"mt-6\" />\n\n <div className=\"mt-6 flex flex-col gap-4 border-t border-border-seam pt-5 md:flex-row md:items-center md:justify-between\">\n {nextStatuses.length > 0 ? (\n <div className=\"flex flex-wrap gap-2\">\n {nextStatuses.map((status) => {\n const meta = TRANSITION_META[status];\n const Icon = meta.icon;\n return (\n <Button\n key={status}\n variant={meta.primary ? 'default' : 'outline'}\n onClick={() => doTransition(status)}\n disabled={transition.isPending}\n >\n <Icon className=\"mr-1.5 h-4 w-4\" aria-hidden=\"true\" />\n {meta.label}\n </Button>\n );\n })}\n </div>\n ) : (\n <p className=\"text-sm text-text-tertiary\">This story is at the end of its lifecycle.</p>\n )}\n\n <p className=\"flex items-center gap-2 text-sm text-text-secondary\">\n <ShieldCheck className=\"h-4 w-4 shrink-0 text-text-tertiary\" aria-hidden=\"true\" />\n Reviewed with fact and sensitivity care before publishing.\n </p>\n </div>\n </GlassCard>\n\n {/* Citations */}\n <GlassCard treatment=\"plain\" className=\"p-5 sm:p-6\">\n <div className=\"flex flex-wrap items-center justify-between gap-3\">\n <div className=\"flex items-center gap-2.5\">\n <span\n className=\"grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[var(--color-accent-soft)] text-text-primary\"\n aria-hidden=\"true\"\n >\n <Scroll className=\"h-5 w-5\" />\n </span>\n <div>\n <h2 className=\"font-display text-xl font-semibold text-text-primary\">Citations</h2>\n <p className=\"text-sm text-text-secondary\">\n Verifiable sources that ground this story.\n </p>\n </div>\n </div>\n <Button variant=\"outline\" onClick={onAddCitation}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Add citation\n </Button>\n </div>\n\n <EditorialCitationsTable storyId={story.id} className=\"mt-5\" />\n\n <p className=\"mt-4 flex items-center gap-2 border-t border-border-seam pt-4 text-xs text-text-tertiary\">\n <ShieldCheck className=\"h-3.5 w-3.5 shrink-0\" aria-hidden=\"true\" />\n All sources are evaluated for accuracy, relevance, and cultural sensitivity.\n </p>\n </GlassCard>\n </div>\n );\n}\n\nfunction EditorialCitationsTable({ storyId, className }: { storyId: string; className?: string }) {\n const { data: citations, isLoading, isError, refetch } = useTcpCitations(storyId);\n\n const columns: ResponsiveTableColumn<TcpCitation>[] = [\n {\n key: 'source',\n header: 'Source',\n priority: 'primary',\n cell: (citation) => {\n const Icon = sourceTypeIcon(citation.sourceType);\n const meta = [citation.author, citation.publisher].filter((v) => v && v.trim()).join(' · ');\n return (\n <div className=\"flex min-w-0 items-start gap-3\">\n <span\n className=\"grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-[var(--color-accent-soft)] text-text-primary\"\n aria-hidden=\"true\"\n >\n <Icon className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0\">\n <p className=\"font-medium text-text-primary\">{citation.title}</p>\n {citation.excerpt && (\n <p className=\"mt-0.5 line-clamp-1 text-xs italic text-text-tertiary\">\n {citation.excerpt}\n </p>\n )}\n {meta && <p className=\"mt-0.5 text-xs text-text-tertiary\">{meta}</p>}\n </div>\n </div>\n );\n },\n },\n {\n key: 'type',\n header: 'Type',\n cell: (citation) => (\n <StatusBadge\n status={citation.sourceType}\n icon={(() => {\n const Icon = sourceTypeIcon(citation.sourceType);\n return <Icon className=\"h-3 w-3\" />;\n })()}\n />\n ),\n },\n {\n key: 'added',\n header: 'Added on',\n priority: 'hidden-on-mobile',\n cell: (citation) => (\n <span className=\"whitespace-nowrap text-sm text-text-secondary\">\n {formatDate(citation.createdAt)}\n </span>\n ),\n },\n {\n key: 'verification',\n header: 'Status',\n cell: (citation) => {\n const meta = VERIFICATION_META[citation.verification];\n const Icon = meta.icon;\n return (\n <StatusBadge\n status={citation.verification}\n icon={<Icon className={cn('h-3 w-3', verificationIconClass(citation.verification))} />}\n />\n );\n },\n },\n {\n key: 'actions',\n header: 'Actions',\n headerClassName: 'text-right',\n cellClassName: 'text-right',\n cell: (citation) => <EditorialCitationActions citation={citation} />,\n },\n ];\n\n if (isLoading) return <LoadingView label=\"Loading citations…\" className={className} />;\n if (isError)\n return (\n <div className={className}>\n <ErrorView message=\"We couldn't load citations.\" onRetry={() => refetch()} />\n </div>\n );\n\n return (\n <ResponsiveTable<TcpCitation>\n className={className}\n columns={columns}\n data={citations ?? []}\n rowKey={(citation) => citation.id}\n emptyState={\n <EmptyState\n icon={<Scroll className=\"h-9 w-9\" />}\n title=\"No citations yet\"\n description=\"Add verifiable sources to ground this story in the record.\"\n />\n }\n />\n );\n}\n\nfunction EditorialCitationActions({ citation }: { citation: TcpCitation }) {\n const verify = useTcpVerifyCitation();\n\n const change = async (verification: TcpVerificationStatus) => {\n if (verification === citation.verification) return;\n try {\n await verify.mutateAsync({ id: citation.id, verification });\n notify.success('Verification updated', formatEnumLabel(verification));\n } catch (err) {\n notify.error('Could not update verification', describeError(err));\n }\n };\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n size=\"sm\"\n variant=\"outline\"\n disabled={verify.isPending}\n aria-label={`Change verification for ${citation.title}`}\n >\n Verify <ChevronDown className=\"ml-1 h-4 w-4\" aria-hidden=\"true\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {VERIFICATIONS.map((status) => {\n const meta = VERIFICATION_META[status];\n const Icon = meta.icon;\n const current = status === citation.verification;\n return (\n <DropdownMenuItem\n key={status}\n onSelect={() => change(status)}\n className={cn('gap-2', current && 'font-semibold')}\n >\n <Icon className={cn('h-4 w-4', verificationIconClass(status))} aria-hidden=\"true\" />\n {meta.label}\n </DropdownMenuItem>\n );\n })}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n\n// ===========================================================================\n// Edit story dialog (local — only used here)\n// ===========================================================================\n\nconst FORMATS: TcpStoryFormat[] = [\n 'ARTICLE',\n 'ESSAY',\n 'EXPLAINER',\n 'ORAL_HISTORY',\n 'PHOTO_STORY',\n 'TIMELINE',\n];\n\nfunction EditStoryDialog({\n open,\n onOpenChange,\n story,\n}: {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n story: TcpStory;\n}) {\n const updateStory = useTcpUpdateStory();\n const topicsQuery = useTcpTopics({ pagination: { limit: 100 } });\n\n const [title, setTitle] = useState(story.title);\n const [summary, setSummary] = useState(story.summary ?? '');\n const [body, setBody] = useState(story.body ?? '');\n const [format, setFormat] = useState<TcpStoryFormat>(story.format);\n const [pickedTopic, setPickedTopic] = useState(story.topicId ?? '');\n const [languages, setLanguages] = useState<string[]>(story.languages);\n const [tags, setTags] = useState<string[]>(story.tags);\n const [readingMins, setReadingMins] = useState(\n story.readingMins ? String(story.readingMins) : ''\n );\n const [coverFileId, setCoverFileId] = useState<string | null>(story.cover?.id ?? null);\n const [error, setError] = useState<string | null>(null);\n\n // Reset local state whenever a different story is opened for editing.\n const storyKey = useMemo(() => `${story.id}:${story.updatedAt}`, [story.id, story.updatedAt]);\n const [seededKey, setSeededKey] = useState(storyKey);\n if (seededKey !== storyKey) {\n setSeededKey(storyKey);\n setTitle(story.title);\n setSummary(story.summary ?? '');\n setBody(story.body ?? '');\n setFormat(story.format);\n setPickedTopic(story.topicId ?? '');\n setLanguages(story.languages);\n setTags(story.tags);\n setReadingMins(story.readingMins ? String(story.readingMins) : '');\n setCoverFileId(story.cover?.id ?? null);\n setError(null);\n }\n\n const submit = async () => {\n if (!title.trim()) {\n setError('A title is required.');\n return;\n }\n try {\n await updateStory.mutateAsync({\n id: story.id,\n input: {\n title: title.trim(),\n summary: summary.trim() || null,\n body: body.trim() || null,\n format,\n topicId: pickedTopic || null,\n languages,\n tags,\n readingMins: readingMins ? Number(readingMins) : null,\n coverFileId,\n },\n });\n notify.success('Story updated', title.trim());\n onOpenChange(false);\n } catch (err) {\n setError(describeError(err));\n }\n };\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"sm:max-w-lg\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Edit story</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Refine the story. The URL slug is fixed once created.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"flex max-h-[62vh] flex-col gap-4 overflow-y-auto py-2\">\n <div className=\"grid gap-1.5\">\n <Label>Cover image</Label>\n <FileUploadField\n folder={FILE_FOLDERS.covers}\n accept=\"image/*\"\n label=\"Upload cover\"\n fileId={coverFileId}\n onUploaded={setCoverFileId}\n onCleared={() => setCoverFileId(null)}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-title\">Title</Label>\n <Input id=\"edit-title\" value={title} onChange={(e) => setTitle(e.target.value)} />\n </div>\n <div className=\"grid grid-cols-2 gap-3\">\n <div className=\"grid gap-1.5\">\n <Label>Format</Label>\n <Select value={format} onValueChange={(v) => setFormat(v as TcpStoryFormat)}>\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {FORMATS.map((f) => (\n <SelectItem key={f} value={f}>\n {formatEnumLabel(f)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-reading\">Reading time (min)</Label>\n <Input\n id=\"edit-reading\"\n type=\"number\"\n min={0}\n value={readingMins}\n onChange={(e) => setReadingMins(e.target.value)}\n />\n </div>\n </div>\n <div className=\"grid gap-1.5\">\n <Label>Topic</Label>\n <Select value={pickedTopic} onValueChange={setPickedTopic}>\n <SelectTrigger>\n <SelectValue placeholder=\"Choose a topic (optional)\" />\n </SelectTrigger>\n <SelectContent>\n {(topicsQuery.data?.items ?? []).map((topic) => (\n <SelectItem key={topic.id} value={topic.id}>\n {topic.name}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-summary\">Summary</Label>\n <Textarea\n id=\"edit-summary\"\n value={summary}\n onChange={(e) => setSummary(e.target.value)}\n rows={2}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-body\">Body</Label>\n <Textarea\n id=\"edit-body\"\n value={body}\n onChange={(e) => setBody(e.target.value)}\n rows={6}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-langs\">Languages</Label>\n <TagInput id=\"edit-langs\" value={languages} onChange={setLanguages} preserveCase />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-tags\">Tags</Label>\n <TagInput id=\"edit-tags\" value={tags} onChange={setTags} />\n </div>\n {error && <p className=\"text-sm text-status-error-text\">{error}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n Cancel\n </Button>\n <Button onClick={submit} disabled={updateStory.isPending}>\n Save changes\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoHA,IAAM,KAA0D;CAC9D,MAAM,CAAC,QAAQ;CACf,OAAO,CAAC,YAAY;CACpB,WAAW,CAAC,aAAa,QAAQ;CACjC,WAAW,CAAC,WAAW;CACvB,UAAU,CAAC,YAAY;CACxB,EAQK,KAA+D;CACnE,MAAM;EAAE,OAAO;EAAgB,MAAM;EAAW;CAChD,OAAO;EAAE,OAAO;EAAsB,MAAM;EAAW;CACvD,WAAW;EAAE,OAAO;EAAkB,MAAM;EAAM;CAClD,WAAW;EAAE,OAAO;EAAW,MAAM;EAAM,SAAS;EAAM;CAC1D,UAAU;EAAE,OAAO;EAAW,MAAM;EAAa;CAClD;AAGD,SAAS,EAAsB,GAAuC;AACpE,SAAQ,EAAkB,GAAQ,QAAlC;EACE,KAAK,UACH,QAAO;EACT,KAAK,UACH,QAAO;EACT,KAAK,cACH,QAAO;EACT,QACE,QAAO;;;AAQb,SAAgB,KAAkB;CAChC,IAAM,EAAE,UAAO,IAA2B,EACpC,EAAE,kBAAe,GAAmB,EACpC,CAAC,GAAc,KAAmB,IAAiB,EAEnD,IAAkB,EAAa,IAAI,OAAO,KAAK,cAAc,cAAc,WAC3E,KAAW,MAAoB;AACnC,KACG,MAAS;GACR,IAAM,IAAS,IAAI,gBAAgB,EAAK;AAGxC,UAFI,MAAS,cAAa,EAAO,IAAI,QAAQ,YAAY,GACpD,EAAO,OAAO,OAAO,EACnB;KAET,EAAE,SAAS,IAAM,CAClB;IAGG,EAAE,MAAM,GAAO,cAAW,YAAS,eAAY,EAAY,EAAG,EAC9D,IAAiB,GAA2B,EAE5C,CAAC,GAAc,KAAmB,EAAS,GAAM,EACjD,CAAC,GAAgB,KAAqB,EAAS,GAAM,EACrD,CAAC,GAAU,KAAe,EAAS,GAAM,EAEzC,IAAmB,EAAkB;EAAE,SAAS;EAAI,YAAY,EAAE,OAAO,IAAI;EAAE,CAAC,EAChF,IAAoB,EAAiB,MAAM,SAAS,EAAE;AAE5D,KAAI,EACF,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf,CACE,kBAAC,GAAD;GAAQ,SAAQ;GAAQ,MAAK;GAAK,eAAe,EAAW,UAAU;aAAtE,CACE,kBAAC,GAAD,EAAW,WAAU,kBAAmB,CAAA,EAAA,WACjC;MACT,kBAAC,GAAD,EAAa,OAAM,kBAAmB,CAAA,CAClC;;AAIV,KAAI,KAAW,CAAC,EACd,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf,CACE,kBAAC,GAAD;GAAQ,SAAQ;GAAQ,MAAK;GAAK,eAAe,EAAW,UAAU;aAAtE,CACE,kBAAC,GAAD,EAAW,WAAU,kBAAmB,CAAA,EAAA,WACjC;MACT,kBAAC,GAAD;GACE,OAAM;GACN,SAAQ;GACR,eAAe,GAAS;GACxB,CAAA,CACE;;CAIV,IAAM,IACJ,kBAAA,IAAA,EAAA,UAAA;EACE,kBAAC,GAAD,EAAa,QAAQ,EAAM,QAAU,CAAA;EACrC,kBAAC,GAAD,EAAa,QAAQ,EAAM,QAAU,CAAA;EACpC,EAAM,SACL,kBAAC,UAAD;GACE,MAAK;GACL,eAAe,EAAW,UAAU,EAAM,MAAO,KAAK;GACtD,WAAU;aAHZ;IAKE,kBAAC,IAAD;KAAK,WAAU;KAAU,eAAY;KAAS,CAAA;;IAAE,EAAM,MAAM;IACrD;;EAEX,kBAAC,QAAD;GAAM,WAAU;aACd,kBAAC,GAAD;IAAa,SAAS,EAAM;IAAU,QAAO;IAAK,UAAA;IAAW,CAAA;GACxD,CAAA;EACN,EAAM,cAAc,KACnB,kBAAC,QAAD;GAAM,WAAU;aAAhB;IACE,kBAAC,IAAD;KAAO,WAAU;KAAc,eAAY;KAAS,CAAA;;IAAE,EAAa,EAAM,YAAY;IAAC;IAEjF;;EAER,EAAM,UAAU,SAAS,KACxB,kBAAC,QAAD;GAAM,WAAU;aAAhB;IACE,kBAAC,IAAD;KAAW,WAAU;KAAc,eAAY;KAAS,CAAA;IAAC;IACxD,EAAM,UAAU,IAAI,EAAgB,CAAC,KAAK,KAAK;IAC3C;;EAER,EAAA,CAAA,EAGC,KAA6B,CACjC;EAAE,OAAO;EAAW,OAAO;EAAW,MAAM,kBAAC,IAAD,EAAU,WAAU,WAAY,CAAA;EAAE,EAC9E;EACE,OAAO;EACP,OAAO;EACP,MAAM,kBAAC,GAAD,EAAW,WAAU,WAAY,CAAA;EACvC,OAAO,EAAM,UAAU;EACxB,CACF;AAED,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf;GACE,kBAAC,GAAD;IACE,SAAQ;IACR,OAAO,EAAM;IACb,aAAa,EAAM,WAAW,KAAA;IAC9B,aAAa,EAAM,OAAO,MAAM,KAAA;IAChC,YAAY;KAAE,OAAO;KAAW,eAAe,EAAW,UAAU;KAAE;IACtE,MAAM;IACN,SACE,kBAAC,OAAD;KAAK,WAAU;eAAf;MACE,kBAAC,GAAD;OACE,SAAS,EAAM,eAAe,YAAY;OAC1C,MAAK;OACL,SAAS,YAAY;AACnB,cAAM,EAAO,QAAQ;AACrB,YAAI;AAEF,eAAM,EADa,MAAM,EAAe,YAAY,EAAM,GAAG,GAE9C,+BAA+B,iCAC7C;iBACM,GAAO;AACd,WAAO,MAAM,EAAc,EAAM,CAAC;;;OAGtC,UAAU,EAAe;OACzB,gBAAc,EAAM;OACpB,cAAY,EAAM,eAAe,6BAA6B;OAC9D,aAAU;iBAjBZ;QAmBE,kBAAC,IAAD,EAAU,WAAW,EAAG,kBAAkB,EAAM,gBAAgB,eAAe,EAAI,CAAA;QAAC;QACnF,EAAM,eAAe,UAAU;QACzB;;MACT,kBAAC,GAAD;OACE,SAAQ;OACR,MAAK;OACL,SAAS,YAAY;AAOnB,QANA,MAAM,EAAO,QAAQ,EACV,MAAM,EAAM;SACrB,OAAO,EAAM;SACb,MAAM,EAAM,WAAW,KAAA;SACvB,KAAK,OAAO,SAAS;SACtB,CAAC,IACM,MAAM,EAAY,cAAc;;OAE1C,cAAW;iBAZb,CAcE,kBAAC,IAAD,EAAQ,WAAU,kBAAmB,CAAA,EAAA,SAC9B;;MACT,kBAAC,GAAD;OACE,SAAQ;OACR,eAAe,EAAY,GAAK;OAChC,aAAU;iBAHZ,CAKE,kBAAC,IAAD,EAAQ,WAAU,kBAAmB,CAAA,EAAA,QAC9B;;MACL;;IAER,CAAA;GAEF,kBAAC,OAAD;IAAK,aAAU;cACb,kBAAC,GAAD;KAAM,OAAO;KAAM,OAAO;KAAM,UAAU;KAAW,CAAA;IACjD,CAAA;GAEL,MAAS,YACR,kBAAC,IAAD;IACS;IACP,aAAa;IACb,oBAAoB,EAAiB;IACrC,uBAAuB,EAAQ,YAAY;IAC3C,wBAAwB,EAAkB,GAAK;IAC/C,4BAA4B,EAAW,cAAc;IACrD,uBAAuB,MAAe,EAAW,eAAe,EAAW,KAAK;IAChF,CAAA,GAEF,kBAAC,IAAD;IAAsB;IAAO,qBAAqB,EAAgB,GAAK;IAAI,CAAA;GAG7E,kBAAC,IAAD;IAAmB,MAAM;IAAc,cAAc;IAAiB,SAAS,EAAM;IAAM,CAAA;GAC3F,kBAAC,GAAD;IACE,MAAM;IACN,cAAc;IACd,SAAS,EAAM;IACf,YAAY,MAAe,EAAW,eAAe,EAAW,KAAK;IACrE,CAAA;GACF,kBAAC,IAAD;IAAiB,MAAM;IAAU,cAAc;IAAoB;IAAS,CAAA;GACxE;;;AAkBV,SAAS,GAAY,EACnB,UACA,gBACA,uBACA,oBACA,qBACA,yBACA,2BACmB;AACnB,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf,CAEE,kBAAC,WAAD;GAAS,WAAU;aAAnB,CACE,kBAAC,IAAD,EAAW,MAAM,EAAM,MAAQ,CAAA,EAE9B,EAAM,KAAK,SAAS,KACnB,kBAAC,OAAD;IAAK,WAAU;cACZ,EAAM,KAAK,KAAK,MACf,kBAAC,QAAD;KAEE,WAAU;eAFZ,CAGC,KACG,EACG;OAJA,EAIA,CACP;IACE,CAAA,CAEA;MAGV,kBAAC,SAAD;GAAO,WAAU;aAAjB,CACE,kBAAC,GAAD;IACE,OAAM;IACN,QACE,kBAAC,UAAD;KACE,MAAK;KACL,SAAS;KACT,WAAU;eACX;KAEQ,CAAA;cAGX,kBAAC,GAAD;KAAc,SAAS,EAAM;KAAI,SAAA;KAAU,CAAA;IACjC,CAAA,EAEZ,kBAAC,GAAD;IACE,OAAM;IACN,QACE,kBAAC,UAAD;KACE,MAAK;KACL,SAAS;KACT,WAAU;eACX;KAEQ,CAAA;cAGV,IACC,kBAAC,GAAD;KAAa,OAAM;KAAuB,WAAU;KAAiB,CAAA,GACnE,EAAY,WAAW,IACzB,kBAAC,GAAD;KACE,MAAM,kBAAC,GAAD,EAAgB,WAAU,WAAY,CAAA;KAC5C,OAAM;KACN,aAAY;KACZ,QACE,kBAAC,GAAD;MAAQ,MAAK;MAAK,SAAQ;MAAU,SAAS;gBAA7C,CACE,kBAAC,GAAD,EAAM,WAAU,kBAAmB,CAAA,EAAA,mBAC5B;;KAEX,CAAA,GAEF,kBAAC,OAAD;KAAK,WAAU;eACZ,EAAY,MAAM,GAAG,EAAE,CAAC,KAAK,MAC5B,kBAAC,IAAD;MAEc;MACZ,cAAc,EAAqB,EAAW;MAC9C,EAHK,EAAW,GAGhB,CACF;KACE,CAAA;IAEE,CAAA,CACN;KACJ;;;AAIV,SAAS,EAAU,EACjB,UACA,WACA,eAKC;AACD,QACE,kBAAC,WAAD,EAAA,UAAA,CACE,kBAAC,OAAD;EAAK,WAAU;YAAf,CACE,kBAAC,MAAD;GAAI,WAAU;aAAwD;GAAW,CAAA,EAChF,EACG;KACL,EACO,EAAA,CAAA;;AAId,SAAS,GAAU,EAAE,WAAkC;CACrD,IAAM,IAAa,SAEd,KAAQ,IACN,MAAM,SAAS,CACf,KAAK,MAAM,EAAE,MAAM,CAAC,CACpB,OAAO,QAAQ,EACpB,CAAC,EAAK,CACP;AAUD,QARI,EAAW,WAAW,IAEtB,kBAAC,KAAD;EAAG,WAAU;YAAuG;EAEhH,CAAA,GAKN,kBAAC,OAAD;EAAK,WAAU;YACZ,EAAW,KAAK,GAAW,MAC1B,kBAAC,GAAD,EAAA,UAAA,CACG,MAAU,KAAK,kBAAC,GAAD,EAAqB,CAAA,EACrC,kBAAC,KAAD;GACE,WAAW,EACT,8CACA,IAAQ,KAAK,MAAU,KAAK,QAC5B,MAAU,KACR,8LACH;aAEA;GACC,CAAA,CACK,EAAA,EAZI,EAYJ,CACX;EACE,CAAA;;AAIV,SAAS,GAAiB,EACxB,eACA,aAIC;AACD,QACE,kBAAC,UAAD;EACE,MAAK;EACL,SAAS;EACT,WAAU;YAEV,kBAAC,OAAD;GAAK,WAAU;aAAf,CACE,kBAAC,QAAD;IACE,WAAU;IACV,eAAY;cAEZ,kBAAC,GAAD,EAAgB,WAAU,WAAY,CAAA;IACjC,CAAA,EACP,kBAAC,OAAD;IAAK,WAAU;cAAf;KACE,kBAAC,KAAD;MAAG,WAAU;gBAAkD,EAAW;MAAU,CAAA;KACnF,EAAW,UACV,kBAAC,KAAD;MAAG,WAAU;gBAAkD,EAAW;MAAW,CAAA;KAEvF,kBAAC,OAAD;MAAK,WAAU;gBAAf;OACE,kBAAC,QAAD,EAAA,UAAA;QACG,EAAa,EAAW,WAAW;QAAE;QACrC,EAAW,eAAe,IAAI,UAAU;QACpC,EAAA,CAAA;OACP,kBAAC,QAAD;QAAM,eAAY;kBAAO;QAAQ,CAAA;OACjC,kBAAC,QAAD,EAAA,UAAO,EAAmB,EAAW,UAAU,EAAQ,CAAA;OACtD,EAAW,gBACV,kBAAC,GAAD;QACE,QAAO;QACP,MAAM,kBAAC,IAAD,EAAU,WAAU,WAAY,CAAA;QACtC,WAAU;QACV,CAAA;OAEH,EAAW,YACV,kBAAC,QAAD;QAAM,WAAU;kBAAhB,CACE,kBAAC,IAAD;SAAM,WAAU;SAAU,eAAY;SAAS,CAAA,EAAA,UAC1C;;OAEL;;KACF;MACF;;EACC,CAAA;;AAQb,SAAS,GAAc,EAAE,UAAO,oBAAiE;CAC/F,IAAM,IAAa,GAAuB,EACpC,IAAe,GAAc,EAAM,WAAW,EAAE,EAEhD,IAAe,OAAO,MAA2B;AACrD,MAAI;AAEF,GADA,MAAM,EAAW,YAAY;IAAE,IAAI,EAAM;IAAI;IAAQ,CAAC,EACtD,EAAO,QAAQ,iBAAiB,YAAY,EAAgB,EAAO,GAAG;WAC/D,GAAK;AACZ,KAAO,MAAM,8BAA8B,EAAc,EAAI,CAAC;;;AAIlE,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf,CAEE,kBAAC,GAAD;GAAW,WAAU;GAAQ,WAAU;aAAvC;IACE,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,QAAD;MACE,WAAU;MACV,eAAY;gBAEZ,kBAAC,GAAD,EAAW,WAAU,WAAY,CAAA;MAC5B,CAAA,EACP,kBAAC,MAAD;MAAI,WAAU;gBAAuD;MAEhE,CAAA,CACD;;IAEN,kBAAC,GAAD;KAAkB,QAAQ,EAAM;KAAQ,WAAU;KAAS,CAAA;IAE3D,kBAAC,OAAD;KAAK,WAAU;eAAf,CACG,EAAa,SAAS,IACrB,kBAAC,OAAD;MAAK,WAAU;gBACZ,EAAa,KAAK,MAAW;OAC5B,IAAM,IAAO,GAAgB,IACvB,IAAO,EAAK;AAClB,cACE,kBAAC,GAAD;QAEE,SAAS,EAAK,UAAU,YAAY;QACpC,eAAe,EAAa,EAAO;QACnC,UAAU,EAAW;kBAJvB,CAME,kBAAC,GAAD;SAAM,WAAU;SAAiB,eAAY;SAAS,CAAA,EACrD,EAAK,MACC;UAPF,EAOE;QAEX;MACE,CAAA,GAEN,kBAAC,KAAD;MAAG,WAAU;gBAA6B;MAA8C,CAAA,EAG1F,kBAAC,KAAD;MAAG,WAAU;gBAAb,CACE,kBAAC,GAAD;OAAa,WAAU;OAAsC,eAAY;OAAS,CAAA,EAAA,6DAEhF;QACA;;IACI;MAGZ,kBAAC,GAAD;GAAW,WAAU;GAAQ,WAAU;aAAvC;IACE,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,QAAD;OACE,WAAU;OACV,eAAY;iBAEZ,kBAAC,GAAD,EAAQ,WAAU,WAAY,CAAA;OACzB,CAAA,EACP,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,MAAD;OAAI,WAAU;iBAAuD;OAAc,CAAA,EACnF,kBAAC,KAAD;OAAG,WAAU;iBAA8B;OAEvC,CAAA,CACA,EAAA,CAAA,CACF;SACN,kBAAC,GAAD;MAAQ,SAAQ;MAAU,SAAS;gBAAnC,CACE,kBAAC,GAAD,EAAM,WAAU,kBAAmB,CAAA,EAAA,gBAC5B;QACL;;IAEN,kBAAC,IAAD;KAAyB,SAAS,EAAM;KAAI,WAAU;KAAS,CAAA;IAE/D,kBAAC,KAAD;KAAG,WAAU;eAAb,CACE,kBAAC,GAAD;MAAa,WAAU;MAAuB,eAAY;MAAS,CAAA,EAAA,+EAEjE;;IACM;KACR;;;AAIV,SAAS,GAAwB,EAAE,YAAS,gBAAsD;CAChG,IAAM,EAAE,MAAM,GAAW,cAAW,YAAS,eAAY,EAAgB,EAAQ;AAqFjF,QARI,IAAkB,kBAAC,GAAD;EAAa,OAAM;EAAgC;EAAa,CAAA,GAClF,IAEA,kBAAC,OAAD;EAAgB;YACd,kBAAC,GAAD;GAAW,SAAQ;GAA8B,eAAe,GAAS;GAAI,CAAA;EACzE,CAAA,GAIR,kBAAC,IAAD;EACa;EACF,SAtFyC;GACpD;IACE,KAAK;IACL,QAAQ;IACR,UAAU;IACV,OAAO,MAAa;KAClB,IAAM,IAAO,EAAe,EAAS,WAAW,EAC1C,IAAO,CAAC,EAAS,QAAQ,EAAS,UAAU,CAAC,QAAQ,MAAM,KAAK,EAAE,MAAM,CAAC,CAAC,KAAK,MAAM;AAC3F,YACE,kBAAC,OAAD;MAAK,WAAU;gBAAf,CACE,kBAAC,QAAD;OACE,WAAU;OACV,eAAY;iBAEZ,kBAAC,GAAD,EAAM,WAAU,WAAY,CAAA;OACvB,CAAA,EACP,kBAAC,OAAD;OAAK,WAAU;iBAAf;QACE,kBAAC,KAAD;SAAG,WAAU;mBAAiC,EAAS;SAAU,CAAA;QAChE,EAAS,WACR,kBAAC,KAAD;SAAG,WAAU;mBACV,EAAS;SACR,CAAA;QAEL,KAAQ,kBAAC,KAAD;SAAG,WAAU;mBAAqC;SAAS,CAAA;QAChE;SACF;;;IAGX;GACD;IACE,KAAK;IACL,QAAQ;IACR,OAAO,MACL,kBAAC,GAAD;KACE,QAAQ,EAAS;KACjB,MAES,kBADM,EAAe,EAAS,WAAW,EACzC,EAAM,WAAU,WAAY,CAAA;KAErC,CAAA;IAEL;GACD;IACE,KAAK;IACL,QAAQ;IACR,UAAU;IACV,OAAO,MACL,kBAAC,QAAD;KAAM,WAAU;eACb,EAAW,EAAS,UAAU;KAC1B,CAAA;IAEV;GACD;IACE,KAAK;IACL,QAAQ;IACR,OAAO,MAAa;KAElB,IAAM,IADO,EAAkB,EAAS,cACtB;AAClB,YACE,kBAAC,GAAD;MACE,QAAQ,EAAS;MACjB,MAAM,kBAAC,GAAD,EAAM,WAAW,EAAG,WAAW,EAAsB,EAAS,aAAa,CAAC,EAAI,CAAA;MACtF,CAAA;;IAGP;GACD;IACE,KAAK;IACL,QAAQ;IACR,iBAAiB;IACjB,eAAe;IACf,OAAO,MAAa,kBAAC,IAAD,EAAoC,aAAY,CAAA;IACrE;GACF;EAcG,MAAM,KAAa,EAAE;EACrB,SAAS,MAAa,EAAS;EAC/B,YACE,kBAAC,GAAD;GACE,MAAM,kBAAC,GAAD,EAAQ,WAAU,WAAY,CAAA;GACpC,OAAM;GACN,aAAY;GACZ,CAAA;EAEJ,CAAA;;AAIN,SAAS,GAAyB,EAAE,eAAuC;CACzE,IAAM,IAAS,GAAsB,EAE/B,IAAS,OAAO,MAAwC;AACxD,YAAiB,EAAS,aAC9B,KAAI;AAEF,GADA,MAAM,EAAO,YAAY;IAAE,IAAI,EAAS;IAAI;IAAc,CAAC,EAC3D,EAAO,QAAQ,wBAAwB,EAAgB,EAAa,CAAC;WAC9D,GAAK;AACZ,KAAO,MAAM,iCAAiC,EAAc,EAAI,CAAC;;;AAIrE,QACE,kBAAC,IAAD,EAAA,UAAA,CACE,kBAAC,IAAD;EAAqB,SAAA;YACnB,kBAAC,GAAD;GACE,MAAK;GACL,SAAQ;GACR,UAAU,EAAO;GACjB,cAAY,2BAA2B,EAAS;aAJlD,CAKC,WACQ,kBAAC,IAAD;IAAa,WAAU;IAAe,eAAY;IAAS,CAAA,CAC3D;;EACW,CAAA,EACtB,kBAAC,IAAD;EAAqB,OAAM;YACxB,EAAc,KAAK,MAAW;GAC7B,IAAM,IAAO,EAAkB,IACzB,IAAO,EAAK;AAElB,UACE,kBAAC,IAAD;IAEE,gBAAgB,EAAO,EAAO;IAC9B,WAAW,EAAG,SALF,MAAW,EAAS,gBAKE,gBAAgB;cAHpD,CAKE,kBAAC,GAAD;KAAM,WAAW,EAAG,WAAW,EAAsB,EAAO,CAAC;KAAE,eAAY;KAAS,CAAA,EACnF,EAAK,MACW;MANZ,EAMY;IAErB;EACkB,CAAA,CACT,EAAA,CAAA;;AAQnB,IAAM,KAA4B;CAChC;CACA;CACA;CACA;CACA;CACA;CACD;AAED,SAAS,GAAgB,EACvB,SACA,iBACA,YAKC;CACD,IAAM,IAAc,GAAmB,EACjC,IAAc,EAAa,EAAE,YAAY,EAAE,OAAO,KAAK,EAAE,CAAC,EAE1D,CAAC,GAAO,KAAY,EAAS,EAAM,MAAM,EACzC,CAAC,GAAS,KAAc,EAAS,EAAM,WAAW,GAAG,EACrD,CAAC,GAAM,KAAW,EAAS,EAAM,QAAQ,GAAG,EAC5C,CAAC,GAAQ,KAAa,EAAyB,EAAM,OAAO,EAC5D,CAAC,GAAa,KAAkB,EAAS,EAAM,WAAW,GAAG,EAC7D,CAAC,GAAW,KAAgB,EAAmB,EAAM,UAAU,EAC/D,CAAC,GAAM,KAAW,EAAmB,EAAM,KAAK,EAChD,CAAC,GAAa,KAAkB,EACpC,EAAM,cAAc,OAAO,EAAM,YAAY,GAAG,GACjD,EACK,CAAC,GAAa,KAAkB,EAAwB,EAAM,OAAO,MAAM,KAAK,EAChF,CAAC,GAAO,KAAY,EAAwB,KAAK,EAGjD,IAAW,QAAc,GAAG,EAAM,GAAG,GAAG,EAAM,aAAa,CAAC,EAAM,IAAI,EAAM,UAAU,CAAC,EACvF,CAAC,GAAW,KAAgB,EAAS,EAAS;AA0CpD,QAzCI,MAAc,MAChB,EAAa,EAAS,EACtB,EAAS,EAAM,MAAM,EACrB,EAAW,EAAM,WAAW,GAAG,EAC/B,EAAQ,EAAM,QAAQ,GAAG,EACzB,EAAU,EAAM,OAAO,EACvB,EAAe,EAAM,WAAW,GAAG,EACnC,EAAa,EAAM,UAAU,EAC7B,EAAQ,EAAM,KAAK,EACnB,EAAe,EAAM,cAAc,OAAO,EAAM,YAAY,GAAG,GAAG,EAClE,EAAe,EAAM,OAAO,MAAM,KAAK,EACvC,EAAS,KAAK,GA+Bd,kBAAC,IAAD;EAAwB;EAAoB;YAC1C,kBAAC,IAAD;GAAyB,WAAU;aAAnC;IACE,kBAAC,IAAD,EAAA,UAAA,CACE,kBAAC,IAAD,EAAA,UAAuB,cAAkC,CAAA,EACzD,kBAAC,IAAD,EAAA,UAA6B,yDAEC,CAAA,CACP,EAAA,CAAA;IAEzB,kBAAC,OAAD;KAAK,WAAU;eAAf;MACE,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,GAAD,EAAA,UAAO,eAAmB,CAAA,EAC1B,kBAAC,GAAD;QACE,QAAQ,EAAa;QACrB,QAAO;QACP,OAAM;QACN,QAAQ;QACR,YAAY;QACZ,iBAAiB,EAAe,KAAK;QACrC,CAAA,CACE;;MACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,GAAD;QAAO,SAAQ;kBAAa;QAAa,CAAA,EACzC,kBAAC,IAAD;QAAO,IAAG;QAAa,OAAO;QAAO,WAAW,MAAM,EAAS,EAAE,OAAO,MAAM;QAAI,CAAA,CAC9E;;MACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,OAAD;QAAK,WAAU;kBAAf,CACE,kBAAC,GAAD,EAAA,UAAO,UAAc,CAAA,EACrB,kBAAC,IAAD;SAAQ,OAAO;SAAQ,gBAAgB,MAAM,EAAU,EAAoB;mBAA3E,CACE,kBAAC,IAAD,EAAA,UACE,kBAAC,IAAD,EAAe,CAAA,EACD,CAAA,EAChB,kBAAC,IAAD,EAAA,UACG,GAAQ,KAAK,MACZ,kBAAC,IAAD;UAAoB,OAAO;oBACxB,EAAgB,EAAE;UACR,EAFI,EAEJ,CACb,EACY,CAAA,CACT;WACL;WACN,kBAAC,OAAD;QAAK,WAAU;kBAAf,CACE,kBAAC,GAAD;SAAO,SAAQ;mBAAe;SAA0B,CAAA,EACxD,kBAAC,IAAD;SACE,IAAG;SACH,MAAK;SACL,KAAK;SACL,OAAO;SACP,WAAW,MAAM,EAAe,EAAE,OAAO,MAAM;SAC/C,CAAA,CACE;UACF;;MACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,GAAD,EAAA,UAAO,SAAa,CAAA,EACpB,kBAAC,IAAD;QAAQ,OAAO;QAAa,eAAe;kBAA3C,CACE,kBAAC,IAAD,EAAA,UACE,kBAAC,IAAD,EAAa,aAAY,6BAA8B,CAAA,EACzC,CAAA,EAChB,kBAAC,IAAD,EAAA,WACI,EAAY,MAAM,SAAS,EAAE,EAAE,KAAK,MACpC,kBAAC,IAAD;SAA2B,OAAO,EAAM;mBACrC,EAAM;SACI,EAFI,EAAM,GAEV,CACb,EACY,CAAA,CACT;UACL;;MACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,GAAD;QAAO,SAAQ;kBAAe;QAAe,CAAA,EAC7C,kBAAC,IAAD;QACE,IAAG;QACH,OAAO;QACP,WAAW,MAAM,EAAW,EAAE,OAAO,MAAM;QAC3C,MAAM;QACN,CAAA,CACE;;MACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,GAAD;QAAO,SAAQ;kBAAY;QAAY,CAAA,EACvC,kBAAC,IAAD;QACE,IAAG;QACH,OAAO;QACP,WAAW,MAAM,EAAQ,EAAE,OAAO,MAAM;QACxC,MAAM;QACN,CAAA,CACE;;MACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,GAAD;QAAO,SAAQ;kBAAa;QAAiB,CAAA,EAC7C,kBAAC,GAAD;QAAU,IAAG;QAAa,OAAO;QAAW,UAAU;QAAc,cAAA;QAAe,CAAA,CAC/E;;MACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,GAAD;QAAO,SAAQ;kBAAY;QAAY,CAAA,EACvC,kBAAC,GAAD;QAAU,IAAG;QAAY,OAAO;QAAM,UAAU;QAAW,CAAA,CACvD;;MACL,KAAS,kBAAC,KAAD;OAAG,WAAU;iBAAkC;OAAU,CAAA;MAC/D;;IAEN,kBAAC,IAAD,EAAA,UAAA,CACE,kBAAC,GAAD;KAAQ,SAAQ;KAAU,eAAe,EAAa,GAAM;eAAE;KAErD,CAAA,EACT,kBAAC,GAAD;KAAQ,SAhID,YAAY;AACzB,UAAI,CAAC,EAAM,MAAM,EAAE;AACjB,SAAS,uBAAuB;AAChC;;AAEF,UAAI;AAgBF,OAfA,MAAM,EAAY,YAAY;QAC5B,IAAI,EAAM;QACV,OAAO;SACL,OAAO,EAAM,MAAM;SACnB,SAAS,EAAQ,MAAM,IAAI;SAC3B,MAAM,EAAK,MAAM,IAAI;SACrB;SACA,SAAS,KAAe;SACxB;SACA;SACA,aAAa,IAAc,OAAO,EAAY,GAAG;SACjD;SACD;QACF,CAAC,EACF,EAAO,QAAQ,iBAAiB,EAAM,MAAM,CAAC,EAC7C,EAAa,GAAM;eACZ,GAAK;AACZ,SAAS,EAAc,EAAI,CAAC;;;KAyGC,UAAU,EAAY;eAAW;KAEjD,CAAA,CACc,EAAA,CAAA;IACD;;EACT,CAAA"}
|
|
1
|
+
{"version":3,"file":"StoryDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/stories/StoryDetailPage.tsx"],"sourcesContent":["/**\n * StoryDetailPage — a single story in two complementary surfaces.\n * @module theculturepost/pages/stories\n *\n * A `?view=` toggle (with a sub-tab control) splits the page into:\n * - READING (tcp-03): a cover-banner hero, serif display title, a drop-cap\n * article body with ornamental section breaks, and a right rail of citations\n * (compact) + linked discussions.\n * - EDITORIAL (tcp-04): a visual lifecycle stepper with validated transitions\n * (Publish / Send back), plus citations as a verifiable-source table with an\n * inline verify control.\n *\n * Reads `tcpStory` (+ citations), `tcpListDiscussions(storyId)`. Writes\n * `tcpTransitionStory`, `tcpUpdateStory`, `tcpVerifyCitation`. Person references\n * are id-only, so we render \"You\" / \"Contributor\".\n */\n\nimport { Fragment, useMemo, useState, type ReactNode } from 'react';\nimport { useParams, useSearchParams } from 'react-router';\nimport {\n ArrowLeft,\n Bookmark,\n BookOpen,\n ChevronDown,\n Clock,\n GitBranch,\n Languages,\n Lightbulb,\n Lock,\n MessagesSquare,\n Pencil,\n Plus,\n RotateCcw,\n Scroll,\n Send,\n Share2,\n ShieldCheck,\n Sparkles,\n Tag,\n Archive as ArchiveIcon,\n type LucideIcon,\n} from 'lucide-react';\nimport {\n Button,\n GlassCard,\n Input,\n Label,\n Textarea,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n ResponsiveTable,\n DropdownMenu,\n DropdownMenuTrigger,\n DropdownMenuContent,\n DropdownMenuItem,\n type ResponsiveTableColumn,\n} from '@burdenoff/fe-libs/ui';\nimport { PageHero } from '../../components/PageHero';\nimport { StatusBadge } from '../../components/StatusBadge';\nimport { Tabs, type TabItem } from '../../components/Tabs';\nimport { LifecycleStepper } from '../../components/LifecycleStepper';\nimport { DecorativeDivider } from '../../components/DecorativeDivider';\nimport { AuthorLabel } from '../../components/AuthorLabel';\nimport {\n CitationList,\n sourceTypeIcon,\n VERIFICATION_META,\n VERIFICATIONS,\n} from '../../components/CitationList';\nimport { AddCitationDialog } from '../../components/AddCitationDialog';\nimport { OpenDiscussionDialog } from '../../components/OpenDiscussionDialog';\nimport { TagInput } from '../../components/TagInput';\nimport { FileUploadField } from '../../components/FileUploadField';\nimport { EmptyState } from '../../components/EmptyState';\nimport { LoadingView, ErrorView } from '../../components/StateViews';\nimport {\n useTcpStory,\n useTcpTransitionStory,\n useTcpUpdateStory,\n useTcpToggleStoryBookmark,\n useTcpTopics,\n useTcpDiscussions,\n useTcpCitations,\n useTcpVerifyCitation,\n FILE_FOLDERS,\n} from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { cn } from '../../utils/cn';\nimport { share, impact, nativeToast } from '../../utils/nativeBridge';\nimport {\n formatDate,\n formatEnumLabel,\n formatNumber,\n formatRelativeTime,\n} from '../../utils/formatters';\nimport { notify, describeError } from '../../lib/toast';\nimport type {\n TcpCitation,\n TcpDiscussion,\n TcpStory,\n TcpStoryFormat,\n TcpStoryStatus,\n TcpVerificationStatus,\n} from '../../types';\n\ntype StoryView = 'reading' | 'editorial';\n\nconst NEXT_STATUSES: Record<TcpStoryStatus, TcpStoryStatus[]> = {\n IDEA: ['DRAFT'],\n DRAFT: ['IN_REVIEW'],\n IN_REVIEW: ['PUBLISHED', 'DRAFT'],\n PUBLISHED: ['ARCHIVED'],\n ARCHIVED: ['PUBLISHED'],\n};\n\ninterface TransitionMetaEntry {\n label: string;\n icon: LucideIcon;\n primary?: boolean;\n}\n\nconst TRANSITION_META: Record<TcpStoryStatus, TransitionMetaEntry> = {\n IDEA: { label: 'Move to Idea', icon: Lightbulb },\n DRAFT: { label: 'Send back to Draft', icon: RotateCcw },\n IN_REVIEW: { label: 'Send to Review', icon: Send },\n PUBLISHED: { label: 'Publish', icon: Send, primary: true },\n ARCHIVED: { label: 'Archive', icon: ArchiveIcon },\n};\n\n/** Semantic-token colour for a verification icon (matches CitationList legend). */\nfunction verificationIconClass(status: TcpVerificationStatus): string {\n switch (VERIFICATION_META[status].accent) {\n case 'success':\n return 'text-status-success-text';\n case 'warning':\n return 'text-status-warning-text';\n case 'destructive':\n return 'text-status-error-text';\n default:\n return 'text-text-tertiary';\n }\n}\n\n// ===========================================================================\n// Page\n// ===========================================================================\n\nexport function StoryDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const [searchParams, setSearchParams] = useSearchParams();\n\n const view: StoryView = searchParams.get('view') === 'editorial' ? 'editorial' : 'reading';\n const setView = (next: StoryView) => {\n setSearchParams(\n (prev) => {\n const params = new URLSearchParams(prev);\n if (next === 'editorial') params.set('view', 'editorial');\n else params.delete('view');\n return params;\n },\n { replace: true }\n );\n };\n\n const { data: story, isLoading, isError, refetch } = useTcpStory(id);\n const toggleBookmark = useTcpToggleStoryBookmark();\n\n const [citationOpen, setCitationOpen] = useState(false);\n const [discussionOpen, setDiscussionOpen] = useState(false);\n const [editOpen, setEditOpen] = useState(false);\n\n const discussionsQuery = useTcpDiscussions({ storyId: id, pagination: { limit: 20 } });\n const linkedDiscussions = discussionsQuery.data?.items ?? [];\n\n if (isLoading) {\n return (\n <div className=\"mx-auto max-w-6xl p-4 sm:p-6\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('stories')}>\n <ArrowLeft className=\"mr-1.5 h-4 w-4\" /> Stories\n </Button>\n <LoadingView label=\"Loading story…\" />\n </div>\n );\n }\n\n if (isError || !story) {\n return (\n <div className=\"mx-auto max-w-6xl p-4 sm:p-6\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => navigateTo('stories')}>\n <ArrowLeft className=\"mr-1.5 h-4 w-4\" /> Stories\n </Button>\n <ErrorView\n title=\"Story not found\"\n message=\"This story may have been archived, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </div>\n );\n }\n\n const heroMeta = (\n <>\n <StatusBadge status={story.format} />\n <StatusBadge status={story.status} />\n {story.topic && (\n <button\n type=\"button\"\n onClick={() => navigateTo(`topics/${story.topic!.id}`)}\n className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary transition-colors hover:text-text-primary\"\n >\n <Tag className=\"h-3 w-3\" aria-hidden=\"true\" /> {story.topic.name}\n </button>\n )}\n <span className=\"inline-flex items-center gap-1.5 text-xs text-text-tertiary\">\n <AuthorLabel actorId={story.authorId} prefix=\"By\" withIcon />\n </span>\n {story.readingMins > 0 && (\n <span className=\"inline-flex items-center gap-1 text-xs text-text-tertiary\">\n <Clock className=\"h-3.5 w-3.5\" aria-hidden=\"true\" /> {formatNumber(story.readingMins)} min\n read\n </span>\n )}\n {story.languages.length > 0 && (\n <span className=\"inline-flex items-center gap-1 text-xs text-text-tertiary\">\n <Languages className=\"h-3.5 w-3.5\" aria-hidden=\"true\" />{' '}\n {story.languages.map(formatEnumLabel).join(', ')}\n </span>\n )}\n </>\n );\n\n const tabs: TabItem<StoryView>[] = [\n { value: 'reading', label: 'Reading', icon: <BookOpen className=\"h-4 w-4\" /> },\n {\n value: 'editorial',\n label: 'Editorial',\n icon: <GitBranch className=\"h-4 w-4\" />,\n count: story.citations.length,\n },\n ];\n\n return (\n <div className=\"mx-auto flex max-w-6xl flex-col gap-6 p-4 sm:p-6\">\n <PageHero\n eyebrow=\"Story\"\n title={story.title}\n description={story.summary ?? undefined}\n coverFileId={story.cover?.id ?? undefined}\n backAction={{ label: 'Stories', onClick: () => navigateTo('stories') }}\n meta={heroMeta}\n actions={\n <div className=\"flex items-center gap-2\">\n <Button\n variant={story.isBookmarked ? 'default' : 'outline'}\n size=\"sm\"\n onClick={async () => {\n await impact('light');\n try {\n const bookmarked = await toggleBookmark.mutateAsync(story.id);\n await nativeToast(\n bookmarked ? 'Saved to your reading list' : 'Removed from your reading list'\n );\n } catch (error) {\n notify.error(describeError(error));\n }\n }}\n disabled={toggleBookmark.isPending}\n aria-pressed={story.isBookmarked}\n aria-label={story.isBookmarked ? 'Remove from reading list' : 'Save to reading list'}\n data-tour=\"theculturepost-story-bookmark\"\n >\n <Bookmark className={cn('mr-1.5 h-4 w-4', story.isBookmarked && 'fill-current')} />{' '}\n {story.isBookmarked ? 'Saved' : 'Save'}\n </Button>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={async () => {\n await impact('light');\n const ok = await share({\n title: story.title,\n text: story.summary ?? undefined,\n url: window.location.href,\n });\n if (ok) await nativeToast('Link shared');\n }}\n aria-label=\"Share story\"\n >\n <Share2 className=\"mr-1.5 h-4 w-4\" /> Share\n </Button>\n <Button\n variant=\"outline\"\n onClick={() => setEditOpen(true)}\n data-tour=\"theculturepost-story-edit\"\n >\n <Pencil className=\"mr-1.5 h-4 w-4\" /> Edit\n </Button>\n </div>\n }\n />\n\n <div data-tour=\"theculturepost-story-tabs\">\n <Tabs items={tabs} value={view} onChange={setView} />\n </div>\n\n {view === 'reading' ? (\n <ReadingView\n story={story}\n discussions={linkedDiscussions}\n discussionsLoading={discussionsQuery.isLoading}\n onViewCitations={() => setView('editorial')}\n onOpenDiscussion={() => setDiscussionOpen(true)}\n onViewAllDiscussions={() => navigateTo('discussions')}\n onOpenDiscussionItem={(discussion) => navigateTo(`discussions/${discussion.id}`)}\n />\n ) : (\n <EditorialView story={story} onAddCitation={() => setCitationOpen(true)} />\n )}\n\n <AddCitationDialog open={citationOpen} onOpenChange={setCitationOpen} storyId={story.id} />\n <OpenDiscussionDialog\n open={discussionOpen}\n onOpenChange={setDiscussionOpen}\n storyId={story.id}\n onCreated={(discussion) => navigateTo(`discussions/${discussion.id}`)}\n />\n <EditStoryDialog open={editOpen} onOpenChange={setEditOpen} story={story} />\n </div>\n );\n}\n\n// ===========================================================================\n// Reading view (tcp-03)\n// ===========================================================================\n\ninterface ReadingViewProps {\n story: TcpStory;\n discussions: TcpDiscussion[];\n discussionsLoading: boolean;\n onViewCitations: () => void;\n onOpenDiscussion: () => void;\n onViewAllDiscussions: () => void;\n onOpenDiscussionItem: (discussion: TcpDiscussion) => void;\n}\n\nfunction ReadingView({\n story,\n discussions,\n discussionsLoading,\n onViewCitations,\n onOpenDiscussion,\n onViewAllDiscussions,\n onOpenDiscussionItem,\n}: ReadingViewProps) {\n return (\n <div className=\"grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_360px]\">\n {/* Article */}\n <article className=\"min-w-0\">\n <StoryBody body={story.body} />\n\n {story.tags.length > 0 && (\n <div className=\"mt-8 flex flex-wrap gap-1.5 border-t border-border-seam pt-6\">\n {story.tags.map((tag) => (\n <span\n key={tag}\n className=\"rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\"\n >\n #{tag}\n </span>\n ))}\n </div>\n )}\n </article>\n\n {/* Right rail */}\n <aside className=\"flex flex-col gap-6 lg:sticky lg:top-4 lg:self-start\">\n <RailPanel\n title=\"Citations\"\n action={\n <button\n type=\"button\"\n onClick={onViewCitations}\n className=\"text-xs font-medium text-accent hover:underline\"\n >\n View all\n </button>\n }\n >\n <CitationList storyId={story.id} compact />\n </RailPanel>\n\n <RailPanel\n title=\"Linked discussions\"\n action={\n <button\n type=\"button\"\n onClick={onViewAllDiscussions}\n className=\"text-xs font-medium text-accent hover:underline\"\n >\n View all\n </button>\n }\n >\n {discussionsLoading ? (\n <LoadingView label=\"Loading discussions…\" className=\"min-h-[8rem]\" />\n ) : discussions.length === 0 ? (\n <EmptyState\n icon={<MessagesSquare className=\"h-8 w-8\" />}\n title=\"No discussions yet\"\n description=\"Open a discussion to invite perspectives on this story.\"\n action={\n <Button size=\"sm\" variant=\"outline\" onClick={onOpenDiscussion}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Open discussion\n </Button>\n }\n />\n ) : (\n <div className=\"flex flex-col gap-3\">\n {discussions.slice(0, 5).map((discussion) => (\n <LinkedDiscussion\n key={discussion.id}\n discussion={discussion}\n onOpen={() => onOpenDiscussionItem(discussion)}\n />\n ))}\n </div>\n )}\n </RailPanel>\n </aside>\n </div>\n );\n}\n\nfunction RailPanel({\n title,\n action,\n children,\n}: {\n title: string;\n action?: ReactNode;\n children: ReactNode;\n}) {\n return (\n <section>\n <div className=\"mb-3 flex items-center justify-between gap-2\">\n <h2 className=\"font-display text-lg font-semibold text-text-primary\">{title}</h2>\n {action}\n </div>\n {children}\n </section>\n );\n}\n\nfunction StoryBody({ body }: { body?: string | null }) {\n const paragraphs = useMemo(\n () =>\n (body ?? '')\n .split(/\\n{2,}/)\n .map((p) => p.trim())\n .filter(Boolean),\n [body]\n );\n\n if (paragraphs.length === 0) {\n return (\n <p className=\"rounded-xl border border-dashed border-border-seam px-4 py-10 text-center text-sm text-text-tertiary\">\n This story has no body yet. Use Edit to write it.\n </p>\n );\n }\n\n return (\n <div className=\"max-w-prose\">\n {paragraphs.map((paragraph, index) => (\n <Fragment key={index}>\n {index === 1 && <DecorativeDivider />}\n <p\n className={cn(\n 'text-[1.05rem] leading-8 text-text-primary',\n index > 0 && index !== 1 && 'mt-5',\n index === 0 &&\n 'first-letter:float-left first-letter:mr-3 first-letter:mt-1 first-letter:font-display first-letter:text-6xl first-letter:font-semibold first-letter:leading-[0.72] first-letter:text-accent'\n )}\n >\n {paragraph}\n </p>\n </Fragment>\n ))}\n </div>\n );\n}\n\nfunction LinkedDiscussion({\n discussion,\n onOpen,\n}: {\n discussion: TcpDiscussion;\n onOpen: () => void;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onOpen}\n className=\"w-full rounded-xl border border-border-seam bg-bg-surface p-3 text-left transition-colors hover:border-border-strong\"\n >\n <div className=\"flex items-start gap-2.5\">\n <span\n className=\"grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-[var(--color-accent-soft)] text-text-primary\"\n aria-hidden=\"true\"\n >\n <MessagesSquare className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium text-text-primary\">{discussion.title}</p>\n {discussion.prompt && (\n <p className=\"mt-0.5 line-clamp-2 text-xs text-text-tertiary\">{discussion.prompt}</p>\n )}\n <div className=\"mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-text-tertiary\">\n <span>\n {formatNumber(discussion.replyCount)}{' '}\n {discussion.replyCount === 1 ? 'reply' : 'replies'}\n </span>\n <span aria-hidden=\"true\">·</span>\n <span>{formatRelativeTime(discussion.createdAt)}</span>\n {discussion.isInterfaith && (\n <StatusBadge\n status=\"Interfaith\"\n icon={<Sparkles className=\"h-3 w-3\" />}\n className=\"ml-0.5\"\n />\n )}\n {discussion.isLocked && (\n <span className=\"inline-flex items-center gap-1 text-text-tertiary\">\n <Lock className=\"h-3 w-3\" aria-hidden=\"true\" /> Locked\n </span>\n )}\n </div>\n </div>\n </div>\n </button>\n );\n}\n\n// ===========================================================================\n// Editorial view (tcp-04)\n// ===========================================================================\n\nfunction EditorialView({ story, onAddCitation }: { story: TcpStory; onAddCitation: () => void }) {\n const transition = useTcpTransitionStory();\n const nextStatuses = NEXT_STATUSES[story.status] ?? [];\n\n const doTransition = async (status: TcpStoryStatus) => {\n try {\n await transition.mutateAsync({ id: story.id, status });\n notify.success('Story updated', `Moved to ${formatEnumLabel(status)}`);\n } catch (err) {\n notify.error('Could not update the story', describeError(err));\n }\n };\n\n return (\n <div className=\"flex flex-col gap-6\">\n {/* Lifecycle */}\n <GlassCard treatment=\"plain\" className=\"p-5 sm:p-6\">\n <div className=\"flex items-center gap-2.5\">\n <span\n className=\"grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[var(--color-accent-soft)] text-text-primary\"\n aria-hidden=\"true\"\n >\n <GitBranch className=\"h-5 w-5\" />\n </span>\n <h2 className=\"font-display text-xl font-semibold text-text-primary\">\n Editorial lifecycle\n </h2>\n </div>\n\n <LifecycleStepper status={story.status} className=\"mt-6\" />\n\n <div className=\"mt-6 flex flex-col gap-4 border-t border-border-seam pt-5 md:flex-row md:items-center md:justify-between\">\n {nextStatuses.length > 0 ? (\n <div className=\"flex flex-wrap gap-2\">\n {nextStatuses.map((status) => {\n const meta = TRANSITION_META[status];\n const Icon = meta.icon;\n return (\n <Button\n key={status}\n variant={meta.primary ? 'default' : 'outline'}\n onClick={() => doTransition(status)}\n disabled={transition.isPending}\n >\n <Icon className=\"mr-1.5 h-4 w-4\" aria-hidden=\"true\" />\n {meta.label}\n </Button>\n );\n })}\n </div>\n ) : (\n <p className=\"text-sm text-text-tertiary\">This story is at the end of its lifecycle.</p>\n )}\n\n <p className=\"flex items-center gap-2 text-sm text-text-secondary\">\n <ShieldCheck className=\"h-4 w-4 shrink-0 text-text-tertiary\" aria-hidden=\"true\" />\n Reviewed with fact and sensitivity care before publishing.\n </p>\n </div>\n </GlassCard>\n\n {/* Citations */}\n <GlassCard treatment=\"plain\" className=\"p-5 sm:p-6\">\n <div className=\"flex flex-wrap items-center justify-between gap-3\">\n <div className=\"flex items-center gap-2.5\">\n <span\n className=\"grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-[var(--color-accent-soft)] text-text-primary\"\n aria-hidden=\"true\"\n >\n <Scroll className=\"h-5 w-5\" />\n </span>\n <div>\n <h2 className=\"font-display text-xl font-semibold text-text-primary\">Citations</h2>\n <p className=\"text-sm text-text-secondary\">\n Verifiable sources that ground this story.\n </p>\n </div>\n </div>\n <Button variant=\"outline\" onClick={onAddCitation}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> Add citation\n </Button>\n </div>\n\n <EditorialCitationsTable storyId={story.id} className=\"mt-5\" />\n\n <p className=\"mt-4 flex items-center gap-2 border-t border-border-seam pt-4 text-xs text-text-tertiary\">\n <ShieldCheck className=\"h-3.5 w-3.5 shrink-0\" aria-hidden=\"true\" />\n All sources are evaluated for accuracy, relevance, and cultural sensitivity.\n </p>\n </GlassCard>\n </div>\n );\n}\n\nfunction EditorialCitationsTable({ storyId, className }: { storyId: string; className?: string }) {\n const { data: citations, isLoading, isError, refetch } = useTcpCitations(storyId);\n\n const columns: ResponsiveTableColumn<TcpCitation>[] = [\n {\n key: 'source',\n header: 'Source',\n priority: 'primary',\n cell: (citation) => {\n const Icon = sourceTypeIcon(citation.sourceType);\n const meta = [citation.author, citation.publisher].filter((v) => v && v.trim()).join(' · ');\n return (\n <div className=\"flex min-w-0 items-start gap-3\">\n <span\n className=\"grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-[var(--color-accent-soft)] text-text-primary\"\n aria-hidden=\"true\"\n >\n <Icon className=\"h-4 w-4\" />\n </span>\n <div className=\"min-w-0\">\n <p className=\"font-medium text-text-primary\">{citation.title}</p>\n {citation.excerpt && (\n <p className=\"mt-0.5 line-clamp-1 text-xs italic text-text-tertiary\">\n {citation.excerpt}\n </p>\n )}\n {meta && <p className=\"mt-0.5 text-xs text-text-tertiary\">{meta}</p>}\n </div>\n </div>\n );\n },\n },\n {\n key: 'type',\n header: 'Type',\n cell: (citation) => (\n <StatusBadge\n status={citation.sourceType}\n icon={(() => {\n const Icon = sourceTypeIcon(citation.sourceType);\n return <Icon className=\"h-3 w-3\" />;\n })()}\n />\n ),\n },\n {\n key: 'added',\n header: 'Added on',\n priority: 'hidden-on-mobile',\n cell: (citation) => (\n <span className=\"whitespace-nowrap text-sm text-text-secondary\">\n {formatDate(citation.createdAt)}\n </span>\n ),\n },\n {\n key: 'verification',\n header: 'Status',\n cell: (citation) => {\n const meta = VERIFICATION_META[citation.verification];\n const Icon = meta.icon;\n return (\n <StatusBadge\n status={citation.verification}\n icon={<Icon className={cn('h-3 w-3', verificationIconClass(citation.verification))} />}\n />\n );\n },\n },\n {\n key: 'actions',\n header: 'Actions',\n headerClassName: 'text-right',\n cellClassName: 'text-right',\n cell: (citation) => <EditorialCitationActions citation={citation} />,\n },\n ];\n\n if (isLoading) return <LoadingView label=\"Loading citations…\" className={className} />;\n if (isError)\n return (\n <div className={className}>\n <ErrorView message=\"We couldn't load citations.\" onRetry={() => refetch()} />\n </div>\n );\n\n return (\n <ResponsiveTable<TcpCitation>\n className={className}\n columns={columns}\n data={citations ?? []}\n rowKey={(citation) => citation.id}\n emptyState={\n <EmptyState\n icon={<Scroll className=\"h-9 w-9\" />}\n title=\"No citations yet\"\n description=\"Add verifiable sources to ground this story in the record.\"\n />\n }\n />\n );\n}\n\nfunction EditorialCitationActions({ citation }: { citation: TcpCitation }) {\n const verify = useTcpVerifyCitation();\n\n const change = async (verification: TcpVerificationStatus) => {\n if (verification === citation.verification) return;\n try {\n await verify.mutateAsync({ id: citation.id, verification });\n notify.success('Verification updated', formatEnumLabel(verification));\n } catch (err) {\n notify.error('Could not update verification', describeError(err));\n }\n };\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n size=\"sm\"\n variant=\"outline\"\n disabled={verify.isPending}\n aria-label={`Change verification for ${citation.title}`}\n >\n Verify <ChevronDown className=\"ml-1 h-4 w-4\" aria-hidden=\"true\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {VERIFICATIONS.map((status) => {\n const meta = VERIFICATION_META[status];\n const Icon = meta.icon;\n const current = status === citation.verification;\n return (\n <DropdownMenuItem\n key={status}\n onSelect={() => change(status)}\n className={cn('gap-2', current && 'font-semibold')}\n >\n <Icon className={cn('h-4 w-4', verificationIconClass(status))} aria-hidden=\"true\" />\n {meta.label}\n </DropdownMenuItem>\n );\n })}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n\n// ===========================================================================\n// Edit story dialog (local — only used here)\n// ===========================================================================\n\nconst FORMATS: TcpStoryFormat[] = [\n 'ARTICLE',\n 'ESSAY',\n 'EXPLAINER',\n 'ORAL_HISTORY',\n 'PHOTO_STORY',\n 'TIMELINE',\n];\n\nfunction EditStoryDialog({\n open,\n onOpenChange,\n story,\n}: {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n story: TcpStory;\n}) {\n const updateStory = useTcpUpdateStory();\n const topicsQuery = useTcpTopics({ pagination: { limit: 100 } });\n\n const [title, setTitle] = useState(story.title);\n const [summary, setSummary] = useState(story.summary ?? '');\n const [body, setBody] = useState(story.body ?? '');\n const [format, setFormat] = useState<TcpStoryFormat>(story.format);\n const [pickedTopic, setPickedTopic] = useState(story.topicId ?? '');\n const [languages, setLanguages] = useState<string[]>(story.languages);\n const [tags, setTags] = useState<string[]>(story.tags);\n const [readingMins, setReadingMins] = useState(\n story.readingMins ? String(story.readingMins) : ''\n );\n const [coverFileId, setCoverFileId] = useState<string | null>(story.cover?.id ?? null);\n const [error, setError] = useState<string | null>(null);\n\n // Reset local state whenever a different story is opened for editing.\n const storyKey = useMemo(() => `${story.id}:${story.updatedAt}`, [story.id, story.updatedAt]);\n const [seededKey, setSeededKey] = useState(storyKey);\n if (seededKey !== storyKey) {\n setSeededKey(storyKey);\n setTitle(story.title);\n setSummary(story.summary ?? '');\n setBody(story.body ?? '');\n setFormat(story.format);\n setPickedTopic(story.topicId ?? '');\n setLanguages(story.languages);\n setTags(story.tags);\n setReadingMins(story.readingMins ? String(story.readingMins) : '');\n setCoverFileId(story.cover?.id ?? null);\n setError(null);\n }\n\n const submit = async () => {\n if (!title.trim()) {\n setError('A title is required.');\n return;\n }\n try {\n await updateStory.mutateAsync({\n id: story.id,\n input: {\n title: title.trim(),\n summary: summary.trim() || null,\n body: body.trim() || null,\n format,\n topicId: pickedTopic || null,\n languages,\n tags,\n readingMins: readingMins ? Number(readingMins) : null,\n coverFileId,\n },\n });\n notify.success('Story updated', title.trim());\n onOpenChange(false);\n } catch (err) {\n setError(describeError(err));\n }\n };\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"sm:max-w-lg\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Edit story</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Refine the story. The URL slug is fixed once created.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"flex max-h-[62vh] flex-col gap-4 overflow-y-auto py-2\">\n <div className=\"grid gap-1.5\">\n <Label>Cover image</Label>\n <FileUploadField\n folder={FILE_FOLDERS.covers}\n accept=\"image/*\"\n label=\"Upload cover\"\n fileId={coverFileId}\n onUploaded={setCoverFileId}\n onCleared={() => setCoverFileId(null)}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-title\">Title</Label>\n <Input id=\"edit-title\" value={title} onChange={(e) => setTitle(e.target.value)} />\n </div>\n <div className=\"grid grid-cols-2 gap-3\">\n <div className=\"grid gap-1.5\">\n <Label>Format</Label>\n <Select value={format} onValueChange={(v) => setFormat(v as TcpStoryFormat)}>\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {FORMATS.map((f) => (\n <SelectItem key={f} value={f}>\n {formatEnumLabel(f)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-reading\">Reading time (min)</Label>\n <Input\n id=\"edit-reading\"\n type=\"number\"\n min={0}\n value={readingMins}\n onChange={(e) => setReadingMins(e.target.value)}\n />\n </div>\n </div>\n <div className=\"grid gap-1.5\">\n <Label>Topic</Label>\n <Select value={pickedTopic} onValueChange={setPickedTopic}>\n <SelectTrigger>\n <SelectValue placeholder=\"Choose a topic (optional)\" />\n </SelectTrigger>\n <SelectContent>\n {(topicsQuery.data?.items ?? []).map((topic) => (\n <SelectItem key={topic.id} value={topic.id}>\n {topic.name}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-summary\">Summary</Label>\n <Textarea\n id=\"edit-summary\"\n value={summary}\n onChange={(e) => setSummary(e.target.value)}\n rows={2}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-body\">Body</Label>\n <Textarea\n id=\"edit-body\"\n value={body}\n onChange={(e) => setBody(e.target.value)}\n rows={6}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-langs\">Languages</Label>\n <TagInput id=\"edit-langs\" value={languages} onChange={setLanguages} preserveCase />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-tags\">Tags</Label>\n <TagInput id=\"edit-tags\" value={tags} onChange={setTags} />\n </div>\n {error && <p className=\"text-sm text-status-error-text\">{error}</p>}\n </div>\n\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n Cancel\n </Button>\n <Button onClick={submit} disabled={updateStory.isPending}>\n Save changes\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@ import { CreateStoryDialog as m } from "../../components/CreateStoryDialog.js";
|
|
|
14
14
|
import "../../components/index.js";
|
|
15
15
|
import { createElement as h, useMemo as g, useState as _ } from "react";
|
|
16
16
|
import { Archive as v, ArrowLeft as y, BookOpen as b, Globe as x, Pencil as S, Plus as C, Sparkles as w } from "lucide-react";
|
|
17
|
-
import { useParams as T } from "react-router
|
|
17
|
+
import { useParams as T } from "react-router";
|
|
18
18
|
import { Fragment as E, jsx as D, jsxs as O } from "react/jsx-runtime";
|
|
19
19
|
import { Button as k, Input as A, Label as j, ResponsiveDialog as M, ResponsiveDialogContent as N, ResponsiveDialogDescription as P, ResponsiveDialogFooter as F, ResponsiveDialogHeader as I, ResponsiveDialogTitle as L, Select as R, SelectContent as z, SelectItem as B, SelectTrigger as V, SelectValue as H, Textarea as U } from "@burdenoff/fe-libs/ui";
|
|
20
20
|
//#region src/theculturepost/pages/topics/TopicDetailPage.tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TopicDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/topics/TopicDetailPage.tsx"],"sourcesContent":["/**\n * TopicDetailPage — a single topic and the stories curated under it.\n * @module theculturepost/pages/topics\n *\n * An editorial {@link PageHero} banner carries the title, description and the\n * Featured / category / tradition badges, plus the feature-toggle, edit and\n * archive actions (`tcpUpdateTopic` / `tcpArchiveTopic`). Below, the topic's own\n * `stories` are shown in a richer {@link StoryCard} grid with a real count and a\n * client-side sort. Reads `tcpTopic` (with its stories) — no fabricated fields.\n */\n\nimport { createElement, useMemo, useState, type ReactNode } from 'react';\nimport { useParams } from 'react-router-dom';\nimport { ArrowLeft, Globe, Sparkles, Pencil, Archive, Plus, BookOpen } from 'lucide-react';\nimport {\n Button,\n Input,\n Label,\n Textarea,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport {\n PageHero,\n StatusBadge,\n StoryCard,\n CreateStoryDialog,\n EmptyState,\n LoadingView,\n ErrorView,\n categoryIcon,\n} from '../../components';\nimport { useTcpTopic, useTcpUpdateTopic, useTcpArchiveTopic } from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { formatEnumLabel } from '../../utils/formatters';\nimport { notify, describeError } from '../../lib/toast';\nimport type { TcpStory, TcpTopic, TcpTopicCategory } from '../../types';\n\nconst CATEGORIES: TcpTopicCategory[] = [\n 'CULTURE_AND_IDENTITY',\n 'RELIGION_AND_FAITH',\n 'HISTORY',\n 'HERITAGE_AND_ARCHIVES',\n 'FESTIVALS_AND_RITUALS',\n 'MIGRATION_AND_DIASPORA',\n 'ORAL_HISTORIES',\n 'PHILOSOPHY_AND_THOUGHT',\n 'VALUES_AND_ETHICS',\n];\n\ntype SortKey = 'newest' | 'oldest' | 'updated' | 'title';\n\nconst SORT_OPTIONS: { value: SortKey; label: string }[] = [\n { value: 'newest', label: 'Newest' },\n { value: 'oldest', label: 'Oldest' },\n { value: 'updated', label: 'Recently updated' },\n { value: 'title', label: 'Title (A–Z)' },\n];\n\n/** ms timestamp of a story's effective publish date (published, else created). */\nconst publishedTs = (story: TcpStory): number => {\n const t = new Date(story.publishedAt ?? story.createdAt).getTime();\n return Number.isNaN(t) ? 0 : t;\n};\n\nconst updatedTs = (story: TcpStory): number => {\n const t = new Date(story.updatedAt).getTime();\n return Number.isNaN(t) ? 0 : t;\n};\n\nfunction sortStories(stories: TcpStory[], key: SortKey): TcpStory[] {\n const copy = [...stories];\n switch (key) {\n case 'newest':\n return copy.sort((a, b) => publishedTs(b) - publishedTs(a));\n case 'oldest':\n return copy.sort((a, b) => publishedTs(a) - publishedTs(b));\n case 'updated':\n return copy.sort((a, b) => updatedTs(b) - updatedTs(a));\n case 'title':\n return copy.sort((a, b) => a.title.localeCompare(b.title));\n default:\n return copy;\n }\n}\n\n/** Minimal scroll shell so hero/loading/error surfaces share one container. */\nfunction DetailShell({ children }: { children: ReactNode }) {\n return (\n <div className=\"h-full overflow-y-auto\">\n <div className=\"mx-auto flex max-w-5xl flex-col gap-6 p-4 sm:p-6\">{children}</div>\n </div>\n );\n}\n\nexport function TopicDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const { data: topic, isLoading, isError, refetch } = useTcpTopic(id);\n const updateTopic = useTcpUpdateTopic();\n const archiveTopic = useTcpArchiveTopic();\n\n const [editOpen, setEditOpen] = useState(false);\n const [createStoryOpen, setCreateStoryOpen] = useState(false);\n const [sort, setSort] = useState<SortKey>('newest');\n\n const stories = useMemo(() => topic?.stories ?? [], [topic]);\n const sortedStories = useMemo(() => sortStories(stories, sort), [stories, sort]);\n\n const backToTopics = () => navigateTo('topics');\n\n if (isLoading) {\n return (\n <DetailShell>\n <button\n type=\"button\"\n onClick={backToTopics}\n className=\"inline-flex w-fit items-center gap-1.5 text-sm font-medium text-text-secondary hover:text-text-primary\"\n >\n <ArrowLeft className=\"h-4 w-4\" aria-hidden=\"true\" /> Topics\n </button>\n <LoadingView label=\"Loading topic…\" />\n </DetailShell>\n );\n }\n\n if (isError || !topic) {\n return (\n <DetailShell>\n <button\n type=\"button\"\n onClick={backToTopics}\n className=\"inline-flex w-fit items-center gap-1.5 text-sm font-medium text-text-secondary hover:text-text-primary\"\n >\n <ArrowLeft className=\"h-4 w-4\" aria-hidden=\"true\" /> Topics\n </button>\n <ErrorView\n title=\"Topic not found\"\n message=\"This topic may have been archived, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </DetailShell>\n );\n }\n\n const toggleFeatured = async () => {\n try {\n await updateTopic.mutateAsync({ id: topic.id, input: { isFeatured: !topic.isFeatured } });\n notify.success(topic.isFeatured ? 'Removed from featured' : 'Topic featured');\n } catch (err) {\n notify.error('Could not update topic', describeError(err));\n }\n };\n\n const doArchive = async () => {\n try {\n await archiveTopic.mutateAsync(topic.id);\n notify.success('Topic archived');\n navigateTo('topics');\n } catch (err) {\n notify.error('Could not archive topic', describeError(err));\n }\n };\n\n const heroMeta = (\n <>\n {topic.isFeatured && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-status-warning-bg-subtle px-2.5 py-0.5 text-xs font-medium text-status-warning-text\">\n <Sparkles className=\"h-3 w-3\" aria-hidden=\"true\" /> Featured\n </span>\n )}\n <StatusBadge\n status={topic.category}\n icon={createElement(categoryIcon(topic.category), { className: 'h-3 w-3' })}\n />\n {topic.tradition && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n <Globe className=\"h-3 w-3\" aria-hidden=\"true\" /> {topic.tradition}\n </span>\n )}\n </>\n );\n\n const heroActions = (\n <>\n <Button variant=\"outline\" size=\"sm\" onClick={toggleFeatured} disabled={updateTopic.isPending}>\n <Sparkles className=\"mr-1.5 h-4 w-4\" />\n {topic.isFeatured ? 'Unfeature' : 'Feature'}\n </Button>\n <Button variant=\"outline\" size=\"sm\" onClick={() => setEditOpen(true)}>\n <Pencil className=\"mr-1.5 h-4 w-4\" /> Edit\n </Button>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={doArchive}\n disabled={archiveTopic.isPending}\n className=\"text-status-error-text\"\n >\n <Archive className=\"mr-1.5 h-4 w-4\" /> Archive\n </Button>\n <Button size=\"sm\" onClick={() => setCreateStoryOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New story\n </Button>\n </>\n );\n\n return (\n <DetailShell>\n <PageHero\n eyebrow=\"Topic\"\n title={topic.name}\n description={topic.description ?? undefined}\n backAction={{ label: 'Topics', onClick: backToTopics }}\n meta={heroMeta}\n actions={heroActions}\n />\n\n <section aria-labelledby=\"topic-stories-heading\">\n <div className=\"mb-4 flex flex-wrap items-center justify-between gap-3\">\n <div className=\"flex items-center gap-2\">\n <h2\n id=\"topic-stories-heading\"\n className=\"font-display text-xl font-semibold text-text-primary\"\n >\n Stories in this topic\n </h2>\n <span className=\"rounded-full bg-bg-sunken px-2 py-0.5 text-sm font-medium tabular-nums text-text-secondary\">\n {stories.length}\n </span>\n </div>\n {stories.length > 1 && (\n <div className=\"flex items-center gap-2\">\n <Label htmlFor=\"topic-story-sort\" className=\"text-sm text-text-secondary\">\n Sort by\n </Label>\n <Select value={sort} onValueChange={(v) => setSort(v as SortKey)}>\n <SelectTrigger\n id=\"topic-story-sort\"\n className=\"w-[12rem]\"\n aria-label=\"Sort stories\"\n >\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {SORT_OPTIONS.map((opt) => (\n <SelectItem key={opt.value} value={opt.value}>\n {opt.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n )}\n </div>\n\n {stories.length === 0 ? (\n <EmptyState\n icon={<BookOpen className=\"h-9 w-9\" />}\n title=\"No stories yet\"\n description=\"Draft the first story under this topic.\"\n action={\n <Button onClick={() => setCreateStoryOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New story\n </Button>\n }\n />\n ) : (\n <div className=\"grid gap-5 sm:grid-cols-2 xl:grid-cols-3\">\n {sortedStories.map((story) => (\n <StoryCard\n key={story.id}\n story={story}\n onOpen={() => navigateTo(`stories/${story.id}`)}\n />\n ))}\n </div>\n )}\n </section>\n\n <CreateStoryDialog\n open={createStoryOpen}\n onOpenChange={setCreateStoryOpen}\n topicId={topic.id}\n onCreated={(story) => navigateTo(`stories/${story.id}`)}\n />\n <EditTopicDialog open={editOpen} onOpenChange={setEditOpen} topic={topic} />\n </DetailShell>\n );\n}\n\n// ---------------------------------------------------------------------------\n// Edit topic dialog (local — only used here)\n// ---------------------------------------------------------------------------\n\nfunction EditTopicDialog({\n open,\n onOpenChange,\n topic,\n}: {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n topic: TcpTopic;\n}) {\n const updateTopic = useTcpUpdateTopic();\n const [name, setName] = useState(topic.name);\n const [tradition, setTradition] = useState(topic.tradition ?? '');\n const [description, setDescription] = useState(topic.description ?? '');\n const [category, setCategory] = useState<TcpTopicCategory>(topic.category);\n const [error, setError] = useState<string | null>(null);\n\n const topicKey = useMemo(() => `${topic.id}:${topic.updatedAt}`, [topic.id, topic.updatedAt]);\n const [seededKey, setSeededKey] = useState(topicKey);\n if (seededKey !== topicKey) {\n setSeededKey(topicKey);\n setName(topic.name);\n setTradition(topic.tradition ?? '');\n setDescription(topic.description ?? '');\n setCategory(topic.category);\n setError(null);\n }\n\n const submit = async () => {\n if (!name.trim()) {\n setError('A topic name is required.');\n return;\n }\n try {\n await updateTopic.mutateAsync({\n id: topic.id,\n input: {\n name: name.trim(),\n tradition: tradition.trim() || null,\n description: description.trim() || null,\n category,\n },\n });\n notify.success('Topic updated', name.trim());\n onOpenChange(false);\n } catch (err) {\n setError(describeError(err));\n }\n };\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"sm:max-w-lg\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Edit topic</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n The URL slug is fixed once created.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n <div className=\"flex flex-col gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-topic-name\">Name</Label>\n <Input id=\"edit-topic-name\" value={name} onChange={(e) => setName(e.target.value)} />\n </div>\n <div className=\"grid gap-1.5\">\n <Label>Category</Label>\n <Select value={category} onValueChange={(v) => setCategory(v as TcpTopicCategory)}>\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {CATEGORIES.map((c) => (\n <SelectItem key={c} value={c}>\n {formatEnumLabel(c)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-topic-tradition\">Tradition</Label>\n <Input\n id=\"edit-topic-tradition\"\n value={tradition}\n onChange={(e) => setTradition(e.target.value)}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-topic-desc\">Description</Label>\n <Textarea\n id=\"edit-topic-desc\"\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n rows={3}\n />\n </div>\n {error && <p className=\"text-sm text-status-error-text\">{error}</p>}\n </div>\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n Cancel\n </Button>\n <Button onClick={submit} disabled={updateTopic.isPending}>\n Save changes\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"TopicDetailPage.js","names":[],"sources":["../../../../src/theculturepost/pages/topics/TopicDetailPage.tsx"],"sourcesContent":["/**\n * TopicDetailPage — a single topic and the stories curated under it.\n * @module theculturepost/pages/topics\n *\n * An editorial {@link PageHero} banner carries the title, description and the\n * Featured / category / tradition badges, plus the feature-toggle, edit and\n * archive actions (`tcpUpdateTopic` / `tcpArchiveTopic`). Below, the topic's own\n * `stories` are shown in a richer {@link StoryCard} grid with a real count and a\n * client-side sort. Reads `tcpTopic` (with its stories) — no fabricated fields.\n */\n\nimport { createElement, useMemo, useState, type ReactNode } from 'react';\nimport { useParams } from 'react-router';\nimport { ArrowLeft, Globe, Sparkles, Pencil, Archive, Plus, BookOpen } from 'lucide-react';\nimport {\n Button,\n Input,\n Label,\n Textarea,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport {\n PageHero,\n StatusBadge,\n StoryCard,\n CreateStoryDialog,\n EmptyState,\n LoadingView,\n ErrorView,\n categoryIcon,\n} from '../../components';\nimport { useTcpTopic, useTcpUpdateTopic, useTcpArchiveTopic } from '../../hooks';\nimport { useTheCulturePost } from '../../context';\nimport { formatEnumLabel } from '../../utils/formatters';\nimport { notify, describeError } from '../../lib/toast';\nimport type { TcpStory, TcpTopic, TcpTopicCategory } from '../../types';\n\nconst CATEGORIES: TcpTopicCategory[] = [\n 'CULTURE_AND_IDENTITY',\n 'RELIGION_AND_FAITH',\n 'HISTORY',\n 'HERITAGE_AND_ARCHIVES',\n 'FESTIVALS_AND_RITUALS',\n 'MIGRATION_AND_DIASPORA',\n 'ORAL_HISTORIES',\n 'PHILOSOPHY_AND_THOUGHT',\n 'VALUES_AND_ETHICS',\n];\n\ntype SortKey = 'newest' | 'oldest' | 'updated' | 'title';\n\nconst SORT_OPTIONS: { value: SortKey; label: string }[] = [\n { value: 'newest', label: 'Newest' },\n { value: 'oldest', label: 'Oldest' },\n { value: 'updated', label: 'Recently updated' },\n { value: 'title', label: 'Title (A–Z)' },\n];\n\n/** ms timestamp of a story's effective publish date (published, else created). */\nconst publishedTs = (story: TcpStory): number => {\n const t = new Date(story.publishedAt ?? story.createdAt).getTime();\n return Number.isNaN(t) ? 0 : t;\n};\n\nconst updatedTs = (story: TcpStory): number => {\n const t = new Date(story.updatedAt).getTime();\n return Number.isNaN(t) ? 0 : t;\n};\n\nfunction sortStories(stories: TcpStory[], key: SortKey): TcpStory[] {\n const copy = [...stories];\n switch (key) {\n case 'newest':\n return copy.sort((a, b) => publishedTs(b) - publishedTs(a));\n case 'oldest':\n return copy.sort((a, b) => publishedTs(a) - publishedTs(b));\n case 'updated':\n return copy.sort((a, b) => updatedTs(b) - updatedTs(a));\n case 'title':\n return copy.sort((a, b) => a.title.localeCompare(b.title));\n default:\n return copy;\n }\n}\n\n/** Minimal scroll shell so hero/loading/error surfaces share one container. */\nfunction DetailShell({ children }: { children: ReactNode }) {\n return (\n <div className=\"h-full overflow-y-auto\">\n <div className=\"mx-auto flex max-w-5xl flex-col gap-6 p-4 sm:p-6\">{children}</div>\n </div>\n );\n}\n\nexport function TopicDetailPage() {\n const { id } = useParams<{ id: string }>();\n const { navigateTo } = useTheCulturePost();\n const { data: topic, isLoading, isError, refetch } = useTcpTopic(id);\n const updateTopic = useTcpUpdateTopic();\n const archiveTopic = useTcpArchiveTopic();\n\n const [editOpen, setEditOpen] = useState(false);\n const [createStoryOpen, setCreateStoryOpen] = useState(false);\n const [sort, setSort] = useState<SortKey>('newest');\n\n const stories = useMemo(() => topic?.stories ?? [], [topic]);\n const sortedStories = useMemo(() => sortStories(stories, sort), [stories, sort]);\n\n const backToTopics = () => navigateTo('topics');\n\n if (isLoading) {\n return (\n <DetailShell>\n <button\n type=\"button\"\n onClick={backToTopics}\n className=\"inline-flex w-fit items-center gap-1.5 text-sm font-medium text-text-secondary hover:text-text-primary\"\n >\n <ArrowLeft className=\"h-4 w-4\" aria-hidden=\"true\" /> Topics\n </button>\n <LoadingView label=\"Loading topic…\" />\n </DetailShell>\n );\n }\n\n if (isError || !topic) {\n return (\n <DetailShell>\n <button\n type=\"button\"\n onClick={backToTopics}\n className=\"inline-flex w-fit items-center gap-1.5 text-sm font-medium text-text-secondary hover:text-text-primary\"\n >\n <ArrowLeft className=\"h-4 w-4\" aria-hidden=\"true\" /> Topics\n </button>\n <ErrorView\n title=\"Topic not found\"\n message=\"This topic may have been archived, or you may not have access.\"\n onRetry={() => refetch()}\n />\n </DetailShell>\n );\n }\n\n const toggleFeatured = async () => {\n try {\n await updateTopic.mutateAsync({ id: topic.id, input: { isFeatured: !topic.isFeatured } });\n notify.success(topic.isFeatured ? 'Removed from featured' : 'Topic featured');\n } catch (err) {\n notify.error('Could not update topic', describeError(err));\n }\n };\n\n const doArchive = async () => {\n try {\n await archiveTopic.mutateAsync(topic.id);\n notify.success('Topic archived');\n navigateTo('topics');\n } catch (err) {\n notify.error('Could not archive topic', describeError(err));\n }\n };\n\n const heroMeta = (\n <>\n {topic.isFeatured && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-status-warning-bg-subtle px-2.5 py-0.5 text-xs font-medium text-status-warning-text\">\n <Sparkles className=\"h-3 w-3\" aria-hidden=\"true\" /> Featured\n </span>\n )}\n <StatusBadge\n status={topic.category}\n icon={createElement(categoryIcon(topic.category), { className: 'h-3 w-3' })}\n />\n {topic.tradition && (\n <span className=\"inline-flex items-center gap-1 rounded-full bg-bg-sunken px-2.5 py-0.5 text-xs font-medium text-text-secondary\">\n <Globe className=\"h-3 w-3\" aria-hidden=\"true\" /> {topic.tradition}\n </span>\n )}\n </>\n );\n\n const heroActions = (\n <>\n <Button variant=\"outline\" size=\"sm\" onClick={toggleFeatured} disabled={updateTopic.isPending}>\n <Sparkles className=\"mr-1.5 h-4 w-4\" />\n {topic.isFeatured ? 'Unfeature' : 'Feature'}\n </Button>\n <Button variant=\"outline\" size=\"sm\" onClick={() => setEditOpen(true)}>\n <Pencil className=\"mr-1.5 h-4 w-4\" /> Edit\n </Button>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={doArchive}\n disabled={archiveTopic.isPending}\n className=\"text-status-error-text\"\n >\n <Archive className=\"mr-1.5 h-4 w-4\" /> Archive\n </Button>\n <Button size=\"sm\" onClick={() => setCreateStoryOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New story\n </Button>\n </>\n );\n\n return (\n <DetailShell>\n <PageHero\n eyebrow=\"Topic\"\n title={topic.name}\n description={topic.description ?? undefined}\n backAction={{ label: 'Topics', onClick: backToTopics }}\n meta={heroMeta}\n actions={heroActions}\n />\n\n <section aria-labelledby=\"topic-stories-heading\">\n <div className=\"mb-4 flex flex-wrap items-center justify-between gap-3\">\n <div className=\"flex items-center gap-2\">\n <h2\n id=\"topic-stories-heading\"\n className=\"font-display text-xl font-semibold text-text-primary\"\n >\n Stories in this topic\n </h2>\n <span className=\"rounded-full bg-bg-sunken px-2 py-0.5 text-sm font-medium tabular-nums text-text-secondary\">\n {stories.length}\n </span>\n </div>\n {stories.length > 1 && (\n <div className=\"flex items-center gap-2\">\n <Label htmlFor=\"topic-story-sort\" className=\"text-sm text-text-secondary\">\n Sort by\n </Label>\n <Select value={sort} onValueChange={(v) => setSort(v as SortKey)}>\n <SelectTrigger\n id=\"topic-story-sort\"\n className=\"w-[12rem]\"\n aria-label=\"Sort stories\"\n >\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {SORT_OPTIONS.map((opt) => (\n <SelectItem key={opt.value} value={opt.value}>\n {opt.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n )}\n </div>\n\n {stories.length === 0 ? (\n <EmptyState\n icon={<BookOpen className=\"h-9 w-9\" />}\n title=\"No stories yet\"\n description=\"Draft the first story under this topic.\"\n action={\n <Button onClick={() => setCreateStoryOpen(true)}>\n <Plus className=\"mr-1.5 h-4 w-4\" /> New story\n </Button>\n }\n />\n ) : (\n <div className=\"grid gap-5 sm:grid-cols-2 xl:grid-cols-3\">\n {sortedStories.map((story) => (\n <StoryCard\n key={story.id}\n story={story}\n onOpen={() => navigateTo(`stories/${story.id}`)}\n />\n ))}\n </div>\n )}\n </section>\n\n <CreateStoryDialog\n open={createStoryOpen}\n onOpenChange={setCreateStoryOpen}\n topicId={topic.id}\n onCreated={(story) => navigateTo(`stories/${story.id}`)}\n />\n <EditTopicDialog open={editOpen} onOpenChange={setEditOpen} topic={topic} />\n </DetailShell>\n );\n}\n\n// ---------------------------------------------------------------------------\n// Edit topic dialog (local — only used here)\n// ---------------------------------------------------------------------------\n\nfunction EditTopicDialog({\n open,\n onOpenChange,\n topic,\n}: {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n topic: TcpTopic;\n}) {\n const updateTopic = useTcpUpdateTopic();\n const [name, setName] = useState(topic.name);\n const [tradition, setTradition] = useState(topic.tradition ?? '');\n const [description, setDescription] = useState(topic.description ?? '');\n const [category, setCategory] = useState<TcpTopicCategory>(topic.category);\n const [error, setError] = useState<string | null>(null);\n\n const topicKey = useMemo(() => `${topic.id}:${topic.updatedAt}`, [topic.id, topic.updatedAt]);\n const [seededKey, setSeededKey] = useState(topicKey);\n if (seededKey !== topicKey) {\n setSeededKey(topicKey);\n setName(topic.name);\n setTradition(topic.tradition ?? '');\n setDescription(topic.description ?? '');\n setCategory(topic.category);\n setError(null);\n }\n\n const submit = async () => {\n if (!name.trim()) {\n setError('A topic name is required.');\n return;\n }\n try {\n await updateTopic.mutateAsync({\n id: topic.id,\n input: {\n name: name.trim(),\n tradition: tradition.trim() || null,\n description: description.trim() || null,\n category,\n },\n });\n notify.success('Topic updated', name.trim());\n onOpenChange(false);\n } catch (err) {\n setError(describeError(err));\n }\n };\n\n return (\n <ResponsiveDialog open={open} onOpenChange={onOpenChange}>\n <ResponsiveDialogContent className=\"sm:max-w-lg\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Edit topic</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n The URL slug is fixed once created.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n <div className=\"flex flex-col gap-4 py-2\">\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-topic-name\">Name</Label>\n <Input id=\"edit-topic-name\" value={name} onChange={(e) => setName(e.target.value)} />\n </div>\n <div className=\"grid gap-1.5\">\n <Label>Category</Label>\n <Select value={category} onValueChange={(v) => setCategory(v as TcpTopicCategory)}>\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {CATEGORIES.map((c) => (\n <SelectItem key={c} value={c}>\n {formatEnumLabel(c)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-topic-tradition\">Tradition</Label>\n <Input\n id=\"edit-topic-tradition\"\n value={tradition}\n onChange={(e) => setTradition(e.target.value)}\n />\n </div>\n <div className=\"grid gap-1.5\">\n <Label htmlFor=\"edit-topic-desc\">Description</Label>\n <Textarea\n id=\"edit-topic-desc\"\n value={description}\n onChange={(e) => setDescription(e.target.value)}\n rows={3}\n />\n </div>\n {error && <p className=\"text-sm text-status-error-text\">{error}</p>}\n </div>\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)}>\n Cancel\n </Button>\n <Button onClick={submit} disabled={updateTopic.isPending}>\n Save changes\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burdenoff/microfe-theculturepost",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.802.1",
|
|
4
4
|
"description": "Burdenoff TheCulturePost Microfrontend",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
"types": "./dist/index.d.ts",
|
|
51
51
|
"scripts": {
|
|
52
52
|
"dev": "vite",
|
|
53
|
-
"build": "tsc && vite build",
|
|
53
|
+
"build": "tsc && bash scripts/build-limits.sh --gate vite build",
|
|
54
54
|
"lint": "eslint .",
|
|
55
55
|
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
56
|
-
"type:check": "tsc --noEmit",
|
|
57
|
-
"sanity": "bun run codegen && bun run lint && bun run format && bun run type:check && bun run build && bun run build-storybook",
|
|
56
|
+
"type:check": "bash scripts/build-limits.sh tsc --noEmit",
|
|
57
|
+
"sanity": "bash scripts/build-limits.sh --gate bash -c 'bun run codegen && bun run lint && bun run format && bun run type:check && bun run build && bun run build-storybook'",
|
|
58
58
|
"storybook": "storybook dev -p 6006",
|
|
59
59
|
"build-storybook": "storybook build",
|
|
60
60
|
"test:storybook": "test-storybook",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@apollo/client": "^4.0.11",
|
|
73
|
-
"@burdenoff/fe-libs": "2026.
|
|
73
|
+
"@burdenoff/fe-libs": "2026.801.1",
|
|
74
74
|
"@tanstack/react-query": "^5.90.16",
|
|
75
75
|
"clsx": "^2.1.1",
|
|
76
76
|
"graphql": "^16.10.0",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"sonner": "^2.0.7",
|
|
79
79
|
"react": "^19.0.0",
|
|
80
80
|
"react-dom": "^19.0.0",
|
|
81
|
-
"react-router
|
|
81
|
+
"react-router": "^8.3.0",
|
|
82
82
|
"tailwind-merge": "^3.4.0",
|
|
83
83
|
"zustand": "^5.0.3",
|
|
84
84
|
"recharts": "^2.15.0",
|