diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 47f44c10f..018c7fe14 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,4 +28,4 @@ jobs: version: latest - run: supabase link --project-ref $SUPABASE_PROJECT_ID - - run: supabase db push + - run: supabase db push diff --git a/components/CommunityAudio/AudioPreview.js b/components/CommunityAudio/AudioPreview.js new file mode 100644 index 000000000..d9d63ce68 --- /dev/null +++ b/components/CommunityAudio/AudioPreview.js @@ -0,0 +1,47 @@ +import AudioPause from 'public/icons/audioPause.svg' +import AudioPlay from 'public/icons/audioPlay.svg' +import Download from 'public/icons/download-audio.svg' +import Loading from 'public/icons/progress.svg' + +export default function AudioPreview({ + audioUrl, + onPlay, + onPause, + isPlaying, + audioName, + loading, +}) { + return ( +
+

{audioName || 'audio'}

+ + {loading && !audioUrl ? ( + + ) : ( + + )} + + +
+ ) +} diff --git a/components/CommunityAudio/BookListReader.js b/components/CommunityAudio/BookListReader.js new file mode 100644 index 000000000..8661b7241 --- /dev/null +++ b/components/CommunityAudio/BookListReader.js @@ -0,0 +1,210 @@ +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useRouter } from 'next/router' + +import { Disclosure, Tab } from '@headlessui/react' +import { useTranslation } from 'next-i18next' + +import ChecksIcon from 'components/Project/BookList/ChecksIcon' +import Card from 'components/Project/Card' + +import { checkChapterVersesExist } from 'utils/helper' +import { useGetChaptersTranslate } from 'utils/hooks' + +import Down from 'public/icons/arrow-down.svg' + +function BookListReader({ books, setReference, reference, project, code }) { + const [currentBook, setCurrentBook] = useState(null) + const [createdOldTestamentBooks, createdNewTestamentBooks] = books + const { query, replace } = useRouter() + const { t } = useTranslation(['common', 'books']) + const refs = useRef([]) + const [chapters] = useGetChaptersTranslate({ code }) + + const scrollRefs = useRef({}) + const handleClose = (index) => { + refs.current.map((closeFunction, refIndex) => { + if (refIndex !== index) { + closeFunction() + } + }) + } + + const handleScroll = (bookid) => { + if (scrollRefs?.current && Object.keys(scrollRefs?.current).length) { + verseRef(scrollRefs.current[bookid]) + } + } + + const scrollTo = (currentBook, position) => { + let offset = 0 + const top = currentBook.offsetTop - 95 + switch (position) { + case 'center': + offset = currentBook.clientHeight / 2 - currentBook.parentNode.clientHeight / 2 + break + case 'top': + default: + break + } + + currentBook.parentNode.scrollTo({ left: 0, top: top + offset, behavior: 'smooth' }) + } + + const { tabs, defaultIndex } = useMemo(() => { + const index = [createdNewTestamentBooks, createdOldTestamentBooks]?.findIndex( + (list) => list?.find((el) => el.code === query.bookid) + ) + + const tabs = + project?.type === 'obs' ? ['OpenBibleStories'] : ['NewTestament', 'OldTestament'] + + const defaultIndex = index === -1 ? 0 : index + + return { defaultIndex, tabs } + }, [createdNewTestamentBooks, createdOldTestamentBooks, query.bookid, project?.type]) + + const verseRef = useCallback((node) => { + if (node !== null) { + setCurrentBook(node) + } + }, []) + + useEffect(() => { + if (currentBook) { + scrollTo(currentBook, 'top') + } + }, [currentBook]) + + useEffect(() => { + if (reference?.bookid) { + handleScroll(reference.bookid) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reference?.bookid]) + return ( + +
+ + + {tabs.map((tab) => ( + + {({ selected }) => ( +
+ {t(tab)} +
+ )} +
+ ))} +
+ + + {[ + ...(createdNewTestamentBooks !== undefined + ? [createdNewTestamentBooks] + : []), + ...(createdOldTestamentBooks !== undefined + ? [createdOldTestamentBooks] + : []), + ].map((list, idx) => ( + + {list?.map((book, index) => ( + (scrollRefs.current[book.code] = ref)} + > + {({ open, close }) => { + return ( + <> + (refs.current[index] = close)} + onClick={() => { + handleClose(index) + replace( + { + query: { ...query, bookid: book.code }, + }, + undefined, + { shallow: true } + ) + }} + className={`flex w-full items-center justify-between py-2 hover:opacity-70 ${ + !open ? 'border-b border-th-secondary-300' : '' + }`} + > +
+ +
{t('books:' + book.code)}
+
+ +
+ +
+ {[...Array(Object.keys(book.chapters).length).keys()] + .map((el) => el + 1) + .map((index) => ( + + ))} +
+
+ + ) + }} +
+ ))} +
+ ))} +
+
+
+
+ ) +} + +export default BookListReader diff --git a/components/CommunityAudio/CommunityAudio.js b/components/CommunityAudio/CommunityAudio.js new file mode 100644 index 000000000..9f0c07846 --- /dev/null +++ b/components/CommunityAudio/CommunityAudio.js @@ -0,0 +1,205 @@ +import { useEffect, useMemo, useState } from 'react' + +import Link from 'next/link' +import { useRouter } from 'next/router' + +import { useTranslation } from 'react-i18next' + +import ParticipantInfo from 'components/Project/ParticipantInfo' + +import BookListReader from './BookListReader' +import CommunityAudioRecorder from './CommunityAudioRecorder' +import Teleprompter from './Teleprompter' +import { useAudioRecorder } from './useAudio' + +import { useCurrentUser } from 'lib/UserContext' + +import { newTestamentList, oldTestamentList, usfmFileNames } from 'utils/config' +import { checkBookCodeExists, getVerseObjectsForBookAndChapter } from 'utils/helper' +import { + useAccess, + useGetBooks, + useGetChaptersTranslate, + useGetResource, + useProject, +} from 'utils/hooks' + +import Left from 'public/icons/left.svg' + +function CommunityAudio({ code, bookid }) { + const defaultValues = { + fontSize: 16, + textSpeed: 1, + } + const [fontSize, setFontSize] = useState(defaultValues.fontSize) + const [textSpeed, setTextSpeed] = useState(defaultValues.textSpeed) + const { t } = useTranslation(['books']) + const { lang } = useRouter() + + const { + isRecording, + isPaused, + audioUrl, + startRecording, + stopRecording, + pauseRecording, + resumeRecording, + loading, + } = useAudioRecorder() + + const { user } = useCurrentUser() + const [reference, setReference] = useState() + const [books] = useGetBooks({ + code, + }) + const [project] = useProject({ code }) + const [{ isCoordinatorAccess }] = useAccess({ + user_id: user?.id, + code: project?.code, + }) + const [chapters] = useGetChaptersTranslate({ code }) + + const resource = useMemo(() => { + if (reference?.checks) { + const resource = reference?.checks?.url?.split('/') + return { + owner: resource[3], + repo: resource[4], + commit: resource[6], + bookPath: + project?.type === 'obs' ? './content' : './' + usfmFileNames[reference?.bookid], + } + } + }, [project?.type, reference?.bookid, reference?.checks]) + + const { isLoading, data: verseObjects } = useGetResource({ + config: { + reference: { book: reference?.bookid, chapter: reference?.chapter }, + resource: resource || { owner: '', repo: '', commit: '', bookPath: '' }, + }, + url: `/api/git/${project?.type}`, + }) + + const verseObjectsToUse = + verseObjects || + getVerseObjectsForBookAndChapter(chapters, reference?.bookid, reference?.chapter) + + const { createdNewTestamentBooks, createdOldTestamentBooks } = useMemo(() => { + const createdNewTestamentBooks = books + ? books + .filter((book) => + Object.keys(newTestamentList).some( + (nt) => + nt === book.code && + (book?.level_checks || checkBookCodeExists(book.code, chapters)) + ) + ) + .sort((a, b) => { + return ( + Object.keys(newTestamentList).indexOf(a.code) - + Object.keys(newTestamentList).indexOf(b.code) + ) + }) + : [] + const createdOldTestamentBooks = books + ? books + .filter((book) => + Object.keys(oldTestamentList).some( + (ot) => + ot === book.code && + (book?.level_checks || checkBookCodeExists(book.code, chapters)) + ) + ) + .sort((a, b) => { + return ( + Object.keys(oldTestamentList).indexOf(a.code) - + Object.keys(oldTestamentList).indexOf(b.code) + ) + }) + : [] + + return { createdNewTestamentBooks, createdOldTestamentBooks } + }, [books, chapters]) + + useEffect(() => { + if (bookid && books) { + const book = books.find((book) => book.code === bookid) + setReference((prev) => ({ + ...prev, + chapter: 1, + bookid, + checks: book.level_checks, + })) + } + }, [bookid, books]) + + const audioName = + reference && + `${t(`books:${reference.bookid}_abbr`)}_${reference.chapter}_${lang === 'ru' ? `${new Date().getDate()}${new Date().getMonth()}` : `${new Date().getMonth()}${new Date().getDate()}`}${new Date().getFullYear().toString().slice(2)}` + + return ( +
+
+
+ +
+
+ +
+
+
+ +
+
+ + + +
+ +
+
+
+ ) +} + +export default CommunityAudio diff --git a/components/CommunityAudio/CommunityAudioRecorder.js b/components/CommunityAudio/CommunityAudioRecorder.js new file mode 100644 index 000000000..bd1b573e4 --- /dev/null +++ b/components/CommunityAudio/CommunityAudioRecorder.js @@ -0,0 +1,55 @@ +import AudioPreview from './AudioPreview' +import FontSizeSetting from './FontSizeSetting' +import PauseButton from './PauseButton' +import RecordButton from './RecordButton' +import SpeedSetting from './SpeedSetting' +import StopButton from './StopButton' +import { useAudioPreview } from './useAudio' + +function CommunityAudioRecorder({ + isRecording = false, + isPaused = false, + audioUrl, + audioName = '', + loading = false, + recordingMethods: { startRecording, stopRecording, pauseRecording, resumeRecording }, + textAdjustment: { fontSize = 16, setFontSize, textSpeed = 1, setTextSpeed }, +}) { + const { isPlaying, play, pause } = useAudioPreview(audioUrl || '') + + return ( +
+
+ + +
+
+ + + +
+
+ +
+
+ ) +} + +export default CommunityAudioRecorder diff --git a/components/CommunityAudio/FontSizeSetting.js b/components/CommunityAudio/FontSizeSetting.js new file mode 100644 index 000000000..f2dc58752 --- /dev/null +++ b/components/CommunityAudio/FontSizeSetting.js @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react' + +import { useTranslation } from 'react-i18next' + +export default function FontSizeSetting({ fontSize, setFontSize }) { + const [isMounted, setIsMounted] = useState(false) + + const { t } = useTranslation(['common']) + + const minSize = 12 + const maxSize = 48 + + useEffect(() => { + setIsMounted(true) + }, []) + + if (!isMounted || !fontSize || !setFontSize) return null + + return ( +
+
+ + +
+
+

