Browse Source

feat: User form refactor + Working user form, image uploader, user info

pull/3/head
Dale 4 months ago
parent
commit
1f128b8e04
Signed by: Deiru GPG Key ID: AA250C0277B927E1
  1. 1
      .env.local
  2. 110
      graphql.schema.json
  3. 37
      src/client/UserSettings/UserCustomization.tsx
  4. 9
      src/client/UserSettings/UserPreferences.tsx
  5. 180
      src/client/components/ImageUploader.tsx
  6. 78
      src/client/components/showcase/Wrapper.tsx
  7. 13
      src/client/components/system/Navbar.tsx
  8. 8
      src/client/components/ui/StreamPreview.tsx
  9. 90
      src/client/components/ui/UserInfo.tsx
  10. 3
      src/client/components/ui/UserMenu.tsx
  11. 12
      src/client/hooks/auth.tsx
  12. 15
      src/client/theme/components/index.ts
  13. 10
      src/client/theme/index.ts
  14. 53
      src/pages/_app.tsx
  15. 10
      src/pages/docs/index.tsx
  16. 7
      src/pages/docs/showcase/button.tsx
  17. 9
      src/pages/docs/showcase/index.tsx
  18. 17
      src/pages/docs/showcase/panel.tsx
  19. 8
      src/pages/home.tsx
  20. 261
      src/pages/user/settings.tsx
  21. 2
      src/server/db/models/Users.ts
  22. 4
      src/server/graphql/index.ts
  23. 13
      src/server/graphql/mutations/file.ts
  24. 8
      src/server/graphql/resolvers/file.ts
  25. 12
      src/server/graphql/resolvers/users/index.ts
  26. 2
      src/server/graphql/schema/Files.graphql
  27. 9
      src/server/graphql/schema/User.graphql
  28. 1
      src/server/server.ts
  29. 91
      src/shared/graphql.ts
  30. 172
      src/shared/hooks.ts
  31. 36
      src/shared/showcase/create.tsx
  32. 12
      src/shared/showcase/styles.ts
  33. 6
      src/shared/showcase/types.ts
  34. 0
      src/shared/theme/colors.ts
  35. 0
      src/shared/theme/components/button.ts
  36. 15
      src/shared/theme/components/index.ts
  37. 0
      src/shared/theme/components/input.ts
  38. 0
      src/shared/theme/components/menu.ts
  39. 0
      src/shared/theme/components/switch.ts
  40. 0
      src/shared/theme/components/textarea.ts
  41. 20
      src/shared/theme/index.ts

1
.env.local

@ -1,2 +1,3 @@
NEXT_PUBLIC_ENV=local
NEXT_PUBLIC_API_URL=http://localhost:4000/graphql
NEXT_PUBLIC_MEDIA_URL=http://localhost:4000/media

110
graphql.schema.json

@ -1715,7 +1715,7 @@
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"name": "ID",
"ofType": null
},
"defaultValue": null,
@ -2460,6 +2460,42 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "avatar",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "header",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "streamThumbnail",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@ -2519,6 +2555,42 @@
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "avatar",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "header",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "streamThumbnail",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
@ -2659,6 +2731,42 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "avatar",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "File",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "header",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "File",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "streamThumbnail",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "File",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,

37
src/client/UserSettings/UserCustomization.tsx

@ -0,0 +1,37 @@
import { Box, Flex, FormLabel } from "@chakra-ui/react";
import { ImageUploader } from "miracle-tv-client/components/ImageUploader";
import React from "react";
export const UserCustomization = () => {
return (
<>
<Flex mb={4} direction={["column", "column", "column", "row"]}>
<Box>
<FormLabel size="sm" mb={4}>
ProfilePicture
</FormLabel>
<ImageUploader
mr={4}
name="avatar"
aspectMaxH="100px"
aspectMaxW="100px"
/>
</Box>
<Flex flex={4} direction="column" align={["center", "unset"]}>
<FormLabel size="sm" mb={4}>
Stream Thumbnail
</FormLabel>
<ImageUploader
name="streamThumbnail"
aspectMaxW="100%"
ratio={16 / 9}
/>
</Flex>
</Flex>
<FormLabel size="sm" mb={4}>
Header
</FormLabel>
<ImageUploader name="header" aspectMaxW="100%" ratio={16 / 6} />
</>
);
};

9
src/client/UserSettings/UserPreferences.tsx

@ -0,0 +1,9 @@
import { FormToggle } from "miracle-tv-client/components/form/FormToggle";
import React from "react";
export const UserPreferences = () => (
<>
<FormToggle name="singleUserMode" label="Single User Mode" />
<FormToggle name="useGravatar" label="Use Gravatar" />
</>
);

180
src/client/components/ImageUploader.tsx

