diff --git a/apps/cloud/app/components/AddDemoLipButton.tsx b/apps/cloud/app/components/AddDemoLipButton.tsx index 8ff0849..648719d 100644 --- a/apps/cloud/app/components/AddDemoLipButton.tsx +++ b/apps/cloud/app/components/AddDemoLipButton.tsx @@ -1,25 +1,41 @@ import { api } from '@repo/api/client' import { Session } from '@repo/db' import Button from '@repo/ui/components/Button' +import { cn } from '@repo/ui/helpers' import { CirclePlus } from 'lucide-react' +import { useEffect, useState } from 'react' export default function AddDemoLipButton({ session, }: Readonly<{ session: Session }>) { + const { isFetching } = api.lip.getBySessionId.useQuery({ + id: session.id, + }) + const utils = api.useUtils() - const { mutate: createDemoLip, isPending } = api.lip.createDemo.useMutation( - { + const { mutate: createDemoLip, isPending: isCreateDemoPending } = + api.lip.createDemo.useMutation({ onSettled: () => { void utils.lip.getBySessionId.invalidate({ id: session.id }) }, - }, - ) + }) const handleAddButtonClick = () => { void createDemoLip({ id: session.id }) } + const [isPending, setIsPending] = useState(false) + + useEffect(() => { + if (!isFetching && !isCreateDemoPending) { + setIsPending(false) + } + if (isCreateDemoPending) { + setIsPending(true) + } + }, [isFetching, isCreateDemoPending]) + return ( ) } diff --git a/apps/cloud/app/components/DragDropList.tsx b/apps/cloud/app/components/DragDropList.tsx index 5f14261..378e7b7 100644 --- a/apps/cloud/app/components/DragDropList.tsx +++ b/apps/cloud/app/components/DragDropList.tsx @@ -18,10 +18,11 @@ const DragDropList = forwardRef< scale: SpringValue }[] bind: (...args: any[]) => ReactDOMAttributes + isLocked: boolean fixTop: number | null header: ReactNode } ->(({ lips, q, springs, bind, fixTop, header }, ref) => { +>(({ lips, q, springs, bind, isLocked, fixTop, header }, ref) => { return (
) })} diff --git a/apps/cloud/app/components/DragDropListItem.tsx b/apps/cloud/app/components/DragDropListItem.tsx index 8a575a5..9bc2c8a 100644 --- a/apps/cloud/app/components/DragDropListItem.tsx +++ b/apps/cloud/app/components/DragDropListItem.tsx @@ -1,6 +1,7 @@ import { animated, SpringValue } from '@react-spring/web' import { LipDTO } from '@repo/api' import SongLip from '@repo/ui/components/SongLip' +import { cn } from '@repo/ui/helpers' import { ReactDOMAttributes } from '@use-gesture/react/dist/declarations/src/types' export default function DragDropListItem({ @@ -8,6 +9,7 @@ export default function DragDropListItem({ q, spring: { zIndex, shadow, x, y, scale }, bind, + isLocked, }: { lip: LipDTO q?: string @@ -19,6 +21,7 @@ export default function DragDropListItem({ scale: SpringValue } bind: (...args: any[]) => ReactDOMAttributes + isLocked: boolean }) { return (
} diff --git a/apps/cloud/app/components/SessionMenu.tsx b/apps/cloud/app/components/SessionMenu.tsx index 2f70bf0..41b4586 100644 --- a/apps/cloud/app/components/SessionMenu.tsx +++ b/apps/cloud/app/components/SessionMenu.tsx @@ -8,16 +8,19 @@ import { CircleDollarSign, House, Lock, LockOpen, Star } from 'lucide-react' export default function SessionMenu({ session, -}: Readonly<{ session: Session }>) { + isSessionPending, +}: Readonly<{ + session: Session + isSessionPending: boolean +}>) { const utils = api.useUtils() - const { mutate: updateSession, isPending } = api.session.update.useMutation( - { + const { mutate: updateSession, isPending: isUpdatePending } = + api.session.update.useMutation({ onSettled: () => { void utils.session.get.invalidate({ id: session.id }) }, - }, - ) + }) const handleLockButtonClick = () => { void updateSession({ id: session.id, isLocked: !session.isLocked }) @@ -57,9 +60,11 @@ export default function SessionMenu({ ) } + const isPending = isSessionPending || isUpdatePending + return (
-
+
- +
+ {isPending && } + + +
) } diff --git a/apps/cloud/app/root.tsx b/apps/cloud/app/root.tsx index a4ed6fe..54e83f7 100644 --- a/apps/cloud/app/root.tsx +++ b/apps/cloud/app/root.tsx @@ -12,9 +12,11 @@ import BoxMain from '@repo/ui/components/BoxMain' import Spinner from '@repo/ui/components/Spinner' import { FONT_SANS_URL, FONT_SERIF_URL } from '@repo/ui/constants' import '@repo/ui/styles.css' +import H1 from '@repo/ui/typography/H1' import { useEffectEvent } from '@repo/utils/hooks' import { ReactNode, useEffect } from 'react' import { handleBeforeUnload } from '~/helpers/handle-before-unload' +import { hasAccess } from '~/helpers/has-access' import { useUserSession } from '~/hooks/useUserSession' import { supabase } from '~/lib/supabase.client' import './tailwind.css' @@ -113,20 +115,21 @@ export default function App() { } if ( - userSession?.expires_at && - new Date(userSession.expires_at * 1000) < new Date() + !userSession || + (userSession.expires_at && + new Date(userSession.expires_at * 1000) < new Date()) ) { navigate('/signin') return null } - // if (!hasAccess(userSession?.user.accessRole)) { - // return ( - // - //

Unauthorized

- //
- // ) - // } + if (!hasAccess(userSession?.user.accessRole)) { + return ( + +

Unauthorized

+
+ ) + } return } diff --git a/apps/cloud/app/routes/session.$sessionId.tsx b/apps/cloud/app/routes/session.$sessionId.tsx index fc5ca1b..7757c27 100644 --- a/apps/cloud/app/routes/session.$sessionId.tsx +++ b/apps/cloud/app/routes/session.$sessionId.tsx @@ -92,7 +92,11 @@ export default function ActiveSession() { ) } - const { data } = api.lip.getBySessionId.useQuery( + const { + data, + isLoading: isLipsLoading, + isFetching: isLipsFetching, + } = api.lip.getBySessionId.useQuery( { id: session.id }, { refetchInterval: 1000 * 60, @@ -101,13 +105,126 @@ export default function ActiveSession() { const lips = data ?? ([] as LipDTO[]) - const { mutate: moveLip, isPending: isLipUpdatePending } = + const { mutate: updateLip, isPending: isLipUpdatePending } = api.lip.move.useMutation({ onSettled: () => { void utils.lip.getBySessionId.invalidate({ id: session.id }) }, }) + const { mutate: moveLip, isPending: isLipMovePending } = + api.lip.move.useMutation({ + onSettled: () => { + void utils.lip.getBySessionId.invalidate({ id: session.id }) + }, + }) + + const handleLipMove = ({ + id, + sessionId, + status, + sortNumber, + }: Pick) => { + if (sortNumber === null) { + throw new Error('Can not move a lip without a target sort number.') + } + + const fromLip = lips.find((lip) => lip.id === id)! + + if (fromLip.status === status && fromLip.sortNumber === sortNumber) { + return + } + + // Optimistic update + void utils.lip.getBySessionId.setData( + { id: session.id }, + (prevLips) => { + return prevLips + ?.map((lip) => { + if (lip.sortNumber === null) { + return lip + } + if (lip.id === id) { + return { + ...lip, + status, + sortNumber, + } + } + if (fromLip.status === status) { + if (lip.status !== status) { + return lip + } + const currentSortNumber = lip.sortNumber + const fromSortNumber = fromLip.sortNumber! + const toSortNumber = sortNumber + + let shift = 0 + + if (currentSortNumber > fromSortNumber) { + if (toSortNumber >= currentSortNumber) { + shift-- + } + } else { + if (toSortNumber <= currentSortNumber) { + shift++ + } + } + + return { + ...lip, + sortNumber: currentSortNumber + shift, + } + } else { + if ( + lip.status === fromLip.status && + lip.sortNumber > fromLip.sortNumber! + ) { + return { + ...lip, + sortNumber: lip.sortNumber - 1, + } + } + if ( + lip.status === status && + lip.sortNumber >= sortNumber + ) { + return { + ...lip, + sortNumber: lip.sortNumber + 1, + } + } + } + return lip + }) + .sort( + (a, b) => + (a.sortNumber ?? Infinity) - + (b.sortNumber ?? Infinity), + ) + }, + ) + + // Database update + moveLip({ + id, + sessionId, + status, + sortNumber, + }) + } + + const [isPending, setIsPending] = useState(false) + + useEffect(() => { + if (!isLipsFetching && !isLipUpdatePending && !isLipMovePending) { + setIsPending(false) + } + if (isLipUpdatePending || isLipMovePending) { + setIsPending(true) + } + }, [isLipsFetching, isLipUpdatePending, isLipMovePending]) + /** * * Check for session expiry @@ -238,6 +355,15 @@ export default function ActiveSession() { const [isActionTarget, setIsActionTarget] = useState(false) + console.log( + 'IDLE', + idleLips.map((lip) => lip.sortNumber), + ) + console.log( + 'SELECTED', + selectedLips.map((lip) => lip.sortNumber), + ) + /** * * Lip drag and drop @@ -475,64 +601,77 @@ export default function ActiveSession() { const targetSpringEffect = createSpringEffect(down, -1, targetIndex) - switch (dragBox) { - case BoxType.LEFT: { - switch (targetBox) { - case BoxType.LEFT: - selectedAPI.start(sameTargetDragSpringEffect) - idleAPI.start(defaultSpringEffect) - actionAPI.start(defaultSpringEffect) - break - case BoxType.RIGHT: - selectedAPI.start(siblingTargetDragSpringEffect) - idleAPI.start(targetSpringEffect) - break - case BoxType.ACTION: - selectedAPI.start(siblingTargetDragSpringEffect) - idleAPI.start(defaultSpringEffect) - break + if (down) { + switch (dragBox) { + case BoxType.LEFT: { + switch (targetBox) { + case BoxType.LEFT: + selectedAPI.start(sameTargetDragSpringEffect) + idleAPI.start(defaultSpringEffect) + actionAPI.start(defaultSpringEffect) + break + case BoxType.RIGHT: + selectedAPI.start(siblingTargetDragSpringEffect) + idleAPI.start(targetSpringEffect) + break + case BoxType.ACTION: + selectedAPI.start(siblingTargetDragSpringEffect) + idleAPI.start(defaultSpringEffect) + break + } + break } - break - } - case BoxType.RIGHT: { - switch (targetBox) { - case BoxType.LEFT: - idleAPI.start(siblingTargetDragSpringEffect) - selectedAPI.start(targetSpringEffect) - break - case BoxType.RIGHT: - idleAPI.start(sameTargetDragSpringEffect) - selectedAPI.start(defaultSpringEffect) - break - case BoxType.ACTION: - idleAPI.start(siblingTargetDragSpringEffect) - selectedAPI.start(defaultSpringEffect) - break + case BoxType.RIGHT: { + switch (targetBox) { + case BoxType.LEFT: + idleAPI.start(siblingTargetDragSpringEffect) + selectedAPI.start(targetSpringEffect) + break + case BoxType.RIGHT: + idleAPI.start(sameTargetDragSpringEffect) + selectedAPI.start(defaultSpringEffect) + break + case BoxType.ACTION: + idleAPI.start(siblingTargetDragSpringEffect) + selectedAPI.start(defaultSpringEffect) + break + } + break } - break - } - case BoxType.ACTION: { - actionAPI.start(createSpringEffect(down, 0, 0, mx, my)) - - switch (targetBox) { - case BoxType.LEFT: - selectedAPI.start(targetSpringEffect) - idleAPI.start(defaultSpringEffect) - break - case BoxType.RIGHT: - selectedAPI.start(defaultSpringEffect) - idleAPI.start(targetSpringEffect) - break - case BoxType.ACTION: - selectedAPI.start(defaultSpringEffect) - idleAPI.start(defaultSpringEffect) - break + case BoxType.ACTION: { + actionAPI.start(createSpringEffect(down, 0, 0, mx, my)) + + switch (targetBox) { + case BoxType.LEFT: + selectedAPI.start(targetSpringEffect) + idleAPI.start(defaultSpringEffect) + break + case BoxType.RIGHT: + selectedAPI.start(defaultSpringEffect) + idleAPI.start(targetSpringEffect) + break + case BoxType.ACTION: + selectedAPI.start(defaultSpringEffect) + idleAPI.start(defaultSpringEffect) + break + } + break } - break } - } + } else { + const resetSpringEffect = () => ({ + x: 0, + y: 0, + scale: 1, + zIndex: 0, + shadow: 0, + immediate: true, + }) + + selectedAPI.start(resetSpringEffect) + idleAPI.start(resetSpringEffect) + actionAPI.start(resetSpringEffect) - if (!down) { console.log('LIP ID', lipId) console.log('DELETE', deleteOnDrop) @@ -555,13 +694,14 @@ export default function ActiveSession() { status = 'staged' // set live to done } - // optimistic update - void moveLip({ - id: lipId, - sessionId: session.id, - status, - sortNumber: targetIndex + 1, - }) + setTimeout(() => { + handleLipMove({ + id: lipId, + sessionId: session.id, + status, + sortNumber: targetIndex + 1, + }) + }, 0) setIsActionTarget(false) unlockScroll() @@ -579,6 +719,7 @@ export default function ActiveSession() {
)}
@@ -652,10 +797,14 @@ export default function ActiveSession() { q={q} springs={idleSprings} bind={bindLipDrag} + isLocked={session.isLocked || isPending} fixTop={scrollTopLock && scrollTopLock[1]} header={
- + { + return updateLip(input) + }), + move: protectedProcedure .input( LipSchema.omit({ id: true }).partial().extend({ diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts index c8108b5..0d6c791 100644 --- a/packages/db/src/queries/index.ts +++ b/packages/db/src/queries/index.ts @@ -29,3 +29,4 @@ export { createLip } from './createLip.js' export { getLipsByGuestId } from './getLipsByGuestId.js' export { getLipsBySessionId } from './getLipsBySessionId.js' export { moveLip } from './moveLip.js' +export { updateLip } from './updateLip.js' diff --git a/packages/db/src/queries/moveLip.ts b/packages/db/src/queries/moveLip.ts index 1f5ff5b..8a142c7 100644 --- a/packages/db/src/queries/moveLip.ts +++ b/packages/db/src/queries/moveLip.ts @@ -87,7 +87,6 @@ export const moveLip = ( lip.sortNumber > fromLip.sortNumber!, ) .map((lip) => { - console.log('FROM', lip.sortNumber! - 1) return tx .update(lipsTable) .set({ sortNumber: lip.sortNumber! - 1 }) @@ -100,7 +99,6 @@ export const moveLip = ( lip.sortNumber >= payload.sortNumber!, ) .map((lip) => { - console.log('TO', lip.sortNumber! + 1) return tx .update(lipsTable) .set({ sortNumber: lip.sortNumber! + 1 }) diff --git a/packages/db/src/queries/updateLip.ts b/packages/db/src/queries/updateLip.ts new file mode 100644 index 0000000..a24beed --- /dev/null +++ b/packages/db/src/queries/updateLip.ts @@ -0,0 +1,8 @@ +import { eq } from 'drizzle-orm' +import { db, Lip, lipsTable } from '../index.js' + +export const updateLip = ( + lip: Pick & Partial>, +): Promise => { + return db.update(lipsTable).set(lip).where(eq(lipsTable.id, lip.id)) +}