@elevasis/ui 2.72.0 → 2.73.0

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.
Files changed (70) hide show
  1. package/dist/api/index.js +3 -3
  2. package/dist/app/index.d.ts +114 -20
  3. package/dist/app/index.js +8 -8
  4. package/dist/auth/index.js +4 -4
  5. package/dist/charts/index.js +4 -4
  6. package/dist/{chunk-A3JHFZDP.js → chunk-3TS5U5BL.js} +1 -1
  7. package/dist/{chunk-IXBRUIYI.js → chunk-4CW2C5BD.js} +1 -1
  8. package/dist/{chunk-753MKWFV.js → chunk-5B2ZFO44.js} +1 -1
  9. package/dist/{chunk-DJWIIPKW.js → chunk-AJOVWB47.js} +3 -3
  10. package/dist/{chunk-RRS2QR3W.js → chunk-CJ2232PQ.js} +1 -1
  11. package/dist/{chunk-2454MK7P.js → chunk-ETN3TTRT.js} +7 -7
  12. package/dist/{chunk-Q3O3WY7Q.js → chunk-IBCEAVB2.js} +33 -13
  13. package/dist/{chunk-7ESZKTQG.js → chunk-IGBPWD6S.js} +3 -3
  14. package/dist/{chunk-UZ5JO5TK.js → chunk-JYBJMVNY.js} +1 -1
  15. package/dist/{chunk-LCHL5SM2.js → chunk-LEPYXLJ2.js} +1 -1
  16. package/dist/{chunk-LEQGGX2U.js → chunk-LZT2H2MA.js} +4 -4
  17. package/dist/{chunk-Z2W5VNN3.js → chunk-NHNTX6ZL.js} +1 -1
  18. package/dist/{chunk-AFBFSXP3.js → chunk-NI37YNRP.js} +4 -4
  19. package/dist/{chunk-KIDZXZM5.js → chunk-OCVSPAKM.js} +501 -25
  20. package/dist/{chunk-IMZTRDY2.js → chunk-PQ4B62HH.js} +2 -2
  21. package/dist/{chunk-SV4M2BUI.js → chunk-SWKAH2YM.js} +52 -20
  22. package/dist/{chunk-XZIOCB5D.js → chunk-U2H3TIHD.js} +1 -1
  23. package/dist/{chunk-DKVGO6RJ.js → chunk-Z4ICFQXG.js} +1 -1
  24. package/dist/{chunk-ZBXCEWKF.js → chunk-ZDKPHFTH.js} +3 -3
  25. package/dist/components/index.d.ts +305 -22
  26. package/dist/components/index.js +316 -26
  27. package/dist/components/navigation/index.js +5 -5
  28. package/dist/features/auth/index.d.ts +76 -0
  29. package/dist/features/auth/index.js +6 -6
  30. package/dist/features/clients/index.js +5 -5
  31. package/dist/features/content/index.d.ts +494 -31
  32. package/dist/features/content/index.js +1090 -44
  33. package/dist/features/crm/index.d.ts +114 -20
  34. package/dist/features/crm/index.js +6 -6
  35. package/dist/features/dashboard/index.d.ts +19 -10
  36. package/dist/features/dashboard/index.js +6 -6
  37. package/dist/features/delivery/index.d.ts +76 -0
  38. package/dist/features/delivery/index.js +5 -5
  39. package/dist/features/knowledge/index.js +4 -4
  40. package/dist/features/lead-gen/index.d.ts +38 -20
  41. package/dist/features/lead-gen/index.js +6 -6
  42. package/dist/features/monitoring/index.js +7 -7
  43. package/dist/features/monitoring/requests/index.js +5 -5
  44. package/dist/features/operations/index.d.ts +38 -20
  45. package/dist/features/operations/index.js +9 -9
  46. package/dist/features/settings/index.d.ts +76 -0
  47. package/dist/features/settings/index.js +6 -6
  48. package/dist/hooks/access/index.js +4 -4
  49. package/dist/hooks/delivery/index.d.ts +76 -0
  50. package/dist/hooks/delivery/index.js +2 -2
  51. package/dist/hooks/index.d.ts +1248 -28
  52. package/dist/hooks/index.js +4 -4
  53. package/dist/hooks/operations/command-view/utils/transformCommandViewData.d.ts +19 -10
  54. package/dist/hooks/published.d.ts +1248 -28
  55. package/dist/hooks/published.js +4 -4
  56. package/dist/initialization/index.d.ts +76 -0
  57. package/dist/knowledge/index.d.ts +38 -20
  58. package/dist/knowledge/index.js +8 -8
  59. package/dist/layout/index.js +7 -7
  60. package/dist/profile/index.d.ts +76 -0
  61. package/dist/provider/index.d.ts +114 -20
  62. package/dist/provider/index.js +4 -4
  63. package/dist/provider/published.d.ts +114 -20
  64. package/dist/provider/published.js +4 -4
  65. package/dist/supabase/index.d.ts +149 -0
  66. package/dist/test-utils/index.js +3 -3
  67. package/dist/types/index.d.ts +125 -13
  68. package/dist/utils/index.d.ts +19 -10
  69. package/dist/utils/index.js +1 -1
  70. package/package.json +5 -5
@@ -1,14 +1,14 @@
1
1
  import { PageContainer } from '../../chunk-BZZCNLT6.js';
2
2
  import '../../chunk-TUMSNGTX.js';
3
3
  import { FilterBar } from '../../chunk-PDHTXPSF.js';
4
- import { CustomModal, useContentItems, useContentDistributions, useContentConfig, SubshellContentContainer, PageTitleCaption, CenteredErrorState, EmptyState, StatCard, getContentItemIdentity, usePaginationState, useContentItem, useContentItemAttempts, useUpdateContentItem, useReviewContentItem, getOpenContentReviewGates, resolveContentWorkspaceRoute, useContentDistribution, useContentPipelineSummary, deriveContentBoard, useExecutions } from '../../chunk-KIDZXZM5.js';
5
- export { CONTENT_ITEMS, ContentSidebar, ContentSidebarMiddle, DEFAULT_CONTENT_VOCABULARY, MyReviewQueuePanel, contentManifest, deriveContentBoard, getContentItemIdentity, resolveContentStepResource, resolveContentVocabulary, useContentConfig } from '../../chunk-KIDZXZM5.js';
4
+ import { CustomModal, useContentItemSourceAssetDetails, useContentSourceAssets, useAddContentItemSourceAsset, useRemoveContentItemSourceAsset, useReorderContentItemSourceAssets, useUpdateContentItemSourceAsset, useCloudStorageTemporaryLinks, useContentItemAttempts, useExecuteAsync, useUpdateContentItem, useExecution, contentKeys, useContentItems, useContentDistributions, useContentConfig, isAwaitingReview, SubshellContentContainer, PageTitleCaption, CenteredErrorState, EmptyState, StatCard, getContentItemIdentity, usePaginationState, resolveCurrentContentStep, useContentItem, useReviewContentItem, resolveReviewTarget, useContentDistribution, useStorageSignedUrls, storageObjectRefKey, useContentPipelineSummary, deriveContentBoard, resolveContentWorkspaceRoute, useExecutions } from '../../chunk-OCVSPAKM.js';
5
+ export { CONTENT_ITEMS, ContentSidebar, ContentSidebarMiddle, DEFAULT_CONTENT_VOCABULARY, MyReviewQueuePanel, contentManifest, deriveContentBoard, getContentItemIdentity, resolveContentStepResource, resolveContentVocabulary, useContentConfig } from '../../chunk-OCVSPAKM.js';
6
6
  import '../../chunk-R6NMRXNO.js';
7
7
  import '../../chunk-RBGVNPA6.js';
8
8
  import '../../chunk-MKH2KOAO.js';
9
- import '../../chunk-Z2W5VNN3.js';
9
+ import '../../chunk-NHNTX6ZL.js';
10
10
  import '../../chunk-L7BZZ4SI.js';
11
- import { showSuccessNotification } from '../../chunk-XZIOCB5D.js';
11
+ import { showSuccessNotification } from '../../chunk-U2H3TIHD.js';
12
12
  import '../../chunk-MISHPEJX.js';
13
13
  import { CardHeader } from '../../chunk-62GVNH5U.js';
14
14
  import '../../chunk-P45GQ3ZW.js';
@@ -20,7 +20,7 @@ import '../../chunk-UD657ICS.js';
20
20
  import '../../chunk-DD3CCMCZ.js';
21
21
  import '../../chunk-RNP5R5I3.js';
22
22
  import '../../chunk-RT4KRGZT.js';
23
- import { formatTimeAgo, formatRelativeTime } from '../../chunk-Q3O3WY7Q.js';
23
+ import { formatTimeAgo, formatRelativeTime } from '../../chunk-IBCEAVB2.js';
24
24
  import '../../chunk-Y6I3IC45.js';
25
25
  import '../../chunk-6SSQGWK7.js';
26
26
  import '../../chunk-BI5VDO2K.js';
@@ -29,12 +29,13 @@ import '../../chunk-TVTSASST.js';
29
29
  import '../../chunk-BRJ3QZ4E.js';
30
30
  import '../../chunk-Q7DJKLEN.js';
31
31
  import '../../chunk-I2KLQ2HA.js';
32
- import { Stack, Group, Text, Badge, Button, Textarea, Box, Loader, Accordion, Tooltip, Card, Alert, List, Paper, Center, SimpleGrid, Table, TextInput, Select, Pagination, Title, Tabs, Anchor, Divider, Progress, Image, ActionIcon, UnstyledButton, ScrollArea } from '@mantine/core';
33
- import { IconAlertTriangle, IconShieldCheck, IconShieldX, IconArticle, IconArrowRight, IconClock, IconSend, IconSearch, IconFileText, IconExternalLink, IconListDetails, IconAlertCircle, IconLayoutGrid, IconShare, IconPlayerPlay, IconCircleCheck, IconCircleDashed, IconMaximize, IconCopy, IconDownload, IconArrowLeft, IconArrowsExchange, IconCheck, IconX, IconChecklist, IconRoute, IconInfoCircle, IconClockHour4 } from '@tabler/icons-react';
32
+ import { Stack, Group, Text, Badge, Button, Textarea, Box, Loader, Accordion, Tooltip, Card, Alert, List, Paper, SegmentedControl, Slider, Title, Table, TextInput, Select, Modal, ActionIcon, Center, SimpleGrid, Pagination, Tabs, Anchor, Divider, Progress, Image as Image$1, UnstyledButton, ScrollArea } from '@mantine/core';
33
+ import { IconAlertTriangle, IconShieldCheck, IconShieldX, IconPhoto, IconGripVertical, IconCrop, IconArrowUp, IconArrowDown, IconUnlink, IconLink, IconPhotoOff, IconChevronLeft, IconChevronRight, IconCheck, IconSparkles, IconEye, IconArticle, IconArrowRight, IconClock, IconSend, IconSearch, IconFileText, IconExternalLink, IconListDetails, IconAlertCircle, IconLayoutGrid, IconShare, IconPlayerPlay, IconCircleCheck, IconCircleDashed, IconMaximize, IconCopy, IconDownload, IconArrowLeft, IconArrowsExchange, IconX, IconChecklist, IconRoute, IconInfoCircle, IconClockHour4 } from '@tabler/icons-react';
34
34
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
35
- import { useState, useMemo, Fragment as Fragment$1, useCallback, useEffect } from 'react';
35
+ import { useState, useRef, useCallback, useMemo, useEffect, Fragment as Fragment$1 } from 'react';
36
+ import { useQueryClient } from '@tanstack/react-query';
37
+ import { useDebouncedValue, useDisclosure } from '@mantine/hooks';
36
38
  import { useNavigate, useSearch } from '@tanstack/react-router';
