Permanent room urls

This commit is contained in:
2024-08-16 22:26:00 +02:00
parent ad84e4626c
commit 55697e670d
20 changed files with 1001 additions and 31 deletions

View File

@@ -76,7 +76,7 @@ type LayoutProps = {
export default async function RootLayout({ children, params }: LayoutProps) {
const config = await getConfig(params.domain);
const { requireLogin, privacy, browse } = config.features;
const { requireLogin, privacy, browse, rooms } = config.features;
const hasAuthCookie = !!cookies().get(SESSION_COOKIE_NAME);
return (
@@ -154,6 +154,21 @@ export default async function RootLayout({ children, params }: LayoutProps) {
) : (
<></>
)}
{rooms ? (
<>
&nbsp;·&nbsp;
<Link
href="/rooms"
as={NextLink}
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
prefetch={false}
>
Rooms
</Link>
</>
) : (
<></>
)}
&nbsp;·&nbsp;
<About buttonText="About" />
{privacy ? (

View File

@@ -0,0 +1,33 @@
"use client";
import "@whereby.com/browser-sdk/embed";
import { useCallback, useEffect, useRef } from "react";
import useRoomMeeting from "../../rooms/useRoomMeeting";
export type RoomDetails = {
params: {
roomName: string;
};
};
export default function Room(details: RoomDetails) {
const wherebyRef = useRef<HTMLElement>(null);
const roomName = details.params.roomName;
const meeting = useRoomMeeting(roomName);
const roomUrl = meeting?.response?.host_room_url
? meeting?.response?.host_room_url
: meeting?.response?.room_url;
return (
<>
{roomUrl && (
<whereby-embed
ref={wherebyRef}
room={roomUrl}
style={{ width: "100%", height: "98%" }}
/>
)}
</>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import {
Box,
Button,
Card,
CardBody,
Flex,
FormControl,
FormHelperText,
FormLabel,
Grid,
Heading,
Input,
Link,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Spacer,
Spinner,
useDisclosure,
VStack,
Text,
Menu,
MenuButton,
MenuList,
MenuItem,
AlertDialog,
IconButton,
} from "@chakra-ui/react";
import NextLink from "next";
import React, { ReactNode, useState } from "react";
import { Container } from "@chakra-ui/react";
import { PlusSquareIcon } from "@chakra-ui/icons";
import useApi from "../../lib/useApi";
import useRoomList from "./useRoomList";
import { FaEllipsisVertical, FaTrash } from "react-icons/fa6";
import next from "next";
export default function RoomsList() {
const { isOpen, onOpen, onClose } = useDisclosure();
const [roomName, setRoomName] = useState("");
const api = useApi();
const [page, setPage] = useState<number>(1);
const { loading, response, refetch } = useRoomList(page);
const handleAddRoom = async () => {
try {
const response = await api?.v1RoomsCreate({
requestBody: { name: roomName },
});
setRoomName("");
refetch();
} catch (err) {}
onClose();
};
const handleDeleteRoom = async (roomId: string) => {
try {
const response = await api?.v1RoomsDelete({
roomId,
});
refetch();
} catch (err) {}
};
const handleRoomNameChange = (e) => {
setRoomName(e.target.value);
};
if (loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Spinner size="xl" />
</Flex>
);
return (
<>
<Container maxW={"container.lg"}>
<Flex
flexDir="row"
justify="flex-end"
align="center"
flexWrap={"wrap-reverse"}
mb={2}
>
<Heading>Rooms</Heading>
<Spacer />
<Button colorScheme="blue" onClick={onOpen}>
Add Room
</Button>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Add Room</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormControl>
<FormLabel>Room name</FormLabel>
<Input
placeholder="room-name"
value={roomName}
onChange={handleRoomNameChange}
/>
<FormHelperText>Please enter room name</FormHelperText>
</FormControl>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="blue" onClick={handleAddRoom}>
Add
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Flex>
<VStack>
{response?.items && response.items.length > 0 ? (
response.items.map((room) => (
<Card w={"full"}>
<CardBody>
<Flex align={"center"}>
<Heading size="md">
<Link
// as={NextLink}
href={`/rooms/${room.name}`}
noOfLines={2}
>
{room.name}
</Link>
</Heading>
<Spacer />
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
onClick={() => handleDeleteRoom(room.id)}
icon={<FaTrash color={"red.500"} />}
>
Delete
</MenuItem>
</MenuList>
</Menu>
</Flex>
</CardBody>
</Card>
))
) : (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Text>No rooms found</Text>
</Flex>
)}
</VStack>
</Container>
</>
);
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi";
import { Page_Room_ } from "../../api";
type RoomList = {
response: Page_Room_ | null;
loading: boolean;
error: Error | null;
refetch: () => void;
};
//always protected
const useRoomList = (page: number): RoomList => {
const [response, setResponse] = useState<Page_Room_ | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
const [refetchCount, setRefetchCount] = useState(0);
const refetch = () => {
setLoading(true);
setRefetchCount(refetchCount + 1);
};
useEffect(() => {
if (!api) return;
setLoading(true);
api
.v1RoomsList({ page })
.then((response) => {
setResponse(response);
setLoading(false);
})
.catch((err) => {
setResponse(null);
setLoading(false);
setError(err);
setErrorState(err);
});
}, [!api, page, refetchCount]);
return { response, loading, error, refetch };
};
export default useRoomList;

View File

@@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import { GetMeeting } from "../../api";
import { shouldShowError } from "../../lib/errorUtils";
import useApi from "../../lib/useApi";
type ErrorMeeting = {
error: Error;
loading: false;
response: null;
reload: () => void;
};
type LoadingMeeting = {
response: null;
loading: true;
error: false;
reload: () => void;
};
type SuccessMeeting = {
response: GetMeeting;
loading: false;
error: null;
reload: () => void;
};
const useRoomMeeting = (
roomName: string | null | undefined,
): ErrorMeeting | LoadingMeeting | SuccessMeeting => {
const [response, setResponse] = useState<GetMeeting | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null);
const [reload, setReload] = useState(0);
const { setError } = useError();
const api = useApi();
const reloadHandler = () => setReload((prev) => prev + 1);
useEffect(() => {
if (!roomName || !api) return;
if (!response) {
setLoading(true);
}
api
.v1RoomsCreateMeeting({ roomName })
.then((result) => {
setResponse(result);
setLoading(false);
console.debug("Meeting Loaded:", result);
})
.catch((error) => {
const shouldShowHuman = shouldShowError(error);
if (shouldShowHuman) {
setError(error, "There was an error loading the meeting");
} else {
setError(error);
}
setErrorState(error);
});
}, [roomName, !api, reload]);
return { response, loading, error, reload: reloadHandler } as
| ErrorMeeting
| LoadingMeeting
| SuccessMeeting;
};
export default useRoomMeeting;

View File

@@ -20,18 +20,6 @@ export default function TranscriptMeeting(details: TranscriptDetails) {
? meeting?.response?.host_room_url
: meeting?.response?.room_url;
const handleLeave = useCallback((event) => {
console.log("LEFT", event);
}, []);
useEffect(() => {
wherebyRef.current?.addEventListener("leave", handleLeave);
return () => {
wherebyRef.current?.removeEventListener("leave", handleLeave);
};
}, [handleLeave]);
return (
<>
{roomUrl && (

View File

@@ -53,6 +53,18 @@ export const $CreateParticipant = {
title: "CreateParticipant",
} as const;
export const $CreateRoom = {
properties: {
name: {
type: "string",
title: "Name",
},
},
type: "object",
required: ["name"],
title: "CreateRoom",
} as const;
export const $CreateTranscript = {
properties: {
name: {
@@ -529,6 +541,62 @@ export const $Page_GetTranscript_ = {
title: "Page[GetTranscript]",
} as const;
export const $Page_Room_ = {
properties: {
items: {
items: {
$ref: "#/components/schemas/Room",
},
type: "array",
title: "Items",
},
total: {
type: "integer",
minimum: 0,
title: "Total",
},
page: {
anyOf: [
{
type: "integer",
minimum: 1,
},
{
type: "null",
},
],
title: "Page",
},
size: {
anyOf: [
{
type: "integer",
minimum: 1,
},
{
type: "null",
},
],
title: "Size",
},
pages: {
anyOf: [
{
type: "integer",
minimum: 0,
},
{
type: "null",
},
],
title: "Pages",
},
},
type: "object",
required: ["items", "total", "page", "size"],
title: "Page[Room]",
} as const;
export const $Participant = {
properties: {
id: {
@@ -556,6 +624,31 @@ export const $Participant = {
title: "Participant",
} as const;
export const $Room = {
properties: {
id: {
type: "string",
title: "Id",
},
name: {
type: "string",
title: "Name",
},
user_id: {
type: "string",
title: "User Id",
},
created_at: {
type: "string",
format: "date-time",
title: "Created At",
},
},
type: "object",
required: ["id", "name", "user_id", "created_at"],
title: "Room",
} as const;
export const $RtcOffer = {
properties: {
sdp: {

View File

@@ -6,6 +6,16 @@ import type {
MetricsResponse,
V1MeetingGetData,
V1MeetingGetResponse,
V1MeetingCreateData,
V1MeetingCreateResponse,
V1RoomsListData,
V1RoomsListResponse,
V1RoomsCreateData,
V1RoomsCreateResponse,
V1RoomsDeleteData,
V1RoomsDeleteResponse,
V1RoomsCreateMeetingData,
V1RoomsCreateMeetingResponse,
V1TranscriptsListData,
V1TranscriptsListResponse,
V1TranscriptsCreateData,
@@ -93,6 +103,117 @@ export class DefaultService {
});
}
/**
* Meeting Create
* @param data The data for the request.
* @param data.roomId
* @returns GetMeeting Successful Response
* @throws ApiError
*/
public v1MeetingCreate(
data: V1MeetingCreateData,
): CancelablePromise<V1MeetingCreateResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/meetings/",
query: {
room_id: data.roomId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms List
* @param data The data for the request.
* @param data.page Page number
* @param data.size Page size
* @returns Page_Room_ Successful Response
* @throws ApiError
*/
public v1RoomsList(
data: V1RoomsListData = {},
): CancelablePromise<V1RoomsListResponse> {
return this.httpRequest.request({
method: "GET",
url: "/v1/rooms",
query: {
page: data.page,
size: data.size,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms Create
* @param data The data for the request.
* @param data.requestBody
* @returns Room Successful Response
* @throws ApiError
*/
public v1RoomsCreate(
data: V1RoomsCreateData,
): CancelablePromise<V1RoomsCreateResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/rooms",
body: data.requestBody,
mediaType: "application/json",
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms Delete
* @param data The data for the request.
* @param data.roomId
* @returns DeletionStatus Successful Response
* @throws ApiError
*/
public v1RoomsDelete(
data: V1RoomsDeleteData,
): CancelablePromise<V1RoomsDeleteResponse> {
return this.httpRequest.request({
method: "DELETE",
url: "/v1/rooms/{room_id}",
path: {
room_id: data.roomId,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Rooms Create Meeting
* @param data The data for the request.
* @param data.roomName
* @returns GetMeeting Successful Response
* @throws ApiError
*/
public v1RoomsCreateMeeting(
data: V1RoomsCreateMeetingData,
): CancelablePromise<V1RoomsCreateMeetingResponse> {
return this.httpRequest.request({
method: "POST",
url: "/v1/rooms/{room_name}/meeting",
path: {
room_name: data.roomName,
},
errors: {
422: "Validation Error",
},
});
}
/**
* Transcripts List
* @param data The data for the request.

View File

@@ -14,6 +14,10 @@ export type CreateParticipant = {
name: string;
};
export type CreateRoom = {
name: string;
};
export type CreateTranscript = {
name: string;
source_language?: string;
@@ -103,12 +107,27 @@ export type Page_GetTranscript_ = {
pages?: number | null;
};
export type Page_Room_ = {
items: Array<Room>;
total: number;
page: number | null;
size: number | null;
pages?: number | null;
};
export type Participant = {
id: string;
speaker: number | null;
name: string;
};
export type Room = {
id: string;
name: string;
user_id: string;
created_at: string;
};
export type RtcOffer = {
sdp: string;
type: string;
@@ -184,6 +203,43 @@ export type V1MeetingGetData = {
export type V1MeetingGetResponse = GetMeeting;
export type V1MeetingCreateData = {
roomId: string;
};
export type V1MeetingCreateResponse = GetMeeting;
export type V1RoomsListData = {
/**
* Page number
*/
page?: number;
/**
* Page size
*/
size?: number;
};
export type V1RoomsListResponse = Page_Room_;
export type V1RoomsCreateData = {
requestBody: CreateRoom;
};
export type V1RoomsCreateResponse = Room;
export type V1RoomsDeleteData = {
roomId: string;
};
export type V1RoomsDeleteResponse = DeletionStatus;
export type V1RoomsCreateMeetingData = {
roomName: string;
};
export type V1RoomsCreateMeetingResponse = GetMeeting;
export type V1TranscriptsListData = {
/**
* Page number
@@ -374,6 +430,79 @@ export type $OpenApiTs = {
};
};
};
"/v1/meetings/": {
post: {
req: V1MeetingCreateData;
res: {
/**
* Successful Response
*/
200: GetMeeting;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms": {
get: {
req: V1RoomsListData;
res: {
/**
* Successful Response
*/
200: Page_Room_;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
post: {
req: V1RoomsCreateData;
res: {
/**
* Successful Response
*/
200: Room;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms/{room_id}": {
delete: {
req: V1RoomsDeleteData;
res: {
/**
* Successful Response
*/
200: DeletionStatus;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/rooms/{room_name}/meeting": {
post: {
req: V1RoomsCreateMeetingData;
res: {
/**
* Successful Response
*/
200: GetMeeting;
/**
* Validation Error
*/
422: HTTPValidationError;
};
};
};
"/v1/transcripts": {
get: {
req: V1TranscriptsListData;

View File

@@ -4,6 +4,7 @@ export const localConfig = {
privacy: true,
browse: true,
sendToZulip: true,
rooms: true,
},
api_url: "http://127.0.0.1:1250",
websocket_url: "ws://127.0.0.1:1250",

View File

@@ -14,8 +14,9 @@ export async function middleware(request: NextRequest) {
) {
// Feature-flag protedted paths
if (
!config.features.browse &&
request.nextUrl.pathname.startsWith("/browse")
(!config.features.browse &&
request.nextUrl.pathname.startsWith("/browse")) ||
(!config.features.rooms && request.nextUrl.pathname.startsWith("/rooms"))
) {
return NextResponse.redirect(request.nextUrl.origin);
}
@@ -27,7 +28,8 @@ export async function middleware(request: NextRequest) {
if (
request.nextUrl.pathname == "/" ||
request.nextUrl.pathname.startsWith("/transcripts") ||
request.nextUrl.pathname.startsWith("/browse")
request.nextUrl.pathname.startsWith("/browse") ||
request.nextUrl.pathname.startsWith("/rooms")
) {
if (!fiefResponse.headers.get("x-middleware-rewrite")) {
fiefResponse.headers.set(