@burdenoff/microfe-theculturepost 2026.801.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/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/lib/offlineOralHistoryQueue.d.ts.map +1 -1
- package/dist/theculturepost/lib/offlineOralHistoryQueue.js.map +1 -1
- package/package.json +1 -1
|
@@ -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 +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"}
|