Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,22 @@ input DeleteContributorAttribution {
contributorId: UUID!
}

"""
Request to delete an audio slice from a document
"""
input DeleteDocumentAudioInput {
documentId: UUID!
audioSliceId: UUID!
}

"""
Request to delete an audio slice from a word
"""
input DeleteWordAudioInput {
wordId: UUID!
audioSliceId: UUID!
}

type DocumentCollection {
"""
Full name of this collection
Expand Down Expand Up @@ -1315,6 +1331,8 @@ type Mutation {
upsertPage(page: NewPageInput!): String!
updateMenu(menu: MenuUpdate!): Menu!
validateTurnstileToken(token: String!): Boolean!
deleteDocumentAudio(input: DeleteDocumentAudioInput!): AnnotatedDoc!
deleteWordAudio(input: DeleteWordAudioInput!): AnnotatedForm!
}

"""
Expand Down
33 changes: 31 additions & 2 deletions graphql/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ use dailp::{
AnnotatedForm, AnnotatedSeg, AttachAudioToDocumentInput, AttachAudioToWordInput,
CollectionChapter, Contributor, ContributorRole, CreateEditedCollectionInput,
CurateDocumentAudioInput, CurateWordAudioInput, Date, DeleteContributorAttribution,
DocumentMetadata, DocumentMetadataUpdate, DocumentParagraph, PositionInDocument,
SourceAttribution, TranslatedPage, TranslatedSection, UpdateContributorAttribution, Uuid,
DeleteDocumentAudioInput, DeleteWordAudioInput, DocumentId, DocumentMetadata,
DocumentMetadataUpdate, DocumentParagraph, PositionInDocument, SourceAttribution,
TranslatedPage, TranslatedSection, UpdateContributorAttribution, Uuid,
};
use itertools::{Itertools, Position};
use log::{debug, info};
Expand Down Expand Up @@ -1001,6 +1002,34 @@ impl Mutation {

Ok(body_json["success"].as_bool().unwrap())
}

async fn delete_document_audio(
&self,
context: &Context<'_>,
input: DeleteDocumentAudioInput,
) -> FieldResult<AnnotatedDoc> {
let db = context.data::<DataLoader<Database>>()?.loader();
db.delete_audio_slice(input.audio_slice_id).await?;

// Return the updated document to sync the UI
Ok(context
.data::<DataLoader<Database>>()?
.load_one(DocumentId(input.document_id))
.await?
.ok_or_else(|| anyhow::anyhow!("Document not found"))?)
}

async fn delete_word_audio(
&self,
context: &Context<'_>,
input: DeleteWordAudioInput,
) -> FieldResult<AnnotatedForm> {
let db = context.data::<DataLoader<Database>>()?.loader();
db.delete_audio_slice(input.audio_slice_id).await?;

// Return the updated word to sync the UI
Ok(db.word_by_id(&input.word_id).await?)
}
}

#[derive(async_graphql::SimpleObject)]
Expand Down
14 changes: 14 additions & 0 deletions types/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,17 @@ pub struct CurateDocumentAudioInput {
/// New value
pub include_in_edited_collection: bool,
}

/// Request to delete an audio slice from a document
#[derive(async_graphql::InputObject)]
pub struct DeleteDocumentAudioInput {
pub document_id: Uuid,
pub audio_slice_id: Uuid,
}

/// Request to delete an audio slice from a word
#[derive(async_graphql::InputObject)]
pub struct DeleteWordAudioInput {
pub word_id: Uuid,
pub audio_slice_id: Uuid,
}
20 changes: 20 additions & 0 deletions types/src/database_sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2713,6 +2713,26 @@ impl Database {
.await?;
Ok(())
}

/// Deletes a media slice and its associated resource if no longer needed.
/// This handles cleanup for both word and document audio.
pub async fn delete_audio_slice(&self, slice_id: Uuid) -> Result<()> {
let mut tx = self.client.begin().await?;

let resource_id: Option<Uuid> = sqlx::query_scalar!(
"SELECT resource_id FROM media_slice WHERE id = $1",
slice_id
)
.fetch_optional(&mut *tx)
.await?;

sqlx::query!("DELETE FROM media_slice WHERE id = $1", slice_id)
.execute(&mut *tx)
.await?;

tx.commit().await?;
Ok(())
}
}

#[async_trait]
Expand Down
47 changes: 47 additions & 0 deletions website/src/components/audio-player.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,53 @@ globalStyle(`${audioElement} svg`, {
cursor: "pointer",
})

export const overlay = style({
position: "fixed",
width: "100vw",
height: "100vh",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(240, 240, 240, 0.9)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
})

export const confirmationBox = style({
backgroundColor: "white",
padding: "16px",
borderRadius: 0,
border: "1px solid #ccc",
width: "90%",
maxWidth: "280px",
textAlign: "center",
})

export const confirmationText = style({
margin: "0 0 16px 0",
fontSize: "14px",
fontWeight: "bold",
color: "#333",
})

export const modalButtonGroup = style({
display: "flex",
gap: "10px",
justifyContent: "center",
})

export const buttonStyle = style({
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
width: "100%",
padding: "8px 12px",
})

// export const timestamp = css`
// //margin-left: 0.4rem;
// //cursor: pointer;
Expand Down
144 changes: 142 additions & 2 deletions website/src/components/audio-player.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import React, { useEffect, useMemo, useState } from "react"
import { FiLoader } from "react-icons/fi/index"
import { isMobile } from "react-device-detect"
import { FiDownload, FiLoader, FiTrash2 } from "react-icons/fi/index"
import { MdPauseCircleOutline, MdPlayCircleOutline } from "react-icons/md/index"
import * as Dailp from "src/graphql/dailp"
import { UserRole, useUser, useUserId, useUserRole } from "../auth"
import { S3Uploader } from "../utils/s3"
import * as css from "./audio-player.css"
import { Button } from "./button"