+ {fontSize} {t('FontSize')} +

+
+
+ ) +} diff --git a/components/CommunityAudio/PauseButton.js b/components/CommunityAudio/PauseButton.js new file mode 100644 index 000000000..583409291 --- /dev/null +++ b/components/CommunityAudio/PauseButton.js @@ -0,0 +1,15 @@ +import AudioPause from 'public/icons/audioPause.svg' + +export default function PauseButton({ isPaused, isRecording, onPause, onResume }) { + return ( + + ) +} diff --git a/components/CommunityAudio/RecordButton.js b/components/CommunityAudio/RecordButton.js new file mode 100644 index 000000000..e83367154 --- /dev/null +++ b/components/CommunityAudio/RecordButton.js @@ -0,0 +1,23 @@ +import Record from 'public/icons/audioRecord.svg' + +export default function RecordButton({ + isPaused, + isRecording, + startRecording, + resumeRecording, +}) { + return ( + + ) +} diff --git a/components/CommunityAudio/SpeedSetting.js b/components/CommunityAudio/SpeedSetting.js new file mode 100644 index 000000000..c01eec75d --- /dev/null +++ b/components/CommunityAudio/SpeedSetting.js @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' + +import { useTranslation } from 'react-i18next' + +import Minus from 'public/icons/audioMinus.svg' +import Plus from 'public/icons/audioPlus.svg' + +export default function SpeedSetting({ textSpeed, setTextSpeed }) { + const [isMounted, setIsMounted] = useState(false) + const { t } = useTranslation(['common']) + + const minSpeed = 1 + const maxSpeed = 15 + + useEffect(() => { + setIsMounted(true) + }, []) + + if (!isMounted || !textSpeed || !setTextSpeed) return null + + return ( +
+
+ + +
+
+

