Skip to content

Commit 68c7198

Browse files
mbyrdLCSclaude
andauthored
Service Order: thumbnails, auto video times, and row-based drag & drop (#415)
- Rows show real media thumbnails: provider links are fetched fresh each page load (Dropbox links expire) and matched by providerContentPath or label; videos render a first frame via <video preload="metadata"> - Videos at 0:00 automatically get their measured duration saved on load; images stay at 0 (operator advances them) but display an italic ~5:00 planning estimate that schedule math includes - Drag & drop reworked: drag by handle only, every row is a drop target with a blue insertion line (no more layout-shifting drop boxes), drop onto a section header to add into that section, window edge auto-scroll - ActionDialog trusts the file-name extension over URL sniffing so .mp4 previews use the video player (provider links carry no extension) - Add @stripe/* devDependencies (optional peer deps of apphelper that Vite 8 fails hard on during a fresh install) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 217f1a5 commit 68c7198

11 files changed

Lines changed: 498 additions & 193 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
"@eslint/js": "^10.0.1",
4747
"@playwright/test": "^1.59.1",
4848
"@rolldown/plugin-babel": "^0.2.3",
49+
"@stripe/react-stripe-js": "^6.6.0",
50+
"@stripe/stripe-js": "^9.8.0",
4951
"@tanstack/react-query-devtools": "^5.100.9",
5052
"@types/babel__core": "^7.20.5",
5153
"@types/react": "^19.2.14",

src/components/DraggableWrapper.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@ type Props = {
88
data: any;
99
onDoubleClick?: () => void;
1010
draggingCallback?: (isDragging: boolean) => void;
11+
/** When set, only the first descendant with this class starts a drag (the whole
12+
* element still renders as the drag preview). Without it, the whole element drags. */
13+
handleClassName?: string;
1114
};
1215

