Skip to content

Commit 1e2bb68

Browse files
authored
added view drafting page (#115)
* added view resource page * review changes * fixed type errors * updated code as per review * review fixes
1 parent 9600341 commit 1e2bb68

8 files changed

Lines changed: 486 additions & 420 deletions

File tree

src/hooks/useDrafting.ts

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2+
3+
import { useBibleTextDebounce } from '@/hooks/useBibleTextDebounce';
4+
import { type Source, type TargetVerse } from '@/lib/types';
5+
6+
interface UseDraftingProps {
7+
sourceVerses: Source[];
8+
targetVerses: TargetVerse[];
9+
readOnly: boolean;
10+
onSave: (verse: number, text: string) => Promise<void>;
11+
}
12+
13+
export const useDrafting = ({ sourceVerses, targetVerses, readOnly, onSave }: UseDraftingProps) => {
14+
const [verses, setVerses] = useState<TargetVerse[]>(targetVerses);
15+
const [activeVerseId, setActiveVerseId] = useState(1);
16+
const [previousActiveVerseId, setPreviousActiveVerseId] = useState<number | null>(null);
17+
const [revealedVerses, setRevealedVerses] = useState<Set<number>>(new Set());
18+
const [buttonTop, setButtonTop] = useState<number>(0);
19+
20+
const targetScrollRef = useRef<HTMLDivElement>(null);
21+
const textareaRefs = useRef<Record<number, HTMLTextAreaElement | null>>({});
22+
const verseRefs = useRef<Record<number, HTMLDivElement | null>>({});
23+
24+
const { debouncedSave, saveImmediately, getSaveStatus, setInitialContent } = useBibleTextDebounce(
25+
{
26+
onSave,
27+
debounceMs: 2000,
28+
retryDelayMs: 10000,
29+
}
30+
);
31+
32+
const lastRevealedVerseNumber = useMemo(
33+
() => (revealedVerses.size > 0 ? Math.max(...Array.from(revealedVerses)) : 1),
34+
[revealedVerses]
35+
);
36+
37+
const lastRevealedVerse = useMemo(
38+
() => verses.find(v => v.verseNumber === lastRevealedVerseNumber),
39+
[verses, lastRevealedVerseNumber]
40+
);
41+
42+
const lastRevealedVerseHasContent = useMemo(
43+
() => Boolean(lastRevealedVerse?.content.trim()),
44+
[lastRevealedVerse]
45+
);
46+
47+
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement) => {
48+
textarea.style.height = 'auto';
49+
textarea.style.height = Math.max(20, textarea.scrollHeight) + 'px';
50+
}, []);
51+
52+
const updateButtonPosition = useCallback(() => {
53+
if (readOnly) return;
54+
const container = targetScrollRef.current;
55+
if (!container) return;
56+
const lastRevealedVerseDiv = verseRefs.current[lastRevealedVerseNumber];
57+
if (!lastRevealedVerseDiv) return;
58+
const containerRect = container.getBoundingClientRect();
59+
const verseRect = lastRevealedVerseDiv.getBoundingClientRect();
60+
const top = container.scrollTop + (verseRect.bottom - containerRect.top);
61+
setButtonTop(top);
62+
}, [lastRevealedVerseNumber, readOnly]);
63+
64+
const scrollVerseToTop = useCallback((verseNumber: number) => {
65+
const container = targetScrollRef.current;
66+
const row = verseRefs.current[verseNumber];
67+
if (!container || !row) return;
68+
const containerRect = container.getBoundingClientRect();
69+
const rowRect = row.getBoundingClientRect();
70+
const newScrollTop = container.scrollTop + (rowRect.top - containerRect.top);
71+
container.scrollTo({ top: newScrollTop, behavior: 'smooth' });
72+
}, []);
73+
74+
const handleTextChange = useCallback(
75+
(verseId: number, text: string) => {
76+
if (readOnly) return;
77+
setVerses(currentVerses =>
78+
currentVerses.map(verse =>
79+
verse.verseNumber === verseId ? { ...verse, content: text } : verse
80+
)
81+
);
82+
debouncedSave(verseId, text);
83+
const textarea = textareaRefs.current[verseId];
84+
if (textarea) autoResizeTextarea(textarea);
85+
updateButtonPosition();
86+
},
87+
[readOnly, debouncedSave, autoResizeTextarea, updateButtonPosition]
88+
);
89+
90+
const handleActiveVerseChange = useCallback(
91+
async (newVerseId: number) => {
92+
if (readOnly) return;
93+
if (previousActiveVerseId !== null && previousActiveVerseId !== newVerseId) {
94+
const previousVerse = verses.find(v => v.verseNumber === previousActiveVerseId);
95+
if (previousVerse) {
96+
const status = getSaveStatus(previousActiveVerseId);
97+
if (status.hasUnsavedChanges) {
98+
await saveImmediately(previousActiveVerseId, previousVerse.content);
99+
}
100+
}
101+
}
102+
setPreviousActiveVerseId(activeVerseId);
103+
setActiveVerseId(newVerseId);
104+
const prevId = Math.max(1, newVerseId - 1);
105+
requestAnimationFrame(() => scrollVerseToTop(prevId));
106+
},
107+
[
108+
readOnly,
109+
previousActiveVerseId,
110+
verses,
111+
activeVerseId,
112+
getSaveStatus,
113+
saveImmediately,
114+
scrollVerseToTop,
115+
]
116+
);
117+
118+
const advanceToVerse = useCallback(
119+
async (nextVerseId: number, verseToSave?: { verseNumber: number; content: string }) => {
120+
if (readOnly || nextVerseId > sourceVerses.length) return;
121+
const nextVerseExists = verses.find(v => v.verseNumber === nextVerseId);
122+
if (!nextVerseExists) {
123+
setVerses(prev => [...prev, { verseNumber: nextVerseId, content: '' }]);
124+
setInitialContent(nextVerseId, '');
125+
}
126+
if (verseToSave) {
127+
const status = getSaveStatus(verseToSave.verseNumber);
128+
if (status.hasUnsavedChanges) {
129+
await saveImmediately(verseToSave.verseNumber, verseToSave.content);
130+
}
131+
}
132+
setPreviousActiveVerseId(activeVerseId);
133+
setActiveVerseId(nextVerseId);
134+
const prevId = Math.max(1, nextVerseId - 1);
135+
requestAnimationFrame(() => scrollVerseToTop(prevId));
136+
},
137+
[
138+
activeVerseId,
139+
verses,
140+
sourceVerses.length,
141+
saveImmediately,
142+
setInitialContent,
143+
getSaveStatus,
144+
scrollVerseToTop,
145+
readOnly,
146+
]
147+
);
148+
149+
const moveToNextVerse = useCallback(async () => {
150+
if (readOnly) return;
151+
const currentVerse = verses.find(v => v.verseNumber === activeVerseId);
152+
if (!currentVerse || currentVerse.content.trim() === '') return;
153+
await advanceToVerse(activeVerseId + 1, currentVerse);
154+
}, [activeVerseId, verses, advanceToVerse, readOnly]);
155+
156+
const revealNextVerse = useCallback(async () => {
157+
if (readOnly || !lastRevealedVerseHasContent || !lastRevealedVerse) return;
158+
await advanceToVerse(lastRevealedVerseNumber + 1, lastRevealedVerse);
159+
}, [
160+
lastRevealedVerseNumber,
161+
lastRevealedVerseHasContent,
162+
lastRevealedVerse,
163+
advanceToVerse,
164+
readOnly,
165+
]);
166+
167+
// Initialize verses and revealed state
168+
useEffect(() => {
169+
if (targetVerses.length === 0) return;
170+
if (!readOnly) {
171+
targetVerses.forEach(verse => setInitialContent(verse.verseNumber, verse.content));
172+
}
173+
const lastVerseWithContent = (() => {
174+
for (let i = targetVerses.length - 1; i >= 0; i--) {
175+
if (targetVerses[i].content.trim() !== '') return targetVerses[i];
176+
}
177+
return targetVerses[0];
178+
})();
179+
const allVersesCompleted = sourceVerses.every(sourceVerse => {
180+
const targetVerse = targetVerses.find(tv => tv.verseNumber === sourceVerse.verseNumber);
181+
return targetVerse && targetVerse.content.trim() !== '';
182+
});
183+
const activeVerseNumber = allVersesCompleted ? 1 : lastVerseWithContent.verseNumber;
184+
setActiveVerseId(activeVerseNumber);
185+
if (!allVersesCompleted && activeVerseNumber > 1 && !readOnly) {
186+
const verseDiv = verseRefs.current[activeVerseNumber];
187+
if (verseDiv) verseDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
188+
}
189+
const initiallyRevealed = new Set<number>();
190+
if (readOnly) {
191+
sourceVerses.forEach(v => initiallyRevealed.add(v.verseNumber));
192+
} else {
193+
targetVerses
194+
.filter(v => v.verseNumber <= lastVerseWithContent.verseNumber)
195+
.forEach(v => initiallyRevealed.add(v.verseNumber));
196+
initiallyRevealed.add(activeVerseNumber);
197+
}
198+
setRevealedVerses(initiallyRevealed);
199+
}, [targetVerses, sourceVerses, setInitialContent, readOnly]);
200+
201+
useEffect(() => {
202+
if (!readOnly) {
203+
setRevealedVerses(prev => {
204+
if (prev.has(activeVerseId)) return prev;
205+
const next = new Set(prev);
206+
next.add(activeVerseId);
207+
return next;
208+
});
209+
}
210+
}, [activeVerseId, readOnly]);
211+
212+
useEffect(() => {
213+
if (!readOnly) {
214+
revealedVerses.forEach(verseNumber => {
215+
const textarea = textareaRefs.current[verseNumber];
216+
if (textarea) autoResizeTextarea(textarea);
217+
});
218+
}
219+
}, [revealedVerses, readOnly, autoResizeTextarea]);
220+
221+
useEffect(() => {
222+
if (verses.length === 0) {
223+
setVerses([{ verseNumber: 1, content: '' }]);
224+
} else if (!readOnly) {
225+
verses.forEach(verse => {
226+
const textarea = textareaRefs.current[verse.verseNumber];
227+
if (textarea && verse.content) autoResizeTextarea(textarea);
228+
});
229+
}
230+
}, [verses, readOnly, autoResizeTextarea]);
231+
232+
useLayoutEffect(() => {
233+
if (readOnly) return;
234+
const textarea = textareaRefs.current[activeVerseId];
235+
if (textarea) {
236+
if (document.activeElement !== textarea) {
237+
textarea.focus();
238+
const len = textarea.value.length;
239+
try {
240+
textarea.setSelectionRange(len, len);
241+
} catch {}
242+
}
243+
autoResizeTextarea(textarea);
244+
}
245+
updateButtonPosition();
246+
}, [activeVerseId, revealedVerses, updateButtonPosition, readOnly, autoResizeTextarea]);
247+
248+
return {
249+
verses,
250+
activeVerseId,
251+
revealedVerses,
252+
buttonTop,
253+
lastRevealedVerseHasContent,
254+
targetScrollRef,
255+
textareaRefs,
256+
verseRefs,
257+
getSaveStatus,
258+
saveImmediately,
259+
handleTextChange,
260+
handleActiveVerseChange,
261+
moveToNextVerse,
262+
revealNextVerse,
263+
updateButtonPosition,
264+
};
265+
};

0 commit comments

Comments
 (0)