@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useRef } from "react";
import { gql } from "@apollo/client";
import {
AspectRatio,
Box,
Flex,
FlexProps,
Heading,
IconButton,
Image,
ResponsiveValue,
Spinner,
} from "@chakra-ui/react";
import {
useGetFileForUploaderQuery,
useUploadFileWithUploaderMutation,
} from "miracle-tv-shared/hooks";
import { useField } from "react-final-form";
import { AttachmentIcon, CloseIcon } from "@chakra-ui/icons";
gql`
query GetFileForUploader($id: ID!) {
fileInfo(id: $id) {
id
filename
mimetype
encoding
}
}
mutation UploadFileWithUploader($input: Upload!) {
uploadFile(file: $input) {
id
filename
mimetype
encoding
}
}
`;
type Props = {
name: string;
ratio?: ResponsiveValue<number>;
aspectMaxW?: ResponsiveValue<string>;
aspectMaxH?: ResponsiveValue<string>;
} & FlexProps;
export const ImageUploader = ({
name,
ratio = 1,
aspectMaxW,
aspectMaxH,
...flexProps
}: Props): JSX.Element => {
const { input } = useField(name);
const inputRef = useRef<HTMLInputElement>(null);
const { data: { fileInfo } = {}, loading } = useGetFileForUploaderQuery({
variables: { id: input.value },
skip: !input.value,
});
const [uploadFile, { loading: fileUploading }] =
useUploadFileWithUploaderMutation({
onCompleted: ({ uploadFile }) => {
input.onChange(uploadFile.id);
},
});
const clearFile = useCallback(() => {
input.onChange(null);
}, [input]);
const onFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target?.files?.item(0);
uploadFile({ variables: { input } });
},
[uploadFile]
);
const openUpload = useCallback(() => {
inputRef.current?.click();
}, [inputRef.current]);
return (
<Flex
direction="column"
align={["center", "unset"]}
w={aspectMaxW}
h={aspectMaxH}
{...flexProps}
>
{!fileInfo && !(loading || fileUploading) && (
<AspectRatio
w="100%"
ratio={ratio}
mb={4}
maxW={aspectMaxW}
maxH={aspectMaxH}
>
<Flex
w={aspectMaxW || "100%"}
h={aspectMaxW || "100%"}
cursor="pointer"
aria-label="Choose file to upload"
onClick={openUpload}
justify="center"
align="center"
direction="column"
backgroundColor="secondary.600"
borderRadius="4px"
>
<Heading size="sm" mb={2}>
Upload
</Heading>
<AttachmentIcon size="lg" w="45%" h="45%" color="primary.200" />
</Flex>
</AspectRatio>
)}
{!fileInfo && (loading || fileUploading) && (
<AspectRatio
w="100%"
ratio={ratio}
mb={4}
maxW={aspectMaxW}
maxH={aspectMaxH}
>
<Box
w="100%"
h="100%"
backgroundColor="secondary.600"
borderRadius="4px"
>
<Spinner color="primary.200" size="xl" />
</Box>
</AspectRatio>
)}
{!!fileInfo && !(loading || fileUploading) && (
<>
<AspectRatio
w="100%"
ratio={ratio}
mb={4}
maxW={aspectMaxW}
maxH={aspectMaxH}
>
<Image
src={`${process.env.NEXT_PUBLIC_MEDIA_URL}/${fileInfo.filename}`}
borderRadius={8}
boxSizing="border-box"
borderWidth="3px"
borderStyle="dashed"
borderColor="primary.200"
/>
</AspectRatio>
<Box>
<IconButton
variant="link"
color="red.300"
aria-label="Remove Image"
onClick={clearFile}
icon={<CloseIcon />}
/>
<IconButton
variant="link"
color="primary.200"
aria-label="Upload New"
onClick={openUpload}
icon={<AttachmentIcon />}
/>
</Box>
</>
)}
<input
ref={inputRef}
type="file"
style={{ display: "none" }}
onChange={onFileSelect}
/>
</Flex>
);
};

78
src/client/components/showcase/Wrapper.tsx

