diff --git a/package-lock.json b/package-lock.json index 2dc9cea6..d772b1d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@electron/notarize": "^2.3.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fingerprintjs/fingerprintjs": "^5.0.1", + "@fingerprintjs/fingerprintjs": "^5.1.0", "@fontsource/roboto": "^5.2.6", "@fortawesome/fontawesome-svg-core": "^7.0.1", "@fortawesome/free-regular-svg-icons": "^7.0.1", @@ -1723,9 +1723,9 @@ } }, "node_modules/@fingerprintjs/fingerprintjs": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.0.1.tgz", - "integrity": "sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.1.0.tgz", + "integrity": "sha512-8h/CscV3xQ4KSLyXbSK8YFpZ5AaezzHfkl82mn8NJIEWNi1zLfbZSIu7MGGtx4pqa10oejhEk4u0MNutuE63Fw==", "license": "MIT" }, "node_modules/@floating-ui/core": { @@ -15882,24 +15882,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 5b7cf9a8..3ab54f38 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@electron/notarize": "^2.3.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fingerprintjs/fingerprintjs": "^5.0.1", + "@fingerprintjs/fingerprintjs": "^5.1.0", "@fontsource/roboto": "^5.2.6", "@fortawesome/fontawesome-svg-core": "^7.0.1", "@fortawesome/free-regular-svg-icons": "^7.0.1", diff --git a/src/renderer/src/components/LimitedMediaPlayer.tsx b/src/renderer/src/components/LimitedMediaPlayer.tsx index 712286df..66f316cb 100644 --- a/src/renderer/src/components/LimitedMediaPlayer.tsx +++ b/src/renderer/src/components/LimitedMediaPlayer.tsx @@ -73,6 +73,8 @@ interface IProps { sx?: SxProps; noRestart?: boolean; noSkipBack?: boolean; + playButtonSize?: 'small' | 'medium' | 'large'; + noContainer?: boolean; } export function LimitedMediaPlayer(props: IProps) { @@ -88,6 +90,8 @@ export function LimitedMediaPlayer(props: IProps) { sx, noRestart, noSkipBack, + playButtonSize = 'small', + noContainer, } = props; const [value, setValue] = useState(0); const [ready, setReady] = useState(false); @@ -256,8 +260,24 @@ export function LimitedMediaPlayer(props: IProps) { {!controls ? ( <> ) : ( - { + const playPauseButtonSx = noContainer + ? { + alignSelf: 'center', + color: 'text.primary', + m: 0, + p: 0.5, + } + : { + alignSelf: 'center', + color: 'text.primary', + m: '0!important', + pb: '0!important', + pt: '7px!important', + pr: '0', + }; + + const mediaControls = ( <> {!noRestart && ( @@ -284,30 +304,33 @@ export function LimitedMediaPlayer(props: IProps) { {playing ? ( - + ) : ( )} - } - label={ - + ); + + const mediaLabel = ( + - } - deleteIcon={ - !noClose ? ( - - - - - - ) : ( - <> - ) - } - sx={{ ...sx, width: '100%' }} - /> + ); + + const closeControl = !noClose ? ( + + + + + + ) : ( + <> + ); + + return noContainer ? ( + + + {mediaControls} + + {mediaLabel} + {closeControl} + + ) : ( + + ); + })() )} { interface IProps { visible: boolean; onVisible: (v: boolean) => void; + bp?: BigDialogBp; uploadType: UploadType; uploadMethod?: ((files: File[]) => void) | undefined; multiple?: boolean | undefined; @@ -51,6 +52,7 @@ function MediaUpload(props: IProps) { const { visible, onVisible, + bp, uploadType, multiple, uploadMethod, @@ -92,7 +94,7 @@ function MediaUpload(props: IProps) { isOpen={visible} onOpen={handleCancel} title={title[uploadType] ?? ''} - bp={BigDialogBp.sm} + bp={bp ?? BigDialogBp.sm} > { shallowEqual ); const ts: ISharedStrings = useSelector(sharedSelector, shallowEqual); + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); const columns: GridColDef[] = !isNote ? [ { field: 'language', headerName: t.language, width: 150 }, @@ -350,7 +354,16 @@ export const PassageDataTable = (props: IProps) => { return (
{isScripture && ( - + {onScope && ( { label={t.passageResource} /> )} - + {!isSmallScreen && } {refLevel !== RefLevel.All && ( <> - + { placeholder: passage?.attributes.reference ?? t.reference, }, }} - sx={{ width: '400px' }} + sx={{ width: isSmallScreen ? '100%' : '400px' }} /> )} @@ -398,7 +411,7 @@ export const PassageDataTable = (props: IProps) => { id="ref-level" value={refLevel ?? RefLevel.All} onChange={handleLevelChange as any} - sx={{ width: '325px' }} + sx={{ width: isSmallScreen ? '100%' : '325px' }} inputProps={{ autoFocus: true }} > {referenceLevel.map((rl) => ( diff --git a/src/renderer/src/components/PassageDetail/Internalization/AddResource.tsx b/src/renderer/src/components/PassageDetail/Internalization/AddResource.tsx index d1f5085c..6a164dc7 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/AddResource.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/AddResource.tsx @@ -1,33 +1,26 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { IPassageDetailArtifactsStrings } from '../../../model'; -import { ListItemText } from '@mui/material'; +import { ListItemText, SxProps, Theme } from '@mui/material'; import InfoIcon from '@mui/icons-material/Info'; import { AltButton, LightTooltip } from '../../../control'; import { resourceSelector } from '../../../selector'; import { shallowEqual, useSelector } from 'react-redux'; import { StyledMenu, StyledMenuItem } from '../../../control'; import { useGlobal } from '../../../context/useGlobal'; -import usePassageDetailContext from '../../../context/usePassageDetailContext'; -import { usePassageType } from '../../../crud/usePassageType'; -import related from '../../../crud/related'; -import { PassageTypeEnum } from '../../../model/passageType'; -import { usePlanType } from '../../../crud'; interface IProps { action?: (what: string) => void; stopPlayer?: () => void; + buttonDark?: boolean; + buttonElevated?: boolean; // setting for some shadowing + buttonSx?: SxProps; } export const AddResource = (props: IProps) => { - const { action, stopPlayer } = props; - const { passage } = usePassageDetailContext(); - const { getPassageTypeFromId } = usePassageType(); - const [biblebrain, setBiblebrain] = useState(true); + const { action, stopPlayer, buttonDark, buttonElevated, buttonSx } = props; const [anchorEl, setAnchorEl] = useState(null); const [offline] = useGlobal('offline'); //verified this is not used in a function 2/18/25 const [offlineOnly] = useGlobal('offlineOnly'); //will be constant here - const [plan] = useGlobal('plan'); //will be constant here - const planType = usePlanType(); const t: IPassageDetailArtifactsStrings = useSelector( resourceSelector, @@ -46,22 +39,16 @@ export const AddResource = (props: IProps) => { action(what); } }; - const isScripture = useMemo( - () => planType(plan)?.scripture, - // eslint-disable-next-line react-hooks/exhaustive-deps - [plan] - ); - useEffect(() => { - if (isScripture) { - const pt = getPassageTypeFromId(related(passage, 'passagetype')); - setBiblebrain(pt === PassageTypeEnum.PASSAGE); - } else setBiblebrain(false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [passage, isScripture]); return (
- + {t.add} { {t.upload} - {biblebrain && ( - - {t.audioScripture} - - )} {t.linkResource} diff --git a/src/renderer/src/components/PassageDetail/Internalization/FindAquifer.tsx b/src/renderer/src/components/PassageDetail/Internalization/FindAquifer.tsx index ef1e11d0..e396559b 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/FindAquifer.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/FindAquifer.tsx @@ -1,5 +1,6 @@ import { Autocomplete, + Box, Grid, IconButton, InputAdornment, @@ -403,9 +404,14 @@ export default function FindAquifer({ onClose }: IProps) { - + )} {data.length > 0 ? ( - + + + ) : ( ([]); @@ -72,6 +83,13 @@ export default function FindTabs({ shallowEqual ); const ts: ISharedStrings = useSelector(sharedSelector, shallowEqual); + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const bibleBrainTabIndex = 0; + const faithBridgeTabIndex = 1; + const createTabIndex = 2; + const aquiferTabIndex = 3; + const findOtherTabIndex = aquifer ? 4 : 3; useEffect(() => { import('../../../assets/bible-resource').then((module) => { @@ -86,50 +104,163 @@ export default function FindTabs({ const handleChange = (event: SyntheticEvent, newValue: number) => { setValue(newValue); + setSelectedValue(newValue); + }; + + const handleSelectChange = (event: SelectChangeEvent) => { + setSelectedValue(Number(event.target.value)); + }; + + const handleLaunchSelectedTab = () => { + setValue(selectedValue); + }; + + const handleSetTab = (tab: number) => { + setValue(tab); + setSelectedValue(tab); }; return ( - + - - {FaithBridge}} - {...a11yProps(0)} - /> - {t.create}} - {...a11yProps(1)} - /> - {aquifer && ( + {isSmallScreen ? ( + + + value={selectedValue} + onChange={handleSelectChange} + displayEmpty + aria-label={t.findResource} + sx={{ fontSize: (theme) => theme.typography.h6.fontSize }} + > + theme.typography.h6.fontSize }} + > + Bible Brain + + theme.typography.h6.fontSize }} + > + {`${FaithBridge} (${ts.ai})`} + + theme.typography.h6.fontSize }} + > + {`${t.create} (${ts.ai})`} + + {aquifer && ( + theme.typography.h6.fontSize }} + > + {t.findBrandedContent.replace('{0}', Aquifer)} + + )} + theme.typography.h6.fontSize }} + > + {aquifer ? t.findOther : t.findResource} + + + + ) : ( + + {FaithBridge}} + {...a11yProps(faithBridgeTabIndex)} /> - )} - - + {t.create}} + {...a11yProps(createTabIndex)} + /> + {aquifer && ( + + )} + + + )} - - - - - setValue(0)} /> - - {aquifer && ( - - + + + + + + + + handleSetTab(faithBridgeTabIndex)} /> + + {aquifer && ( + + + + )} + + + + + {isSmallScreen && ( + + + {t.launch} + + )} - - - setLink('')} /> ); diff --git a/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx new file mode 100644 index 00000000..d9be4038 --- /dev/null +++ b/src/renderer/src/components/PassageDetail/Internalization/PassageDetailsArtifactsMobile.tsx @@ -0,0 +1,1099 @@ +import { useState, useContext, useMemo, useRef, useEffect } from 'react'; +import { useGetGlobal, useGlobal } from '../../../context/useGlobal'; +import { + IPassageDetailArtifactsStrings, + Passage, + Section, + MediaFileD, + SectionResourceD, + MediaFile, + ArtifactType, + Resource, + SheetLevel, + ISharedStrings, +} from '../../../model'; +import { arrayMoveImmutable as arrayMove } from 'array-move'; +import { PlayInPlayer } from '../../../context/PlayInPlayer'; +import { useSnackBar } from '../../../hoc/SnackBar'; +import Uploader from '../../Uploader'; +import AddResource from './AddResource'; +import { IRow } from '../../../context/PassageDetailContext'; +import { AltButton } from '../../../control'; +import { AIGenerated } from '.'; +import { AudioResourceCard } from './mobile components/AudioResourceCard'; +import { TextResourceCard } from './mobile components/TextResourceCard'; +import { SettingsDialog } from './mobile components/SettingsDialog'; +import { + remoteIdGuid, + useSecResCreate, + useMediaResCreate, + useSecResUpdate, + useSecResDelete, + related, + useSecResUserCreate, + useSecResUserRead, + useSecResUserDelete, + useOrganizedBy, + findRecord, + useArtifactCategory, + IArtifactCategory, + ArtifactCategoryType, + mediaFileName, + usePlanType, + usePlan, +} from '../../../crud'; +import BigDialog from '../../../hoc/BigDialog'; +import { BigDialogBp } from '../../../hoc/BigDialogBp'; +import MediaDisplay from '../../MediaDisplay'; +import SelectSharedResource from './SelectSharedResource'; +import SelectProjectResource from './SelectProjectResource'; +import SelectSections from './SelectSections'; +import ResourceData from './ResourceData'; +import { MarkDownType, UriLinkType } from '../../MediaUpload'; +import { + Badge, + Box, + Grid, + Stack, + Typography, + IconButton +} from '@mui/material'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import { ReplaceRelatedRecord } from '../../../model/baseModel'; +import { PassageResourceButton } from './PassageResourceButton'; +import ProjectResourceConfigure from './ProjectResourceConfigure'; +import { useProjectResourceSave } from './useProjectResourceSave'; +import { UnsavedContext } from '../../../context/UnsavedContext'; +import Confirm from '../../AlertDialog'; +import { + getSegments, + NamedRegions, + removeExtension, + isVisual, + isUrl, +} from '../../../utils'; +import { useOrbitData } from '../../../hoc/useOrbitData'; +import { + RecordIdentity, + RecordKeyMap, + RecordTransformBuilder, +} from '@orbit/records'; +import { shallowEqual, useSelector } from 'react-redux'; +import { + passageDetailArtifactsSelector, + sharedSelector, +} from '../../../selector'; +import { passageTypeFromRef } from '../../../control/passageTypeFromRef'; +import { PassageTypeEnum } from '../../../model/passageType'; +import { VertListDnd } from '../../../hoc/VertListDnd'; +import usePassageDetailContext from '../../../context/usePassageDetailContext'; +import { LaunchLink } from '../../../control/LaunchLink'; +import FindTabs from './FindTabs'; +import { storedCompareKey } from '../../../utils/storedCompareKey'; +import { mediaContentType } from '../../../utils/contentType'; +import { useStepPermissions } from '../../../utils/useStepPermission'; +import FindBibleBrain from './FindBibleBrain'; +import { useHandleLink } from './addLinkKind'; +import { usePassageRef } from './usePassageRef'; +import { CompactMarkDownView } from '../../../control/MarkDownView'; +import { UploadType } from '../../UploadType'; +import { ResourceTypeEnum } from './ResourceTypeEnum'; +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; + +export function PassageDetailArtifactsMobile() { + const sectionResources = useOrbitData('sectionresource'); + const mediafiles = useOrbitData('mediafile'); + const artifactTypes = useOrbitData('artifacttype'); + const [memory] = useGlobal('memory'); + const [busy, setBusy] = useGlobal('importexportBusy'); //verified this is not used in a function 2/18/25 + const [remoteBusy] = useGlobal('remoteBusy'); //verified this is not used in a function 2/18/25 + const [offline] = useGlobal('offline'); //verified this is not used in a function 2/18/25 + const [offlineOnly] = useGlobal('offlineOnly'); //will be constant here + const [, setComplete] = useGlobal('progress'); + const { + rowData, + section, + passage, + setSelected, + playItem, + setPlayItem, + setMediaSelected, + itemPlaying, + setItemPlaying, + currentstep, + toggleDone, + forceRefresh, + handleItemPlayEnd, + getProjectResources, + } = usePassageDetailContext(); + const { getOrganizedBy } = useOrganizedBy(); + const { AddSectionResource } = useSecResCreate(section); + const AddSectionResourceUser = useSecResUserCreate(); + const ReadSectionResourceUser = useSecResUserRead(); + const RemoveSectionResourceUser = useSecResUserDelete(); + const AddMediaFileResource = useMediaResCreate(passage, currentstep); + const UpdateSectionResource = useSecResUpdate(); + const DeleteSectionResource = useSecResDelete(); + const { getArtifactCategorys } = useArtifactCategory(); + const catRef = useRef([]); + const [uploadVisible, setUploadVisible] = useState(false); + const [aiGenerated, setAIGenerated] = useState(false); + const [findOpen, setFindOpen] = useState(false); + const [visual, setVisual] = useState(false); + const [sortKey, setSortKey] = useState(0); + const cancelled = useRef(false); + const [displayId, setDisplayId] = useState(''); + const [link, setLink] = useState(); + const [markDown, setMarkDoan] = useState(''); + const [markDownTitle, setMarkDownTitle] = useState(''); + const [audioScriptureVisible, setAudioScriptureVisible] = useState(false); + const [nonAudio, setNonAudio] = useState(false); + const [sharedResourceVisible, setSharedResourceVisible] = useState(false); + const [projectResourceVisible, setProjectResourceVisible] = useState(false); + const [projResPassageVisible, setProjResPassageVisible] = useState(false); + const [projResWizVisible, setProjResWizVisible] = useState(false); + const [projResSetup, setProjResSetup] = useState(new Array()); + const [editResource, setEditResource] = useState< + SectionResourceD | undefined + >(); + const [allowEditSave, setAllowEditSave] = useState(false); + const [artifactState] = useState<{ id?: string | null }>({}); + // const [artifactTypeId, setArtifactTypeId] = useState(); + const [uploadType, setUploadType] = useState(UploadType.Resource); + const [initDescription, setInitDescription] = useState(''); + const [recordAudio, setRecordAudio] = useState(false); + const mediaRef = useRef(undefined); + const textRef = useRef(undefined); + const catIdRef = useRef(undefined); + const descriptionRef = useRef(''); + + const resourceTypeRef = useRef( + ResourceTypeEnum.sectionResource + ); + const projIdentRef = useRef([]); + const projMediaRef = useRef(undefined); + const [allResources, setAllResources] = useState(false); + const { showMessage } = useSnackBar(); + const [confirm, setConfirm] = useState(''); + const { waitForSave } = useContext(UnsavedContext).state; + const [mediaStart, setMediaStart] = useState(); + const [mediaEnd, setMediaEnd] = useState(); + const [performedBy, setPerformedBy] = useState(''); + const [markdownValue, setMarkdownValue] = useState(''); + const projectResourceSave = useProjectResourceSave(); + const { removeKey } = storedCompareKey(passage, section); + const [plan] = useGlobal('plan'); //will be constant here + const planType = usePlanType(); + const { getPlan } = usePlan(); + const t: IPassageDetailArtifactsStrings = useSelector( + passageDetailArtifactsSelector, + shallowEqual + ); + const ts: ISharedStrings = useSelector(sharedSelector, shallowEqual); + const { canDoSectionStep } = useStepPermissions(); + const hasPermission = canDoSectionStep(currentstep, section); + const [biblebrainClose, setBiblebrainClose] = useState(false); + const getGlobal = useGetGlobal(); + const handleLink = useHandleLink({ passage, setLink }); + const { passageRef } = usePassageRef(); + + const planRec = plan ? getPlan(plan) : null; + const filename = planRec?.attributes?.slug + ? `${planRec.attributes.slug}resource` + : 'resource'; + + const resourceType = useMemo(() => { + const resourceType = artifactTypes.find( + (t) => + t.attributes?.typename === 'resource' && + Boolean(t?.keys?.remoteId) === !offlineOnly + ); + // setArtifactTypeId(resourceType?.id); + artifactState.id = resourceType?.id || null; + return resourceType?.id; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artifactTypes, offlineOnly]); + + const otherResourcesAvailable = useMemo( + () => rowData.some((r) => r.passageId && r.passageId !== passage.id), + [passage, rowData] + ); + + const handleNonAudio = (value: boolean) => setNonAudio(value); + + const isPassageResource = () => + resourceTypeRef.current === ResourceTypeEnum.passageResource; + const isProjectResource = () => + resourceTypeRef.current === ResourceTypeEnum.projectResource; + + const projResourceType = useMemo(() => { + const resourceType = artifactTypes.find( + (t) => + t.attributes?.typename === 'projectresource' && + Boolean(t?.keys?.remoteId) === !offlineOnly + ); + return resourceType?.id; + }, [artifactTypes, offlineOnly]); + + const handlePlay = (id: string) => { + if (id === playItem) { + setItemPlaying(!itemPlaying); + } else { + const row = rowData.find((r) => r.id === id); + if (row) { + const segs = getSegments( + NamedRegions.ProjectResource, + row.mediafile.attributes.segments + ); + const regions = JSON.parse(segs); + if (regions.length > 0) { + const { start, end } = regions[0]; + setMediaStart(start); + setMediaEnd(end); + setMediaSelected(id, start, end); + return; + } else { + setMediaStart(undefined); + setMediaEnd(undefined); + } + } + setSelected(id, PlayInPlayer.no); + } + }; + + const handleDisplayId = (id: string) => { + setDisplayId(id); + }; + + const handleFinish = () => { + setDisplayId(''); + }; + + const handleLinkId = (id: string) => { + setLink( + rowData.find((r) => r.id === id)?.mediafile?.attributes?.originalFile + ); + }; + + const handleMarkDownId = (id: string) => { + setMarkDownTitle( + rowData.find((r) => r.id === id)?.artifactName || t.textResource + ); + setMarkDoan( + rowData.find((r) => r.id === id)?.mediafile?.attributes?.originalFile ?? + '' + ); + }; + + const handleDone = async (id: string, res: SectionResourceD | null) => { + if (!res) return; + const rec = await ReadSectionResourceUser(res); + if (rec !== null) { + await RemoveSectionResourceUser(res, rec); + } else { + await AddSectionResourceUser(res); + } + toggleDone(id); + setTimeout(() => { + setBusy(true); + forceRefresh(); + setTimeout(() => setBusy(false), 500); + }, 500); + }; + + const handleDelete = (id: string) => setConfirm(id); + const handleDeleteRefused = () => setConfirm(''); + const handleDeleteConfirmed = () => { + setBusy(true); + const secRes = sectionResources.find( + (r) => related(r, 'mediafile') === confirm + ); + if (secRes) { + removeKey(related(secRes, 'mediafile')); + DeleteSectionResource(secRes); + } + setConfirm(''); + setBusy(false); + }; + const handleUploadVisible = (v: boolean) => { + setUploadVisible(v); + }; + + const handleFindVisible = (v: boolean) => { + setFindOpen(v); + }; + + const handleSharedResourceVisible = (v: boolean) => { + setSharedResourceVisible(v); + }; + + const handleProjectResourceVisible = (v: boolean) => { + const complete = getGlobal('progress'); + if (complete === 0 || complete === 100) { + setProjectResourceVisible(v); + } + }; + + const handleProjResPassageVisible = (v: boolean) => { + setProjResPassageVisible(v); + }; + + const handleProjResWizVisible = (v: boolean) => { + if (v) { + setProjResWizVisible(v); + } else { + waitForSave(undefined, 200).then(() => { + setProjResWizVisible(v); + projMediaRef.current = undefined; + setVisual(false); + }); + } + }; + + const handleAllResources = () => { + setAllResources(!allResources); + }; + + const handleEdit = (id: string) => { + const secRes = sectionResources.find( + (r) => related(r, 'mediafile') === id + ) as SectionResourceD; + setEditResource(secRes); + setAllowEditSave(true); + resourceTypeRef.current = related(secRes, 'passage') + ? ResourceTypeEnum.passageResource + : ResourceTypeEnum.sectionResource; + descriptionRef.current = secRes?.attributes.description || ''; + const mf = mediafiles.find((m) => m.id === related(secRes, 'mediafile')); + catIdRef.current = mf ? related(mf, 'artifactCategory') : undefined; + mediaRef.current = mf as MediaFileD; + const ct = mediaContentType(mf); + setUploadType( + ct === MarkDownType + ? UploadType.MarkDown + : ct === UriLinkType + ? UploadType.Link + : UploadType.Resource + ); + }; + const resetEdit = () => { + setEditResource(undefined); + catIdRef.current = undefined; + descriptionRef.current = ''; + resourceTypeRef.current = ResourceTypeEnum.sectionResource; + setUploadVisible(false); + setMarkdownValue(''); + setInitDescription(''); + setAIGenerated(false); + }; + const handleEditResourceVisible = (v: boolean) => { + if (!v) resetEdit(); + }; + const handleEditSave = async () => { + if (editResource) { + UpdateSectionResource({ + ...editResource, + attributes: { + ...editResource.attributes, + description: descriptionRef.current, + }, + }); + if (Boolean(related(editResource, 'passage')) !== isPassageResource()) { + await memory.update((t) => [ + ...ReplaceRelatedRecord( + t, + editResource, + 'passage', + 'passage', + isPassageResource() ? passage.id : '' + ), + ]); + } + const mf = mediafiles.find( + (m) => m.id === related(editResource, 'mediafile') + ) as MediaFileD | undefined; + if (mf && textRef.current) { + await memory.update((t) => [ + t.replaceAttribute(mf, 'originalFile', textRef.current), + ]); + } + if (mf && catIdRef.current) { + await memory.update((t) => [ + ...ReplaceRelatedRecord( + t, + mf, + 'artifactCategory', + 'artifactcategory', + catIdRef.current + ), + ]); + } + if (mf && isPassageResource() !== Boolean(related(mf, 'passage'))) { + await memory.update((t) => [ + ...ReplaceRelatedRecord( + t, + mf, + 'passage', + 'passage', + isPassageResource() ? passage.id : '' + ), + ]); + } + } + resetEdit(); + }; + const handleEditCancel = () => { + resetEdit(); + }; + const handleAction = (what: string) => { + artifactState.id = resourceType ?? null; + resourceTypeRef.current = ResourceTypeEnum.sectionResource; + if (what === 'upload') { + mediaRef.current = undefined; + setUploadType(UploadType.Resource); + setRecordAudio(false); + setUploadVisible(true); + } else if (what === 'scripture') { + setAudioScriptureVisible(true); + } else if (what === 'link') { + setUploadType(UploadType.Link); + setRecordAudio(false); + setUploadVisible(true); + } else if (what === 'record') { + setUploadType(UploadType.Resource); + setRecordAudio(true); + setUploadVisible(true); + } else if (what === 'text') { + setUploadType(UploadType.MarkDown); + setRecordAudio(false); + setUploadVisible(true); + } else if (what === 'shared') { + resourceTypeRef.current = ResourceTypeEnum.sectionResource; + setSharedResourceVisible(true); + } + }; + + const handleMarkdownValue = ( + query: string, + audioUrl: string, + transcript: string + ) => { + setInitDescription(query); + descriptionRef.current = query; + if (audioUrl) { + setUploadType(UploadType.FaithbridgeLink); + } else { + setUploadType(UploadType.MarkDown); + } + setMarkdownValue(audioUrl ? `${audioUrl}||${transcript}` : transcript); + setRecordAudio(false); + setAIGenerated(true); + setUploadVisible(true); + }; + + const passDesc = useMemo( + () => + passageTypeFromRef(passage?.attributes?.reference) === + PassageTypeEnum.NOTE + ? t.noteResource + : t.passageResource, + // eslint-disable-next-line react-hooks/exhaustive-deps + [passage] + ); + + const getSectionType = () => { + const level = section.attributes?.level; + if (level === SheetLevel.Book) return 'BOOK'; + if (level === SheetLevel.Movement) return 'MOVE'; + return undefined; + }; + + const sectDesc = useMemo( + () => + getSectionType() === PassageTypeEnum.BOOK + ? t.bookResource + : getSectionType() === PassageTypeEnum.MOVEMENT + ? t.movementResource + : getOrganizedBy(true), + // eslint-disable-next-line react-hooks/exhaustive-deps + [section] + ); + + const listFilter = (r: IRow) => + r?.isResource && + (allResources || r.passageId === '' || r.passageId === passage.id); + + const [selectedRows, setSelectedRows] = useState( + rowData.filter(listFilter) + ); + + useEffect(() => { + if (!busy && !remoteBusy) { + setSelectedRows(rowData.filter(listFilter)); + setSortKey((sortKey) => sortKey + 1); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rowData, allResources, busy, remoteBusy]); + + const onSortEnd = ({ + oldIndex, + newIndex, + }: { + oldIndex: number; + newIndex: number; + }) => { + if (oldIndex === newIndex) return; + const indexes = Array(); + rowData.forEach((r, i) => { + if (listFilter(r)) indexes.push(i); + }); + const newIndexes = arrayMove(indexes, oldIndex, newIndex) as number[]; + for (let i = 0; i < newIndexes.length; i += 1) { + const secResRec = sectionResources.find( + (r) => + related(r, 'mediafile') === (rowData[newIndexes[i] ?? 0] as IRow).id + ); + if (secResRec && secResRec.attributes?.sequenceNum !== i) { + UpdateSectionResource({ + ...secResRec, + attributes: { ...secResRec?.attributes, sequenceNum: i }, + }); + } + } + const newRows = rowData + .map((r, i) => (listFilter(r) ? rowData[newIndexes[i] ?? 0] : r)) + .filter((r) => r !== undefined); + forceRefresh(newRows); + }; + + const afterUpload = async (planId: string, mediaRemoteIds?: string[]) => { + let cnt = rowData.length; + const projRes = new Array(); + if (mediaRemoteIds && mediaRemoteIds.length > 0) { + for (const remId of mediaRemoteIds) { + cnt += 1; + const id = + remoteIdGuid('mediafile', remId, memory?.keyMap as RecordKeyMap) || + remId; + const mediaRecId = { type: 'mediafile', id }; + if (descriptionRef.current) { + await memory.update((t) => [ + t.replaceAttribute(mediaRecId, 'topic', descriptionRef.current), + ]); + } + if (catIdRef.current) { + await memory.update((t) => [ + ...ReplaceRelatedRecord( + t, + mediaRecId, + 'artifactCategory', + 'artifactcategory', + catIdRef.current + ), + ]); + } + if (isPassageResource()) { + await memory.update((t) => [ + ...ReplaceRelatedRecord( + t, + mediaRecId, + 'passage', + 'passage', + passage.id + ), + ]); + } + if (!isProjectResource()) { + await AddSectionResource( + cnt, + descriptionRef.current, + mediaRecId, + isPassageResource() ? passage.id : null + ); + } else { + projRes.push(findRecord(memory, 'mediafile', id) as MediaFileD); + } + } + if (projRes.length === 1) setProjResSetup(projRes); + resetEdit(); + } + }; + + const resourceSourcePassages = useMemo(() => { + const results: number[] = []; + sectionResources.forEach((sr) => { + const rec = findRecord(memory, 'mediafile', related(sr, 'mediafile')) as + | MediaFileD + | undefined; + if (rowData.find((r) => r.id === rec?.id)) { + const passageId = rec?.attributes.resourcePassageId; + if (passageId) results.push(passageId); + } + }); + return results; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sectionResources, rowData]); + + useEffect(() => { + getArtifactCategorys(ArtifactCategoryType.Resource).then( + (cats) => (catRef.current = cats) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSelectShared = async (res: Resource[]) => { + let cnt = rowData.length; + for (const r of res) { + const catRec = catRef.current.find( + (c) => c.slug === r?.attributes?.categoryName + ); + const newMediaRec = await AddMediaFileResource(r, catRec?.id || ''); + cnt += 1; + await AddSectionResource( + cnt, + r?.attributes?.title || r?.attributes?.reference, + newMediaRec, + isPassageResource() ? passage.id : null + ); + } + }; + + const handleSelectProjectResource = (m: MediaFileD) => { + setSelected(m.id, PlayInPlayer.yes); + projMediaRef.current = m; + setVisual(isVisual(m)); + setProjectResourceVisible(false); + setProjResPassageVisible(true); + }; + + const writeVisualResource = async (items: RecordIdentity[]) => { + const t = new RecordTransformBuilder(); + let cnt = 0; + const total = items.length; + for (const i of items) { + const rec = memory.cache.query((q) => q.findRecord(i)) as + | Passage + | Section; + const secRec = + rec?.type === 'section' + ? (rec as Section) + : (memory.cache.query((q) => + q.findRecord({ type: 'section', id: related(rec, 'section') }) + ) as Section); + const secNum = secRec?.attributes.sequencenum || 0; + const topicIn = + projMediaRef.current?.attributes?.topic || + removeExtension(projMediaRef.current?.attributes?.originalFile || '') + ?.name; + const passage = rec?.type === 'passage' ? (rec as Passage) : undefined; + await projectResourceSave({ + t, + media: projMediaRef.current as MediaFile, + i: { secNum, section: secRec, passage }, + topicIn, + limitValue: '', + mediafiles, + sectionResources, + }); + cnt += 1; + setComplete(Math.min((cnt * 100) / total, 100)); + } + // Ensure setComplete(0) is always called after processing + setComplete(0); + }; + + const handleTextChange = (text: string) => { + textRef.current = text; + const ct = mediaContentType(mediaRef.current); + if (ct === MarkDownType) { + const newAllow = text.trim() !== ''; + if (allowEditSave !== newAllow) { + setAllowEditSave(newAllow); + } + return; + } + const validUrl = isUrl(text); + if (ct === UriLinkType) { + if (allowEditSave !== validUrl) { + setAllowEditSave(validUrl); + } + } + }; + + useEffect(() => { + if (!projResPassageVisible && !projResWizVisible && projMediaRef.current) + setProjResSetup(projResSetup.filter((m) => m !== projMediaRef.current)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projResPassageVisible, projResWizVisible]); + + useEffect(() => { + if (projResSetup.length) { + handleSelectProjectResource(projResSetup[0] as MediaFileD); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projResSetup]); + + const handleSelectProjectResourcePassage = (items: RecordIdentity[]) => { + projIdentRef.current = items; + if (isVisual(projMediaRef.current)) { + writeVisualResource(items).then(() => { + setProjResPassageVisible(false); + }); + } else { + setProjResWizVisible(true); + setProjResPassageVisible(false); + } + }; + + const handleCategory = (categoryId: string) => { + catIdRef.current = categoryId; + }; + + const handleDescription = (desc: string) => { + descriptionRef.current = desc; + }; + + const handlePassRes = (newValue: ResourceTypeEnum) => { + resourceTypeRef.current = newValue; + if (isProjectResource()) { + artifactState.id = projResourceType ?? null; + setUploadType(UploadType.ProjectResource); + } else if ( + artifactState.id === projResourceType || + uploadType === UploadType.ProjectResource + ) { + artifactState.id = resourceType ?? null; + setUploadType(UploadType.Resource); + } + }; + + const handleEnded = () => { + setPlayItem(''); + handleItemPlayEnd(); + }; + + const [hasProjRes, setHasProjRes] = useState(false); + + useEffect(() => { + getProjectResources().then((res) => setHasProjRes(res.length > 0)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mediafiles]); + + const isScripture = useMemo( + () => planType(plan)?.scripture, + // eslint-disable-next-line react-hooks/exhaustive-deps + [plan] + ); + + const modifiable = useMemo( + () => hasPermission && (!offline || offlineOnly), + [hasPermission, offline, offlineOnly] + ); + + const [settingsOpen, setSettingsOpen] = useState(false); + + return ( + <> + + + {isScripture && ( + + handleFindVisible(true)} + sx={{ fontSize: (theme) => theme.typography.h6.fontSize }} + > + {t.research} + + + )} + {hasPermission && (!offline || offlineOnly) && ( + <> + + theme.typography.h6.fontSize }} + /> + + {/* {hasProjRes && ( + + setProjectResourceVisible(true)}> + {t.configure} + + + )} */} + + )} + + (otherResourcesAvailable || hasProjRes) && setSettingsOpen(true) + } + > + + + {/* {otherResourcesAvailable && ( + + + + )} */} + + + + + {selectedRows.map((value, index) => ( + + {/^audio/.test(mediaContentType(value.mediafile)) ? ( + + ) : ( + + )} + + ))} + + + true} + onNonAudio={handleNonAudio} + performedBy={performedBy} + onSpeakerChange={(value) => setPerformedBy(value)} + inValue={markdownValue} + eafUrl={aiGenerated ? AIGenerated : ''} + defaultFilename={filename} + metaData={ + + } + /> + {t.findResourceDesc}} + isOpen={findOpen} + onOpen={handleFindVisible} + bp={BigDialogBp.mobile} + > + handleFindVisible(false)} + canAdd={hasPermission} + onMarkdown={handleMarkdownValue} + /> + + + (resourceTypeRef.current = val)} + onSelect={handleSelectShared} + onOpen={handleSharedResourceVisible} + /> + + + + + + {projResPassageVisible ? ( + + ) : ( + <> + )} + + + {projResWizVisible ? ( + + ) : ( + <> + )} + + + + + setSettingsOpen(false)} + showResources={otherResourcesAvailable} + showConfigureGeneral={hasProjRes} + resourcesChecked={allResources} + handleShowResources={handleAllResources} + handleConfigureGeneral={() => { + setSettingsOpen(false); + setProjectResourceVisible(true); + }} + /> + {confirm && ( + + )} + {displayId && ( + + )} + {markDown && ( + } + titleVariant="h6" + showTopCloseButton={false} + showBottomCloseButton + bottomCloseLabel={ts.close} + paperOutlineColor="black" + mobileThickScrollbar + mobileNoHorizontalScroll + isOpen={Boolean(markDown)} + onOpen={() => { + setMarkDoan(''); + setMarkDownTitle(''); + }} + bp={BigDialogBp.mobile} + > + + + )} + {audioScriptureVisible && ( + setAudioScriptureVisible(false)} + bp={BigDialogBp.mobile} + setCloseRequested={setBiblebrainClose} + > + setAudioScriptureVisible(false)} + closeRequested={biblebrainClose} + /> + + )} + setLink('')} /> + + ); +} + +export default PassageDetailArtifactsMobile; diff --git a/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx b/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx index f54942f6..05bfad4d 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx +++ b/src/renderer/src/components/PassageDetail/Internalization/ResourceData.tsx @@ -46,6 +46,7 @@ interface IProps { catAllowNew?: boolean | undefined; sectDesc?: string | undefined; passDesc?: string | undefined; + wrapPreviewOverflow?: boolean; } export function ResourceData(props: IProps) { const { @@ -63,6 +64,7 @@ export function ResourceData(props: IProps) { media, uploadType, onTextChange, + wrapPreviewOverflow, } = props; const [description, setDescription] = useState(initDescription); const { getOrganizedBy } = useOrganizedBy(); @@ -105,7 +107,11 @@ export function ResourceData(props: IProps) { )} {mediaContentType(media) === MarkDownType && ( - + )} diff --git a/src/renderer/src/components/PassageDetail/Internalization/index.ts b/src/renderer/src/components/PassageDetail/Internalization/index.ts index 22a3a538..8f5711ce 100644 --- a/src/renderer/src/components/PassageDetail/Internalization/index.ts +++ b/src/renderer/src/components/PassageDetail/Internalization/index.ts @@ -9,3 +9,4 @@ export * from './ResourceEditAction'; export * from './ViewButton'; export * from './PassageResourceButton'; export * from './useFullReference'; +export * from './mobile components/AudioResourceCard'; diff --git a/src/renderer/src/components/PassageDetail/Internalization/mobile components/AudioResourceCard.tsx b/src/renderer/src/components/PassageDetail/Internalization/mobile components/AudioResourceCard.tsx new file mode 100644 index 00000000..454fb0ac --- /dev/null +++ b/src/renderer/src/components/PassageDetail/Internalization/mobile components/AudioResourceCard.tsx @@ -0,0 +1,142 @@ +import { Box, Card, Checkbox, IconButton, SxProps, Typography } from '@mui/material'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import EditIcon from '@mui/icons-material/Edit'; +import { IRow } from '../../../../context/PassageDetailContext'; +import { SectionResourceD } from '../../../../model'; +import LimitedMediaPlayer from '../../../LimitedMediaPlayer'; + +// This card is used for audio resources in the mobile list. +// It is selected when the media content type starts with "audio/" +// (for example mp3, m4a, wav, ogg). + +interface IProps { + row: IRow; + isPlaying: boolean; + onPlay: (id: string) => void; + onDone?: (id: string, res: SectionResourceD | null) => void; + onEdit?: (id: string) => void; + onDelete?: (id: string) => void; + onEnded?: () => void; + subtitle?: string; + limits?: { + start?: number; + end?: number; + }; + sx?: SxProps; +} + +export function AudioResourceCard({ + row, + isPlaying, + onPlay, + onDone, + onEdit, + onDelete, + onEnded, + subtitle = 'Scripture', + limits, + sx, +}: IProps) { + const handleDoneToggle = () => { + if (onDone) { + onDone(row.id, row.resource); + } + }; + + return ( + + + + + + {row.artifactName} + + + + + + {subtitle} + + + {/* Audio playback UI for audio/* resource files. */} + + {})} + onTogglePlay={() => onPlay(row.id)} + controls + limits={limits ?? {}} + noClose + noRestart + noSkipBack + playButtonSize="large" + noContainer + /> + + {onEdit && ( + onEdit(row.id)} + aria-label={`Edit ${row.artifactName}`} + sx={{ p: 0.25 }} + > + + + )} + {onDelete && ( + onDelete(row.id)} + aria-label={`Delete ${row.artifactName}`} + sx={{ p: 0.25 }} + > + + + )} + + + + ); +} + +export default AudioResourceCard; diff --git a/src/renderer/src/components/PassageDetail/Internalization/mobile components/SettingsDialog.tsx b/src/renderer/src/components/PassageDetail/Internalization/mobile components/SettingsDialog.tsx new file mode 100644 index 00000000..aae906d9 --- /dev/null +++ b/src/renderer/src/components/PassageDetail/Internalization/mobile components/SettingsDialog.tsx @@ -0,0 +1,45 @@ +import { Dialog, Stack, Button, Checkbox, FormControlLabel } from '@mui/material'; + + + +interface IProps { + handleShowResources: () => void; + handleConfigureGeneral: () => void; + onClose: () => void; + open: boolean; + showResources: boolean; + showConfigureGeneral: boolean; + resourcesChecked: boolean; +} + +export const SettingsDialog = (props: IProps) => { + return ( + + + {props.showResources && ( + { + if (checked !== props.resourcesChecked) { + props.handleShowResources(); + } + }} + /> + } + label="Show resources for all passages" + /> + )} + {props.showConfigureGeneral && ( + + )} + + + ); +}; diff --git a/src/renderer/src/components/PassageDetail/Internalization/mobile components/TextResourceCard.tsx b/src/renderer/src/components/PassageDetail/Internalization/mobile components/TextResourceCard.tsx new file mode 100644 index 00000000..5e31a618 --- /dev/null +++ b/src/renderer/src/components/PassageDetail/Internalization/mobile components/TextResourceCard.tsx @@ -0,0 +1,133 @@ +import { Box, Card, Checkbox, IconButton, SxProps, Typography } from '@mui/material'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import EditIcon from '@mui/icons-material/Edit'; +import { IRow } from '../../../../context/PassageDetailContext'; +import { SectionResourceD } from '../../../../model'; + +// This card is used for non-audio resources in the mobile list. +// It covers markdown text, URI links, PDFs, images, and other file types +// that are not routed to the audio player card. + +interface IProps { + row: IRow; + onView: (id: string) => void; + onDone?: (id: string, res: SectionResourceD | null) => void; + onEdit?: (id: string) => void; + onDelete?: (id: string) => void; + subtitle?: string; + sx?: SxProps; +} + +export function TextResourceCard({ + row, + onView, + onDone, + onEdit, + onDelete, + subtitle = 'Translation Resource', + sx, +}: IProps) { + const handleDoneToggle = () => { + if (onDone) { + onDone(row.id, row.resource); + } + }; + + return ( + + + + + + {row.artifactName} + + + + + + + {subtitle} + + + + onView(row.id)} + aria-label={`View ${row.artifactName}`} + sx={{ p: 0.25 }} + > + + + + {onEdit && ( + onEdit(row.id)} + aria-label={`Edit ${row.artifactName}`} + sx={{ p: 0.25 }} + > + + + )} + {onDelete && ( + onDelete(row.id)} + aria-label={`Delete ${row.artifactName}`} + sx={{ p: 0.25 }} + > + + + )} + + + + + ); +} +export default TextResourceCard; diff --git a/src/renderer/src/components/Uploader.tsx b/src/renderer/src/components/Uploader.tsx index 68b18b21..4ef738eb 100644 --- a/src/renderer/src/components/Uploader.tsx +++ b/src/renderer/src/components/Uploader.tsx @@ -36,6 +36,7 @@ import { getContentType } from '../utils/contentType'; import { OrbitNetworkErrorRetries } from '../../api-variable'; import { UploadType } from './UploadType'; import { Box } from '@mui/material'; +import { BigDialogBp } from '../hoc/BigDialogBp'; interface IProps { noBusy?: boolean | undefined; @@ -65,6 +66,7 @@ interface IProps { inValue?: string | undefined; // used when adding Aquifer markdown team?: string | undefined; // used when adding a card to check speakers onNonAudio?: ((nonAudio: boolean) => void) | undefined; + uploadDialogBp?: BigDialogBp; } export const Uploader = (props: IProps) => { @@ -93,6 +95,7 @@ export const Uploader = (props: IProps) => { team, onNonAudio, finish, + uploadDialogBp, } = props; const { metaData, ready } = props; const [isDeveloper] = useGlobal('developer'); @@ -458,6 +461,7 @@ export const Uploader = (props: IProps) => { ( +interface IAltButtonProps extends ButtonProps { + dark?: boolean; + elevated?: boolean; +} + +export const AltButton = ({ + children, + dark, + elevated, + sx, + ...rest +}: IAltButtonProps) => (