+ {textSpeed.toString().padStart(2, '0')}{' '} + {t('TextSpeed')} +

+
+
+ ) +} diff --git a/components/CommunityAudio/StopButton.js b/components/CommunityAudio/StopButton.js new file mode 100644 index 000000000..fc31a3577 --- /dev/null +++ b/components/CommunityAudio/StopButton.js @@ -0,0 +1,15 @@ +import Stop from 'public/icons/audioStop.svg' + +export default function StopButton({ isRecording, stopRecording }) { + return ( + + ) +} diff --git a/components/CommunityAudio/Teleprompter.js b/components/CommunityAudio/Teleprompter.js new file mode 100644 index 000000000..68741cd4c --- /dev/null +++ b/components/CommunityAudio/Teleprompter.js @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { useRouter } from 'next/router' + +import { useTranslation } from 'next-i18next' + +import Breadcrumbs from './TeleprompterBreadCrumbs' + +import { getVerseCount, getVerseCountOBS } from 'utils/helper' +import { useAccess, useGetBooks, useProject } from 'utils/hooks' + +function Teleprompter({ + verseObjects, + user, + reference, + isLoading, + isRecording, + isPaused, + stopRecording, + textProperties: { fontSize, textSpeed }, +}) { + const { + push, + query: { bookid, code }, + } = useRouter() + + const [{ isCoordinatorAccess }] = useAccess({ + user_id: user?.id, + code, + }) + const [project] = useProject({ code }) + const { t } = useTranslation(['common', 'books']) + const [books] = useGetBooks({ code }) + + const [isPlaying, setIsPlaying] = useState(false) + const containerRef = useRef(null) + const animationRef = useRef(null) + const scrollPositionRef = useRef(0) + + const verseCount = useMemo(() => { + if (project?.type === 'obs') { + return getVerseCountOBS(books, reference?.chapter) + } else { + return getVerseCount(books, bookid, reference?.chapter) + } + }, [books, project?.type, bookid, reference?.chapter]) + + const handleReset = useCallback(() => { + setIsPlaying(false) + scrollPositionRef.current = 0 + if (containerRef.current) { + containerRef.current.scrollTop = 0 + } + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + }, []) + + const LoadingSection = () => ( +
+
+ {[...Array(22).keys()].map((el) => ( +
+
+
+ ))} +
+ ) + + const NoContentSection = () => ( + <> +

{t('NoContent')}

+ {isCoordinatorAccess && ( +
+ push({ + pathname: `/projects/${project?.code}`, + query: { properties: bookid, levels: true }, + }) + } + > + {t('CheckLinkResource')} +
+ )} + + ) + + useEffect(() => { + if (isRecording && !isPaused) { + setIsPlaying(true) + } + if (isPaused) { + setIsPlaying(false) + } + + if (!isRecording) { + handleReset() + } + }, [fontSize, textSpeed, isRecording, isPaused, handleReset]) + + useEffect(() => { + let previousTimestamp = null + + const animate = (timestamp) => { + if (!previousTimestamp) previousTimestamp = timestamp + if (!containerRef.current) return + + const elapsed = timestamp - previousTimestamp + const speed = textSpeed * 0.004 + + scrollPositionRef.current += elapsed * speed + containerRef.current.scrollTop = scrollPositionRef.current + + previousTimestamp = timestamp + + if ( + containerRef.current.scrollTop >= + containerRef.current.scrollHeight - containerRef.current.clientHeight + ) { + stopRecording() + setIsPlaying(false) + handleReset() + return + } + + if (isPlaying) { + animationRef.current = requestAnimationFrame(animate) + } + } + + if (isPlaying) { + animationRef.current = requestAnimationFrame(animate) + } + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + } + }, [isPlaying, textSpeed, handleReset, stopRecording]) + + return ( +
+
+ +
+
+
+ +
+ {!isLoading ? ( + verseObjects ? ( +
+ {reference?.chapter && ( +

{t('Chapter') + ' ' + reference?.chapter}

+ )} + + {Array.from({ length: Math.min(verseCount + 1, 200) }).map((_, index) => { + const verseIndex = verseObjects?.verseObjects?.findIndex( + (verse) => parseInt(verse.verse) === index + ) + const text = + verseObjects?.verseObjects && verseIndex !== -1 + ? verseObjects.verseObjects[verseIndex].text + : ' ' + + return ( +
+

+ {index !== 0 && {index}} {text} +

+
+ ) + })} + {verseObjects?.verseObjects && ( +
+ {verseObjects.verseObjects.find((verse) => verse.verse === 200)?.text} +
+ )} +
+ ) : ( + + ) + ) : ( + + )} + +
+
+
+
+ ) +} + +export default Teleprompter diff --git a/components/CommunityAudio/TeleprompterBreadCrumbs.js b/components/CommunityAudio/TeleprompterBreadCrumbs.js new file mode 100644 index 000000000..67df3dac9 --- /dev/null +++ b/components/CommunityAudio/TeleprompterBreadCrumbs.js @@ -0,0 +1,16 @@ +import Link from 'next/link' + +import LeftArrow from 'public/icons/left.svg' + +export default function Breadcrumbs({ full, title, backLink }) { + return ( +
+
+ + + +

{title}

+
+
+ ) +} diff --git a/components/CommunityAudio/useAudio.js b/components/CommunityAudio/useAudio.js new file mode 100644 index 000000000..1475a8f7d --- /dev/null +++ b/components/CommunityAudio/useAudio.js @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Mp3Encoder } from 'lamejs' +import toast from 'react-hot-toast' +import { useTranslation } from 'react-i18next' + +export function useAudioRecorder(bitrate = 128) { + const [isRecording, setIsRecording] = useState(false) + const [isPaused, setIsPaused] = useState(false) + const [audioUrl, setAudioUrl] = useState(null) + const [loading, setLoading] = useState(false) + + const mediaRecorder = useRef(null) + const audioChunks = useRef([]) + + const { t } = useTranslation(['audio']) + + const encodeToMp3 = useCallback( + async (chunks) => { + try { + const samplesAmount = 1152 + const maxPosValue = 32767 + const audioBlob = new Blob(chunks, { type: 'audio/webm' }) + const arrayBuffer = await audioBlob.arrayBuffer() + + const audioContext = new AudioContext() + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer) + + const mp3Data = [] + const encoder = new Mp3Encoder(1, audioBuffer.sampleRate, bitrate) + + const samples = audioBuffer.getChannelData(0) + const buffer = new Int16Array(samples.length) + + for (let i = 0; i < samples.length; i++) { + buffer[i] = samples[i] * maxPosValue + } + + let sampleIndex = 0 + while (sampleIndex < buffer.length) { + const sampleChunk = buffer.subarray(sampleIndex, sampleIndex + samplesAmount) + const mp3Chunk = encoder.encodeBuffer(sampleChunk) + if (mp3Chunk.length > 0) mp3Data.push(new Uint8Array(mp3Chunk)) + sampleIndex += samplesAmount + } + + const mp3Final = encoder.flush() + if (mp3Final.length > 0) mp3Data.push(new Uint8Array(mp3Final)) + + const mp3Blob = new Blob(mp3Data, { type: 'audio/mp3' }) + return URL.createObjectURL(mp3Blob) + } catch (error) { + console.error('Error encoding MP3:', error) + toast.error(t('audio:EncodingError'), { position: 'bottom-right' }) + return null + } + }, + [t, bitrate] + ) + + const startRecording = useCallback(async () => { + audioChunks.current = [] + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + mediaRecorder.current = new MediaRecorder(stream) + + mediaRecorder.current.ondataavailable = (event) => { + audioChunks.current.push(event.data) + } + + mediaRecorder.current.onstop = async () => { + const mp3Url = await encodeToMp3(audioChunks.current) + setAudioUrl(mp3Url) + } + + mediaRecorder.current.start() + setIsRecording(true) + setIsPaused(false) + } catch (error) { + toast.error(t('audio:TurnMicrophone'), { position: 'bottom-right' }) + console.error('Error accessing microphone:', error) + } + }, [t, encodeToMp3]) + + const stopRecording = useCallback(() => { + if (mediaRecorder.current) { + mediaRecorder.current.stop() + setIsRecording(false) + setIsPaused(false) + setLoading(true) + } + }, []) + + const pauseRecording = useCallback(() => { + if (mediaRecorder.current) { + mediaRecorder.current.pause() + setIsPaused(true) + } + }, []) + + const resumeRecording = useCallback(() => { + if (mediaRecorder.current) { + mediaRecorder.current.resume() + setIsPaused(false) + } + }, []) + + useEffect(() => { + if (audioUrl) setLoading(false) + }, [audioUrl]) + + return { + isRecording, + isPaused, + audioUrl, + startRecording, + stopRecording, + pauseRecording, + resumeRecording, + loading, + } +} + +export function useAudioPreview(audioUrl) { + const [isPlaying, setIsPlaying] = useState(false) + const [preview, setPreview] = useState(null) + + useEffect(() => { + if (audioUrl) { + const audio = new Audio(audioUrl) + setPreview(audio) + + audio.addEventListener('ended', () => setIsPlaying(false)) + + return () => audio.removeEventListener('ended', () => setIsPlaying(false)) + } + }, [audioUrl]) + + const play = useCallback(() => { + if (preview) { + preview.play() + setIsPlaying(true) + } + }, [preview]) + + const pause = useCallback(() => { + if (preview) { + preview.pause() + setIsPlaying(false) + } + }, [preview]) + + return { isPlaying, play, pause } +} diff --git a/components/CustomComboBox.js b/components/CustomComboBox.js new file mode 100644 index 000000000..76d7fc52a --- /dev/null +++ b/components/CustomComboBox.js @@ -0,0 +1,52 @@ +import { useState } from 'react' + +function CustomComboBox({ topics, selectedTopic, onChange }) { + const [isOpen, setIsOpen] = useState(false) + + const handleItemClick = (link) => { + onChange(link) + setIsOpen(false) + } + + const renderNestedList = (items) => { + return items.map((item) => ( +
  • handleItemClick(item.link)} + className={`m-2 cursor-pointer p-2 hover:bg-gray-100 ${ + selectedTopic === item.link ? 'bg-gray-200 font-bold' : '' + } truncate`} + > + {item.title} +
  • + )) + } + + const selectedTitle = + topics.find((topic) => topic.link === selectedTopic)?.title || 'Select a topic' + + return ( +
    +
    setIsOpen(!isOpen)} + title={selectedTitle} + > + {selectedTitle} +
    + {isOpen && ( +
    +
      + {topics.length > 0 ? ( + renderNestedList(topics) + ) : ( +
    • No topics found
    • + )} +
    +
    + )} +
    + ) +} + +export default CustomComboBox diff --git a/components/DropdownSearch.js b/components/DropdownSearch.js new file mode 100644 index 000000000..5de40d3c9 --- /dev/null +++ b/components/DropdownSearch.js @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react' + +function DropdownSearch({ + options, + value, + onChange, + placeholder, + searchQuery, + setSearchQuery, +}) { + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + const handleInputChange = (e) => { + setSearchQuery(e.target.value) + } + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + return ( +
    + setIsOpen(true)} + placeholder={placeholder} + className="w-full rounded border border-gray-300 p-3" + /> + {isOpen && ( +
    + {options.length > 0 ? ( + options.map((option, index) => ( +
    { + onChange(option.link) + setIsOpen(false) + }} + className={`cursor-pointer px-4 py-2 hover:bg-gray-100 ${ + value === option.link ? 'bg-gray-200' : '' + }`} + > + {`${'\u00A0'.repeat((option.depth || 0) * 4)}${option.title}`} +
    + )) + ) : ( +
    There are no matches
    + )} +
    + )} +
    + ) +} + +export default DropdownSearch diff --git a/components/ModalInSideBar.js b/components/ModalInSideBar.js index 7f970d3cf..f49da5d54 100644 --- a/components/ModalInSideBar.js +++ b/components/ModalInSideBar.js @@ -8,6 +8,7 @@ function ModalInSideBar({ buttonTitle, collapsed, contentClassName = 'p-4', + width = '30rem', }) { return ( <> @@ -23,7 +24,10 @@ function ModalInSideBar({ {isOpen && (
    e.stopPropagation()} >
    diff --git a/components/Panel/UI/TAContent.js b/components/Panel/UI/TAContent.js index 7c9873af0..bceb140cf 100644 --- a/components/Panel/UI/TAContent.js +++ b/components/Panel/UI/TAContent.js @@ -13,7 +13,7 @@ function TAContent({ item, setHref, config, goBack }) { return (
    diff --git a/components/Panel/UI/TaTopics.js b/components/Panel/UI/TaTopics.js index 4587890ea..2f4f07465 100644 --- a/components/Panel/UI/TaTopics.js +++ b/components/Panel/UI/TaTopics.js @@ -1,19 +1,28 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/router' +import yaml from 'js-yaml' + +import DropdownSearch from 'components/DropdownSearch' + import TaContentInfo from '../Resources/TAContentInfo' import TAContent from './TAContent' import { getFile } from 'utils/apiHelper' import { academyLinks } from 'utils/config' -import { getWordsAcademy, resolvePath } from 'utils/helper' +import { + getTableOfContent, + getTitleOfContent, + getWordsAcademy, + parseYAML, + resolvePath, +} from 'utils/helper' import Loading from 'public/icons/progress.svg' function TaTopics() { const { locale } = useRouter() - const config = locale === 'ru' ? academyLinks['ru'] : academyLinks['en'] const [href, setHref] = useState('intro/ta-intro') @@ -22,30 +31,94 @@ function TaTopics() { const [loading, setLoading] = useState(false) const scrollRef = useRef(null) - const updateHref = (newRelativePath) => { - const { absolutePath } = resolvePath(config.base, href, newRelativePath) - const newHref = absolutePath.replace(config.base + '/', '') + const [selectedCategory, setSelectedCategory] = useState('') + const [selectedTopic, setSelectedTopic] = useState('') + const [topics, setTopics] = useState([]) + const [categoryOptions, setCategoryOptions] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [allTopics, setAllTopics] = useState([]) + + const filteredTopics = useMemo(() => { + const query = searchQuery.trim().toLowerCase() - if (newHref === href) { - setHref('') - setTimeout(() => setHref(newHref), 0) - } else { - setHistory((prev) => [...prev, href]) - setHref(newHref) + if (!query) { + return allTopics + .filter((topic) => topic.category === selectedCategory) + .map((topic) => ({ ...topic })) } - } - const goBack = () => { + return allTopics.filter((topic) => topic.title.toLowerCase().includes(query)) + }, [searchQuery, selectedCategory, allTopics]) + + const handleCategoryChange = useCallback( + (event) => { + const newCategory = event.target.value + setSelectedCategory(newCategory) + setSelectedTopic('') + setTopics([]) + setSearchQuery('') + setHistory((prev) => + prev.length > 10 ? [...prev.slice(-10), href] : [...prev, href] + ) + }, + [href, setHistory] + ) + + const handleTopicChange = useCallback( + async (newTopic) => { + if (newTopic) { + setSelectedTopic(newTopic) + setHistory((prev) => + prev.length > 10 ? [...prev.slice(-10), href] : [...prev, href] + ) + + const topicData = allTopics.find((topic) => topic.link === newTopic) + const categoryForTopic = topicData?.category || selectedCategory + + const newHref = `${categoryForTopic}/${newTopic}` + setHref(newHref) + } + }, + [allTopics, selectedCategory, href] + ) + + const updateHref = useCallback( + (newRelativePath) => { + const { absolutePath } = resolvePath(config.base, href, newRelativePath) + const newHref = absolutePath.replace(config.base + '/', '') + + if (newHref !== href) { + setHistory((prev) => + prev.length > 10 ? [...prev.slice(-10), href] : [...prev, href] + ) + + setHref(newHref) + + const [newCategory, newTopic] = newHref.split('/') + setSelectedCategory(newCategory || '') + setSelectedTopic(newTopic || '') + } + }, + [href, config.base] + ) + + const goBack = useCallback(() => { setHistory((prev) => { const newHistory = [...prev] const lastHref = newHistory.pop() - if (lastHref) setHref(lastHref) + if (lastHref) { + setHref(lastHref) + + const [category, topic] = lastHref.split('/') + setSelectedCategory(category || '') + setSelectedTopic(topic || '') + } return newHistory }) - } + }, []) useEffect(() => { - const getData = async () => { + const fetchData = async () => { setLoading(true) try { const zip = await getFile({ @@ -55,6 +128,135 @@ function TaTopics() { apiUrl: '/api/git/ta', }) + const getFileContent = async (fileName) => { + const file = zip.files[fileName] + if (file) { + const content = await file.async('text') + return content + } + throw new Error(`File not found: ${fileName}`) + } + + const names = Object.values(zip.files).map((item) => item.name) + + const filteredArray = names.filter( + (name) => name.includes('title.md') && !name.includes('sub-title.md') + ) + + const titleFiles = [] + + for (const file of filteredArray) { + const fileRef = file + .replace(/^.*?\/(.*)/, '$1') + .split('/') + .slice(0, -1) + .join('/') + try { + const fileContent = await getFileContent(file) + + titleFiles.push({ + title: fileContent, + ref: fileRef, + }) + } catch (error) { + console.error(`Error reading file ${file}:`, error) + } + } + + const updateParsedYamlTitles = (parsedYaml, titleFiles, selectedCategory) => { + const titleMap = titleFiles.reduce((map, file) => { + map[file.ref] = file.title + return map + }, {}) + + const updateSections = (sections) => { + return sections.map((section, index, array) => { + const updatedSection = { ...section } + const tempLink = `${selectedCategory}/${updatedSection.link}` + + if (!updatedSection.link && array[index + 1]?.link) { + updatedSection.link = array[index + 1].link + } + + if (updatedSection.link && titleMap[tempLink]) { + updatedSection.title = titleMap[tempLink] + } + + if (updatedSection.sections) { + updatedSection.sections = updateSections(updatedSection.sections) + } + + return updatedSection + }) + } + + return { + ...parsedYaml, + sections: updateSections(parsedYaml.sections), + } + } + + const titleContent = await getTitleOfContent({ + zip, + href: `${config.base}/manifest.yaml`, + }) + + const titleContentDataString = titleContent['manifest.yaml'] + const titleContentData = yaml.load(titleContentDataString) + + const projects = titleContentData?.projects + if (!projects || projects.length === 0) { + console.error('Projects not found in manifest.yaml') + return + } + + const projectOptions = projects.map((project) => ({ + value: project.identifier, + label: project.title, + })) + setCategoryOptions(projectOptions) + if (!selectedCategory) setSelectedCategory(projectOptions[0]?.value || '') + + const tempAllTopics = [] + + for (const project of projects) { + const tableContent = await getTableOfContent({ + zip, + href: `${config.base}/${project.identifier}/toc.yaml`, + }) + + const yamlString = tableContent['toc.yaml'] + if (!yamlString) throw new Error('YAML file not found') + + const parsedYaml = parseYAML(yamlString) + const updatedYaml = updateParsedYamlTitles( + parsedYaml, + titleFiles, + project.identifier + ) + + const sectionsWithCategory = (updatedYaml.sections || []).map((section) => ({ + ...section, + category: project.identifier, + })) + + tempAllTopics.push(...sectionsWithCategory) + } + setAllTopics(tempAllTopics) + const tableContent = await getTableOfContent({ + zip, + href: `${config.base}/${selectedCategory || projectOptions[0]?.value || ''}/toc.yaml`, + }) + + const yamlString = tableContent['toc.yaml'] + if (!yamlString) throw new Error('YAML file not found') + + const tempYAML = parseYAML(yamlString) + + const parsedYaml = updateParsedYamlTitles(tempYAML, titleFiles, selectedCategory) + const sections = parsedYaml?.sections || [] + setTopics(sections) + const fetchedWords = await getWordsAcademy({ zip, href: `${config.base}/${href}`, @@ -62,12 +264,11 @@ function TaTopics() { const title = fetchedWords?.['sub-title'] || href const text = fetchedWords?.['01'] || href - const item = { + setItem({ title, text, type: 'ta', - } - setItem?.(item) + }) } catch (error) { console.error('Error fetching data:', error) } finally { @@ -75,8 +276,8 @@ function TaTopics() { } } - getData() - }, [href, config.base, config.resource]) + fetchData() + }, [href, selectedCategory, config.base, config.resource]) useEffect(() => { if (scrollRef.current) { @@ -94,7 +295,32 @@ function TaTopics() {
    )} +
    +
    + + + handleTopicChange(newValue)} + placeholder="Search topics" + searchQuery={searchQuery} + setSearchQuery={setSearchQuery} + className="mt-2 sm:mt-0" + /> +
    +
    +
    ) diff --git a/components/Project/BookList/Testament.js b/components/Project/BookList/Testament.js index d2fd59b67..02b1246dc 100644 --- a/components/Project/BookList/Testament.js +++ b/components/Project/BookList/Testament.js @@ -19,6 +19,7 @@ import DownloadIcon from 'public/icons/download.svg' import Elipsis from 'public/icons/elipsis.svg' import Gear from 'public/icons/gear.svg' import Play from 'public/icons/play.svg' +import Recorder from 'public/icons/recorder.svg' function Testament({ bookList, @@ -106,6 +107,20 @@ function Testament({ >
    + + + + {isCoordinatorAccess && isBookCreated && ( )} + {!isBookCreated && isCoordinatorAccess && (