diff --git a/website/src/auth.tsx b/website/src/auth.tsx index 07496bf1..3bb1b6bd 100644 --- a/website/src/auth.tsx +++ b/website/src/auth.tsx @@ -8,6 +8,7 @@ import { } from "amazon-cognito-identity-js" import React, { createContext, useContext, useEffect, useState } from "react" import { navigate } from "vite-plugin-ssr/client/router" +import { UserGroup } from "./graphql/dailp" type UserContextType = { user: CognitoUser | null @@ -320,24 +321,32 @@ export const useCredentials = () => { return creds ?? null } -export const useCognitoUserGroups = () => { +export const useCognitoUserGroups = (): UserGroup[] => { const { user } = useContext(UserContext) const groups: string[] = user?.getSignInUserSession()?.getIdToken().payload["cognito:groups"] ?? [] return groups + .map((g) => g.toUpperCase()) + .filter((g): g is UserGroup => + Object.values(UserGroup).includes(g as UserGroup) + ) } +/** + * A user has one and only one `role`. A role is typically a users most + * permissive `group`, or `READER` if they have no `groups`. + */ export enum UserRole { - READER = "READER", - CONTRIBUTOR = "CONTRIBUTOR", - EDITOR = "EDITOR", + Reader = "READER", + Contributor = "CONTRIBUTOR", + Editor = "EDITOR", } export function useUserRole(): UserRole { const groups = useCognitoUserGroups() - if (groups.includes("Editors")) return UserRole.EDITOR - else if (groups.includes("Contributors")) return UserRole.CONTRIBUTOR - else return UserRole.READER + if (groups.includes(UserGroup.Editors)) return UserRole.Editor + else if (groups.includes(UserGroup.Contributors)) return UserRole.Contributor + else return UserRole.Reader } export const useUserId = () => { diff --git a/website/src/components/edit-word-audio/contributor-audio-panel.tsx b/website/src/components/edit-word-audio/contributor-audio-panel.tsx new file mode 100644 index 00000000..129e3068 --- /dev/null +++ b/website/src/components/edit-word-audio/contributor-audio-panel.tsx @@ -0,0 +1,79 @@ +import cx from "classnames" +import { ReactElement, ReactNode } from "react" +import { IconType } from "react-icons" +import * as Dailp from "src/graphql/dailp" +import { CollapsiblePanel } from "src/panel-layout" +import * as css from "../../panel-layout.css" +import { + contributeAudioContainer, + statusMessage, + statusMessageError, +} from "./contributor.css" +import { useAudioUpload } from "./utils" + +export function ContributeAudioPanel(p: { + panelTitle: string + Icon: IconType + Component: ContributeAudioComponent + word: Dailp.FormFieldsFragment +}) { + return ( + } + icon={} + /> + ) +} + +type ContributeAudioComponent = (p: { + uploadAudio: (blob: Blob) => Promise +}) => ReactElement + +/** + * This wrapper component provides upload functionality and feedback for an + * audio contribution component. + */ +function ContributeAudioSection(p: { + word: Dailp.FormFieldsFragment + Component: ContributeAudioComponent +}): ReactElement { + const [uploadAudio, uploadState, clearUploadError] = useAudioUpload(p.word.id) + + return ( +
+ + + {uploadState === "uploading" && ( + +

Uploading...

+
+ )} + + {uploadState === "error" && ( + +

+ Something went wrong with the upload{" "} + +

+
+ )} +
+ ) +} + +function StatusMessage({ + children, + error = false, +}: { + children: ReactNode + error?: boolean +}) { + return ( +
+ {children} +
+ ) +} diff --git a/website/src/components/edit-word-audio/contributor.css.ts b/website/src/components/edit-word-audio/contributor.css.ts index 85314a52..88efa4f0 100644 --- a/website/src/components/edit-word-audio/contributor.css.ts +++ b/website/src/components/edit-word-audio/contributor.css.ts @@ -23,7 +23,7 @@ export const statusMessage = style({ bottom: 0, backgroundColor: "rgba(255,255,255,0.65)", color: "black", - padding: 40, + padding: 10, backdropFilter: "blur(2px)", border: "4px solid grey", }) diff --git a/website/src/components/edit-word-audio/contributor.tsx b/website/src/components/edit-word-audio/contributor.tsx index f74ae9e5..8965a28a 100644 --- a/website/src/components/edit-word-audio/contributor.tsx +++ b/website/src/components/edit-word-audio/contributor.tsx @@ -1,102 +1,8 @@ -import { CognitoIdentityClient } from "@aws-sdk/client-cognito-identity" -import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3" -import { fromCognitoIdentityPool } from "@aws-sdk/credential-provider-cognito-identity" -import { CognitoUser } from "amazon-cognito-identity-js" -import cx from "classnames" -import { - ChangeEvent, - Fragment, - ReactElement, - ReactNode, - useEffect, - useMemo, - useState, -} from "react" -import { MdRecordVoiceOver, MdUploadFile } from "react-icons/md/index" -import { VisuallyHidden } from "reakit" -import { v4 } from "uuid" -import { AudioPlayer, CleanButton } from ".." -import { useUser, useUserId } from "../../auth" +import { Fragment } from "react" +import { useUserId } from "src/auth" +import { WordAudio } from "src/panel-layout" +import { AudioPlayer } from "../" import * as Dailp from "../../graphql/dailp" -import { WordAudio } from "../../panel-layout" -import { - MediaPermissionStatus, - useMediaRecorder, -} from "../../use-media-recorder" -import { IconTextButton } from "../button" -import { SubtleButton } from "../subtle-button" -import { subtleButton, subtleButtonActive } from "../subtle-button.css" -import { - contributeAudioContainer, - contributeAudioOptions, - contributeAudioOptionsItem, - statusMessage, - statusMessageError, -} from "./contributor.css" - -type UploadAudioState = "ready" | "uploading" | "error" - -function useAudioUpload(wordId: string) { - const [uploadAudioState, setUploadAudioState] = - useState("ready") - const { user } = useUser() - const [_contributeAudioResult, contributeAudio] = - Dailp.useAttachAudioToWordMutation() - - /** - * Try to upload the audio. - * - * Returns success boolean and updates state to ready or error - */ - const uploadAudio = useMemo( - () => - async function (data: Blob) { - setUploadAudioState("uploading") - try { - const { resourceUrl } = await uploadContributorAudioToS3(user!, data) - // const resourceUrl = "https://" + prompt("url?") - const result = await contributeAudio({ - input: { - wordId, - contributorAudioUrl: resourceUrl, - }, - }) - if (result.error) { - console.log(result.error) - setUploadAudioState("error") - return false - } - } catch (error) { - console.log(error) - setUploadAudioState("error") - return false - } - - setUploadAudioState("ready") - return true - }, - [user, contributeAudio, wordId] - ) - - function clearError() { - // only map "error" -> "ready" otherwise do nothing - setUploadAudioState((s) => (s === "error" ? "ready" : s)) - } - - // `as const` makes sure this gets typed as a tuple, not a list with a union type - return [uploadAudio, uploadAudioState, clearError] as const -} - -export function ContributorEditWordAudio(p: { - word: Dailp.FormFieldsFragment -}) { - return ( - <> - - - - ) -} function AvailableAudioSection(p: { word: Dailp.FormFieldsFragment }) { const userId = useUserId() @@ -161,262 +67,26 @@ function AvailableAudioSection(p: { word: Dailp.FormFieldsFragment }) { ) - return ( - <> - {[curatedAudioContent, userAudioContent, otherUserAudioContent] - .filter((content) => content !== false) - .map((content, idx) => ( - - {idx > 0 &&
} - {content} -
- ))} - - ) -} - -function ContributeAudioSection(p: { - word: Dailp.FormFieldsFragment -}): ReactElement { - const [currentTab, setCurrentTab] = useState<"upload" | "record">() - const [selectedFile, setSelectedFile] = useState() - const [uploadAudio, uploadState, clearUploadError] = useAudioUpload(p.word.id) - - function onFileChanged(event: ChangeEvent) { - if (!event.currentTarget.files) return - - const [file] = event.currentTarget.files - if (!file) return - console.log("New file selected", file) - setSelectedFile(file) - setCurrentTab("upload") - } - - async function uploadAudioAndReset(data: Blob) { - const success = await uploadAudio(data) - if (success) { - setCurrentTab(undefined) - setSelectedFile(undefined) - } - } + const content = [curatedAudioContent, userAudioContent, otherUserAudioContent] + .filter((content) => content !== false) + .map((content, idx) => ( + + {idx > 0 &&
} + {content} +
+ )) return ( -
- - - -
-
- } - // @ts-ignore -- I couldn't find a way to fix how this is typed - // this is part of React's ugly insides - as="label" - htmlFor="file-upload" - className={ - currentTab === "upload" ? subtleButtonActive : subtleButton - } - > - Upload audio - -
-
- } - className={ - currentTab === "record" ? subtleButtonActive : subtleButton - } - onClick={() => { - setCurrentTab("record") - }} - > - Record audio - -
-
- - {currentTab === "upload" && ( - - )} - - {currentTab === "record" && ( - - )} - - {uploadState === "uploading" && ( - -

Uploading...

-
- )} - - {uploadState === "error" && ( - -

Something went wrong with the upload

- -
- )} -
+ <>{content.length > 0 ? content : "No audio available for this word."} ) } -function StatusMessage({ - children, - error = false, -}: { - children: ReactNode - error?: boolean +export function ContributorEditWordAudio(p: { + word: Dailp.FormFieldsFragment }) { - return ( -
- {children} -
- ) -} - -function UploadAudioSection({ - selectedFile, - uploadAudio, -}: { - selectedFile?: File - uploadAudio: (data: Blob) => Promise -}): ReactElement { - const selectedFileDataUrl = useMemo( - () => selectedFile && window.URL.createObjectURL(selectedFile), - [selectedFile] - ) - - if (!selectedFileDataUrl || !selectedFile) - return ( - - No file selected! Hit upload file above to select a file from your - computer. - - ) - - return ( - <> - - Below is the audio you selected. If you would like to select a different - file click "Upload audio" again. - - - { - uploadAudio(selectedFile) - }} - > - Save audio - - - ) -} - -function RecordAudioSection({ - uploadAudio, -}: { - uploadAudio: (data: Blob) => Promise -}): ReactElement { - const { - permissionStatus, - recordingStatus, - startRecording, - stopRecording, - clearRecording, - requestMediaPermissions, - } = useMediaRecorder() - - useEffect(() => { - if (permissionStatus === MediaPermissionStatus.UNREQUESTED) - requestMediaPermissions() - }, [permissionStatus]) - - if (permissionStatus !== MediaPermissionStatus.APPROVED) - return ( - - You need to allow DAILP to use your microphone in order to record audio. - - ) - return (
- - Below are the controls to record audio on your device now. If you are - happy with your recording, you can hit "Save audio" below. - - {recordingStatus.lastRecording === undefined ? ( - - recordingStatus.isRecording ? stopRecording() : startRecording() - } - > - {recordingStatus.isRecording ? "Stop" : "Start"} recording - - ) : ( - <> - -
- { - uploadAudio(recordingStatus.lastRecording!.data) - }} - > - Save audio - - { - clearRecording() - }} - > - Delete recording - -
- - )} +
) } - -async function uploadContributorAudioToS3(user: CognitoUser, data: Blob) { - // Get the Amazon Cognito ID token for the user. 'getToken()' below. - const REGION = process.env["DAILP_AWS_REGION"] - // TF Stage matches infra environment names: "dev" "prod" or "uat". If TF_STAGE not found, fall back to dev - const BUCKET = `dailp-${process.env["TF_STAGE"] || "dev"}-media-storage` - // let accessToken = user.getSignInUserSession()?.getAccessToken() // 'COGNITO_ID' has the format 'COGNITO_USER_POOL_ID' - // let loginData = { - // [COGNITO_ID]: token, - // } - - const s3Client = new S3Client({ - region: REGION, - credentials: fromCognitoIdentityPool({ - identityPoolId: process.env["DAILP_IDENTITY_POOL"]!, - client: new CognitoIdentityClient({ - region: REGION, - }), - logins: { - [`cognito-idp.${REGION}.amazonaws.com/${process.env["DAILP_USER_POOL"]}`]: - user.getSignInUserSession()?.getIdToken().getJwtToken() ?? "", - }, - }), - }) - - const key = `user-uploaded-audio/${v4()}` - await s3Client.send( - new PutObjectCommand({ - Body: data, - Bucket: BUCKET, - Key: key, - }) - ) - - return { resourceUrl: `https://${process.env["CF_URL"]}/${key}` } -} diff --git a/website/src/components/edit-word-audio/index.tsx b/website/src/components/edit-word-audio/index.tsx index 46234f2c..072ed445 100644 --- a/website/src/components/edit-word-audio/index.tsx +++ b/website/src/components/edit-word-audio/index.tsx @@ -1,4 +1,5 @@ import { ReactElement } from "react" +import { WordAudio } from "src/panel-layout" import { UserRole, useCognitoUserGroups, useUserRole } from "../../auth" import * as Dailp from "../../graphql/dailp" import { ContributorEditWordAudio } from "./contributor" @@ -7,17 +8,17 @@ import { EditorEditWordAudio } from "./editor" export const EditWordAudio = (p: { word: Dailp.FormFieldsFragment }): ReactElement => { - const groups = useCognitoUserGroups() - const userRole = useUserRole() + const role = useUserRole() - console.log("Branching based on", { groups, userRole }) + console.log("Branching based on", { role }) - switch (userRole) { - case UserRole.CONTRIBUTOR: - return - case UserRole.EDITOR: + switch (role) { + case UserRole.Editor: return - case UserRole.READER: - return
You do not have permission to edit word audio.
+ + case UserRole.Contributor: + return + case UserRole.Reader: + return } } diff --git a/website/src/components/edit-word-audio/record.tsx b/website/src/components/edit-word-audio/record.tsx new file mode 100644 index 00000000..733c62be --- /dev/null +++ b/website/src/components/edit-word-audio/record.tsx @@ -0,0 +1,103 @@ +import { ReactElement, useEffect } from "react" +import { FaMicrophone, FaRegStopCircle } from "react-icons/fa/index" +import { RiRecordCircleFill } from "react-icons/ri/index" +import { AudioPlayer } from ".." +import * as Dailp from "../../graphql/dailp" +import { + MediaPermissionStatus, + useMediaRecorder, +} from "../../use-media-recorder" +import { IconTextButton } from "../button" +import { SubtleButton } from "../subtle-button" +import { subtleButton } from "../subtle-button.css" +import { ContributeAudioPanel } from "./contributor-audio-panel" +import { contributeAudioOptions } from "./contributor.css" + +export function RecordAudioPanel(p: { word: Dailp.FormFieldsFragment }) { + return ( + + ) +} + +function RecordAudioContent({ + uploadAudio, +}: { + uploadAudio: (data: Blob) => Promise +}): ReactElement { + const { + permissionStatus, + recordingStatus, + startRecording, + stopRecording, + clearRecording, + requestMediaPermissions, + } = useMediaRecorder() + + useEffect(() => { + if (permissionStatus === MediaPermissionStatus.UNREQUESTED) + requestMediaPermissions() + }, [permissionStatus]) + + async function uploadAudioAndReset(data: Blob) { + if (await uploadAudio(data)) { + clearRecording?.() + } + } + + if (permissionStatus !== MediaPermissionStatus.APPROVED) + return ( + + You need to allow DAILP to use your microphone in order to record audio. + + ) + + return ( +
+ {recordingStatus.lastRecording === undefined ? ( + + ) : ( + + ) + } + onClick={() => + recordingStatus.isRecording ? stopRecording() : startRecording() + } + className={subtleButton} + > + {recordingStatus.isRecording ? "Stop" : "Start"} recording + + ) : ( + <> +
+ { + uploadAudioAndReset(recordingStatus.lastRecording!.data) + }} + > + Save audio + + { + clearRecording() + }} + > + Delete recording + +
+ + + )} +
+ ) +} diff --git a/website/src/components/edit-word-audio/upload.tsx b/website/src/components/edit-word-audio/upload.tsx new file mode 100644 index 00000000..50166d4f --- /dev/null +++ b/website/src/components/edit-word-audio/upload.tsx @@ -0,0 +1,124 @@ +import { ChangeEvent, ReactElement, useMemo, useState } from "react" +import { MdUploadFile } from "react-icons/md/index" +import { VisuallyHidden } from "reakit" +import * as Dailp from "src/graphql/dailp" +import { AudioPlayer } from "../audio-player" +import { CleanButton, IconTextButton } from "../button" +import { subtleButton } from "../subtle-button.css" +import { ContributeAudioPanel } from "./contributor-audio-panel" +import { contributeAudioOptions } from "./contributor.css" + +export function UploadAudioPanel(p: { word: Dailp.FormFieldsFragment }) { + return ( + + ) +} + +const ALLOWED_EXTENSIONS = ["mp3"] + +export function UploadAudioContent({ + uploadAudio, +}: { + uploadAudio: (data: Blob) => Promise +}): ReactElement { + const [selectedFile, setSelectedFile] = useState() + + function onFileChanged(event: ChangeEvent) { + if (!event.currentTarget.files) return + + const [file] = event.currentTarget.files + + if (!file) return + + const fileExtension = file.name.split(".").pop() + if (!fileExtension) return + + if (!ALLOWED_EXTENSIONS.includes(fileExtension)) { + window.alert( + `The file you've selected does not have a supported extension (${fileExtension}). The following formats are supported:\n\t${ALLOWED_EXTENSIONS.join( + "\n\t" + )}` + ) + return + } + + setSelectedFile(file) + } + + async function uploadAudioAndReset(data: Blob) { + const success = await uploadAudio(data) + if (success) { + setSelectedFile(undefined) + } + } + + const selectedFileDataUrl = useMemo( + () => selectedFile && window.URL.createObjectURL(selectedFile), + [selectedFile] + ) + + if (!selectedFileDataUrl || !selectedFile) + return ( + <> + + "." + s).join(",")} + /> + + } + className={subtleButton} + // @ts-ignore -- I couldn't find a way to fix how this is typed + // this is part of React's ugly insides + as="label" + htmlFor="file-upload" + > + Upload file + + + No file selected! Hit upload file above to select a file from your + computer. + + + ) + + return ( + <> + + + + + Below is the audio you selected. If you would like to select a different + file click "Upload audio" again. + + +
+ { + uploadAudioAndReset(selectedFile) + }} + > + Save audio + + + Choose a different file + +
+ + ) +} diff --git a/website/src/components/edit-word-audio/utils.ts b/website/src/components/edit-word-audio/utils.ts new file mode 100644 index 00000000..a6410a27 --- /dev/null +++ b/website/src/components/edit-word-audio/utils.ts @@ -0,0 +1,100 @@ +import { CognitoIdentityClient } from "@aws-sdk/client-cognito-identity" +import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3" +import { fromCognitoIdentityPool } from "@aws-sdk/credential-provider-cognito-identity" +import { CognitoUser } from "amazon-cognito-identity-js" +import { useMemo, useState } from "react" +import { v4 } from "uuid" +import { useUser } from "src/auth" +import * as Dailp from "../../graphql/dailp" + +type UploadAudioState = "ready" | "uploading" | "error" + +export function useAudioUpload(wordId: string) { + const [uploadAudioState, setUploadAudioState] = + useState("ready") + const { user } = useUser() + const [_contributeAudioResult, contributeAudio] = + Dailp.useAttachAudioToWordMutation() + + /** + * Try to upload the audio. + * + * Returns success boolean and updates state to ready or error + */ + const uploadAudio = useMemo( + () => + async function (data: Blob) { + setUploadAudioState("uploading") + try { + const { resourceUrl } = await uploadContributorAudioToS3(user!, data) + // const resourceUrl = "https://" + prompt("url?") + const result = await contributeAudio({ + input: { + wordId, + contributorAudioUrl: resourceUrl, + }, + }) + if (result.error) { + console.log(result.error) + setUploadAudioState("error") + return false + } + } catch (error) { + console.log(error) + setUploadAudioState("error") + return false + } + + setUploadAudioState("ready") + return true + }, + [user, contributeAudio, wordId] + ) + + function clearError() { + // only map "error" -> "ready" otherwise do nothing + setUploadAudioState((s) => (s === "error" ? "ready" : s)) + } + + // `as const` makes sure this gets typed as a tuple, not a list with a union type + return [uploadAudio, uploadAudioState, clearError] as const +} + +export async function uploadContributorAudioToS3( + user: CognitoUser, + data: Blob +) { + // Get the Amazon Cognito ID token for the user. 'getToken()' below. + const REGION = process.env["DAILP_AWS_REGION"] + // TF Stage matches infra environment names: "dev" "prod" or "uat". If TF_STAGE not found, fall back to dev + const BUCKET = `dailp-${process.env["TF_STAGE"] || "dev"}-media-storage` + // let accessToken = user.getSignInUserSession()?.getAccessToken() // 'COGNITO_ID' has the format 'COGNITO_USER_POOL_ID' + // let loginData = { + // [COGNITO_ID]: token, + // } + + const s3Client = new S3Client({ + region: REGION, + credentials: fromCognitoIdentityPool({ + identityPoolId: process.env["DAILP_IDENTITY_POOL"]!, + client: new CognitoIdentityClient({ + region: REGION, + }), + logins: { + [`cognito-idp.${REGION}.amazonaws.com/${process.env["DAILP_USER_POOL"]}`]: + user.getSignInUserSession()?.getIdToken().getJwtToken() ?? "", + }, + }), + }) + + const key = `user-uploaded-audio/${v4()}` + await s3Client.send( + new PutObjectCommand({ + Body: data, + Bucket: BUCKET, + Key: key, + }) + ) + + return { resourceUrl: `https://${process.env["CF_URL"]}/${key}` } +} diff --git a/website/src/edit-doc-data-panel.tsx b/website/src/edit-doc-data-panel.tsx index 71e2ae81..d5ad32f7 100644 --- a/website/src/edit-doc-data-panel.tsx +++ b/website/src/edit-doc-data-panel.tsx @@ -115,7 +115,7 @@ export const EditDocPanel = (props: { document?: Dailp.AnnotatedDoc }) => { {...form} className={css.formInput} name={["document", "title"]} - disabled={!(userRole == UserRole.EDITOR)} + disabled={!(userRole == UserRole.Editor)} />