37
- import { useDisclosure } from '@mantine/hooks';
38
39
 
39
40
  function renderFieldValue(value, kind) {
40
41
  if (value === void 0 || value === null || value === "") {
@@ -536,6 +537,1007 @@ function ContentReviewCard({
536
537
  /* @__PURE__ */ jsx(ReviewActionBar, { actions, onAction, pending: actionsPending })
537
538
  ] }) });
538
539
  }
540
+ function moveItem(items, fromIndex, toIndex) {
541
+ const next = items.slice();
542
+ const [moved] = next.splice(fromIndex, 1);
543
+ next.splice(toIndex, 0, moved);
544
+ return next;
545
+ }
546
+ function SelectedAssetStrip({ assets, onReorder }) {
547
+ const [dragOverIndex, setDragOverIndex] = useState(null);
548
+ const draggingIndexRef = useRef(null);
549
+ const handleDragStart = useCallback((e, index) => {
550
+ draggingIndexRef.current = index;
551
+ e.dataTransfer.effectAllowed = "move";
552
+ e.dataTransfer.setData("text/plain", String(index));
553
+ }, []);
554
+ const handleDragOver = useCallback((e, index) => {
555
+ e.preventDefault();
556
+ e.dataTransfer.dropEffect = "move";
557
+ setDragOverIndex(index);
558
+ }, []);
559
+ const handleDragLeave = useCallback(() => {
560
+ setDragOverIndex(null);
561
+ }, []);
562
+ const handleDrop = useCallback(
563
+ (e, targetIndex) => {
564
+ e.preventDefault();
565
+ setDragOverIndex(null);
566
+ const sourceIndex = draggingIndexRef.current;
567
+ draggingIndexRef.current = null;
568
+ if (sourceIndex === null || sourceIndex === targetIndex) return;
569
+ onReorder(moveItem(assets, sourceIndex, targetIndex));
570
+ },
571
+ [assets, onReorder]
572
+ );
573
+ const handleDragEnd = useCallback(() => {
574
+ draggingIndexRef.current = null;
575
+ setDragOverIndex(null);
576
+ }, []);
577
+ if (assets.length === 0) {
578
+ return /* @__PURE__ */ jsx(
579
+ Paper,
580
+ {
581
+ withBorder: true,
582
+ p: "md",
583
+ "data-testid": "selected-asset-strip-empty",
584
+ style: { borderStyle: "dashed", borderColor: "var(--color-border)" },
585
+ children: /* @__PURE__ */ jsx(Text, { size: "sm", c: "dimmed", ta: "center", children: "No assets selected yet." })
586
+ }
587
+ );
588
+ }
589
+ return /* @__PURE__ */ jsx(Group, { gap: "sm", wrap: "wrap", "data-testid": "selected-asset-strip", role: "list", children: assets.map((asset, index) => {
590
+ const isCover = index === 0;
591
+ const isDragTarget = dragOverIndex === index;
592
+ return /* @__PURE__ */ jsx(
593
+ Paper,
594
+ {
595
+ withBorder: true,
596
+ role: "listitem",
597
+ "data-testid": `selected-asset-strip-item-${asset.id}`,
598
+ draggable: assets.length > 1,
599
+ onDragStart: (e) => handleDragStart(e, index),
600
+ onDragOver: (e) => handleDragOver(e, index),
601
+ onDragLeave: handleDragLeave,
602
+ onDrop: (e) => handleDrop(e, index),
603
+ onDragEnd: handleDragEnd,
604
+ style: {
605
+ width: 120,
606
+ overflow: "hidden",
607
+ cursor: assets.length > 1 ? "grab" : "default",
608
+ userSelect: "none",
609
+ borderColor: isDragTarget ? "var(--color-primary)" : "var(--color-border)",
610
+ transition: `border-color var(--duration-fast) var(--easing)`
611
+ },
612
+ children: /* @__PURE__ */ jsxs(Stack, { gap: 4, p: 6, children: [
613
+ /* @__PURE__ */ jsxs(
614
+ Box,
615
+ {
616
+ style: {
617
+ position: "relative",
618
+ width: "100%",
619
+ aspectRatio: "1 / 1",
620
+ borderRadius: 4,
621
+ overflow: "hidden",
622
+ backgroundColor: "var(--color-surface)"
623
+ },
624
+ children: [
625
+ asset.thumbnailUrl ? /* @__PURE__ */ jsx(
626
+ "img",
627
+ {
628
+ src: asset.thumbnailUrl,
629
+ alt: asset.name,
630
+ style: { width: "100%", height: "100%", objectFit: "cover", display: "block" }
631
+ }
632
+ ) : /* @__PURE__ */ jsx(Group, { justify: "center", align: "center", style: { width: "100%", height: "100%" }, children: /* @__PURE__ */ jsx(IconPhoto, { size: 24, color: "var(--color-textDimmed)" }) }),
633
+ isCover && /* @__PURE__ */ jsx(
634
+ Badge,
635
+ {
636
+ size: "xs",
637
+ variant: "filled",
638
+ "data-testid": `selected-asset-strip-cover-${asset.id}`,
639
+ style: { position: "absolute", top: 4, left: 4 },
640
+ children: "Cover"
641
+ }
642
+ ),
643
+ assets.length > 1 && /* @__PURE__ */ jsx(Box, { style: { position: "absolute", top: 4, right: 4, color: "var(--color-textDimmed)" }, children: /* @__PURE__ */ jsx(IconGripVertical, { size: 16 }) })
644
+ ]
645
+ }
646
+ ),
647
+ /* @__PURE__ */ jsx(Text, { size: "xs", lineClamp: 1, title: asset.name, children: asset.name })
648
+ ] })
649
+ },
650
+ asset.id
651
+ );
652
+ }) });
653
+ }
654
+ var CROP_ASPECT_OPTIONS = [
655
+ { value: "original", label: "Original", ratio: null },
656
+ { value: "portrait", label: "4:5", ratio: 4 / 5 },
657
+ { value: "square", label: "1:1", ratio: 1 },
658
+ { value: "landscape", label: "1.91:1", ratio: 1.91 }
659
+ ];
660
+ var MIN_CROP_SCALE = 0.2;
661
+ function fitBox(pixelRatio, naturalWidth, naturalHeight) {
662
+ if (pixelRatio === null || naturalWidth === 0 || naturalHeight === 0) return { width: 1, height: 1 };
663
+ const normalizedRatio = pixelRatio * naturalHeight / naturalWidth;
664
+ return normalizedRatio >= 1 ? { width: 1, height: 1 / normalizedRatio } : { width: normalizedRatio, height: 1 };
665
+ }
666
+ function clamp(value, min, max) {
667
+ return Math.min(Math.max(value, min), max);
668
+ }
669
+ function round(value) {
670
+ return Math.round(value * 1e4) / 1e4;
671
+ }
672
+ function AssetCropEditor({
673
+ imageUrl,
674
+ value,
675
+ onChange,
676
+ defaultAspect = "original",
677
+ aspect,
678
+ disabled = false,
679
+ label
680
+ }) {
681
+ const [internalAspect, setInternalAspect] = useState(defaultAspect);
682
+ const activeAspect = aspect ?? internalAspect;
683
+ const [natural, setNatural] = useState(null);
684
+ const frameRef = useRef(null);
685
+ const dragOffsetRef = useRef(null);
686
+ const ratio = useMemo(
687
+ () => CROP_ASPECT_OPTIONS.find((option) => option.value === activeAspect)?.ratio ?? null,
688
+ [activeAspect]
689
+ );
690
+ const maxBox = useMemo(
691
+ () => fitBox(ratio, natural?.width ?? 0, natural?.height ?? 0),
692
+ [ratio, natural?.width, natural?.height]
693
+ );
694
+ const crop = value ?? { x: 0, y: 0, width: 1, height: 1 };
695
+ const scale = maxBox.width === 0 ? 1 : clamp(crop.width / maxBox.width, MIN_CROP_SCALE, 1);
696
+ const emit = useCallback(
697
+ (next) => {
698
+ const width = clamp(next.width, 0.01, 1);
699
+ const height = clamp(next.height, 0.01, 1);
700
+ onChange({
701
+ x: round(clamp(next.x, 0, 1 - width)),
702
+ y: round(clamp(next.y, 0, 1 - height)),
703
+ width: round(width),
704
+ height: round(height)
705
+ });
706
+ },
707
+ [onChange]
708
+ );
709
+ const refit = useCallback(
710
+ (nextScale) => {
711
+ const width = maxBox.width * nextScale;
712
+ const height = maxBox.height * nextScale;
713
+ emit({ x: (1 - width) / 2, y: (1 - height) / 2, width, height });
714
+ },
715
+ [emit, maxBox.height, maxBox.width]
716
+ );
717
+ const fittedRatioRef = useRef(void 0);
718
+ useEffect(() => {
719
+ if (natural === null || disabled) return;
720
+ const isFirstMeasurement = fittedRatioRef.current === void 0;
721
+ fittedRatioRef.current = ratio;
722
+ if (isFirstMeasurement && value !== null) return;
723
+ if (ratio === null && value === null) return;
724
+ refit(scale);
725
+ }, [ratio, natural?.width, natural?.height]);
726
+ const handlePointerDown = (event) => {
727
+ if (disabled || frameRef.current === null) return;
728
+ const frame = frameRef.current.getBoundingClientRect();
729
+ dragOffsetRef.current = {
730
+ x: (event.clientX - frame.left) / frame.width - crop.x,
731
+ y: (event.clientY - frame.top) / frame.height - crop.y
732
+ };
733
+ event.currentTarget.setPointerCapture(event.pointerId);
734
+ };
735
+ const handlePointerMove = (event) => {
736
+ const offset = dragOffsetRef.current;
737
+ if (offset === null || frameRef.current === null) return;
738
+ const frame = frameRef.current.getBoundingClientRect();
739
+ emit({
740
+ x: (event.clientX - frame.left) / frame.width - offset.x,
741
+ y: (event.clientY - frame.top) / frame.height - offset.y,
742
+ width: crop.width,
743
+ height: crop.height
744
+ });
745
+ };
746
+ const endDrag = (event) => {
747
+ dragOffsetRef.current = null;
748
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
749
+ event.currentTarget.releasePointerCapture(event.pointerId);
750
+ }
751
+ };
752
+ const handleKeyDown = (event) => {
753
+ if (disabled) return;
754
+ const step = 0.01;
755
+ const nudge = {
756
+ ArrowLeft: [-step, 0],
757
+ ArrowRight: [step, 0],
758
+ ArrowUp: [0, -step],
759
+ ArrowDown: [0, step]
760
+ };
761
+ const delta = nudge[event.key];
762
+ if (delta === void 0) return;
763
+ event.preventDefault();
764
+ emit({ x: crop.x + delta[0], y: crop.y + delta[1], width: crop.width, height: crop.height });
765
+ };
766
+ const croppedPixels = natural === null ? null : `${Math.round(crop.width * natural.width)}x${Math.round(crop.height * natural.height)}`;
767
+ return /* @__PURE__ */ jsxs(Stack, { gap: "xs", "data-testid": "asset-crop-editor", children: [
768
+ /* @__PURE__ */ jsxs(Group, { justify: "space-between", gap: "xs", children: [
769
+ label !== void 0 && /* @__PURE__ */ jsx(Text, { fw: 500, children: label }),
770
+ croppedPixels !== null && /* @__PURE__ */ jsx(Badge, { variant: "light", size: "sm", children: croppedPixels })
771
+ ] }),
772
+ /* @__PURE__ */ jsx(Paper, { withBorder: true, p: 0, style: { overflow: "hidden", position: "relative" }, children: /* @__PURE__ */ jsxs("div", { ref: frameRef, style: { position: "relative", lineHeight: 0 }, children: [
773
+ imageUrl === null ? /* @__PURE__ */ jsx(Group, { justify: "center", p: "xl", children: /* @__PURE__ */ jsx(Loader, { size: "sm" }) }) : /* @__PURE__ */ jsx(
774
+ "img",
775
+ {
776
+ src: imageUrl,
777
+ alt: "",
778
+ style: { width: "100%", display: "block" },
779
+ onLoad: (event) => setNatural({
780
+ width: event.currentTarget.naturalWidth,
781
+ height: event.currentTarget.naturalHeight
782
+ })
783
+ }
784
+ ),
785
+ imageUrl !== null && /* @__PURE__ */ jsx(
786
+ "div",
787
+ {
788
+ role: "slider",
789
+ tabIndex: disabled ? -1 : 0,
790
+ "aria-label": `Crop area${label === void 0 ? "" : ` for ${label}`}`,
791
+ "aria-valuetext": `x ${Math.round(crop.x * 100)}%, y ${Math.round(crop.y * 100)}%, ${Math.round(
792
+ crop.width * 100
793
+ )}% wide`,
794
+ "data-testid": "asset-crop-box",
795
+ onPointerDown: handlePointerDown,
796
+ onPointerMove: handlePointerMove,
797
+ onPointerUp: endDrag,
798
+ onPointerCancel: endDrag,
799
+ onKeyDown: handleKeyDown,
800
+ style: {
801
+ position: "absolute",
802
+ left: `${crop.x * 100}%`,
803
+ top: `${crop.y * 100}%`,
804
+ width: `${crop.width * 100}%`,
805
+ height: `${crop.height * 100}%`,
806
+ border: "2px solid var(--mantine-color-blue-5)",
807
+ boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.45)",
808
+ cursor: disabled ? "default" : "move",
809
+ touchAction: "none"
810
+ }
811
+ }
812
+ )
813
+ ] }) }),
814
+ /* @__PURE__ */ jsxs(Group, { gap: "sm", align: "center", wrap: "wrap", children: [
815
+ aspect === void 0 && /* @__PURE__ */ jsx(
816
+ SegmentedControl,
817
+ {
818
+ size: "xs",
819
+ disabled,
820
+ value: activeAspect,
821
+ onChange: setInternalAspect,
822
+ data: CROP_ASPECT_OPTIONS.map((option) => ({ value: option.value, label: option.label }))
823
+ }
824
+ ),
825
+ /* @__PURE__ */ jsx(
826
+ Slider,
827
+ {
828
+ "aria-label": "Crop size",
829
+ style: { flex: 1, minWidth: 140 },
830
+ disabled: disabled || imageUrl === null,
831
+ min: MIN_CROP_SCALE,
832
+ max: 1,
833
+ step: 0.01,
834
+ value: scale,
835
+ label: (current) => `${Math.round(current * 100)}%`,
836
+ onChange: refit
837
+ }
838
+ )
839
+ ] })
840
+ ] });
841
+ }
842
+ var ASSET_PICKER_FETCH_LIMIT = 100;
843
+ function describeAsset(membership, asset) {
844
+ return asset?.title ?? membership.sourceAssetId;
845
+ }
846
+ function readStorageReference(asset) {
847
+ const metadata = asset?.metadata;
848
+ if (typeof metadata !== "object" || metadata === null) return null;
849
+ const { credentialName, cloudStorageId } = metadata;
850
+ if (typeof credentialName !== "string" || typeof cloudStorageId !== "string") return null;
851
+ return { credentialName, cloudStorageId };
852
+ }
853
+ function SourceAssetsPanel({ itemId }) {
854
+ const { memberships, isMembershipLoaded } = useContentItemSourceAssetDetails(itemId);
855
+ const assetsQuery = useContentSourceAssets({ limit: ASSET_PICKER_FETCH_LIMIT });
856
+ const addMembership = useAddContentItemSourceAsset();
857
+ const removeMembership = useRemoveContentItemSourceAsset();
858
+ const reorderMemberships = useReorderContentItemSourceAssets();
859
+ const updateMembership = useUpdateContentItemSourceAsset();
860
+ const [assetToLink, setAssetToLink] = useState(null);
861
+ const [altDrafts, setAltDrafts] = useState({});
862
+ const [croppingAssetId, setCroppingAssetId] = useState(null);
863
+ const [cropDraft, setCropDraft] = useState(null);
864
+ const croppingDetail = memberships.find((detail) => detail.membership.sourceAssetId === croppingAssetId);
865
+ const croppingReference = readStorageReference(croppingDetail?.asset);
866
+ const { links } = useCloudStorageTemporaryLinks(
867
+ croppingReference?.credentialName ?? "",
868
+ croppingReference === null ? [] : [croppingReference.cloudStorageId]
869
+ );
870
+ const linkedIds = useMemo(() => new Set(memberships.map((detail) => detail.membership.sourceAssetId)), [memberships]);
871
+ const linkableOptions = useMemo(
872
+ () => (assetsQuery.data?.data ?? []).filter((asset) => !linkedIds.has(asset.id)).map((asset) => ({ value: asset.id, label: `${asset.title} (${asset.kind})` })),
873
+ [assetsQuery.data, linkedIds]
874
+ );
875
+ const orderedIds = memberships.map((detail) => detail.membership.sourceAssetId);
876
+ const handleLink = () => {
877
+ if (!assetToLink) return;
878
+ const highestPosition = memberships.reduce((highest, detail) => Math.max(highest, detail.membership.position), -1);
879
+ addMembership.mutate(
880
+ { itemId, membership: { sourceAssetId: assetToLink, position: highestPosition + 1 } },
881
+ { onSuccess: () => setAssetToLink(null) }
882
+ );
883
+ };
884
+ const stripAssets = useMemo(
885
+ () => memberships.map((detail) => ({
886
+ id: detail.membership.sourceAssetId,
887
+ name: describeAsset(detail.membership, detail.asset),
888
+ kind: "file"
889
+ })),
890
+ [memberships]
891
+ );
892
+ const handleMove = (fromIndex, toIndex) => {
893
+ if (toIndex < 0 || toIndex >= orderedIds.length) return;
894
+ const next = orderedIds.slice();
895
+ const [moved] = next.splice(fromIndex, 1);
896
+ next.splice(toIndex, 0, moved);
897
+ reorderMemberships.mutate({ itemId, orderedAssetIds: next });
898
+ };
899
+ const handleSaveAltText = (sourceAssetId) => {
900
+ const draft = altDrafts[sourceAssetId];
901
+ if (draft === void 0) return;
902
+ const trimmed = draft.trim();
903
+ updateMembership.mutate(
904
+ // `altText` is `.min(1)` when present, so clearing the field is `null` rather
905
+ // than an empty string -- an empty string is a validation error, not a clear.
906
+ { itemId, sourceAssetId, updates: { altText: trimmed === "" ? null : trimmed } },
907
+ {
908
+ onSuccess: () => setAltDrafts((drafts) => {
909
+ const { [sourceAssetId]: _cleared, ...rest } = drafts;
910
+ return rest;
911
+ })
912
+ }
913
+ );
914
+ };
915
+ const isMutating = addMembership.isPending || removeMembership.isPending || reorderMemberships.isPending || updateMembership.isPending;
916
+ return /* @__PURE__ */ jsxs(Paper, { withBorder: true, p: "md", "data-testid": "source-assets-panel", children: [
917
+ /* @__PURE__ */ jsxs(Stack, { gap: "md", children: [
918
+ /* @__PURE__ */ jsxs(Group, { justify: "space-between", children: [
919
+ /* @__PURE__ */ jsx(Title, { order: 4, children: "Source Assets" }),
920
+ isMembershipLoaded && /* @__PURE__ */ jsxs(Badge, { variant: "light", size: "sm", children: [
921
+ memberships.length,
922
+ " linked"
923
+ ] })
924
+ ] }),
925
+ !isMembershipLoaded ? /* @__PURE__ */ jsx(Group, { justify: "center", py: "md", children: /* @__PURE__ */ jsx(Loader, { size: "sm" }) }) : memberships.length === 0 ? /* @__PURE__ */ jsx(Text, { size: "sm", c: "dimmed", children: "No source assets linked to this item." }) : /* @__PURE__ */ jsxs(Fragment, { children: [
926
+ /* @__PURE__ */ jsx(
927
+ SelectedAssetStrip,
928
+ {
929
+ assets: stripAssets,
930
+ onReorder: (reordered) => reorderMemberships.mutate({ itemId, orderedAssetIds: reordered.map((asset) => asset.id) })
931
+ }
932
+ ),
933
+ /* @__PURE__ */ jsxs(Table, { children: [
934
+ /* @__PURE__ */ jsx(Table.Thead, { children: /* @__PURE__ */ jsxs(Table.Tr, { children: [
935
+ /* @__PURE__ */ jsx(Table.Th, { children: "Position" }),
936
+ /* @__PURE__ */ jsx(Table.Th, { children: "Asset" }),
937
+ /* @__PURE__ */ jsx(Table.Th, { children: "Kind" }),
938
+ /* @__PURE__ */ jsx(Table.Th, { children: "Alt text" }),
939
+ /* @__PURE__ */ jsx(Table.Th, { children: "Crop" }),
940
+ /* @__PURE__ */ jsx(Table.Th, {})
941
+ ] }) }),
942
+ /* @__PURE__ */ jsx(Table.Tbody, { children: memberships.map((detail, index) => {
943
+ const { membership, asset } = detail;
944
+ const draft = altDrafts[membership.sourceAssetId];
945
+ const storedAltText = membership.altText ?? "";
946
+ return /* @__PURE__ */ jsxs(
947
+ Table.Tr,
948
+ {
949
+ "data-testid": `source-asset-row-${membership.sourceAssetId}`,
950
+ children: [
951
+ /* @__PURE__ */ jsx(Table.Td, { children: /* @__PURE__ */ jsxs(Group, { gap: "xs", children: [
952
+ /* @__PURE__ */ jsx(Text, { size: "sm", children: membership.position }),
953
+ index === 0 && /* @__PURE__ */ jsx(Badge, { size: "xs", variant: "filled", children: "Cover" })
954
+ ] }) }),
955
+ /* @__PURE__ */ jsx(Table.Td, { children: /* @__PURE__ */ jsx(Text, { size: "sm", children: describeAsset(membership, asset) }) }),
956
+ /* @__PURE__ */ jsx(Table.Td, { children: /* @__PURE__ */ jsx(Text, { size: "sm", c: "dimmed", children: asset?.kind ?? "\u2014" }) }),
957
+ /* @__PURE__ */ jsx(Table.Td, { children: /* @__PURE__ */ jsxs(Group, { gap: "xs", wrap: "nowrap", children: [
958
+ /* @__PURE__ */ jsx(
959
+ TextInput,
960
+ {
961
+ size: "xs",
962
+ placeholder: "Describe this slide",
963
+ value: draft ?? storedAltText,
964
+ onChange: (event) => setAltDrafts((drafts) => ({
965
+ ...drafts,
966
+ [membership.sourceAssetId]: event.currentTarget.value
967
+ }))
968
+ }
969
+ ),
970
+ draft !== void 0 && draft !== storedAltText && /* @__PURE__ */ jsx(
971
+ Button,
972
+ {
973
+ size: "compact-xs",
974
+ variant: "light",
975
+ disabled: isMutating,
976
+ onClick: () => handleSaveAltText(membership.sourceAssetId),
977
+ children: "Save"
978
+ }
979
+ )
980
+ ] }) }),
981
+ /* @__PURE__ */ jsx(Table.Td, { children: /* @__PURE__ */ jsxs(Group, { gap: "xs", wrap: "nowrap", children: [
982
+ /* @__PURE__ */ jsx(Text, { size: "sm", c: "dimmed", children: membership.crop ? "set" : "\u2014" }),
983
+ /* @__PURE__ */ jsx(
984
+ Button,
985
+ {
986
+ size: "compact-xs",
987
+ variant: "subtle",
988
+ leftSection: /* @__PURE__ */ jsx(IconCrop, { size: 14 }),
989
+ disabled: isMutating || readStorageReference(asset) === null,
990
+ onClick: () => {
991
+ setCropDraft(membership.crop);
992
+ setCroppingAssetId(membership.sourceAssetId);
993
+ },
994
+ children: "Crop"
995
+ }
996
+ )
997
+ ] }) }),
998
+ /* @__PURE__ */ jsx(Table.Td, { children: /* @__PURE__ */ jsxs(Group, { gap: 4, justify: "flex-end", wrap: "nowrap", children: [
999
+ /* @__PURE__ */ jsx(
1000
+ Button,
1001
+ {
1002
+ size: "compact-xs",
1003
+ variant: "subtle",
1004
+ "aria-label": "Move up",
1005
+ disabled: index === 0 || isMutating,
1006
+ onClick: () => handleMove(index, index - 1),
1007
+ children: /* @__PURE__ */ jsx(IconArrowUp, { size: 14 })
1008
+ }
1009
+ ),
1010
+ /* @__PURE__ */ jsx(
1011
+ Button,
1012
+ {
1013
+ size: "compact-xs",
1014
+ variant: "subtle",
1015
+ "aria-label": "Move down",
1016
+ disabled: index === memberships.length - 1 || isMutating,
1017
+ onClick: () => handleMove(index, index + 1),
1018
+ children: /* @__PURE__ */ jsx(IconArrowDown, { size: 14 })
1019
+ }
1020
+ ),
1021
+ /* @__PURE__ */ jsx(
1022
+ Button,
1023
+ {
1024
+ size: "compact-xs",
1025
+ variant: "subtle",
1026
+ color: "red",
1027
+ leftSection: /* @__PURE__ */ jsx(IconUnlink, { size: 14 }),
1028
+ disabled: isMutating,
1029
+ onClick: () => removeMembership.mutate({ itemId, sourceAssetId: membership.sourceAssetId }),
1030
+ children: "Unlink"
1031
+ }
1032
+ )
1033
+ ] }) })
1034
+ ]
1035
+ },
1036
+ membership.sourceAssetId
1037
+ );
1038
+ }) })
1039
+ ] })
1040
+ ] }),
1041
+ /* @__PURE__ */ jsxs(Group, { gap: "sm", align: "flex-end", children: [
1042
+ /* @__PURE__ */ jsx(
1043
+ Select,
1044
+ {
1045
+ size: "xs",
1046
+ searchable: true,
1047
+ clearable: true,
1048
+ label: "Link an existing source asset",
1049
+ placeholder: linkableOptions.length === 0 ? "Nothing left to link" : "Choose an asset",
1050
+ data: linkableOptions,
1051
+ value: assetToLink,
1052
+ onChange: setAssetToLink,
1053
+ disabled: linkableOptions.length === 0 || isMutating,
1054
+ style: { minWidth: 320 }
1055
+ }
1056
+ ),
1057
+ /* @__PURE__ */ jsx(
1058
+ Button,
1059
+ {
1060
+ size: "compact-sm",
1061
+ variant: "light",
1062
+ leftSection: /* @__PURE__ */ jsx(IconLink, { size: 14 }),
1063
+ disabled: !assetToLink || isMutating,
1064
+ onClick: handleLink,
1065
+ children: "Link"
1066
+ }
1067
+ )
1068
+ ] }),
1069
+ /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", children: "Position 0 is the cover. Unlinking leaves the remaining positions untouched, so a gap is normal \u2014 use move up / move down to renumber the whole set. Unlinking never deletes the source asset itself." })
1070
+ ] }),
1071
+ /* @__PURE__ */ jsx(
1072
+ Modal,
1073
+ {
1074
+ opened: croppingAssetId !== null,
1075
+ onClose: () => setCroppingAssetId(null),
1076
+ title: croppingDetail === void 0 ? "Crop" : `Crop ${describeAsset(croppingDetail.membership, croppingDetail.asset)}`,
1077
+ size: "lg",
1078
+ children: /* @__PURE__ */ jsxs(Stack, { gap: "md", children: [
1079
+ /* @__PURE__ */ jsx(
1080
+ AssetCropEditor,
1081
+ {
1082
+ imageUrl: croppingReference === null ? null : links[croppingReference.cloudStorageId] ?? null,
1083
+ value: cropDraft,
1084
+ onChange: setCropDraft,
1085
+ disabled: isMutating
1086
+ }
1087
+ ),
1088
+ /* @__PURE__ */ jsxs(Group, { justify: "space-between", children: [
1089
+ /* @__PURE__ */ jsx(
1090
+ Button,
1091
+ {
1092
+ variant: "subtle",
1093
+ color: "red",
1094
+ disabled: isMutating || cropDraft === null,
1095
+ onClick: () => setCropDraft(null),
1096
+ children: "Clear crop"
1097
+ }
1098
+ ),
1099
+ /* @__PURE__ */ jsxs(Group, { gap: "xs", children: [
1100
+ /* @__PURE__ */ jsx(Button, { variant: "default", onClick: () => setCroppingAssetId(null), children: "Cancel" }),
1101
+ /* @__PURE__ */ jsx(
1102
+ Button,
1103
+ {
1104
+ disabled: isMutating || croppingAssetId === null,
1105
+ onClick: () => {
1106
+ if (croppingAssetId === null) return;
1107
+ updateMembership.mutate(
1108
+ { itemId, sourceAssetId: croppingAssetId, updates: { crop: cropDraft } },
1109
+ { onSuccess: () => setCroppingAssetId(null) }
1110
+ );
1111
+ },
1112
+ children: "Save crop"
1113
+ }
1114
+ )
1115
+ ] })
1116
+ ] })
1117
+ ] })
1118
+ }
1119
+ )
1120
+ ] });
1121
+ }
1122
+ var PREVIEW_MAX_WIDTH = 380;
1123
+ function PostPreview({ slides, caption, hashtags = [], aspectRatio = 4 / 5, title }) {
1124
+ const [index, setIndex] = useState(0);
1125
+ useEffect(() => {
1126
+ if (index > slides.length - 1) setIndex(Math.max(0, slides.length - 1));
1127
+ }, [slides.length, index]);
1128
+ const slide = slides[index];
1129
+ return /* @__PURE__ */ jsxs(Stack, { gap: "xs", style: { maxWidth: PREVIEW_MAX_WIDTH }, "data-testid": "post-preview", children: [
1130
+ title !== void 0 && /* @__PURE__ */ jsx(Text, { fw: 500, lineClamp: 1, children: title }),
1131
+ /* @__PURE__ */ jsx(Paper, { withBorder: true, p: 0, style: { overflow: "hidden" }, children: /* @__PURE__ */ jsxs(
1132
+ Box,
1133
+ {
1134
+ style: {
1135
+ position: "relative",
1136
+ width: "100%",
1137
+ aspectRatio: `${aspectRatio}`,
1138
+ overflow: "hidden",
1139
+ background: "var(--mantine-color-gray-1)"
1140
+ },
1141
+ "data-testid": "post-preview-frame",
1142
+ children: [
1143
+ slide === void 0 || slide.imageUrl === null ? /* @__PURE__ */ jsx(Group, { justify: "center", align: "center", h: "100%", children: /* @__PURE__ */ jsx(IconPhotoOff, { size: 20, color: "var(--mantine-color-dimmed)" }) }) : /* @__PURE__ */ jsx(
1144
+ "img",
1145
+ {
1146
+ src: slide.imageUrl,
1147
+ alt: slide.altText ?? "",
1148
+ "data-testid": "post-preview-image",
1149
+ style: cropStyle(slide.crop ?? null)
1150
+ }
1151
+ ),
1152
+ index > 0 && /* @__PURE__ */ jsx(
1153
+ ActionIcon,
1154
+ {
1155
+ variant: "default",
1156
+ radius: "xl",
1157
+ size: "lg",
1158
+ "aria-label": "Previous slide",
1159
+ onClick: () => setIndex((current) => Math.max(0, current - 1)),
1160
+ style: { position: "absolute", left: 8, top: "50%", transform: "translateY(-50%)" },
1161
+ children: /* @__PURE__ */ jsx(IconChevronLeft, { size: 18 })
1162
+ }
1163
+ ),
1164
+ index < slides.length - 1 && /* @__PURE__ */ jsx(
1165
+ ActionIcon,
1166
+ {
1167
+ variant: "default",
1168
+ radius: "xl",
1169
+ size: "lg",
1170
+ "aria-label": "Next slide",
1171
+ onClick: () => setIndex((current) => Math.min(slides.length - 1, current + 1)),
1172
+ style: { position: "absolute", right: 8, top: "50%", transform: "translateY(-50%)" },
1173
+ children: /* @__PURE__ */ jsx(IconChevronRight, { size: 18 })
1174
+ }
1175
+ )
1176
+ ]
1177
+ }
1178
+ ) }),
1179
+ slides.length > 1 && /* @__PURE__ */ jsx(Group, { justify: "center", gap: 6, "data-testid": "post-preview-dots", children: slides.map((each, dotIndex) => /* @__PURE__ */ jsx(
1180
+ Box,
1181
+ {
1182
+ style: {
1183
+ width: 6,
1184
+ height: 6,
1185
+ borderRadius: "50%",
1186
+ background: dotIndex === index ? "var(--mantine-color-blue-5)" : "var(--mantine-color-gray-4)"
1187
+ }
1188
+ },
1189
+ each.id
1190
+ )) }),
1191
+ /* @__PURE__ */ jsx(Text, { size: "sm", style: { whiteSpace: "pre-wrap" }, "data-testid": "post-preview-caption", children: caption === null || caption === void 0 || caption.length === 0 ? /* @__PURE__ */ jsx(Text, { component: "span", size: "sm", c: "dimmed", children: "No caption yet" }) : caption }),
1192
+ hashtags.length > 0 && /* @__PURE__ */ jsx(Text, { size: "sm", c: "blue", "data-testid": "post-preview-hashtags", children: hashtags.map((tag) => `#${tag.replace(/^#/, "")}`).join(" ") })
1193
+ ] });
1194
+ }
1195
+ function cropStyle(crop) {
1196
+ if (crop === null) {
1197
+ return { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" };
1198
+ }
1199
+ return {
1200
+ position: "absolute",
1201
+ left: `${-crop.x / crop.width * 100}%`,
1202
+ top: `${-crop.y / crop.height * 100}%`,
1203
+ width: `${1 / crop.width * 100}%`,
1204
+ height: `${1 / crop.height * 100}%`,
1205
+ objectFit: "cover"
1206
+ };
1207
+ }
1208
+
1209
+ // src/features/content/renderCrop.ts
1210
+ var CONTENT_DERIVATIVES_BUCKET = "content-derivatives";
1211
+ var DERIVATIVE_MAX_WIDTH = 1080;
1212
+ var DERIVATIVE_JPEG_QUALITY = 0.9;
1213
+ function derivativeObjectKey(contentItemId, sourceAssetId) {
1214
+ return `${contentItemId}/${sourceAssetId}.jpg`;
1215
+ }
1216
+ function isDerivativeStale(crop, derivativeCrop, derivativePath) {
1217
+ if (crop === null || derivativePath === null) return false;
1218
+ if (derivativeCrop === null) return true;
1219
+ return crop.x !== derivativeCrop.x || crop.y !== derivativeCrop.y || crop.width !== derivativeCrop.width || crop.height !== derivativeCrop.height;
1220
+ }
1221
+ function loadImage(url) {
1222
+ return new Promise((resolve, reject) => {
1223
+ const image = new Image();
1224
+ image.crossOrigin = "anonymous";
1225
+ image.onload = () => resolve(image);
1226
+ image.onerror = () => reject(new Error(`Could not load the image to crop: ${url}`));
1227
+ image.src = url;
1228
+ });
1229
+ }
1230
+ function canvasToJpegBlob(canvas, quality) {
1231
+ return new Promise((resolve, reject) => {
1232
+ canvas.toBlob(
1233
+ (blob) => {
1234
+ if (blob === null) {
1235
+ reject(new Error("The browser produced no image data for the crop"));
1236
+ return;
1237
+ }
1238
+ resolve(blob);
1239
+ },
1240
+ "image/jpeg",
1241
+ quality
1242
+ );
1243
+ });
1244
+ }
1245
+ async function renderCropToJpeg(imageUrl, crop, options = {}) {
1246
+ const maxWidth = options.maxWidth ?? DERIVATIVE_MAX_WIDTH;
1247
+ const quality = options.quality ?? DERIVATIVE_JPEG_QUALITY;
1248
+ const image = await loadImage(imageUrl);
1249
+ const box = crop ?? { x: 0, y: 0, width: 1, height: 1 };
1250
+ const sourceX = box.x * image.naturalWidth;
1251
+ const sourceY = box.y * image.naturalHeight;
1252
+ const sourceWidth = box.width * image.naturalWidth;
1253
+ const sourceHeight = box.height * image.naturalHeight;
1254
+ if (sourceWidth < 1 || sourceHeight < 1) {
1255
+ throw new Error("The crop resolves to less than one pixel of the source");
1256
+ }
1257
+ const width = Math.max(1, Math.round(Math.min(maxWidth, sourceWidth)));
1258
+ const height = Math.max(1, Math.round(width * sourceHeight / sourceWidth));
1259
+ const canvas = document.createElement("canvas");
1260
+ canvas.width = width;
1261
+ canvas.height = height;
1262
+ const context = canvas.getContext("2d");
1263
+ if (context === null) throw new Error("The browser would not provide a 2D canvas context");
1264
+ context.fillStyle = "#ffffff";
1265
+ context.fillRect(0, 0, width, height);
1266
+ context.drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, width, height);
1267
+ return canvasToJpegBlob(canvas, quality);
1268
+ }
1269
+ var CAPTION_STEP_KEY = "caption-generation";
1270
+ var CAPTION_WORKFLOW_ID = "cnt-caption-generation-workflow";
1271
+ var AUTOSAVE_DELAY_MS = 800;
1272
+ function readCaptionAttempt(attempt) {
1273
+ if (attempt.stepKey !== CAPTION_STEP_KEY) return null;
1274
+ const payload = attempt.payload;
1275
+ const caption = payload?.caption;
1276
+ if (typeof caption !== "string" || caption.length === 0) return null;
1277
+ return {
1278
+ attempt,
1279
+ caption,
1280
+ hashtags: Array.isArray(payload?.hashtags) ? payload.hashtags.filter((tag) => typeof tag === "string") : [],
1281
+ instructions: typeof payload?.instructions === "string" ? payload.instructions : ""
1282
+ };
1283
+ }
1284
+ function CaptionPanel({ itemId, body, onDraftChange }) {
1285
+ const queryClient = useQueryClient();
1286
+ const savedBody = body ?? "";
1287
+ const [instructions, setInstructions] = useState("");
1288
+ const [executionId, setExecutionId] = useState(null);
1289
+ const [failure, setFailure] = useState(null);
1290
+ const [draft, setDraft] = useState(savedBody);
1291
+ const attemptsQuery = useContentItemAttempts(itemId);
1292
+ const executeAsync = useExecuteAsync();
1293
+ const updateItem = useUpdateContentItem();
1294
+ const execution = useExecution(CAPTION_WORKFLOW_ID, executionId ?? "");
1295
+ const captions = useMemo(() => {
1296
+ const rows = (attemptsQuery.data?.data ?? []).map(readCaptionAttempt).filter((row) => row !== null);
1297
+ rows.sort((left, right) => right.attempt.attemptNumber - left.attempt.attemptNumber);
1298
+ return rows.map((row, index) => ({ ...row, displayNumber: rows.length - index }));
1299
+ }, [attemptsQuery.data]);
1300
+ const status = execution.data?.status;
1301
+ const isRunning = executeAsync.isPending || executionId !== null && (status === void 0 || status === "running");
1302
+ const lastSyncedRef = useRef(savedBody);
1303
+ const [debouncedDraft] = useDebouncedValue(draft, AUTOSAVE_DELAY_MS);
1304
+ useEffect(() => {
1305
+ if (savedBody === lastSyncedRef.current) return;
1306
+ lastSyncedRef.current = savedBody;
1307
+ setDraft(savedBody);
1308
+ }, [savedBody]);
1309
+ useEffect(() => {
1310
+ if (debouncedDraft === lastSyncedRef.current) return;
1311
+ lastSyncedRef.current = debouncedDraft;
1312
+ updateItem.mutate({ itemId, updates: { body: debouncedDraft } });
1313
+ }, [debouncedDraft, itemId, updateItem]);
1314
+ useEffect(() => {
1315
+ onDraftChange?.(draft);
1316
+ }, [draft, onDraftChange]);
1317
+ const isDirty = draft !== lastSyncedRef.current;
1318
+ useEffect(() => {
1319
+ if (executionId === null || status === void 0 || status === "running") return;
1320
+ if (status === "failed") {
1321
+ setFailure(execution.data?.error ?? "The caption workflow failed. Check the execution log for details.");
1322
+ }
1323
+ void queryClient.invalidateQueries({ queryKey: contentKeys.items() });
1324
+ setExecutionId(null);
1325
+ }, [status, executionId, execution.data?.error, queryClient]);
1326
+ const handleGenerate = async () => {
1327
+ setFailure(null);
1328
+ try {
1329
+ const result = await executeAsync.mutateAsync({
1330
+ resourceId: CAPTION_WORKFLOW_ID,
1331
+ resourceType: "workflow",
1332
+ input: {
1333
+ contentItemId: itemId,
1334
+ ...instructions.trim().length > 0 ? { instructions: instructions.trim() } : {}
1335
+ }
1336
+ });
1337
+ setExecutionId(result.executionId);
1338
+ } catch (error) {
1339
+ setFailure(error instanceof Error ? error.message : "Could not start the caption workflow.");
1340
+ }
1341
+ };
1342
+ const handleUse = (candidate) => {
1343
+ setDraft(
1344
+ candidate.hashtags.length === 0 ? candidate.caption : `${candidate.caption}
1345
+
1346
+ ${candidate.hashtags.map((tag) => `#${tag.replace(/^#/, "")}`).join(" ")}`
1347
+ );
1348
+ };
1349
+ return /* @__PURE__ */ jsx(Paper, { withBorder: true, p: "md", "data-testid": "caption-panel", children: /* @__PURE__ */ jsxs(Stack, { gap: "sm", children: [
1350
+ /* @__PURE__ */ jsxs(Group, { justify: "space-between", children: [
1351
+ /* @__PURE__ */ jsx(Title, { order: 5, children: "Caption" }),
1352
+ /* @__PURE__ */ jsxs(Badge, { variant: "light", size: "sm", children: [
1353
+ captions.length,
1354
+ " generated"
1355
+ ] })
1356
+ ] }),
1357
+ failure !== null && /* @__PURE__ */ jsx(Alert, { color: "red", icon: /* @__PURE__ */ jsx(IconAlertTriangle, { size: 16 }), "data-testid": "caption-panel-error", children: failure }),
1358
+ /* @__PURE__ */ jsx(
1359
+ Textarea,
1360
+ {
1361
+ label: "The caption",
1362
+ description: "Edit it freely. Changes save on their own.",
1363
+ placeholder: "Write it yourself, or generate one below and edit from there.",
1364
+ autosize: true,
1365
+ minRows: 4,
1366
+ maxRows: 16,
1367
+ value: draft,
1368
+ onChange: (event) => setDraft(event.currentTarget.value),
1369
+ "data-testid": "caption-panel-body"
1370
+ }
1371
+ ),
1372
+ /* @__PURE__ */ jsx(Group, { justify: "flex-end", gap: "xs", mt: -6, children: updateItem.isPending ? /* @__PURE__ */ jsxs(Group, { gap: 6, children: [
1373
+ /* @__PURE__ */ jsx(Loader, { size: 12 }),
1374
+ /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", children: "Saving" })
1375
+ ] }) : isDirty ? /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", "data-testid": "caption-panel-save-state", children: "Unsaved" }) : /* @__PURE__ */ jsxs(Group, { gap: 4, children: [
1376
+ /* @__PURE__ */ jsx(IconCheck, { size: 12, color: "var(--mantine-color-dimmed)" }),
1377
+ /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", "data-testid": "caption-panel-save-state", children: "Saved" })
1378
+ ] }) }),
1379
+ /* @__PURE__ */ jsx(
1380
+ Textarea,
1381
+ {
1382
+ label: "Direction (optional)",
1383
+ description: "The angle, the occasion, who is in frame -- anything the photos cannot show on their own.",
1384
+ placeholder: "e.g. this is the second shop opening, keep it short and warm",
1385
+ autosize: true,
1386
+ minRows: 2,
1387
+ maxRows: 5,
1388
+ value: instructions,
1389
+ disabled: isRunning,
1390
+ onChange: (event) => setInstructions(event.currentTarget.value)
1391
+ }
1392
+ ),
1393
+ /* @__PURE__ */ jsxs(Group, { children: [
1394
+ /* @__PURE__ */ jsx(
1395
+ Button,
1396
+ {
1397
+ leftSection: isRunning ? /* @__PURE__ */ jsx(Loader, { size: 14 }) : /* @__PURE__ */ jsx(IconSparkles, { size: 16 }),
1398
+ disabled: isRunning,
1399
+ onClick: () => void handleGenerate(),
1400
+ "data-testid": "caption-panel-generate",
1401
+ children: captions.length === 0 ? "Generate caption" : "Regenerate"
1402
+ }
1403
+ ),
1404
+ isRunning && /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", children: "Reading the slides and writing a caption. This does not overwrite the previous one." })
1405
+ ] }),
1406
+ attemptsQuery.isLoading ? /* @__PURE__ */ jsx(Group, { justify: "center", p: "md", children: /* @__PURE__ */ jsx(Loader, { size: "sm" }) }) : captions.map((candidate) => {
1407
+ const isLive = draft.startsWith(candidate.caption);
1408
+ return /* @__PURE__ */ jsx(Paper, { withBorder: true, p: "sm", "data-testid": "caption-panel-attempt", children: /* @__PURE__ */ jsxs(Stack, { gap: 6, children: [
1409
+ /* @__PURE__ */ jsxs(Group, { justify: "space-between", gap: "xs", children: [
1410
+ /* @__PURE__ */ jsxs(Group, { gap: "xs", children: [
1411
+ /* @__PURE__ */ jsxs(Text, { size: "xs", c: "dimmed", children: [
1412
+ "Attempt ",
1413
+ candidate.displayNumber
1414
+ ] }),
1415
+ isLive && /* @__PURE__ */ jsx(Badge, { size: "xs", color: "green", variant: "light", leftSection: /* @__PURE__ */ jsx(IconCheck, { size: 10 }), children: "In use" })
1416
+ ] }),
1417
+ /* @__PURE__ */ jsx(
1418
+ Button,
1419
+ {
1420
+ size: "compact-xs",
1421
+ variant: "light",
1422
+ disabled: isLive || updateItem.isPending,
1423
+ onClick: () => handleUse(candidate),
1424
+ children: "Use this caption"
1425
+ }
1426
+ )
1427
+ ] }),
1428
+ /* @__PURE__ */ jsx(Text, { size: "sm", style: { whiteSpace: "pre-wrap" }, children: candidate.caption }),
1429
+ candidate.hashtags.length > 0 && /* @__PURE__ */ jsx(Text, { size: "sm", c: "blue", children: candidate.hashtags.map((tag) => `#${tag.replace(/^#/, "")}`).join(" ") }),
1430
+ candidate.instructions.length > 0 && /* @__PURE__ */ jsxs(Text, { size: "xs", c: "dimmed", children: [
1431
+ "Asked for: ",
1432
+ candidate.instructions
1433
+ ] })
1434
+ ] }) }, candidate.attempt.id);
1435
+ }),
1436
+ !attemptsQuery.isLoading && captions.length === 0 && /* @__PURE__ */ jsx(Text, { size: "sm", c: "dimmed", children: "Nothing generated yet. Analysed slides make for a caption that describes what is actually in them -- or just write the caption above yourself." })
1437
+ ] }) });
1438
+ }
1439
+ var IMAGE_ANALYSIS_WORKFLOW_ID = "cnt-image-analysis-workflow";
1440
+ function readStoredAnalysis(asset) {
1441
+ const metadata = asset?.metadata;
1442
+ if (typeof metadata !== "object" || metadata === null) return null;
1443
+ const analysis = metadata.analysis;
1444
+ if (typeof analysis !== "object" || analysis === null) return null;
1445
+ const raw = analysis;
1446
+ const asString = (key) => typeof raw[key] === "string" ? raw[key] : void 0;
1447
+ return {
1448
+ description: asString("description"),
1449
+ subjects: Array.isArray(raw.subjects) ? raw.subjects.filter((one) => typeof one === "string") : void 0,
1450
+ setting: asString("setting"),
1451
+ mood: asString("mood"),
1452
+ textInImage: asString("textInImage"),
1453
+ altText: asString("altText"),
1454
+ model: asString("model"),
1455
+ generatedAt: asString("generatedAt")
1456
+ };
1457
+ }
1458
+ function SlideAnalysisPanel({ itemId }) {
1459
+ const queryClient = useQueryClient();
1460
+ const [executionId, setExecutionId] = useState(null);
1461
+ const [failure, setFailure] = useState(null);
1462
+ const { memberships, isMembershipLoaded } = useContentItemSourceAssetDetails(itemId);
1463
+ const executeAsync = useExecuteAsync();
1464
+ const execution = useExecution(IMAGE_ANALYSIS_WORKFLOW_ID, executionId ?? "");
1465
+ const status = execution.data?.status;
1466
+ const isRunning = executeAsync.isPending || executionId !== null && (status === void 0 || status === "running");
1467
+ const analyzedCount = useMemo(
1468
+ () => memberships.filter((detail) => readStoredAnalysis(detail.asset) !== null).length,
1469
+ [memberships]
1470
+ );
1471
+ useEffect(() => {
1472
+ if (executionId === null || status === void 0 || status === "running") return;
1473
+ if (status === "failed") {
1474
+ setFailure(execution.data?.error ?? "The analysis workflow failed. Check the execution log for details.");
1475
+ }
1476
+ void queryClient.invalidateQueries({ queryKey: contentKeys.sourceAssets() });
1477
+ setExecutionId(null);
1478
+ }, [status, executionId, execution.data?.error, queryClient]);
1479
+ const handleAnalyze = async () => {
1480
+ setFailure(null);
1481
+ try {
1482
+ const result = await executeAsync.mutateAsync({
1483
+ resourceId: IMAGE_ANALYSIS_WORKFLOW_ID,
1484
+ resourceType: "workflow",
1485
+ input: { contentItemId: itemId }
1486
+ });
1487
+ setExecutionId(result.executionId);
1488
+ } catch (error) {
1489
+ setFailure(error instanceof Error ? error.message : "Could not start the analysis workflow.");
1490
+ }
1491
+ };
1492
+ return /* @__PURE__ */ jsx(Paper, { withBorder: true, p: "md", "data-testid": "slide-analysis-panel", children: /* @__PURE__ */ jsxs(Stack, { gap: "sm", children: [
1493
+ /* @__PURE__ */ jsxs(Group, { justify: "space-between", children: [
1494
+ /* @__PURE__ */ jsx(Title, { order: 5, children: "Slide analysis" }),
1495
+ /* @__PURE__ */ jsxs(Badge, { variant: "light", size: "sm", children: [
1496
+ analyzedCount,
1497
+ " of ",
1498
+ memberships.length,
1499
+ " analysed"
1500
+ ] })
1501
+ ] }),
1502
+ failure !== null && /* @__PURE__ */ jsx(Alert, { color: "red", icon: /* @__PURE__ */ jsx(IconAlertTriangle, { size: 16 }), "data-testid": "slide-analysis-error", children: failure }),
1503
+ /* @__PURE__ */ jsxs(Group, { children: [
1504
+ /* @__PURE__ */ jsx(
1505
+ Button,
1506
+ {
1507
+ leftSection: isRunning ? /* @__PURE__ */ jsx(Loader, { size: 14 }) : /* @__PURE__ */ jsx(IconEye, { size: 16 }),
1508
+ disabled: isRunning || memberships.length === 0,
1509
+ onClick: () => void handleAnalyze(),
1510
+ "data-testid": "slide-analysis-run",
1511
+ children: analyzedCount === 0 ? "Analyse slides" : "Re-analyse slides"
1512
+ }
1513
+ ),
1514
+ isRunning && /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", children: "Reading each slide with a vision model." })
1515
+ ] }),
1516
+ !isMembershipLoaded ? /* @__PURE__ */ jsx(Group, { justify: "center", p: "md", children: /* @__PURE__ */ jsx(Loader, { size: "sm" }) }) : memberships.map((detail) => {
1517
+ const analysis = readStoredAnalysis(detail.asset);
1518
+ const label = detail.asset?.title ?? detail.membership.sourceAssetId;
1519
+ return /* @__PURE__ */ jsx(Paper, { withBorder: true, p: "sm", "data-testid": "slide-analysis-row", children: /* @__PURE__ */ jsxs(Stack, { gap: 4, children: [
1520
+ /* @__PURE__ */ jsxs(Group, { gap: "xs", children: [
1521
+ /* @__PURE__ */ jsxs(Badge, { size: "xs", variant: "light", children: [
1522
+ "Slide ",
1523
+ detail.membership.position + 1
1524
+ ] }),
1525
+ /* @__PURE__ */ jsx(Text, { size: "sm", fw: 500, lineClamp: 1, children: label })
1526
+ ] }),
1527
+ analysis === null ? /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", children: "Not analysed yet." }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1528
+ analysis.description !== void 0 && /* @__PURE__ */ jsx(Text, { size: "sm", children: analysis.description }),
1529
+ analysis.subjects !== void 0 && analysis.subjects.length > 0 && /* @__PURE__ */ jsx(Group, { gap: 4, children: analysis.subjects.map((subject) => /* @__PURE__ */ jsx(Badge, { size: "xs", variant: "outline", children: subject }, subject)) }),
1530
+ (analysis.setting !== void 0 || analysis.mood !== void 0) && /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", children: [analysis.setting, analysis.mood].filter(Boolean).join(" - ") }),
1531
+ analysis.textInImage !== void 0 && analysis.textInImage.length > 0 && /* @__PURE__ */ jsxs(Text, { size: "xs", c: "dimmed", children: [
1532
+ "Text in image: ",
1533
+ analysis.textInImage
1534
+ ] })
1535
+ ] })
1536
+ ] }) }, detail.membership.id);
1537
+ }),
1538
+ /* @__PURE__ */ jsx(Text, { size: "xs", c: "dimmed", children: "Analysis is stored on the photo itself, so every post that reuses it reads the same description and a re-crop does not invalidate it." })
1539
+ ] }) });
1540
+ }
539
1541
 
