Introduction
In the first part of this series, we introduced the project's concept, explained the technologies we will use, coded the portion of the frontend that uses Whisper
to transcribe recorded audio, and then coded the relevant UI. In this part of the series, we will incorporate Strapi CMS to save data with our own custom API and then show how to hook this up to the frontend code.
Outline
You can find the outline for this series below:
- Part 1: Implement Audio Recording and User Interface
- Part 2: Incorporate Strapi CMS and Save Transcriptions
- Part 3: Implement connection to chatGPT and deploy to Strapi cloud
How to Install Strapi
Let's create a folder containing the source code for our Strapi CMS API. First, open a terminal and navigate to the root directory transcribe-tutorial
, and run the command below:
npx create-strapi-app@latest strapi-transcribe-api
We should receive a confirmation once this is complete and it finishes installing the dependencies.
It should automatically direct us to the Strapi dashboard as it starts our project. We must create our administrator here, so fill out the form and click the "Let's start" button.
We should now see the dashboard, which looks like this:
Create Strapi Collection Types and Fields
We will need to create data structures to save data to the Content management system (CMS).
Navigate to the dashboard in your browser, and on the left-hand side menu, select Content-Type Builder
, which should bring you to the below screen:
Click on Create new collection type to open a form where you can specify the name and type of the data you wish to store.
Perhaps a word on how the app will be displayed before we describe the shape of the data. It will display chunks of transcribed text alongside an analysis or description of said parts, and we will also display an overview of the meeting at the top. Here is the data structure:
const meeting = {
title: "",
overview: "",
ended: false,
transcribedChunks: [
{
text: "",
analysis: "",
answer: ""
},
{
text: "",
analysis: "",
answer: ""
}
]
};
Create Strapi Collections for Meetings and Transcriptions
This means we will create two collections: meetings
and transcribed chunks
. One meeting will have many transcribed chunks, requiring us to set up a one-to-many relationship between these two collections.
Our data structure also has the field ended
, allowing us to lock the meeting for further transcriptions once it has finished.
First, let's create the transcribed-chunks collection by typing transcribed-chunk
into the display name field and clicking continue:
Create all three of the fields mentioned above:
text
analysis
-
answer
with a type of Long text:
Then click finish; at the top right-hand corner, click save
. Clicking this will trigger a server restart; just wait for that to finish.
Now, we will create our meeting
collection and the relationship between it and the transcribed chunks. Click on create new collection type
again, type meeting
for the name of our collection, and then click continue
.
Create the following fields:
a text field for the
-
title
(Short Text) -
overview
(Long Text) -
ended
(relation:meeting has many transcribed-chunks
):
Then click Finish and save in the top right-hand corner. Now that we have the CMS set up, we can save meetings with an overview
generated by AI. We also have a boolean field named ended
to indicate that the meeting has finished and that it is a record of what was transcribed. Each meeting will have an array of many transcribed chunks
that will contain the transcribed text and a mix of answers or analyses depending on what the user has chosen to generate.
Enable API Public Access
By default, Strapi requires authentication to query the API and receive information, but that is outside the scope of this tutorial. Instead, we will make our API publicly accessible. Learn more about Guide on Authenticating Requests with the REST API..
From the left sidebar, click on Settings. Again, on the left panel under USERS & PERMISSIONS PLUGIN, click on Roles, then click on Public from the table on the right. Now scroll down, click on Meeting, and tick Select all. Then click on Transcribed-chunk and do the same. Then save in the top right to allow the user to access information without authentication.
How to connect Strapi with Next.js
Now that the Strapi backend is set up to save and display our transcriptions, let's code the API calls in the frontend and connect between the frontend and backend.
We must create a meeting
record every time a user clicks on New meeting on the dashboard. To ensure a record is created in the database, we will first call the API to create a meeting
, and once we have confirmation, we will navigate to the transcription page.
Setting Up the API Structure
First, create an api
folder in the root directory to manage our API calls. This will help us maintain clean and modular code by keeping all of our API interactions in one place and making them easier to manage.
Inside the api
folder, create a file called meetings.js
and paste in the following code:
const baseUrl = 'http://localhost:1337';
const url = `${baseUrl}/api/meetings`;
export async function fetchMeetings() {
try {
const res = await fetch(url);
return await res.json();
} catch (e) {
console.error('Error fetching meetings:', error);
throw error;
}
}
export async function createNewMeeting() {
// Create new empty meeting
const payload = {
data: {
title: '',
overview: '',
ended: false,
transcribed_chunks: [],
},
};
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
return await res.json();
} catch (error) {
console.error('Error creating new meeting:', error);
throw error;
}
}
fetchMeetings
will call the Strapi
API to get all of the meeting records from the database, and createNewMeeting
will create a new empty meeting in the database ready to save information.
Create Custom React Hooks for Meetings
Now that we have a place to keep our API calls, let's create a react hook that will look after some of the logic and state surrounding the meeting collection. This way, if our application grows and we need to do anything with the meeting collection in other components, we can just import and re-use this logic. Conveniently, there will be just one place we need to change the code and one place to test it.
Create and Import the useMeetings
Hook
So in the main directory, create a folder called hooks
, and inside, create a file called useMeetings.js
and paste the following code:
import { useState } from 'react';
import {
fetchMeetings,
createNewMeeting,
} from '../api/meetings';
export const useMeetings = () => {
const [meetings, setMeetings] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const getMeetings = async () => {
try {
const response = await fetchMeetings();
const { data } = await response;
setMeetings(data);
setLoading(false);
} catch (error) {
setError(error);
setLoading(false);
}
};
const createMeeting = async () => {
try {
const response = await createNewMeeting();
const { data } = await response;
return data;
} catch (error) {
setError(error);
}
};
return {
meetings,
createMeeting,
getMeetings,
loading,
error,
};
};
Now, let's import this logic in to our main MeetingDashboardContainer
:
import React, { useEffect } from 'react';
import styles from '../styles/Meeting.module.css';
import MeetingCard from '../components/meeting/MeetingCard';
import { useRouter } from 'next/router';
import { useMeetings } from '../hooks/useMeetings';
const MeetingDashboardContainer = () => {
const router = useRouter();
const {
meetings,
loading,
error,
getMeetings,
createMeeting,
} = useMeetings();
useEffect(() => {
fetchMeetings();
}, []);
const fetchMeetings = async () => {
await getMeetings();
};
const handleNewMeeting = async () => {
try {
// Call the API to create a new meeting
const newMeeting = await createMeeting();
// Route to transcription page and pass the newly created meeting id
router.push(`/transcription?meetingId=${newMeeting.id}`);
} catch (error) {
console.error('Error creating new meeting:', error);
alert('Failed to create a new meeting. Please try again.');
}
};
const openMeeting = (meetingId) => {
router.push(`/transcription?meetingId=${meetingId}`);
};
return (
<div id={styles['meeting-container']}>
<div className={styles['cs-container']}>
<div className={styles['cs-content']}>
<div className={styles['cs-content-flex']}>
<span className={styles['cs-topper']}>Meeting dashboard</span>
<h2 className={styles['cs-title']}>Start a new meeting!</h2>
</div>
<button
onClick={handleNewMeeting}
className={styles['cs-button-solid']}
>
New meeting
</button>
</div>
<ul className={styles['cs-card-group']}>
{loading ? (
<p>Loading...</p>
) : error ? (
<p>Error loading previous meetings</p>
) : (
meetings?.map((val, i) => {
const { title, overview } = val.attributes;
return (
<MeetingCard
key={val.id}
title={title}
id={val.id}
overview={overview}
openMeeting={openMeeting}
/>
);
})
)}
</ul>
</div>
</div>
);
};
export default MeetingDashboardContainer;
In the useEffect
lifecycle hook:
- We are fetching any previous meetings to display when the component first mounts.
- We have a function called
handleNewMeeting
used when the user clicks the new meeting button. - This creates a new meeting using the function from our hook.
- This then routes to the transcription component and passes through the newly created
meetingId
.
Open Previous Meetings Using the openMeeting
Function
We also have a function called openMeeting
passed to the MeetingCard
component. This allows us to open previous meetings by routing to the transcription component and passing through the meetingId
.
Update the MeetingCard
component like so:
import styles from '../../styles/Meeting.module.css';
const MeetingCard = ({ title, id, overview, openMeeting }) => {
return (
<li className={styles['cs-item']}>
<div className={styles['cs-flex']}>
<div style={{ display: 'flex', width: '100%' }}>
<h3 className={styles['cs-h3']}>{title}</h3>
</div>
<p className={styles['cs-item-text']}>{overview}</p>
<p onClick={() => openMeeting(id)} className={styles['cs-link']}>
Open meeting
<img
className={styles['cs-arrow']}
loading="lazy"
decoding="async"
src="https://csimg.nyc3.cdn.digitaloceanspaces.com/Icons/event-chevron.svg"
alt="icon"
width="20"
height="20"
aria-hidden="true"
/>
</p>
</div>
</li>
);
};
export default MeetingCard;
Fetch Meeting Details Using the fetchMeetingDetails
Function
Now, because we are passing the meetingId
to the transcription container, we will need a way to request the details of a specific meeting using the id; let's add this functionality to the app.
First, let's create another fetch request in our meetings.js
API file called fetchMeetingDetails
:
export async function fetchMeetingDetails(meetingId) {
try {
const response = await fetch(
`${baseUrl}/api/meetings/${meetingId}?populate=*`
);
return await response.json();
} catch (error) {
console.error('Error fetching meeting details:', error);
throw error;
}
}
This function takes the meetingId
and interpolates it into the URL. We also add a parameter ?populate=*
to populate any relations. In our case, this will be any transcribed_chunks
our meeting has saved.
Let's add this API call to our useMeetings.js
hooks file so we can import it in and use it easily in the TranscribeContainer.js
We just need to make sure we import it at the top:
import {
fetchMeetings,
fetchMeetingDetails,
createNewMeeting,
} from '../api/meetings';
Add useState
so we can save the details:
const [meetingDetails, setMeetingDetails] = useState({});
Paste this new function in, which just uses the API to get the details with the meetingId
:
const getMeetingDetails = async (meetingId) => {
setLoading(true);
try {
const response = await fetchMeetingDetails(meetingId);
const { data } = await response;
setLoading(false);
setMeetingDetails(data.attributes);
} catch (error) {
setError(error);
setLoading(false);
}
};
And export the function and state in the return statement:
return {
meetings,
getMeetingDetails,
createMeeting,
getMeetings,
loading,
error,
meetingDetails,
};
Now, when we route to the transcription container we can use this hook to fetch the individual meeting details with our new function easily, replace the code in TranscribeContainer
with the following:
import React, { useState, useEffect } from 'react';
import styles from '../styles/Transcribe.module.css';
import { useAudioRecorder } from '../hooks/useAudioRecorder';
import RecordingControls from '../components/transcription/RecordingControls';
import TranscribedText from '../components/transcription/TranscribedText';
import { useRouter } from 'next/router';
import { useMeetings } from '../hooks/useMeetings';
const mockAnswer =
'Example answer to transcription here: Lorem ipsum dolor sit amet consectetur adipisicing elit. Velit distinctio quas asperiores reiciendis! Facilis quia recusandae velfacere delect corrupti!';
const mockAnalysis =
'Example analysis to transcription here: Lorem ipsum dolor sit amet consectetur adipisicing elit. Velit distinctio quas asperiores reiciendis! Facilis quia recusandae velfacere delect corrupti!';
const TranscribeContainer = ({ streaming = true, timeSlice = 1000 }) => {
const router = useRouter();
const [analysis, setAnalysis] = useState('');
const [answer, setAnswer] = useState('');
const [meetingId, setMeetingId] = useState(null);
const [meetingTitle, setMeetingTitle] = useState('');
const {
getMeetingDetails,
loading,
error,
meetingDetails,
} = useMeetings();
const apiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY;
const whisperApiEndpoint = 'https://api.openai.com/v1/audio/';
const { recording, transcribed, handleStartRecording, handleStopRecording } =
useAudioRecorder(streaming, timeSlice, apiKey, whisperApiEndpoint);
useEffect(() => {
const fetchDetails = async () => {
if (router.isReady) {
const { meetingId } = router.query;
if (meetingId) {
try {
await getMeetingDetails(meetingId);
setMeetingId(meetingId);
} catch (err) {
console.log('Error getting meeting details - ', err);
}
}
}
};
fetchDetails();
}, [router.isReady, router.query]);
useEffect(() => {
setMeetingTitle(meetingDetails.title);
}, [meetingDetails]);
const handleGetAnalysis = () => {
setAnalysis(mockAnalysis);
};
const handleGetAnswer = () => {
setAnswer(mockAnswer);
};
const handleStopMeeting = () => {};
if (loading) return <p>Loading...</p>;
return (
<div style={{ margin: '20px' }}>
<button
className={styles['end-meeting-button']}
onClick={handleStopMeeting}
>
End Meeting
</button>
<input
onChange={(e) => setMeetingTitle(e.target.value)}
value={meetingTitle}
type="text"
placeholder="Meeting title here..."
className={styles['custom-input']}
/>
<div>
<RecordingControls
handleStartRecording={handleStartRecording}
handleStopRecording={handleStopRecording}
/>
{recording ? (
<p className={styles['primary-text']}>Recording</p>
) : (
<p>Not recording</p>
)}
{transcribed && <h1>Current transcription</h1>}
<TranscribedText
transcribed={transcribed}
answer={answer}
analysis={analysis}
handleGetAnalysis={handleGetAnalysis}
handleGetAnswer={handleGetAnswer}
/>
</div>
</div>
);
};
export default TranscribeContainer;
In the lifecycle hook useEffect
above:
- We destructure the
meetingId
parameter passed to the router. - Pass it to the
getMeetingDetails
function in our hook. - Once that asynchronous request is complete, we set the meeting ID in the state, and the
meeting details
will now be available to us from the hook.
We will use this to display things like the title and meeting transcriptions.
Create a New Transcription in Strapi
Now, we can start a new meeting, which creates a new record in the database. We have the meetingId
in the TranscribeContainer
component, which we can use to call the API to save the transcribed_chunks
to that particular meeting.
Implement the createNewTranscription
Function
First, we need a way to create a new transcribed_chunk
. Let's create another file called transcriptions.js
in the api
directory with the following code:
const baseUrl = 'http://localhost:1337';
const url = `${baseUrl}/api/transcribed-chunks`;
export async function createNewTranscription(transcription) {
const payload = {
data: {
text: transcription,
analysis: '',
answer: '',
},
};
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
return await res.json();
} catch (error) {
console.error('Error saving meeting:', error);
throw error;
}
}
This POST
request will create a new transcribed_chunk
record in the database with the text field filled with the transcribed text. We will call the request and then take the ID of the new transcription to link to our meeting.
Link a Transcription to a Meeting
Let's create the API call to link the transcribed text to our meeting. Paste the following code into meetings.js
:
export async function connectTranscriptionToMeeting(
meetingId,
meetingTitle,
transcriptionId
) {
const updateURL = `${baseUrl}/api/meetings/${meetingId}`;
const payload = {
data: {
title: meetingTitle,
transcribed_chunks: {
connect: [transcriptionId],
position: { start: true },
},
},
};
try {
const res = await fetch(updateURL, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
return await res.json();
} catch (error) {
console.error('Error connecting transcription to meeting:', error);
throw error;
}
}
We are linking the transcribed text to our meeting by:
- Using a
PUT
request to update our meeting, - Using the
connect
parameter to save our newly createdtranscribed_chunk
to our meeting. - Then, we use the
position
argument to define the order of our relations. In our case, every new transcription will be saved to the start of the array.
You can read more about how to handle relations in Strapi here.
Open up the useMeetings
hook file and paste the following function in to handle this new functionality:
const saveTranscriptionToMeeting = async (meetingId, meetingTitle, transcriptionId) => {
try {
await connectTranscriptionToMeeting(meetingId, meetingTitle, transcriptionId);
} catch (error) {
setError(error);
}
};
Like before, import the method connectTranscriptionToMeeting
and return thesaveTranscriptionToMeeting
from the hook.
Implement Saving Transcriptions
Now, we're ready to start saving our transcriptions to our meetings. Open TranscribeContainer
and import the createNewTranscription
method at the top of the file.
import { createNewTranscription } from '../api/transcriptions';
Add saveTranscriptionToMeeting
to the list of methods we are destructuring from the useMeetings
hook, and add the following async function to the component:
const stopAndSaveTranscription = async () => {
// save transcription first
let {
data: { id: transcriptionId },
} = await createNewTranscription(transcribed);
// make a call to save the transcription chunk here
await saveTranscriptionToMeeting(meetingId, meetingTitle, transcriptionId);
// re-fetch current meeting which should have updated transcriptions
await getMeetingDetails(meetingId);
// Stop and clear the current transcription as it's now saved
handleStopRecording();
};
- We save the transcription and await the call to destructure the ID.
- Then, we save the transcription to our meeting by creating the relation.
- We re-fetch the meeting details so we can display a history of the transcriptions
- We call
handleStopRecording
to stop the recording.
Display History of Transcriptions
Now, we will want to display the history of transcription, which we can get from meetingdetails
. Add the following variable to the top of TranscribeContainer
under the useMeetings
hook:
const transcribedHistory = meetingDetails?.transcribed_chunks?.data;
Here, we access the data from the transcribed_chunks
array and assign it to a variable with a more readable name.
And then, in the return statement of TranscribeContainer
under the TranscribedText
add the following:
{/*Transcribed history*/}
<h1>History</h1>
{transcribedHistory?.map((val, i) => {
const transcribedChunk = val.attributes;
return (
<TranscribedText
key={i}
transcribed={transcribedChunk.text}
answer={transcribedChunk.answer}
analysis={transcribedChunk.analysis}
handleGetAnalysis={handleGetAnalysis}
handleGetAnswer={handleGetAnswer}
/>
);
})}
You should now be able to view the history of transcriptions as demonstrated below:
Implement Function to End a Meeting
Now, let's add a way for the user to end the meeting, which will lock it to further transcriptions by changing the ended
parameter on the meeting to true. We can also request a meeting overview, which will be covered later in this series.
Under the api directory in meetings.js
add the following method, which will update our meetings:
export async function updateMeeting(updatedMeeting, meetingId) {
const updateURL = `${baseUrl}/api/meetings/${meetingId}`;
const payload = {
data: updatedMeeting,
};
try {
const res = await fetch(updateURL, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
return await res.json();
} catch (error) {
console.error('Error updating meeting:', error);
throw error;
}
}
With this PUT
request, we can just pass our meeting object with the fields we want to be updated to this method, and Strapi will update any fields we include and leave the rest as they are. For instance, we could include updated values for ended
and overview
, but transcribed_chunks
will be left as they are.
Add the following function to the useMeetings
hook and remember to import the API method and return this function:
const updateMeetingDetails = async (updatedMeeting, meetingId) => {
try {
await updateMeeting(updatedMeeting, meetingId);
} catch (error) {
setError(error);
}
};
Now add the following function to the TranscribeContainer
component, remembering to destructure the updateMeetingDetails
method from the useMeetings
hook:
const handleStopMeeting = async () => {
await updateMeetingDetails(
{
title: meetingTitle,
ended: true,
},
meetingId
);
// re-fetch meeting details
await getMeetingDetails(meetingId);
setTranscribed("")
};
Just below the useMeetings
hook in this component add the following variable:
const { ended } = meetingDetails;
The above code is just destructuring ended
from meetingDetails
.
Now replace the code in the return statement for TranscribeContainer
as below:
return (
<div style={{ margin: '20px' }}>
{ended && (
<button onClick={handleGoBack} className={styles.goBackButton}>
Go Back
</button>
)}
{!ended && (
<button
className={styles['end-meeting-button']}
onClick={handleStopMeeting}
>
End Meeting
</button>
)}
{ended ? (
<p className={styles.title}>{meetingTitle}</p>
) : (
<input
onChange={(e) => setMeetingTitle(e.target.value)}
value={meetingTitle}
type="text"
placeholder="Meeting title here..."
className={styles['custom-input']}
/>
)}
<div>
{!ended && (
<div>
<RecordingControls
handleStartRecording={handleStartRecording}
handleStopRecording={stopAndSaveTranscription}
/>
{recording ? (
<p className={styles['primary-text']}>Recording</p>
) : (
<p>Not recording</p>
)}
</div>
)}
{/*Current transcription*/}
{transcribed && <h1>Current transcription</h1>}
<TranscribedText
transcribed={transcribed}
answer={answer}
analysis={analysis}
handleGetAnalysis={handleGetAnalysis}
handleGetAnswer={handleGetAnswer}
/>
{/*Transcribed history*/}
<h1>History</h1>
{transcribedHistory?.map((val, i) => {
const transcribedChunk = val.attributes;
return (
<TranscribedText
key={i}
transcribed={transcribedChunk.text}
answer={transcribedChunk.answer}
analysis={transcribedChunk.analysis}
handleGetAnalysis={handleGetAnalysis}
handleGetAnswer={handleGetAnswer}
/>
);
})}
</div>
</div>
);
These are just some minor changes to the UI. If the meeting has ended, we hide the end meeting
button and transcription controls, lock the title for further editing, and show the go back
button.
Add the following method so the user can navigate back to the dashboard once they have ended the meeting:
const handleGoBack = () => {
router.back();
};
Delete Recordings of a Meeting
Let's add a way for the user to delete meetings from the dashboard.
Under the api
directory in the meetings.js
file add the following method:
export async function deleteMeeting(meetingId) {
try {
const response = await fetch(`${baseUrl}/api/meetings/${meetingId}`, {
method: 'DELETE',
});
return await response.json();
} catch (error) {
console.error('Error deleting meeting:', error);
throw error;
}
}
We just send a DELETE
request with the meetingId
through to the Strapi API.
Add the following function to the useMeetings
hook, remembering to import the method from the API directory, and return our new function from the hook:
const deleteMeetingDetails = async (meetingId) => {
try {
await deleteMeeting(meetingId);
} catch (error) {
setError(error);
}
};
In the MeetingDashboardContainer
file, first destructure deleteMeetingDetails
from the useMeeting
hook, then add the following function:
const deleteMeeting = async (meetingId) => {
try {
await deleteMeetingDetails(meetingId);
// refetch meeting dashboard data
await getMeetings();
} catch (error) {
console.error('Error deleting meeting:', error);
alert('Failed to delete meeting. Please try again.');
}
};
Now pass deleteMeeting
to the MeetingCard
component and replace its code with the following:
import styles from '../../styles/Meeting.module.css';
const MeetingCard = ({ title, id, overview, openMeeting, deleteMeeting }) => {
return (
<li className={styles['cs-item']}>
<div className={styles['cs-flex']}>
<div style={{ display: 'flex', width: '100%' }}>
<h3 className={styles['cs-h3']}>{title}</h3>
<div
onClick={() => deleteMeeting(id)}
style={{
marginLeft: 'auto',
cursor: 'pointer',
padding: '5px',
}}
>
X
</div>
</div>
<p className={styles['cs-item-text']}>{overview}</p>
<p onClick={() => openMeeting(id)} className={styles['cs-link']}>
Open meeting
<img
className={styles['cs-arrow']}
loading="lazy"
decoding="async"
src="https://csimg.nyc3.cdn.digitaloceanspaces.com/Icons/event-chevron.svg"
alt="icon"
width="20"
height="20"
aria-hidden="true"
/>
</p>
</div>
</li>
);
};
export default MeetingCard;
Now, the user can create new meetings and save information such as transcriptions, titles, etc. They can also end meetings, locking out any further editing. They also now have the ability to delete meetings when they are no longer relevant.
Stay Tuned for Part 3
In part three of this series, we will create custom endpoints to link our transcription app to chatGPT. We will also run through some testing, error handling, and how to deploy the app to Strapi cloud.
Additional Resources
- Github link to the complete code.