/**
* Get our default load status for the audio player.
Expand All @@ -23,11 +28,15 @@ function getDefaultLoadStatus() {
}

interface Props {
sliceId?: string | null | undefined
parentId?: string | null | undefined
sliceType?: "word" | "document" | null
audioUrl: string
showProgress?: boolean
slices?: { start: number; end: number }
style?: any
contributor?: string
contributorId?: string
recordedAt?: Date
}

Expand All @@ -43,12 +52,24 @@ export const AudioPlayer = (props: Props) => {
}

const AudioPlayerImpl = (props: Props) => {
const { user } = useUser()

const role = useUserRole()
const userId = useUserId()
const audio = useMemo(() => new Audio(props.audioUrl), [props.audioUrl])

const [progress, setProgress] = useState(0)
const [loadStatus, setLoadStatus] = useState(getDefaultLoadStatus())
const [isPlaying, setIsPlaying] = useState(false)

const canDelete =
role === UserRole.Admin ||
role === UserRole.Editor ||
(role === UserRole.Contributor && props.contributorId === userId)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
const [, deleteWordAudio] = Dailp.useDeleteWordAudioMutation()
const [, deleteDocAudio] = Dailp.useDeleteDocumentAudioMutation()

// FIXME Issue: Word audio drifts forward and backward for no apparent reason
// Steps (Todo)
// - verify [start, end] match incoming data from GQL
Expand Down Expand Up @@ -96,8 +117,97 @@ const AudioPlayerImpl = (props: Props) => {
setLoadStatus(true)
}

useEffect(() => {
if (isConfirmingDelete) {
document.body.style.overflow = "hidden"
} else {
document.body.style.overflow = "unset"
}

return () => {
document.body.style.overflow = "unset"
}
}, [isConfirmingDelete])

const handleDelete = async () => {
if (!props.sliceId || !props.parentId) {
return
}
setIsConfirmingDelete(false)
try {
if (props.sliceType === "word" && props.parentId && props.sliceId) {
await deleteWordAudio({
input: {
wordId: props.parentId,
audioSliceId: props.sliceId,
},
})
} else if (
props.sliceType === "document" &&
props.parentId &&
props.sliceId
) {
await deleteDocAudio({
input: {
documentId: props.parentId,
audioSliceId: props.sliceId,
},
})
}
console.log("deleted from database")
if (user) {
const s3 = new S3Uploader(user)

const url = new URL(props.audioUrl)
let key = url.pathname

if (key.startsWith("/")) {
key = key.substring(1)
}

console.log("Deleting S3 Key:", key)

await s3.deleteContributorAudio(key)
console.log("S3 Deletion Successful")
}
} catch (err) {
console.error("Deletion sequence failed:", err)
}
}

return (
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
paddingBottom: "10px",
}}
>
{isConfirmingDelete && (
<div className={css.overlay}>
<div className={css.confirmationBox}>
<p className={css.confirmationText}>
Are you sure you want to delete this audio clip?
</p>

<div className={css.modalButtonGroup}>
<Button onClick={() => setIsConfirmingDelete(false)}>
Cancel
</Button>

<Button
onClick={handleDelete}
style={{
backgroundColor: "#b72d3b",
}}
>
Confirm
</Button>
</div>
</div>
</div>
)}
{props.contributor && props.recordedAt && (
<div>
<span>
Expand Down Expand Up @@ -127,9 +237,39 @@ const AudioPlayerImpl = (props: Props) => {
) : (
<FiLoader size={buttonSize} />
)}

{props.showProgress ? (
<ProgressBar progress={progress} bounds={{ start, end }} />
) : null}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
alignItems: "center",
width: props.sliceType === "word" ? "40px" : "120px",
}}
>
{canDelete && props.sliceType && (
<Button
className={css.buttonStyle}
onClick={() => setIsConfirmingDelete(true)}
>
<FiTrash2 size={22} />
{props.sliceType !== "word" ? "Delete" : ""}
</Button>
)}

{!isMobile && props.sliceType && (
<Button
onClick={() => (window.location.href = props.audioUrl)}
className={css.buttonStyle}
>
<FiDownload size={22} />
{props.sliceType !== "word" ? "Download" : ""}
</Button>
)}
</div>
</div>
</div>
)
Expand Down
10 changes: 10 additions & 0 deletions website/src/components/edit-word-audio/contributor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ function AvailableAudioSection(p: { word: Dailp.FormFieldsFragment }) {
{audioByUser.map((audio) => (
<AudioPlayer
audioUrl={audio.resourceUrl}
sliceId={audio.sliceId}
sliceType={"word"}
parentId={p.word.id}
contributor={audio.recordedBy?.displayName ?? "Unknown Contributor"}
contributorId={audio.recordedBy?.id ?? "Unknown Contributor"}
slices={
audio.startTime && audio.endTime
? {
Expand All @@ -53,6 +58,11 @@ function AvailableAudioSection(p: { word: Dailp.FormFieldsFragment }) {
{audioByOthers.map((audio) => (
<AudioPlayer
audioUrl={audio.resourceUrl}
sliceId={audio.sliceId}
sliceType={"word"}
parentId={p.word.id}
contributor={audio.recordedBy?.displayName ?? "Unknown Contributor"}
contributorId={audio.recordedBy?.id ?? "Unknown Contributor"}
slices={
audio.startTime && audio.endTime
? {
Expand Down
Loading
Loading