540
1542
  // src/lib/content/stage-colors.ts
541
1543
  var FALLBACK_CONTENT_STAGE_COLORS = {
@@ -555,6 +1557,10 @@ function getContentStageColor(entry) {
555
1557
  }
556
1558
  var OVERVIEW_ITEMS_LIMIT = 100;
557
1559
  var RECENT_ITEMS_LIMIT = 8;
1560
+ function resolveItemCurrentStep(item, pipelines) {
1561
+ const pipeline = pipelines.find((candidate) => candidate.id === item.pipelineId);
1562
+ return resolveCurrentContentStep(item, pipeline?.steps ?? []);
1563
+ }
558
1564
  function ContentOverviewPage({ headerActions, footer } = {}) {
559
1565
  const navigate = useNavigate();
560
1566
  const itemsQuery = useContentItems({ limit: OVERVIEW_ITEMS_LIMIT });
@@ -569,7 +1575,9 @@ function ContentOverviewPage({ headerActions, footer } = {}) {
569
1575
  }
570
1576
  return counts;
571
1577
  }, [items]);
572
- const pendingReviewCount = items.filter((item) => item.reviewedAt === null).length;
1578
+ const pendingReviewCount = items.filter(
1579
+ (item) => isAwaitingReview(item, resolveItemCurrentStep(item, contentConfig.pipelines))
1580
+ ).length;
573
1581
  const totalItems = itemsQuery.data?.total ?? items.length;