@ -0,0 +1,78 @@
import { Box, Flex, Heading } from "@chakra-ui/react";
import { useRouter } from "next/dist/client/router";
import Link from "next/link";
import React from "react";
export type ComponentConfig = {
url: string;
title: string;
};
const components: ComponentConfig[] = [
{ url: "/docs/showcase", title: "Home" },
{ url: "/docs/showcase/button", title: "Button" },
{ url: "/docs/showcase/panel", title: "Panel" },
];
const docs: ComponentConfig[] = [{ url: "/docs", title: "Home" }];
const ShowcaseLink = ({ url, title }: ComponentConfig) => {
const { asPath } = useRouter();
const isActive = asPath === url;
return (
<Box
cursor="pointer"
p={2}
textAlign="center"
bgColor={isActive ? "primary.500" : "white"}
color={isActive ? "white" : "black"}
_notLast={{
borderBottom: "1px solid gray",
}}
transition="all 0.4s ease-in-out"
>
{" "}
<Link href={url}>
<Box w="100%" h="100%">
{title}
</Box>
</Link>
</Box>
);
};
type Props = {
children: React.ReactNode | React.ReactNode[];
};
export const ShowcaseWrapper = ({ children }: Props) => {
return (
<Flex w="100%" h="100%">
<Flex
flex={1}
direction="column"
borderRightWidth="4px"
borderRightColor="primary.200"
borderRightStyle="solid"
>
<Heading p={2} mb={2} size="md" textAlign="center">
Documentation
</Heading>
<Flex direction="column">
{docs.map((cmp) => (
<ShowcaseLink key={cmp.url} {...cmp} />
))}
</Flex>
<Heading p={2} mt={2} mb={2} size="md" textAlign="center">
Showcase
</Heading>
<Flex direction="column">
{components.map((cmp) => (
<ShowcaseLink key={cmp.url} {...cmp} />
))}
</Flex>
</Flex>
<Box flex={9}>{children}</Box>
</Flex>
);
};

13
src/client/components/system/Navbar.tsx

