diff --git a/server/lib/orcasite/radio/bout.ex b/server/lib/orcasite/radio/bout.ex index 60e379c4..29452642 100644 --- a/server/lib/orcasite/radio/bout.ex +++ b/server/lib/orcasite/radio/bout.ex @@ -36,11 +36,15 @@ defmodule Orcasite.Radio.Bout do relationships do belongs_to :created_by_user, Orcasite.Accounts.User - belongs_to :feed, Orcasite.Radio.Feed + belongs_to :feed, Orcasite.Radio.Feed do + public? true + end + has_many :bout_feed_streams, Orcasite.Radio.BoutFeedStream many_to_many :feed_streams, Orcasite.Radio.FeedStream do through Orcasite.Radio.BoutFeedStream + public? true end end @@ -63,7 +67,7 @@ defmodule Orcasite.Radio.Bout do end actions do - defaults [:read, :update, :destroy] + defaults [:read, :destroy] read :index do pagination do @@ -113,17 +117,37 @@ defmodule Orcasite.Radio.Bout do changeset end end + + update :update do + primary? true + accept [:category, :start_time, :end_time] + + change fn changeset, _ -> + end_time = Ash.Changeset.get_argument_or_attribute(changeset, :end_time) + start_time = Ash.Changeset.get_argument_or_attribute(changeset, :start_time) + + if start_time && end_time do + changeset + |> Ash.Changeset.change_attribute(:duration, DateTime.diff(end_time, start_time, :millisecond) / 1000) + else + changeset + end + end + end end graphql do type :bout + attribute_types [feed_id: :id, feed_stream_id: :id] queries do list :bouts, :index + get :bout, :read end mutations do create :create_bout, :create + update :update_bout, :update end end end diff --git a/ui/src/components/Bouts/BoutPage.tsx b/ui/src/components/Bouts/BoutPage.tsx index b2c2301c..a26661fc 100644 --- a/ui/src/components/Bouts/BoutPage.tsx +++ b/ui/src/components/Bouts/BoutPage.tsx @@ -45,11 +45,13 @@ import SpectrogramTimeline, { import { BoutPlayer, PlayerControls } from "@/components/Player/BoutPlayer"; import { AudioCategory, + BoutQuery, FeedQuery, useCreateBoutMutation, useDetectionsQuery, useGetCurrentUserQuery, useListFeedStreamsQuery, + useUpdateBoutMutation, } from "@/graphql/generated"; import { formatTimestamp } from "@/utils/time"; @@ -64,10 +66,11 @@ export default function BoutPage({ feed: FeedQuery["feed"]; targetAudioCategory?: AudioCategory; targetTime?: Date; - // bout?: BoutQuery["bout"]; + bout?: BoutQuery["bout"]; }) { const now = useMemo(() => new Date(), []); - targetTime = targetTime ?? now; + targetTime = + targetTime ?? (bout?.startTime && new Date(bout.startTime)) ?? now; const { currentUser } = useGetCurrentUserQuery().data ?? {}; const playerTime = useRef(targetTime); @@ -78,15 +81,19 @@ export default function BoutPage({ const [playerControls, setPlayerControls] = useState(); const spectrogramControls = useRef(); - const [boutStartTime, setBoutStartTime] = useState(); - const [boutEndTime, setBoutEndTime] = useState(); + const [boutStartTime, setBoutStartTime] = useState( + bout?.startTime && new Date(bout.startTime), + ); + const [boutEndTime, setBoutEndTime] = useState( + (bout?.endTime && new Date(bout.endTime)) ?? undefined, + ); const [currentTab, setCurrentTab] = useState(0); const audioCategories: AudioCategory[] = useMemo( () => ["ANTHROPHONY", "BIOPHONY", "GEOPHONY"], [], ); const [audioCategory, setAudioCategory] = useState( - targetAudioCategory, + targetAudioCategory ?? bout?.category, ); const timeBuffer = 5; // minutes @@ -159,15 +166,40 @@ export default function BoutPage({ } }, }); + const updateBoutMutation = useUpdateBoutMutation({ + onSuccess: ({ updateBout: { errors } }) => { + if (errors && errors.length > 0) { + console.error(errors); + setBoutForm((form) => ({ + ...form, + errors: { + ...form.errors, + ...Object.fromEntries( + errors.map(({ code, message }) => [code, message] as const), + ), + }, + })); + } + }, + }); const saveBout = () => { setBoutForm((form) => ({ ...form, errors: {} })); if (audioCategory && boutStartTime) { - createBoutMutation.mutate({ - feedId: feed.id, - startTime: boutStartTime, - endTime: boutEndTime, - category: audioCategory, - }); + if (isNew) { + createBoutMutation.mutate({ + feedId: feed.id, + startTime: boutStartTime, + endTime: boutEndTime, + category: audioCategory, + }); + } else if (bout) { + updateBoutMutation.mutate({ + id: bout.id, + startTime: boutStartTime, + endTime: boutEndTime, + category: audioCategory, + }); + } } else { const errors: Record = {}; if (!audioCategory) { @@ -190,14 +222,14 @@ export default function BoutPage({ > - New Bout + Bout {feed.name} {currentUser?.moderator && ( )} diff --git a/ui/src/graphql/fragments/FeedParts.graphql b/ui/src/graphql/fragments/FeedParts.graphql new file mode 100644 index 00000000..db7765b4 --- /dev/null +++ b/ui/src/graphql/fragments/FeedParts.graphql @@ -0,0 +1,15 @@ +fragment FeedParts on Feed { + id + name + slug + nodeName + latLng { + lat + lng + } + introHtml + thumbUrl + imageUrl + mapUrl + bucket +} diff --git a/ui/src/graphql/generated/index.ts b/ui/src/graphql/generated/index.ts index c71eac7e..a2ef8206 100644 --- a/ui/src/graphql/generated/index.ts +++ b/ui/src/graphql/generated/index.ts @@ -292,10 +292,20 @@ export type Bout = { category: AudioCategory; duration?: Maybe; endTime?: Maybe; + feed?: Maybe; + feedId?: Maybe; + feedStreams: Array; id: Scalars["ID"]["output"]; startTime: Scalars["DateTime"]["output"]; }; +export type BoutFeedStreamsArgs = { + filter?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + sort?: InputMaybe>>; +}; + /** Join table between Bout and FeedStream */ export type BoutFeedStream = { __typename?: "BoutFeedStream"; @@ -360,6 +370,10 @@ export type BoutFilterEndTime = { notEq?: InputMaybe; }; +export type BoutFilterFeedId = { + isNil?: InputMaybe; +}; + export type BoutFilterId = { isNil?: InputMaybe; }; @@ -369,6 +383,9 @@ export type BoutFilterInput = { category?: InputMaybe; duration?: InputMaybe; endTime?: InputMaybe; + feed?: InputMaybe; + feedId?: InputMaybe; + feedStreams?: InputMaybe; id?: InputMaybe; not?: InputMaybe>; or?: InputMaybe>; @@ -390,6 +407,7 @@ export type BoutSortField = | "CATEGORY" | "DURATION" | "END_TIME" + | "FEED_ID" | "ID" | "START_TIME"; @@ -1759,6 +1777,7 @@ export type RootMutationType = { signInWithPassword?: Maybe; signOut?: Maybe; submitDetection: SubmitDetectionResult; + updateBout: UpdateBoutResult; }; export type RootMutationTypeCancelCandidateNotificationsArgs = { @@ -1808,9 +1827,15 @@ export type RootMutationTypeSubmitDetectionArgs = { input: SubmitDetectionInput; }; +export type RootMutationTypeUpdateBoutArgs = { + id: Scalars["ID"]["input"]; + input?: InputMaybe; +}; + export type RootQueryType = { __typename?: "RootQueryType"; audioImages?: Maybe; + bout?: Maybe; bouts?: Maybe; candidate?: Maybe; candidates?: Maybe; @@ -1832,6 +1857,10 @@ export type RootQueryTypeAudioImagesArgs = { sort?: InputMaybe>>; }; +export type RootQueryTypeBoutArgs = { + id: Scalars["ID"]["input"]; +}; + export type RootQueryTypeBoutsArgs = { feedId?: InputMaybe; filter?: InputMaybe; @@ -1953,6 +1982,21 @@ export type SubmitDetectionResult = { result?: Maybe; }; +export type UpdateBoutInput = { + category?: InputMaybe; + endTime?: InputMaybe; + startTime?: InputMaybe; +}; + +/** The result of the :update_bout mutation */ +export type UpdateBoutResult = { + __typename?: "UpdateBoutResult"; + /** Any errors generated, if the mutation failed */ + errors: Array; + /** The successful result of the mutation */ + result?: Maybe; +}; + export type User = { __typename?: "User"; admin: Scalars["Boolean"]["output"]; @@ -2074,6 +2118,20 @@ export type AudioImagePartsFragment = { imageType?: ImageType | null; }; +export type FeedPartsFragment = { + __typename?: "Feed"; + id: string; + name: string; + slug: string; + nodeName: string; + introHtml?: string | null; + thumbUrl?: string | null; + imageUrl?: string | null; + mapUrl?: string | null; + bucket: string; + latLng: { __typename?: "LatLng"; lat: number; lng: number }; +}; + export type FeedSegmentPartsFragment = { __typename?: "FeedSegment"; id: string; @@ -2371,6 +2429,65 @@ export type SubmitDetectionMutation = { }; }; +export type UpdateBoutMutationVariables = Exact<{ + id: Scalars["ID"]["input"]; + startTime: Scalars["DateTime"]["input"]; + endTime?: InputMaybe; + category: AudioCategory; +}>; + +export type UpdateBoutMutation = { + __typename?: "RootMutationType"; + updateBout: { + __typename?: "UpdateBoutResult"; + result?: { + __typename?: "Bout"; + id: string; + category: AudioCategory; + duration?: number | null; + endTime?: Date | null; + startTime: Date; + } | null; + errors: Array<{ + __typename?: "MutationError"; + code?: string | null; + fields?: Array | null; + message?: string | null; + shortMessage?: string | null; + vars?: { [key: string]: any } | null; + }>; + }; +}; + +export type BoutQueryVariables = Exact<{ + id: Scalars["ID"]["input"]; +}>; + +export type BoutQuery = { + __typename?: "RootQueryType"; + bout?: { + __typename?: "Bout"; + id: string; + category: AudioCategory; + duration?: number | null; + startTime: Date; + endTime?: Date | null; + feed?: { + __typename?: "Feed"; + id: string; + name: string; + slug: string; + nodeName: string; + introHtml?: string | null; + thumbUrl?: string | null; + imageUrl?: string | null; + mapUrl?: string | null; + bucket: string; + latLng: { __typename?: "LatLng"; lat: number; lng: number }; + } | null; + } | null; +}; + export type CandidateQueryVariables = Exact<{ id: Scalars["ID"]["input"]; }>; @@ -2635,6 +2752,23 @@ export const AudioImagePartsFragmentDoc = ` imageType } `; +export const FeedPartsFragmentDoc = ` + fragment FeedParts on Feed { + id + name + slug + nodeName + latLng { + lat + lng + } + introHtml + thumbUrl + imageUrl + mapUrl + bucket +} + `; export const FeedSegmentPartsFragmentDoc = ` fragment FeedSegmentParts on FeedSegment { id @@ -3291,6 +3425,104 @@ useSubmitDetectionMutation.fetcher = ( options, ); +export const UpdateBoutDocument = ` + mutation updateBout($id: ID!, $startTime: DateTime!, $endTime: DateTime, $category: AudioCategory!) { + updateBout( + id: $id + input: {category: $category, startTime: $startTime, endTime: $endTime} + ) { + result { + id + category + duration + endTime + startTime + endTime + } + errors { + code + fields + message + shortMessage + vars + } + } +} + `; + +export const useUpdateBoutMutation = ( + options?: UseMutationOptions< + UpdateBoutMutation, + TError, + UpdateBoutMutationVariables, + TContext + >, +) => { + return useMutation< + UpdateBoutMutation, + TError, + UpdateBoutMutationVariables, + TContext + >({ + mutationKey: ["updateBout"], + mutationFn: (variables?: UpdateBoutMutationVariables) => + fetcher( + UpdateBoutDocument, + variables, + )(), + ...options, + }); +}; + +useUpdateBoutMutation.getKey = () => ["updateBout"]; + +useUpdateBoutMutation.fetcher = ( + variables: UpdateBoutMutationVariables, + options?: RequestInit["headers"], +) => + fetcher( + UpdateBoutDocument, + variables, + options, + ); + +export const BoutDocument = ` + query bout($id: ID!) { + bout(id: $id) { + id + category + duration + startTime + endTime + feed { + ...FeedParts + } + } +} + ${FeedPartsFragmentDoc}`; + +export const useBoutQuery = ( + variables: BoutQueryVariables, + options?: Omit, "queryKey"> & { + queryKey?: UseQueryOptions["queryKey"]; + }, +) => { + return useQuery({ + queryKey: ["bout", variables], + queryFn: fetcher(BoutDocument, variables), + ...options, + }); +}; + +useBoutQuery.document = BoutDocument; + +useBoutQuery.getKey = (variables: BoutQueryVariables) => ["bout", variables]; + +useBoutQuery.fetcher = ( + variables: BoutQueryVariables, + options?: RequestInit["headers"], +) => fetcher(BoutDocument, variables, options); + export const CandidateDocument = ` query candidate($id: ID!) { candidate(id: $id) { @@ -3411,22 +3643,10 @@ useGetCurrentUserQuery.fetcher = ( export const FeedDocument = ` query feed($slug: String!) { feed(slug: $slug) { - id - name - slug - nodeName - latLng { - lat - lng - } - introHtml - thumbUrl - imageUrl - mapUrl - bucket + ...FeedParts } } - `; + ${FeedPartsFragmentDoc}`; export const useFeedQuery = ( variables: FeedQueryVariables, diff --git a/ui/src/graphql/mutations/updateBout.graphql b/ui/src/graphql/mutations/updateBout.graphql new file mode 100644 index 00000000..d2a06f1c --- /dev/null +++ b/ui/src/graphql/mutations/updateBout.graphql @@ -0,0 +1,27 @@ +mutation updateBout( + $id: ID! + $startTime: DateTime! + $endTime: DateTime + $category: AudioCategory! +) { + updateBout( + id: $id + input: { category: $category, startTime: $startTime, endTime: $endTime } + ) { + result { + id + category + duration + endTime + startTime + endTime + } + errors { + code + fields + message + shortMessage + vars + } + } +} diff --git a/ui/src/graphql/queries/getBout.graphql b/ui/src/graphql/queries/getBout.graphql new file mode 100644 index 00000000..3765ba05 --- /dev/null +++ b/ui/src/graphql/queries/getBout.graphql @@ -0,0 +1,12 @@ +query bout($id: ID!) { + bout(id: $id) { + id + category + duration + startTime + endTime + feed { + ...FeedParts + } + } +} diff --git a/ui/src/graphql/queries/getFeed.graphql b/ui/src/graphql/queries/getFeed.graphql index 93a9d36a..e90ef0cb 100644 --- a/ui/src/graphql/queries/getFeed.graphql +++ b/ui/src/graphql/queries/getFeed.graphql @@ -1,17 +1,5 @@ query feed($slug: String!) { feed(slug: $slug) { - id - name - slug - nodeName - latLng { - lat - lng - } - introHtml - thumbUrl - imageUrl - mapUrl - bucket + ...FeedParts } } diff --git a/ui/src/pages/bouts/[boutId].tsx b/ui/src/pages/bouts/[boutId].tsx index e69de29b..c1362109 100644 --- a/ui/src/pages/bouts/[boutId].tsx +++ b/ui/src/pages/bouts/[boutId].tsx @@ -0,0 +1,44 @@ +import Head from "next/head"; +import { useParams } from "next/navigation"; + +import BoutPage from "@/components/Bouts/BoutPage"; +import { getSimpleLayout } from "@/components/layouts/SimpleLayout"; +import LoadingSpinner from "@/components/LoadingSpinner"; +import { useBoutQuery } from "@/graphql/generated"; +import type { NextPageWithLayout } from "@/pages/_app"; + +const BoutShowPage: NextPageWithLayout = () => { + const targetTime = new Date("2024-12-11 19:55:44.013Z"); + + const params = useParams<{ boutId?: string }>(); + const boutId = params?.boutId; + + const boutQueryResult = useBoutQuery( + { id: boutId || "" }, + { enabled: !!boutId }, + ); + + const bout = boutQueryResult.data?.bout; + + if (!boutId || boutQueryResult.isLoading) return ; + if (!bout) return

Bout not found

; + + const feed = bout.feed; + if (!feed) return

Feed not found

; + + return ( +
+ + Bout | Orcasound + + +
+ +
+
+ ); +}; + +BoutShowPage.getLayout = getSimpleLayout; + +export default BoutShowPage;