574
1582
  const totalDistributions = distributionsQuery.data?.data.length ?? 0;
575
1583
  const recentItems = useMemo(
@@ -711,10 +1719,9 @@ function ContentItemsPage({ headerActions } = {}) {
711
1719
  const pagination = usePaginationState(PAGE_SIZE, [searchQuery, statusFilter, pillarFilter, reviewFilterActive]);
712
1720
  const openItem = (item) => {
713
1721
  const pipeline = contentConfig.pipelines.find((candidate) => candidate.id === item.pipelineId);
714
- const openGate = getOpenContentReviewGates(item.processingState ?? {}, pipeline?.steps ?? [])[0];
715
- const workspaceRoute = openGate ? resolveContentWorkspaceRoute(pipeline, openGate.step) : void 0;
716
- if (workspaceRoute) {
717
- void navigate({ to: workspaceRoute });
1722
+ const target = resolveReviewTarget(item, pipeline, pipeline?.steps ?? []);
1723
+ if (target.kind === "workspace") {
1724
+ void navigate({ to: target.route });
718
1725
  return;
719
1726
  }
720
1727
  void navigate({ to: "/content/items/$itemId", params: { itemId: item.id } });
@@ -727,7 +1734,11 @@ function ContentItemsPage({ headerActions } = {}) {
727
1734
  offset: pagination.offset
728
1735
  });
729
1736
  const fetchedItems = itemsQuery.data?.data ?? [];
730
- const items = reviewFilterActive ? fetchedItems.filter((item) => item.reviewedAt === null) : fetchedItems;
1737
+ const items = reviewFilterActive ? fetchedItems.filter((item) => {
1738
+ const pipeline = contentConfig.pipelines.find((candidate) => candidate.id === item.pipelineId);
1739
+ const step = resolveCurrentContentStep(item, pipeline?.steps ?? []);
1740
+ return isAwaitingReview(item, step);
1741
+ }) : fetchedItems;
731
1742
  const total = itemsQuery.data?.total ?? 0;
732
1743
  const statusOptions = [
733
1744
  { value: "", label: "All statuses" },
@@ -945,18 +1956,18 @@ function ContentItemReviewPage({ itemId, headerActions }) {
945
1956
  const pipeline = item ? contentConfig.pipelines.find((candidate) => candidate.id === item.pipelineId) : void 0;
946
1957
  const pipelineSteps = useMemo(() => pipeline?.steps ?? [], [pipeline]);
947
1958
  const isTerminalPipeline = pipelineSteps.length <= 1;
948
- const openGates = useMemo(
949
- () => getOpenContentReviewGates(item?.processingState ?? {}, pipelineSteps),
950
- [item?.processingState, pipelineSteps]
1959
+ const reviewTarget = useMemo(
1960
+ () => item ? resolveReviewTarget(item, pipeline, pipelineSteps) : { kind: "none" },
1961
+ [item, pipeline, pipelineSteps]
951
1962
  );
952
- const earliestOpenGate = openGates[0];
1963
+ const earliestOpenGate = reviewTarget.kind === "queued" ? reviewTarget.gate : void 0;
1964
+ const workspaceRoute = reviewTarget.kind === "workspace" ? reviewTarget.route : void 0;
953
1965
  const reviewDisabledReason = useMemo(() => {
954
- if (earliestOpenGate) return void 0;
1966
+ if (reviewTarget.kind !== "none") return void 0;
955
1967
  if (!item?.pipelineId) return "This item has no pipeline, so there is no review gate to clear.";
956
1968
  if (!pipeline) return `Unknown pipeline "${item.pipelineId}" -- there is no review gate to clear.`;
957
1969
  return "No open review gate on this item. Every queued step has already been reviewed, or the current step is still running.";
958
- }, [earliestOpenGate, item?.pipelineId, pipeline]);
959
- const workspaceRoute = resolveContentWorkspaceRoute(pipeline, earliestOpenGate?.step);
1970
+ }, [reviewTarget, item?.pipelineId, pipeline]);
960
1971
  const reviewActions = useMemo(
961
1972
  () => workspaceRoute ? [] : buildReviewActions(isTerminalPipeline, earliestOpenGate, reviewDisabledReason),
962
1973
  [workspaceRoute, isTerminalPipeline, earliestOpenGate, reviewDisabledReason]
@@ -983,8 +1994,8 @@ function ContentItemReviewPage({ itemId, headerActions }) {
983
1994
  });
984
1995
  const reviewQueue = useMemo(() => {
985
1996
  if (!item?.pipelineId) return [];
986
- return (pipelineItemsQuery.data?.data ?? []).filter((candidate) => candidate.reviewedAt === null).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
987
- }, [pipelineItemsQuery.data, item?.pipelineId]);
1997
+ return (pipelineItemsQuery.data?.data ?? []).filter((candidate) => isAwaitingReview(candidate, resolveCurrentContentStep(candidate, pipelineSteps))).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
1998
+ }, [pipelineItemsQuery.data, item?.pipelineId, pipelineSteps]);
988
1999
  const queueIndex = item ? reviewQueue.findIndex((candidate) => candidate.id === item.id) : -1;
989
2000
  const queuePosition = queueIndex === -1 ? void 0 : queueIndex + 1;
990
2001
  const nextQueueItemId = queueIndex === -1 ? void 0 : reviewQueue[queueIndex + 1]?.id;
@@ -1128,7 +2139,7 @@ function ContentItemReviewPage({ itemId, headerActions }) {
1128
2139
  children: /* @__PURE__ */ jsx(Text, { size: "sm", children: reviewDisabledReason })
1129
2140
  }
1130
2141
  ),
1131
- workspaceRoute && earliestOpenGate && /* @__PURE__ */ jsx(
2142
+ workspaceRoute && /* @__PURE__ */ jsx(
1132
2143
  Alert,
1133
2144
  {
1134
2145
  icon: /* @__PURE__ */ jsx(IconExternalLink, { size: 16 }),
@@ -1174,6 +2185,9 @@ function ContentItemReviewPage({ itemId, headerActions }) {
1174
2185
  /* @__PURE__ */ jsx(Text, { size: "xs", c: "var(--color-error)", fw: 600, children: "Reject reason" }),
1175
2186
  /* @__PURE__ */ jsx(Text, { size: "sm", children: item.rejectReason })
1176
2187
  ] }) }),