@ -1,21 +1,10 @@
import React, { useCallback, useState } from "react";
import {
Button,
Text,
Flex,
Heading,
IconButton,
MenuButton,
Menu,
MenuItem,
MenuList,
} from "@chakra-ui/react";
import { Button, Flex, Heading, IconButton } from "@chakra-ui/react";
import { BurgerMenuIcon } from "../icons/BurgerMenu";
import { Sidebar } from "./Sidebar";
import Link from "next/link";
import { useRouter } from "next/dist/client/router";
import { useCurrentUser } from "miracle-tv-client/hooks/auth";
import { ChevronDownIcon, LockIcon, SettingsIcon } from "@chakra-ui/icons";
import { UserMenu } from "../ui/UserMenu";
export const Navbar = () => {

8
src/client/components/ui/StreamPreview.tsx

@ -5,6 +5,7 @@ import {
PlaybackActions,
Player as PlayerType,
} from "miracle-tv-client/components/player/Player";
import { UserInfo } from "./UserInfo";
const Player = dynamic(
() =>
@ -15,7 +16,8 @@ const Player = dynamic(
);
type Props = {
name: string;
name?: string;
user: any;
w?: string;
h?: string;
alwaysShowInfo?: boolean;
@ -31,7 +33,7 @@ type Controls = {
};
export const StreamPreview = ({
name,
user,
h = "100%",
w = "100%",
alwaysShowInfo = false,
@ -117,7 +119,7 @@ export const StreamPreview = ({
</Flex>
<Flex zIndex={3} justify="flex-end">
<Box bgColor="secondary.400" px={2} py={1}>
{name}
<UserInfo user={user} />
</Box>
</Flex>
</Flex>

90
src/client/components/ui/UserInfo.tsx

@ -0,0 +1,90 @@
import React, { useEffect } from "react";
import {
AspectRatio,
Box,
Flex,
FlexProps,
Image,
Skeleton,
Text,
} from "@chakra-ui/react";
import { gql } from "@apollo/client";
import { useUserInfoLazyQuery } from "miracle-tv-shared/hooks";
type UserInfo = {
avatar: {
filename: string;
};
username: string;
displayName: string;
};
type Props = {
id?: string;
user?: UserInfo;
imageHeight?: string;
} & FlexProps;
gql`
query UserInfo($id: ID!) {
user(id: $id) {
avatar {
filename
}
username
displayName
}
}
`;
export const UserInfo = ({
id,
user: propsUser,
imageHeight = "30px",
...props
}: Props) => {
const [
loadUser,
{ data: { user: remoteUser } = { user: undefined }, loading },
] = useUserInfoLazyQuery();
const user = propsUser || remoteUser;
useEffect(() => {
if (id && props.user) {
console.log("ERROR! Cannot user both ID and User in UserInfo!");
} else if (id && !props.user) {
loadUser({ variables: { id } });
}
}, [loadUser, props.user, id]);
return (
<Flex align="center" {...props}>
{loading && <Skeleton width="120px" height="14px" />}
{!!user && (
<>
<AspectRatio ratio={1} h={imageHeight} w={imageHeight}>
<Box
w="100%"
h="100%"
borderRadius="6px"
bgColor="secondary.600"
borderStyle="solid"
borderWidth="1px"
borderColor="secondary.400"
>
<Image
w="100%"
h="100%"
src={`${process.env.NEXT_PUBLIC_MEDIA_URL}/${user.avatar.filename}`}
/>
</Box>
</AspectRatio>
<Text as="span" ml={2}>
{user.displayName || user.username}
</Text>
</>
)}
</Flex>
);
};

3
src/client/components/ui/UserMenu.tsx

@ -4,6 +4,7 @@ import { ChevronDownIcon, LockIcon, SettingsIcon } from "@chakra-ui/icons";
import { useCurrentUser } from "miracle-tv-client/hooks/auth";
import { useRouter } from "next/dist/client/router";
import Link from "next/link";
import { UserInfo } from "./UserInfo";
type LinkProps = {
url: string;
@ -37,7 +38,7 @@ export const UserMenu = () => {
rightIcon={<ChevronDownIcon />}
textTransform="none"
>
{user?.displayName || user?.username}
<UserInfo id={user?.id} />
</MenuButton>
<MenuList>
<MenuLink

12
src/client/hooks/auth.tsx

@ -99,6 +99,18 @@ export const CurrentUserFullFragment = gql`
bio
singleUserMode
emailHash
avatar {
id
filename
}
header {
id
filename
}
streamThumbnail {
id
filename
}
roles {
id
parentId

15
src/client/theme/components/index.ts

@ -1,15 +0,0 @@
import { buttonStyles as Button } from "miracle-tv-client/theme/components/button";
import { inputStyles as Input } from "miracle-tv-client/theme/components/input";
import { textareaStyles as Textarea } from "miracle-tv-client/theme/components/textarea";
import { menuStyles as Menu } from "miracle-tv-client/theme/components/menu";
import { switchStyles as Switch } from "miracle-tv-client/theme/components/switch";
const components = {
Button,
Input,
Menu,
Switch,
Textarea,
};
export default components;

10
src/client/theme/index.ts

@ -1,10 +0,0 @@
import { extendTheme } from "@chakra-ui/react";
import colors from "miracle-tv-client/theme/colors";
import components from "miracle-tv-client/theme/components";
const theme = extendTheme({
colors,
components,
});
export default theme;

53
src/pages/_app.tsx

@ -3,13 +3,15 @@ import { Global, css } from "@emotion/react";
import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import Head from "next/head";
import { propOr } from "ramda";
import { any, propOr } from "ramda";
import theme from "miracle-tv-client/theme";
import theme from "miracle-tv-shared/theme";
import { Navbar } from "miracle-tv-client/components/system/Navbar";
import React from "react";
import { useRouter } from "next/dist/client/router";
import { createUploadLink } from "apollo-upload-client";
import Link from "next/link";
import { ShowcaseWrapper } from "miracle-tv-client/components/showcase/Wrapper";
const env = process.env.NEXT_PUBLIC_ENV;
@ -42,15 +44,19 @@ const client = new ApolloClient({
link: authLink.concat(uploadLink),
});
const noNavbarRoutes = ["/auth/login"];
const noNavbarRoutes = ["/auth/login", "/docs"];
function MyApp({ Component, pageProps }: any) {
function MyApp({ Component, pageProps }: any): JSX.Element {
const router = useRouter();
const showNavbar = !noNavbarRoutes.includes(router.asPath);
const showNavbar = !any(
(path) => router.asPath.startsWith(path),
noNavbarRoutes
);
const isShowcase = router.asPath.startsWith("/docs");
return (
<>
<Head>
<title>Кофейня - Учёт</title>
<title>Miracle TV</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
</Head>
<Global
@ -67,21 +73,28 @@ function MyApp({ Component, pageProps }: any) {
/>
<ApolloProvider client={client}>
<ChakraProvider theme={theme}>
<Flex h="100%" w="100%" direction="column">
{showNavbar && <Navbar />}
<Box
width="100%"
height="100%"
px={15}
py={5}
position="relative"
overflowY="auto"
color="white"
bgColor="secondary.600"
>
{!isShowcase && (
<Flex h="100%" w="100%" direction="column">
{showNavbar && <Navbar />}
<Box
width="100%"
height="100%"
px={15}
py={5}
position="relative"
overflowY="auto"
color="white"
bgColor="secondary.600"
>
<Component {...pageProps} />
</Box>
</Flex>
)}
{isShowcase && (
<ShowcaseWrapper>
<Component {...pageProps} />
</Box>
</Flex>
</ShowcaseWrapper>
)}
</ChakraProvider>
</ApolloProvider>
</>

10
src/pages/docs/index.tsx

@ -0,0 +1,10 @@
import React from "react";
import { Panel } from "miracle-tv-client/components/ui/Panel";
import { createShowcase } from "miracle-tv-shared/showcase/create";
import { Heading } from "@chakra-ui/layout";
export default createShowcase(
<Panel>
<Heading>Documentation</Heading>
</Panel>
);

7
src/pages/docs/showcase/button.tsx

@ -0,0 +1,7 @@
import React from "react";
import { Button } from "@chakra-ui/react";
import { createShowcase } from "miracle-tv-shared/showcase/create";
export default createShowcase(<Button children={"Button"} />, {
title: "Button",
});

9
src/pages/docs/showcase/index.tsx

@ -0,0 +1,9 @@
import { Text } from "@chakra-ui/react";
import { createShowcase } from "miracle-tv-shared/showcase/create";
import React from "react";
const Page = () => {
return <Text color="white">SHOWCASE</Text>;
};
export default createShowcase(<Page />, { title: "Home" });

17
src/pages/docs/showcase/panel.tsx

@ -0,0 +1,17 @@
import React from "react";
import { Form } from "react-final-form";
import { Panel } from "miracle-tv-client/components/ui/Panel";
import { createShowcase } from "miracle-tv-shared/showcase/create";
import { UserEditForm } from "miracle-tv-client/UserSettings/UserEditForm";
import { Heading } from "@chakra-ui/layout";
import { Box } from "@chakra-ui/react";
export default createShowcase(
<Panel>
<Heading mb={4}>Panel Content</Heading>
<Box w="55vw">
<Form onSubmit={() => {}}>{() => <UserEditForm />}</Form>
</Box>
</Panel>,
{ title: "Panel" }
);

8
src/pages/home.tsx

@ -91,7 +91,13 @@ const Home = () => {
<StreamPreview
alwaysShowInfo
w="100%"
name={"Dale's amazing stream"}
user={{
displayName: "Dale",
username: "Dale",
avatar: {
filename: "58ed5aec-b6a5-4fa0-b890-6fd4b84ecdfe.png",
},
}}
boxSizing="border-box"
/>
</GridItem>

261
src/pages/user/settings.tsx

@ -1,31 +1,29 @@
import { gql } from "@apollo/client";
import {
AspectRatio,
Box,
Button,
Flex,
Heading,
Skeleton,
SkeletonText,
Stack,
useToast,
VStack,
Image,
FormLabel,
} from "@chakra-ui/react";
import { AuthRedirect } from "miracle-tv-client/components/auth/Redirect";
import { FormToggle } from "miracle-tv-client/components/form/FormToggle";
import { Panel } from "miracle-tv-client/components/ui/Panel";
import {
CurrentUserFullFragment,
useCurrentUser,
} from "miracle-tv-client/hooks/auth";
import { CurrentUserFullFragment } from "miracle-tv-client/hooks/auth";
import { UserCustomization } from "miracle-tv-client/UserSettings/UserCustomization";
import { UserEditForm } from "miracle-tv-client/UserSettings/UserEditForm";
import { UpdateSelfInput } from "miracle-tv-shared/graphql";
import { UserPreferences } from "miracle-tv-client/UserSettings/UserPreferences";
import { UpdateSelfInput, UpdateUserInput } from "miracle-tv-shared/graphql";
import {
UserSettingsFormDataQueryResult,
useSettingsUpdateUserMutation,
useUploadSettingsMediaMutation,
useUserSettingsFormDataQuery,
} from "miracle-tv-shared/hooks";
import { omit } from "ramda";
import React, { useCallback } from "react";
import React, { useCallback, useMemo } from "react";
import { Form } from "react-final-form";
gql`
@ -34,6 +32,15 @@ gql`
displayName
bio
singleUserMode
avatar {
id
}
header {
id
}
streamThumbnail {
id
}
}
}
`;
@ -44,17 +51,47 @@ gql`
...CurrentUser
}
}
mutation UploadSettingsMedia($input: Upload!) {
uploadFile(file: $input) {
id
filename
mimetype
encoding
}
}
${CurrentUserFullFragment}
`;
const convertUserToForm = ({
avatar,
streamThumbnail,
header,
...user
}: UserSettingsFormDataQueryResult["data"]["self"]): UpdateUserInput => {
return {
header: header?.id,
avatar: avatar?.id,
streamThumbnail: streamThumbnail?.id,
...(user as UpdateUserInput),
};
};
type LoaderProps = {
isActive?: boolean;
rows?: number;
rowHeight?: string;
children: React.ReactNode | React.ReactNode[];
};
const Loader = ({
children,
isActive = false,
rows = 3,
rowHeight = "20px",
}: LoaderProps) => {
const rowEls = useMemo(() => [...Array(rows).keys()], [rows]);
return isActive ? (
<Stack>
<Skeleton height={rowHeight} />
<SkeletonText mt="4" noOfLines={rows} spacing="4" />
</Stack>
) : (
<>{children}</>
);
};
const UserSettingsPage = (): JSX.Element => {
const toast = useToast();
const { data, loading: userLoading } = useUserSettingsFormDataQuery();
@ -68,7 +105,11 @@ const UserSettingsPage = (): JSX.Element => {
},
});
const [uploadFile] = useUploadSettingsMediaMutation();
const updateUserData = useMemo(
() =>
data?.self ? convertUserToForm(omit(["__typename"], data?.self)) : {},
[data]
);
const onSubmit = useCallback((input: UpdateSelfInput) => {
updateSelf({ variables: { input } });
@ -80,42 +121,17 @@ const UserSettingsPage = (): JSX.Element => {
<Box bgColor="secondary.400" p={4} mb={4}>
<Heading>My Settings</Heading>
</Box>
<Flex direction={["column", "row"]} px={4}>
<VStack flex={1} mr={[0, 6]} mb={[4, 0]}>
<Box w="100%">
<Heading mb={4}>My Profile</Heading>
<Panel>
<Form<UpdateSelfInput>
onSubmit={onSubmit}
initialValues={omit(["__typename"], data?.self)}
>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<UserEditForm />
<Button
mt={4}
type="submit"
isLoading={mutationLoading}
isDisabled={userLoading}
>
Save
</Button>
</form>
)}
</Form>
</Panel>
</Box>
<Box w="100%">
<Heading mb={4}>My preferences</Heading>
<Panel>
<Form<any> onSubmit={() => {}}>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<FormToggle
name="singleUserMode"
label="Single User Mode"
/>
<FormToggle name="useGravatar" label="Use Gravatar" />
<Form<UpdateSelfInput> onSubmit={onSubmit} initialValues={updateUserData}>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<Flex direction={["column", "column", "row"]} px={4}>
<VStack w="100%" mr={[0, 0, 6]} mb={[4, 0]}>
<Box w="100%">
<Heading mb={4}>My Profile</Heading>
<Panel>
<Loader isActive={userLoading} rows={6} rowHeight="30px">
<UserEditForm />
</Loader>
<Button
mt={4}
type="submit"
@ -124,95 +140,50 @@ const UserSettingsPage = (): JSX.Element => {
>
Save
</Button>
</form>
)}
</Form>
</Panel>
</Box>
</VStack>
<VStack flex={1}>
<Box w="100%">
<Heading mb={4}>Customization</Heading>
<Panel>
<Flex mb={4} direction={["column", "row"]}>
<Flex
flex={1}
direction="column"
align={["center", "unset"]}
mr={16}
>
<FormLabel size="sm" mb={4}>
Profile Picture
</FormLabel>
<Box w="100%">
<AspectRatio h="100%" w="100%" ratio={1} mr={4} mb={4}>
<Image
borderRadius={8}
boxSizing="border-box"
borderWidth="3px"
borderStyle="dashed"
borderColor="primary.200"
src="/yuuka-avatar.jpg"
/>
</AspectRatio>
</Box>
<Box flex="auto" />
<Box>
<input
type="file"
onChange={({ target }) => {
const input = target?.files?.item(0);
uploadFile({ variables: { input } });
}}
style={{ color: "white", width: "250px" }}
/>
</Box>
</Flex>
<Flex flex={4} direction="column" align={["center", "unset"]}>
<FormLabel size="sm" mb={4}>
Stream Thumbnail
</FormLabel>
<Box w="100%">
<AspectRatio h="100%" w="100%" ratio={16 / 9} mr={4} mb={8}>
<Image
borderRadius={8}
boxSizing="border-box"
borderWidth="3px"
borderStyle="dashed"
borderColor="primary.200"
src="/yuuka-thumbnail.jpg"
/>
</AspectRatio>
</Box>
<Box flex="auto" />
<Box>
<input
type="file"
style={{ color: "white", width: "250px" }}
/>
</Box>
</Flex>
</Flex>
<FormLabel size="sm" mb={4}>
Header
</FormLabel>
<Flex direction="column" align={["center", "unset"]}>
<AspectRatio w="100%" ratio={16 / 5} mb={4}>
<Image
src="/yuuka-header.jpg"
borderRadius={8}
boxSizing="border-box"
borderWidth="3px"
borderStyle="dashed"
borderColor="primary.200"
/>
</AspectRatio>
<input type="file" style={{ color: "white", width: "250px" }} />
</Flex>
</Panel>
</Box>
</VStack>
</Flex>
</Panel>
</Box>
<Box w="100%">
<Heading mb={4}>My preferences</Heading>
<Panel>
<Loader isActive={userLoading} rows={3} rowHeight="30px">
<UserPreferences />
<Button
mt={4}
type="submit"
isLoading={mutationLoading}
isDisabled={userLoading}
>
Save
</Button>
</Loader>
</Panel>
</Box>
</VStack>
<VStack
w={["100%", "100%", "50vh", "unset"]}
flex={[1, 1, "unset", 1]}
>
<Box w="545px">
<Heading mb={4}>Customization</Heading>
<Panel>
<Loader isActive={userLoading} rows={12} rowHeight="30px">
<UserCustomization />
<Button
mt={4}
type="submit"
isLoading={mutationLoading}
isDisabled={userLoading}
>
Save
</Button>
</Loader>
</Panel>
</Box>
</VStack>
</Flex>
</form>
)}
</Form>
</>
);
};

2
src/server/db/models/Users.ts

@ -76,6 +76,6 @@ export class UsersModel extends Model {
if (errors) {
throw new ServerError("Error updating user");
}
return { id, ...user, ...input };
return { id, ...user, ...input } as User;
}
}

4
src/server/graphql/index.ts

@ -56,6 +56,7 @@ import {
} from "miracle-tv-server/graphql/mutations/stream-keys";
import { fileMutations } from "./mutations/file";
import { FilesModel } from "miracle-tv-server/db/models/Files";
import { fileResolvers } from "./resolvers/file";
const schemaString = glob
.sync(path.resolve(__dirname, "./**/*.graphql"))
@ -87,6 +88,7 @@ const resolvers: Resolvers<ResolverContext> = {
self: userSelfQueryResolver,
selfStreamKeys: selfStreamKeysQueryResolver,
test: userTestQueryResolver,
...fileResolvers,
},
Mutation: {
ping: (...args) => {
@ -114,7 +116,7 @@ export const graphqlEndpoint = new ApolloServer({
uploads: false,
typeDefs: schema,
resolvers,
introspection: false,
introspection: true,
playground: false,
context: async ({ req }) => {
const con = await connection;

13
src/server/graphql/mutations/file.ts

@ -7,6 +7,7 @@ import config from "miracle-tv-server/config";
import { v4 as uuidv4 } from "uuid";
import { last } from "ramda";
import { ResolverContext } from "miracle-tv-server/types/resolver";
import { ServerError } from "../errors/general";
const currentDir = process.cwd();
@ -26,9 +27,15 @@ export const fileMutations: FileMutations = {
const newFilename = `${id}${extension}`;
const stream = createReadStream();
const out = fs.createWriteStream(path.join(saveDir, newFilename));
stream.pipe(out);
await new Promise<void>((resolve, reject) =>
stream
.on("error", (error) => {
reject(error);
})
.pipe(fs.createWriteStream(path.join(saveDir, newFilename)))
.on("error", (error) => reject(error))
.on("finish", () => resolve())
);
const response = await files.createFile({
id,
encoding,

8
src/server/graphql/resolvers/file.ts

@ -0,0 +1,8 @@
import { ResolverContext } from "miracle-tv-server/types/resolver";
import { QueryResolvers } from "miracle-tv-shared/graphql";
export const fileResolvers: QueryResolvers<ResolverContext> = {
async fileInfo(_, { id }, { db: { files } }) {
return await files.getFileById(id);
},
};

12
src/server/graphql/resolvers/users/index.ts

@ -44,6 +44,15 @@ export const userTestQueryResolver: QueryResolvers<ResolverContext>["test"] = (
throw new AuthenticationError();
};
type FileResolver = (field: string) => UserResolvers["avatar"];
const fileResolver: FileResolver =
(field) =>
async (user, _, { db: { files } }) => {
return user[field as keyof typeof user]
? await files.getFileById(user[field as keyof typeof user])
: null;
};
export const userResolver: UserResolvers = {
id: (user) => user.id,
username: (user) => user.username,
@ -55,4 +64,7 @@ export const userResolver: UserResolvers = {
const rolesList = await roles.getAll(user.roles || []);
return rolesList as Role[];
},
avatar: fileResolver("avatar"),
header: fileResolver("header"),
streamThumbnail: fileResolver("streamThumbnail"),
};

2
src/server/graphql/schema/Files.graphql

@ -6,7 +6,7 @@ type File {
}
extend type Query {
fileInfo(id: String): File
fileInfo(id: ID): File
}
extend type Mutation {

9
src/server/graphql/schema/User.graphql

@ -7,6 +7,9 @@ type User {
emailHash: String
roles: [Role]!
channels: [Channel]!
avatar: File
header: File
streamThumbnail: File
}
type Session {
@ -46,6 +49,9 @@ input UpdateSelfInput {
displayName: String
bio: String
singleUserMode: Boolean
avatar: ID
header: ID
streamThumbnail: ID
}
input UpdateUserInput {
@ -53,6 +59,9 @@ input UpdateUserInput {
displayName: String
bio: String
singleUserMode: Boolean
avatar: ID
header: ID
streamThumbnail: ID
}
extend type Mutation {

1
src/server/server.ts

@ -14,6 +14,7 @@ const main = async () => {
await graphqlEndpoint.start();
const app = Express();
app.use(graphqlUploadExpress());
app.use("/media/", Express.static(`${config.dataDir}/media`));
graphqlEndpoint.applyMiddleware({ app });
app.listen(
config.server?.port || 4000,

91
src/shared/graphql.ts

@ -274,7 +274,7 @@ export type QueryChannelsArgs = {
export type QueryFileInfoArgs = {
id?: Maybe<Scalars['String']>;
id?: Maybe<Scalars['ID']>;
};
@ -357,6 +357,9 @@ export type UpdateSelfInput = {
displayName?: Maybe<Scalars['String']>;
bio?: Maybe<Scalars['String']>;
singleUserMode?: Maybe<Scalars['Boolean']>;
avatar?: Maybe<Scalars['ID']>;
header?: Maybe<Scalars['ID']>;
streamThumbnail?: Maybe<Scalars['ID']>;
};
export type UpdateUserInput = {
@ -364,6 +367,9 @@ export type UpdateUserInput = {
displayName?: Maybe<Scalars['String']>;
bio?: Maybe<Scalars['String']>;
singleUserMode?: Maybe<Scalars['Boolean']>;
avatar?: Maybe<Scalars['ID']>;
header?: Maybe<Scalars['ID']>;
streamThumbnail?: Maybe<Scalars['ID']>;
};
@ -377,6 +383,9 @@ export type User = {
emailHash?: Maybe<Scalars['String']>;
roles: Array<Maybe<Role>>;
channels: Array<Maybe<Channel>>;
avatar?: Maybe<File>;
header?: Maybe<File>;
streamThumbnail?: Maybe<File>;
};
export type UserActions = {
@ -705,6 +714,9 @@ export type UserResolvers<ContextType = any, ParentType extends ResolversParentT
emailHash?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
roles?: Resolver<Array<Maybe<ResolversTypes['Role']>>, ParentType, ContextType>;
channels?: Resolver<Array<Maybe<ResolversTypes['Channel']>>, ParentType, ContextType>;
avatar?: Resolver<Maybe<ResolversTypes['File']>, ParentType, ContextType>;
header?: Resolver<Maybe<ResolversTypes['File']>, ParentType, ContextType>;
streamThumbnail?: Resolver<Maybe<ResolversTypes['File']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@ -759,10 +771,62 @@ export type DirectiveResolvers<ContextType = any> = {
* Use "DirectiveResolvers" root object instead. If you wish to get "IDirectiveResolvers", add "typesPrefix: I" to your config.
*/
export type IDirectiveResolvers<ContextType = any> = DirectiveResolvers<ContextType>;
export type GetFileForUploaderQueryVariables = Exact<{
id: Scalars['ID'];
}>;
export type GetFileForUploaderQuery = (
{ __typename?: 'Query' }
& { fileInfo?: Maybe<(
{ __typename?: 'File' }
& Pick<File, 'id' | 'filename' | 'mimetype' | 'encoding'>
)> }
);
export type UploadFileWithUploaderMutationVariables = Exact<{
input: Scalars['Upload'];
}>;
export type UploadFileWithUploaderMutation = (
{ __typename?: 'Mutation' }
& { uploadFile: (
{ __typename?: 'File' }
& Pick<File, 'id' | 'filename' | 'mimetype' | 'encoding'>
) }
);
export type UserInfoQueryVariables = Exact<{
id: Scalars['ID'];
}>;
export type UserInfoQuery = (
{ __typename?: 'Query' }
& { user?: Maybe<(
{ __typename?: 'User' }
& Pick<User, 'username' | 'displayName'>
& { avatar?: Maybe<(
{ __typename?: 'File' }
& Pick<File, 'filename'>
)> }
)> }
);
export type CurrentUserFragment = (
{ __typename?: 'User' }
& Pick<User, 'id' | 'username' | 'displayName' | 'emailHash' | 'bio' | 'singleUserMode'>
& { roles: Array<Maybe<(
& { avatar?: Maybe<(
{ __typename?: 'File' }
& Pick<File, 'id' | 'filename'>
)>, header?: Maybe<(
{ __typename?: 'File' }
& Pick<File, 'id' | 'filename'>
)>, streamThumbnail?: Maybe<(
{ __typename?: 'File' }
& Pick<File, 'id' | 'filename'>
)>, roles: Array<Maybe<(
{ __typename?: 'Role' }
& Pick<Role, 'id' | 'parentId' | 'name'>
& { access: (
@ -875,6 +939,16 @@ export type UserSettingsFormDataQuery = (
& { self: (
{ __typename?: 'User' }
& Pick<User, 'displayName' | 'bio' | 'singleUserMode'>
& { avatar?: Maybe<(
{ __typename?: 'File' }
& Pick<File, 'id'>
)>, header?: Maybe<(
{ __typename?: 'File' }
& Pick<File, 'id'>
)>, streamThumbnail?: Maybe<(
{ __typename?: 'File' }
& Pick<File, 'id'>
)> }
) }
);
@ -890,16 +964,3 @@ export type SettingsUpdateUserMutation = (
& CurrentUserFragment
) }
);
export type UploadSettingsMediaMutationVariables = Exact<