1316
export function DraggableWrapper(props: Props) {
14-
const { dndType, data, draggingCallback, onDoubleClick, children } = props;
17+
const { dndType, data, draggingCallback, onDoubleClick, children, handleClassName } = props;
1518
const callbackRef = React.useRef(draggingCallback);
1619

1720
useEffect(() => {
1821
callbackRef.current = draggingCallback;
1922
}, [draggingCallback]);
2023

21-
const [{ isDragging }, drag] = useDrag(
24+
const [{ isDragging }, drag, preview] = useDrag(
2225
() => ({
2326
type: dndType,
2427
item: { data },
@@ -41,8 +44,16 @@ export function DraggableWrapper(props: Props) {
4144

4245
return (
4346
<div
44-
ref={(node) => { drag(node); }}
45-
style={{ opacity, transition: "opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1)", cursor: isDragging ? "grabbing" : "grab" }}
47+
ref={(node) => {
48+
if (handleClassName) {
49+
preview(node);
50+
const handle = node?.querySelector("." + handleClassName) as HTMLElement | null;
51+
drag(handle || node);
52+
} else {
53+
drag(node);
54+
}
55+
}}
56+
style={{ opacity, transition: "opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1)", ...(handleClassName ? {} : { cursor: isDragging ? "grabbing" : "grab" }) }}
4657
className="dragButton"
4758
onDoubleClick={onDoubleClick}
4859
data-testid="draggable-wrapper"

src/serving/components/ActionDialog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export const ActionDialog: React.FC<Props> = (props) => {
2929
fallbackUrl: props.downloadUrl
3030
});
3131

32+
// Provider download links often carry no file extension, so URL sniffing wrongly
33+
// defaults videos to "image". The item name still has the extension — trust it.
34+
const nameSaysVideo = /\.(mp4|webm|mov|m4v|avi|mkv)\s*$/i.test(props.contentName || "");
35+
const effectiveMediaType = nameSaysVideo && (!content?.mediaType || content.mediaType === "image")
36+
? "video"
37+
: content?.mediaType;
38+
3239
useEffect(() => {
3340
const handleMessage = (event: MessageEvent) => {
3441
if (typeof event.data?.height === "number") {
@@ -48,7 +55,7 @@ export const ActionDialog: React.FC<Props> = (props) => {
4855
<DialogContent sx={{ p: 0, overflow: "hidden" }}>
4956
<ContentRenderer
5057
url={content?.url}
51-
mediaType={content?.mediaType}
58+
mediaType={effectiveMediaType}
5259
title={props.contentName}
5360
description={content?.description}
5461
loading={loading}

src/serving/components/LessonPreview.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Locale } from "@churchapps/apphelper";
44
import { type PlanItemInterface } from "../../helpers";
55
import { getSectionDuration } from "./PlanUtils";
66
import { PlanItem } from "./PlanItem";
7+
import { type ProviderMediaInfo } from "./planItemUtils";
78

89
interface Props {
910
lessonItems: PlanItemInterface[];
@@ -12,6 +13,7 @@ interface Props {
1213
associatedProviderId?: string;
1314
associatedContentPath?: string;
1415
ministryId?: string;
16+
mediaLookup?: Record<string, ProviderMediaInfo>;
1517
}
1618

1719
export const LessonPreview = memo((props: Props) => {
@@ -34,6 +36,7 @@ export const LessonPreview = memo((props: Props) => {
3436
associatedProviderId={props.associatedProviderId}
3537
associatedContentPath={props.associatedContentPath}
3638
ministryId={props.ministryId}
39+
mediaLookup={props.mediaLookup}
3740
/>
3841
);
3942
cumulativeTime += getSectionDuration(item);

src/serving/components/PlanItem.tsx

Lines changed: 35 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import React from "react";
2-
import { Box, Menu, MenuItem } from "@mui/material";
2+
import { Menu, MenuItem } from "@mui/material";
33
import { FormatListBulleted as FormatListBulletedIcon, MenuBook as MenuBookIcon, MusicNote as MusicNoteIcon } from "@mui/icons-material";
44
import { type PlanItemInterface } from "../../helpers";
55
import { DraggableWrapper } from "../../components/DraggableWrapper";
6-
import { DroppableWrapper } from "../../components/DroppableWrapper";
6+
import { RowDropZone } from "./RowDropZone";
77
import { type TimeInterface, type PlanItemTimeInterface } from "@churchapps/helpers";
88
import { ApiHelper, Locale } from "@churchapps/apphelper";
99
import { SongDialog } from "./SongDialog";
1010
import { LessonDialog } from "./LessonDialog";
11-
import { getNextChildSort } from "./planItemUtils";
11+
import { getNextChildSort, estimateSeconds, type ProviderMediaInfo } from "./planItemUtils";
1212
import { ActionDialog } from "./ActionDialog";
1313
import { ActionSelector } from "./ActionSelector";
1414
import { PlanItemHeader, PlanItemRow } from "./planItem/index";
@@ -29,6 +29,7 @@ interface Props {
2929
exclusions?: PlanItemTimeInterface[];
3030
selectedServiceTimeId?: string;
3131
excluded?: boolean;
32+
mediaLookup?: Record<string, ProviderMediaInfo>;
3233
}
3334

3435
export const PlanItem = React.memo((props: Props) => {
@@ -123,63 +124,34 @@ export const PlanItem = React.memo((props: Props) => {
123124
props.planItem.children?.forEach((c, index) => {
124125
const childStartTime = cumulativeTime;
125126
const childExcluded = c.itemType !== "header" && isChildExcluded(c.id || "");
127+
const childPlanItem = (
128+
<PlanItem key={c.id} planItem={c} setEditPlanItem={props.setEditPlanItem} readOnly={props.readOnly} showItemDrop={props.showItemDrop} onDragChange={props.onDragChange} onChange={props.onChange} startTime={childStartTime} associatedContentPath={props.associatedContentPath} associatedProviderId={props.associatedProviderId} ministryId={props.ministryId} serviceTime={props.serviceTime} exclusions={props.exclusions} selectedServiceTimeId={props.selectedServiceTimeId} excluded={childExcluded} mediaLookup={props.mediaLookup} />
129+
);
126130
result.push(
127131
<React.Fragment key={c.id || `child-${index}`}>
128-
{props.showItemDrop && (
129-
<DroppableWrapper
132+
{props.readOnly ? (
133+
childPlanItem
134+
) : (
135+
<RowDropZone
130136
accept="planItem"
131-
onDrop={(item) => {
132-
handleDrop(item, index + 0.5);
137+
onDrop={(item, position) => {
138+
handleDrop(item, index + (position === "before" ? 0.5 : 1.5));
133139
}}>
134-
<Box
135-
sx={{
136-
height: "30px",
137-
border: "2px dashed",
138-
borderColor: "primary.main",
139-
borderRadius: 1,
140-
backgroundColor: "primary.light",
141-
opacity: 0.3,
142-
mb: 0.5
143-
}}
144-
/>
145-
</DroppableWrapper>
140+
<DraggableWrapper
141+
dndType="planItem"
142+
data={c}
143+
handleClassName="dragHandle"
144+
draggingCallback={(isDragging) => {
145+
if (props.onDragChange) props.onDragChange(isDragging);
146+
}}>
147+
{childPlanItem}
148+
</DraggableWrapper>
149+
</RowDropZone>
146150
)}
147-
148-
<DraggableWrapper
149-
dndType="planItem"
150-
data={c}
151-
draggingCallback={(isDragging) => {
152-
if (props.onDragChange) props.onDragChange(isDragging);
153-
}}>
154-
<PlanItem key={c.id} planItem={c} setEditPlanItem={props.setEditPlanItem} readOnly={props.readOnly} showItemDrop={props.showItemDrop} onDragChange={props.onDragChange} onChange={props.onChange} startTime={childStartTime} associatedContentPath={props.associatedContentPath} associatedProviderId={props.associatedProviderId} ministryId={props.ministryId} serviceTime={props.serviceTime} exclusions={props.exclusions} selectedServiceTimeId={props.selectedServiceTimeId} excluded={childExcluded} />
155-
</DraggableWrapper>
156151
</React.Fragment>
157152
);
158-
if (!childExcluded) cumulativeTime += c.seconds || 0;
153+
if (!childExcluded) cumulativeTime += estimateSeconds(c, props.mediaLookup);
159154
});
160-
if (props.showItemDrop) {
161-
result.push(
162-
<React.Fragment key="trailing-drop-zone">
163-
<DroppableWrapper
164-
accept="planItem"
165-
onDrop={(item) => {
166-
handleDrop(item, getNextChildSort(props.planItem.children));
167-
}}>
168-
<Box
169-
sx={{
170-
height: 30,
171-
border: "2px dashed",
172-
borderColor: "primary.main",
173-
borderRadius: 1,
174-
backgroundColor: "primary.light",
175-
opacity: 0.3,
176-
mb: 0.5
177-
}}
178-
/>
179-
</DroppableWrapper>
180-
</React.Fragment>
181-
);
182-
}
183155
return result;
184156
};
185157

@@ -191,6 +163,16 @@ export const PlanItem = React.memo((props: Props) => {
191163
readOnly={props.readOnly}
192164
onAddClick={(e) => setAnchorEl(e.currentTarget)}
193165
onEditClick={() => props.setEditPlanItem(props.planItem)}
166+
wrapRow={props.readOnly ? undefined : (row) => (
167+
<RowDropZone
168+
accept="planItem"
169+
mode="into"
170+
onDrop={(item) => {
171+
handleDrop(item, 0.5);
172+
}}>
173+
{row}
174+
</RowDropZone>
175+
)}
194176
>
195177
{getChildren()}
196178
</PlanItemHeader>
@@ -205,6 +187,7 @@ export const PlanItem = React.memo((props: Props) => {
205187
readOnly={props.readOnly}
206188
onLabelClick={onLabelClick}
207189
onEditClick={() => props.setEditPlanItem(props.planItem)}
190+
mediaLookup={props.mediaLookup}
208191
/>
209192
);
210193

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import React from "react";
2+
import { useDrop } from "react-dnd";
3+
4+
interface Props {
5+
accept: string;
6+
/** "reorder": drop above/below based on cursor half, shown as an insertion line.
7+
* "into": whole child is one target (e.g. a section header = add to that section). */
8+
mode?: "reorder" | "into";
9+
onDrop: (data: any, position: "before" | "after") => void;
10+
children: React.ReactNode;
11+
}
12+
13+
/**
14+
* Makes its child row a drop target for plan item drags. Unlike separate drop-zone
15+
* boxes, rows are always targets, so nothing shifts or appears when a drag starts —
16+
* a blue insertion line previews exactly where the item will land.
17+
*/
18+
export const RowDropZone: React.FC<Props> = ({ accept, mode = "reorder", onDrop, children }) => {
19+
const ref = React.useRef<HTMLDivElement>(null);
20+
const [hoverHalf, setHoverHalf] = React.useState<"before" | "after">("before");
21+
22+
const getHalf = (monitor: { getClientOffset: () => { y: number } | null }): "before" | "after" => {
23+
const rect = ref.current?.getBoundingClientRect();
24+
const y = monitor.getClientOffset()?.y;
25+
if (!rect || y == null) return "after";
26+
return y < rect.top + rect.height / 2 ? "before" : "after";
27+
};
28+
29+
const [{ isOver }, drop] = useDrop(() => ({
30+
accept,
31+
hover: (_item: any, monitor: any) => {
32+
if (mode === "reorder") setHoverHalf(getHalf(monitor));
33+
},
34+
drop: (item: any, monitor: any) => {
35+
onDrop(item, mode === "reorder" ? getHalf(monitor) : "after");
36+
},
37+
collect: (monitor) => ({ isOver: !!monitor.isOver({ shallow: true }) })
38+
}), [accept, mode, onDrop]);
39+
40+
drop(ref);
41+
42+
return (
43+
<div
44+
ref={ref}
45+
style={{
46+
position: "relative",
47+
...(mode === "into" && isOver
48+
? { outline: "2px solid var(--c1)", outlineOffset: -2, borderRadius: 4, background: "rgba(21, 101, 192, 0.06)" }
49+
: {})
50+
}}
51+
>
52+
{children}
53+
{isOver && mode === "reorder" && (
54+
<div
55+
style={{
56+
position: "absolute",
57+
left: 0,
58+
right: 0,
59+
[hoverHalf === "before" ? "top" : "bottom"]: -2,
60+
height: 4,
61+
background: "var(--c1)",
62+
borderRadius: 2,
63+
zIndex: 10,
64+
pointerEvents: "none",
65+
boxShadow: "0 0 4px rgba(21, 101, 192, 0.6)"
66+
}}
67+
/>
68+
)}
69+
</div>
70+
);
71+
};

0 commit comments

Comments
 (0)