@@ -130,7 +130,7 @@ export const EditDocPanel = (props: { document?: Dailp.AnnotatedDoc }) => { onChange={(date: any) => handleDateChange(date)} value={selectedDate} format="dd-MM-y" - disabled={!(userRole == UserRole.EDITOR)} + disabled={!(userRole == UserRole.Editor)} /> diff --git a/website/src/edit-word-feature.tsx b/website/src/edit-word-feature.tsx index 86659037..45177b11 100644 --- a/website/src/edit-word-feature.tsx +++ b/website/src/edit-word-feature.tsx @@ -94,7 +94,7 @@ export const EditWordFeature = (props: { as={props.input ? props.input : "input"} className={css.formInput} name={["word", props.feature]} - disabled={userRole == UserRole.READER} + disabled={userRole == UserRole.Reader} /> ) diff --git a/website/src/graphql/wordpress/index.ts b/website/src/graphql/wordpress/index.ts index fa125fd3..c08e9959 100644 --- a/website/src/graphql/wordpress/index.ts +++ b/website/src/graphql/wordpress/index.ts @@ -63,6 +63,8 @@ export type ActionMonitorAction = ContentNode & readonly enqueuedStylesheets: Maybe /** The global unique identifier for this post. This currently matches the value stored in WP_Post->guid and the guid column in the "post_objects" database table. */ readonly guid: Maybe + /** Whether the action_monitor object is password protected. */ + readonly hasPassword: Maybe /** The globally unique identifier of the action_monitor object. */ readonly id: Scalars["ID"] /** Whether the node is a Comment */ @@ -87,6 +89,8 @@ export type ActionMonitorAction = ContentNode & readonly modified: Maybe /** The GMT modified time for a post. If a post was recently updated the modified field will change to match the corresponding time in GMT. */ readonly modifiedGmt: Maybe + /** The password for the action_monitor object. */ + readonly password: Maybe /** Connection between the ActionMonitorAction type and the ActionMonitorAction type */ readonly preview: Maybe /** The preview data of the post that triggered this action. */ @@ -2903,6 +2907,8 @@ export type MediaItem = ContentNode & readonly fileSize: Maybe /** The global unique identifier for this post. This currently matches the value stored in WP_Post->guid and the guid column in the "post_objects" database table. */ readonly guid: Maybe + /** Whether the attachment object is password protected. */ + readonly hasPassword: Maybe /** The globally unique identifier of the attachment object. */ readonly id: Scalars["ID"] /** Whether the node is a Comment */ @@ -2946,6 +2952,8 @@ export type MediaItem = ContentNode & readonly parentDatabaseId: Maybe /** The globally unique identifier of the parent node. */ readonly parentId: Maybe + /** The password for the attachment object. */ + readonly password: Maybe /** The database id of the preview node */ readonly previewRevisionDatabaseId: Maybe /** Whether the object is a node in the preview state */ @@ -4030,6 +4038,8 @@ export type Page = ContentNode & readonly featuredImageId: Maybe /** The global unique identifier for this post. This currently matches the value stored in WP_Post->guid and the guid column in the "post_objects" database table. */ readonly guid: Maybe + /** Whether the page object is password protected. */ + readonly hasPassword: Maybe /** The globally unique identifier of the page object. */ readonly id: Scalars["ID"] /** Whether the node is a Comment */ @@ -4071,6 +4081,8 @@ export type Page = ContentNode & readonly parentDatabaseId: Maybe /** The globally unique identifier of the parent node. */ readonly parentId: Maybe + /** The password for the page object. */ + readonly password: Maybe /** Connection between the Page type and the page type */ readonly preview: Maybe /** The database id of the preview node */ @@ -4542,6 +4554,8 @@ export type Post = ContentNode & readonly featuredImageId: Maybe /** The global unique identifier for this post. This currently matches the value stored in WP_Post->guid and the guid column in the "post_objects" database table. */ readonly guid: Maybe + /** Whether the post object is password protected. */ + readonly hasPassword: Maybe /** The globally unique identifier of the post object. */ readonly id: Scalars["ID"] /** Whether the node is a Comment */ @@ -4570,6 +4584,8 @@ export type Post = ContentNode & readonly modified: Maybe /** The GMT modified time for a post. If a post was recently updated the modified field will change to match the corresponding time in GMT. */ readonly modifiedGmt: Maybe + /** The password for the post object. */ + readonly password: Maybe /** Whether the pings are open or closed for this particular post. */ readonly pingStatus: Maybe /** URLs that have been pinged. */ diff --git a/website/src/panel-layout.tsx b/website/src/panel-layout.tsx index 86799440..db490195 100644 --- a/website/src/panel-layout.tsx +++ b/website/src/panel-layout.tsx @@ -20,6 +20,8 @@ import { AudioPlayer, Button, IconButton } from "./components" import { CommentSection } from "./components/comment-section" import { CustomCreatable } from "./components/creatable" import { EditWordAudio } from "./components/edit-word-audio" +import { RecordAudioPanel } from "./components/edit-word-audio/record" +import { UploadAudioPanel } from "./components/edit-word-audio/upload" import { SubtleButton } from "./components/subtle-button" import { EditButton, EditWordFeature } from "./edit-word-feature" import { formInput } from "./edit-word-feature.css" @@ -203,6 +205,8 @@ export const PanelContent = (p: { word: Dailp.FormFieldsFragment options: GroupedOption[] }) => { + const userGroups = useCognitoUserGroups() + // what should be used to render word features? eg, syllabary, commentary, etc. const PanelFeatureComponent = p.panel === PanelType.EditWordPanel ? EditWordFeature : WordFeature @@ -286,13 +290,23 @@ export const PanelContent = (p: { <> {(p.word.editedAudio.length || p.panel === PanelType.EditWordPanel) && ( } icon={ } /> )} + {p.panel === PanelType.EditWordPanel && + userGroups.includes(Dailp.UserGroup.Contributors) && ( + + )} + {p.panel === PanelType.EditWordPanel && + userGroups.includes(Dailp.UserGroup.Editors) && ( + + )}