2188
+ /* @__PURE__ */ jsx(SourceAssetsPanel, { itemId }),
2189
+ /* @__PURE__ */ jsx(SlideAnalysisPanel, { itemId }),
2190
+ /* @__PURE__ */ jsx(CaptionPanel, { itemId, body: item.body }),
1177
2191
  /* @__PURE__ */ jsx(Paper, { withBorder: true, p: "md", children: /* @__PURE__ */ jsxs(Stack, { gap: "md", children: [
1178
2192
  /* @__PURE__ */ jsx(Title, { order: 4, children: "Attempt History" }),
1179
2193
  attempts.length === 0 ? /* @__PURE__ */ jsx(Text, { size: "sm", c: "dimmed", children: "No recorded attempts." }) : /* @__PURE__ */ jsxs(Table, { children: [
@@ -1304,13 +2318,13 @@ var ACTOR_LEAD = {
1304
2318
  workflow: "An automated workflow",
1305
2319
  agent: "An AI agent"
1306
2320
  };
1307
- var REVIEW_MODE_TAIL = {
1308
- live: "and you review it live, in the moment",
1309
- queued: "then it waits for your review",
2321
+ var REVIEW_VENUE_TAIL = {
2322
+ workspace: "and you review it on your own screen, in the moment",
2323
+ queue: "then it waits for your review",
1310
2324
  none: "and moves on without review"
1311
2325
  };
1312
2326
  function describeStep(step) {
1313
- return `${ACTOR_LEAD[step.actor]} handles "${step.label}," ${REVIEW_MODE_TAIL[step.reviewMode]}.`;
2327
+ return `${ACTOR_LEAD[step.actor]} handles "${step.label}," ${REVIEW_VENUE_TAIL[step.reviewVenue]}.`;
1314
2328
  }
1315
2329
  function StepRow({ step, resourceId }) {
1316
2330
  return /* @__PURE__ */ jsxs(Group, { gap: "sm", align: "flex-start", wrap: "nowrap", children: [
@@ -1959,6 +2973,12 @@ function ContentDistributionDetailPage({
1959
2973
  const itemQuery = useContentItem(dist?.contentItemId);
1960
2974
  const [mediaOpened, { open: openMedia, close: closeMedia }] = useDisclosure(false);
1961
2975
  const [previewUrl, setPreviewUrl] = useState(null);
2976
+ const mediaEntries = useMemo(() => dist?.mediaUrls ?? [], [dist]);
2977
+ const mediaRefs = useMemo(
2978
+ () => mediaEntries.filter((entry) => entry.kind === "storage").map(({ bucket, path }) => ({ bucket, path })),
2979
+ [mediaEntries]
2980
+ );
2981
+ const { urls: signedMediaUrls } = useStorageSignedUrls(mediaRefs);
1962
2982
  const backButton = /* @__PURE__ */ jsx(
1963
2983
  Button,
1964
2984
  {
@@ -1985,7 +3005,9 @@ function ContentDistributionDetailPage({
1985
3005
  const contentTitle = contentItem?.title ?? dist.contentItemId;
1986
3006
  const checklist = toChecklist(dist.checklist);
1987
3007
  const checklistProgress = getChecklistProgress(checklist);
1988
- const mediaUrls = dist.mediaUrls ?? [];
3008
+ const mediaItems = mediaEntries.map(
3009
+ (entry) => entry.kind === "storage" ? { key: storageObjectRefKey(entry), url: signedMediaUrls[storageObjectRefKey(entry)] ?? null } : { key: entry.url, url: entry.url }
3010
+ );
1989
3011
  const platformEntry = contentConfig.stageCatalog[dist.platform];
1990
3012
  const platformColors = getContentStageColor(platformEntry);
1991
3013
  const pillarEntry = contentItem?.pillar ? contentConfig.stageCatalog[contentItem.pillar] : void 0;
@@ -2161,24 +3183,24 @@ function ContentDistributionDetailPage({
2161
3183
  /* @__PURE__ */ jsx(Card, { withBorder: true, children: /* @__PURE__ */ jsxs(Stack, { gap: "sm", children: [
2162
3184
  /* @__PURE__ */ jsxs(Group, { justify: "space-between", children: [
2163
3185
  /* @__PURE__ */ jsx(Title, { order: 3, children: "Media" }),
2164
- mediaUrls.length > 0 ? /* @__PURE__ */ jsxs(Text, { size: "xs", c: "dimmed", children: [
2165
- mediaUrls.length,
3186
+ mediaItems.length > 0 ? /* @__PURE__ */ jsxs(Text, { size: "xs", c: "dimmed", children: [
3187
+ mediaItems.length,
2166
3188
  " image",
2167
- mediaUrls.length !== 1 ? "s" : ""
3189
+ mediaItems.length !== 1 ? "s" : ""
2168
3190
  ] }) : null
2169
3191
  ] }),
2170
3192
  /* @__PURE__ */ jsx(Divider, {}),
2171
- mediaUrls.length === 0 ? /* @__PURE__ */ jsx(Text, { size: "sm", c: "dimmed", children: "No media yet." }) : /* @__PURE__ */ jsx(SimpleGrid, { cols: { base: 1, xs: 2, sm: 3 }, spacing: "sm", children: mediaUrls.map((url, index) => /* @__PURE__ */ jsxs(Card, { withBorder: true, radius: "md", style: { overflow: "hidden" }, children: [
2172
- /* @__PURE__ */ jsx(
2173
- Image,
3193
+ mediaItems.length === 0 ? /* @__PURE__ */ jsx(Text, { size: "sm", c: "dimmed", children: "No media yet." }) : /* @__PURE__ */ jsx(SimpleGrid, { cols: { base: 1, xs: 2, sm: 3 }, spacing: "sm", children: mediaItems.map((item, index) => /* @__PURE__ */ jsxs(Card, { withBorder: true, radius: "md", style: { overflow: "hidden" }, children: [
3194
+ item.url === null ? /* @__PURE__ */ jsx(Center, { h: 120, children: /* @__PURE__ */ jsx(Loader, { size: "sm" }) }) : /* @__PURE__ */ jsx(
3195
+ Image$1,
2174
3196
  {
2175
- src: url,
3197
+ src: item.url,
2176
3198
  alt: `Media variant ${index + 1}`,
2177
3199
  w: "100%",
2178
3200
  radius: "sm",
2179
3201
  style: { cursor: "pointer" },
2180
3202
  onClick: () => {
2181
- setPreviewUrl(url);
3203
+ setPreviewUrl(item.url);
2182
3204
  openMedia();
2183
3205
  }
2184
3206
  }
@@ -2189,21 +3211,45 @@ function ContentDistributionDetailPage({
2189
3211
  {
2190
3212
  variant: "subtle",
2191
3213
  size: "sm",
3214
+ disabled: item.url === null,
2192
3215
  onClick: () => {
2193
- setPreviewUrl(url);
3216
+ setPreviewUrl(item.url);
2194
3217
  openMedia();
2195
3218
  },
2196
3219
  children: /* @__PURE__ */ jsx(IconMaximize, { size: 16 })
2197
3220
  }
2198
3221
  ) }),
2199
- /* @__PURE__ */ jsx(Tooltip, { label: "Copy URL", children: /* @__PURE__ */ jsx(ActionIcon, { variant: "subtle", size: "sm", onClick: () => navigator.clipboard.writeText(url), children: /* @__PURE__ */ jsx(IconCopy, { size: 16 }) }) }),
2200
- /* @__PURE__ */ jsx(Tooltip, { label: "Download", children: /* @__PURE__ */ jsx(ActionIcon, { variant: "subtle", size: "sm", component: "a", href: url, download: true, target: "_blank", children: /* @__PURE__ */ jsx(IconDownload, { size: 16 }) }) })
3222
+ /* @__PURE__ */ jsx(Tooltip, { label: "Copy URL", children: /* @__PURE__ */ jsx(
3223
+ ActionIcon,
3224
+ {
3225
+ variant: "subtle",
3226
+ size: "sm",
3227
+ disabled: item.url === null,
3228
+ onClick: () => {
3229
+ if (item.url !== null) void navigator.clipboard.writeText(item.url);
3230
+ },
3231
+ children: /* @__PURE__ */ jsx(IconCopy, { size: 16 })
3232
+ }
3233
+ ) }),
3234
+ /* @__PURE__ */ jsx(Tooltip, { label: "Download", children: /* @__PURE__ */ jsx(
3235
+ ActionIcon,
3236
+ {
3237
+ variant: "subtle",
3238
+ size: "sm",
3239
+ disabled: item.url === null,
3240
+ component: "a",
3241
+ href: item.url ?? void 0,
3242
+ download: true,
3243
+ target: "_blank",
3244
+ children: /* @__PURE__ */ jsx(IconDownload, { size: 16 })
3245
+ }
3246
+ ) })
2201
3247
  ] })
2202
- ] }, url)) })
3248
+ ] }, item.key)) })
2203
3249
  ] }) })
2204
3250
  ] }),
2205
- /* @__PURE__ */ jsx(CustomModal, { opened: mediaOpened, onClose: closeMedia, size: "xl", children: previewUrl ? /* @__PURE__ */ jsx(Image, { src: previewUrl, alt: "Media preview", fit: "contain" }) : null })
3251
+ /* @__PURE__ */ jsx(CustomModal, { opened: mediaOpened, onClose: closeMedia, size: "xl", children: previewUrl ? /* @__PURE__ */ jsx(Image$1, { src: previewUrl, alt: "Media preview", fit: "contain" }) : null })
2206
3252
  ] });
2207
3253
  }
2208
3254
 
2209
- export { AlternatesPanel, ContentDistributionDetailPage, ContentDistributionsPage, ContentItemReviewPage, ContentItemsPage, ContentOverviewPage, ContentPipelineBoardPage, ContentPipelineBoardPage as ContentPipelineWorkspacePage, ContentPipelinesPage, ContentReviewCard, PayloadBody, ProcessingStateStrip, ReviewActionBar, firstLine, formatElapsed, lastLine, swapFirstLine, swapLastLine };
3255
+ export { AlternatesPanel, AssetCropEditor, CAPTION_STEP_KEY, CAPTION_WORKFLOW_ID, CONTENT_DERIVATIVES_BUCKET, CROP_ASPECT_OPTIONS, CaptionPanel, ContentDistributionDetailPage, ContentDistributionsPage, ContentItemReviewPage, ContentItemsPage, ContentOverviewPage, ContentPipelineBoardPage, ContentPipelineBoardPage as ContentPipelineWorkspacePage, ContentPipelinesPage, ContentReviewCard, DERIVATIVE_JPEG_QUALITY, DERIVATIVE_MAX_WIDTH, IMAGE_ANALYSIS_WORKFLOW_ID, PayloadBody, PostPreview, ProcessingStateStrip, ReviewActionBar, SelectedAssetStrip, SlideAnalysisPanel, SourceAssetsPanel, cropStyle, derivativeObjectKey, firstLine, formatElapsed, isDerivativeStale, lastLine, readStoredAnalysis, renderCropToJpeg, swapFirstLine, swapLastLine };