Browse Source

feat: File upload, front

develop
Dale 2 weeks ago
parent
commit
c382eabd3f
Signed by: Deiru GPG Key ID: AA250C0277B927E1
  1. 1
      .env.development
  2. 2
      .env.local
  3. 1
      .env.production
  4. 1
      .gitignore
  5. 2
      codegen.yml
  6. 4
      default.nix
  7. 311
      graphql.schema.json
  8. 40
      module.nix
  9. 3
      next-production.config.js
  10. 31
      package.json
  11. 34
      packages.nix
  12. BIN
      public/game-example.png
  13. BIN
      public/videos/kill-the-night.mkv
  14. BIN
      public/videos/kill-the-night.mp4
  15. BIN
      public/yuuka-avatar.jpg
  16. BIN
      public/yuuka-header.jpg
  17. BIN
      public/yuuka-thumbnail.jpg
  18. 1
      result
  19. 19
      src/client/UserSettings/UserEditForm.tsx
  20. 20
      src/client/components/auth/Redirect.tsx
  21. 47
      src/client/components/form/FormGroup.tsx
  22. 30
      src/client/components/form/FormInput.tsx
  23. 28
      src/client/components/form/FormTextarea.tsx
  24. 28
      src/client/components/form/FormToggle.tsx
  25. 10
      src/client/components/icons/BurgerMenu.tsx
  26. 77
      src/client/components/player/Player.tsx
  27. 94
      src/client/components/system/Navbar.tsx
  28. 62
      src/client/components/system/Sidebar/SidebarNav.tsx
  29. 40
      src/client/components/system/Sidebar/index.tsx
  30. 39
      src/client/components/ui/Panel.tsx
  31. 134
      src/client/components/ui/StreamPreview.tsx
  32. 61
      src/client/components/ui/UserMenu.tsx
  33. 145
      src/client/hooks/auth.tsx
  34. 14
      src/client/theme/colors.ts
  35. 9
      src/client/theme/components/button.ts
  36. 15
      src/client/theme/components/index.ts
  37. 30
      src/client/theme/components/input.ts
  38. 27
      src/client/theme/components/menu.ts
  39. 6
      src/client/theme/components/switch.ts
  40. 28
      src/client/theme/components/textarea.ts
  41. 10
      src/client/theme/index.ts
  42. 7
      src/client/types/form.ts
  43. 56
      src/pages/_app.tsx
  44. 106
      src/pages/auth/login.tsx
  45. 49
      src/pages/dashboard.tsx
  46. 9
      src/pages/feed.tsx
  47. 123
      src/pages/home.tsx
  48. 46
      src/pages/index.tsx
  49. 9
      src/pages/profile.tsx
  50. 9
      src/pages/subscriptions.tsx
  51. 217
      src/pages/user/settings.tsx
  52. 1
      src/server/config/index.ts
  53. 1
      src/server/config/local.json
  54. 4
      src/server/db/acl/roles.ts
  55. 2
      src/server/db/generate-roles.ts
  56. 2
      src/server/db/models/Activities.ts
  57. 2
      src/server/db/models/Channels.ts
  58. 25
      src/server/db/models/Files.ts
  59. 2
      src/server/db/models/Roles.ts
  60. 2
      src/server/db/models/Sessions.ts
  61. 2
      src/server/db/models/StreamKeys.ts
  62. 27
      src/server/db/models/Users.ts
  63. 1
      src/server/db/setup-db.ts
  64. 2
      src/server/db/types.ts
  65. 37
      src/server/graphql/index.ts
  66. 2
      src/server/graphql/mutations/activities/index.ts
  67. 2
      src/server/graphql/mutations/channels/index.ts
  68. 40
      src/server/graphql/mutations/file.ts
  69. 2
      src/server/graphql/mutations/stream-keys/index.ts
  70. 2
      src/server/graphql/mutations/users/auth.ts
  71. 36
      src/server/graphql/mutations/users/index.ts
  72. 5
      src/server/graphql/resolvers/activities/index.ts
  73. 5
      src/server/graphql/resolvers/channels/index.ts
  74. 2
      src/server/graphql/resolvers/roles/index.ts
  75. 2
      src/server/graphql/resolvers/stream-keys/index.ts
  76. 2
      src/server/graphql/resolvers/users/index.ts
  77. 14
      src/server/graphql/schema/Files.graphql
  78. 1
      src/server/graphql/schema/Scalars.graphql
  79. 17
      src/server/graphql/schema/User.graphql
  80. 14
      src/server/server.ts
  81. 697
      src/server/types/graphql.ts
  82. 18
      src/server/types/resolver.ts
  83. 198
      src/shared/graphql.ts
  84. 314
      src/shared/hooks.ts
  85. 3
      tsconfig.json
  86. 1055
      yarn.lock
  87. 7069
      yarn.nix

1
.env.development

@ -0,0 +1 @@
NEXT_PUBLIC_ENV=development

2
.env.local

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

1
.env.production

@ -0,0 +1 @@
NEXT_PUBLIC_ENV=production

1
.gitignore

@ -6,6 +6,7 @@ dev.sqlite3
result/
.log/
.vscode/
.media/
miracle-tv.*.zip
result/

2
codegen.yml

@ -14,6 +14,6 @@ generates:
src/shared/hooks.ts:
preset: import-types
presetConfig:
typesPath: 'shared/graphql'
typesPath: 'miracle-tv-shared/graphql'
plugins:
- typescript-react-apollo

4
default.nix

@ -1,7 +1,7 @@
with import <nixpkgs> {};
let
version = "0.1.0.3";
version = "0.1.4";
src = ./.;
nodePkg = pkgs.nodejs-14_x;
yarnPkg = pkgs.yarn.override { nodejs = nodePkg; };
@ -13,7 +13,7 @@ in mkYarnPackage rec {
packageJSON = "${src}/package.json";
yarnLock = "${src}/yarn.lock";
configurePhase = ''
configurePhase = ''
rm -rf ./node_modules
mkdir ./node_modules
cp -R $node_modules/* ./node_modules

311
graphql.schema.json

@ -952,6 +952,77 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "File",
"description": null,
"fields": [
{
"name": "filename",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "mimetype",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "encoding",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "InfoResponse",
@ -1148,6 +1219,39 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "uploadFile",
"description": null,
"args": [
{
"name": "file",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Upload",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "File",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createRole",
"description": null,
@ -1338,9 +1442,13 @@
"name": "input",
"description": null,
"type": {
"kind": "INPUT_OBJECT",
"name": "SignInInput",
"ofType": null
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "SignInInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
@ -1354,6 +1462,72 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateUser",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "UpdateUserInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "User",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateSelf",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "UpdateSelfInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "User",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@ -1532,6 +1706,31 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "fileInfo",
"description": null,
"args": [
{
"name": "id",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "OBJECT",
"name": "File",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "role",
"description": null,
@ -2220,6 +2419,112 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "UpdateSelfInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "displayName",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "bio",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "singleUserMode",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "UpdateUserInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "id",
"description": null,
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "displayName",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "bio",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "singleUserMode",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "Upload",

40
module.nix

@ -13,6 +13,21 @@ in {
type = types.str;
default = "MiracleTV";
};
enableNginx = mkEnableOption "Enable Nginx Management";
url = mkOption {
type = types.str;
default = "localhost";
};
client = {
hostname = mkOption {
type = types.str;
default = "0.0.0.0";
};
port = mkOption {
type = types.int;
default = 4000;
};
};
server = {
hostname = mkOption {
type = types.str;
@ -43,9 +58,32 @@ in {
config = let
configFile = pkgs.writeText "config.json" (builtins.toJSON cfg.settings);
in lib.mkIf cfg.enable {
services.nginx.virtualHosts = lib.mkIf cfg.settings.enableNginx {
"${cfg.settings.url}" = {
enableACME = true;
forceSSL = true;
root = "${coffeeServer}/client";
locations."/" = {
proxyPass = "http://localhost:${toString cfg.settings.client.port}/";
extraConfig = ''
proxy_pass_request_headers on;
'';
};
locations."/api/" = {
proxyPass = "http://localhost:${toString cfg.settings.server.port}/";
extraConfig = ''
proxy_pass_request_headers on;
'';
};
};
};
systemd.services.miracle-tv = {
wantedBy = [ "multi-user.target" ];
serviceConfig.ExecStart = "${miracle-tv}/bin/miracle-tv ${configFile}";
serviceConfig.ExecStart = "${miracle-tv}/bin/server ${configFile}";
};
systemd.services.miracle-tv-frontend = {
wantedBy = [ "multi-user.target" ];
serviceConfig.ExecStart = "${miracle-tv}/bin/client -p ${toString cfg.settings.client.port} -h ${cfg.settings.client.host}";
};
};
}

3
next-production.config.js

@ -0,0 +1,3 @@
module.exports = {
distDir: "dist",
};

31
package.json

@ -1,31 +1,45 @@
{
"name": "miracle-tv",
"version": "0.1.0.3",
"main": "server.js",
"version": "0.1.4",
"repository": "https://code.gensokyo.social/Gensokyo.social/miracle-tv",
"license": "MIT",
"scripts": {
"daemon:start": "NODE_ENV=production name=miracle-tv pm2 start yarn --name miracle-tv -- server",
"daemon:stop": "pm2 delete miracle-tv",
"daemon:restart": "name=miracle-tv pm2 restart miracle-tv --update-env",
"client:dev": "next dev",
"server": "NODE_ENV=production $node_modules/.bin/ts-node -r tsconfig-paths/register src/server/server.ts",
"codegen": "graphql-codegen --config codegen.yml",
"dev": "nodemon -e ts,tsx,graphql --exec \"yarn run server\""
"dev:server": "nodemon -e ts,tsx,graphql --ignore src/client --ignore src/pages --exec \"yarn run run:server\"",
"run:server": "NODE_ENV=production $node_modules/.bin/ts-node -r tsconfig-paths/register src/server/server.ts",
"build:server": "ncc build src/server/server.ts -o ./dist/server",
"build:client": "NODE_ENV=local next build",
"run:client": "next start",
"dev:client": "next dev",
"codegen": "graphql-codegen --config codegen.yml"
},
"dependencies": {
"@apollo/client": "^3.3.21",
"@chakra-ui/icons": "^1.0.14",
"@chakra-ui/react": "^1.6.5",
"@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0",
"@types/apollo-upload-client": "^14.1.0",
"@types/graphql-upload": "^8.0.6",
"@types/node": "^16.3.3",
"@types/uuid": "^8.3.1",
"apollo-server": "^2.24.0",
"apollo-server-express": "^2.24.0",
"apollo-upload-client": "^16.0.0",
"async": "^3.2.0",
"classnames": "^2.3.1",
"dash": "^3.20.0",
"dashjs": "^4.0.0-npm",
"express": "^4.17.1",
"express-graphql": "^0.12.0",
"final-form": "^4.20.2",
"framer-motion": "^4.1.17",
"glob": "^7.1.7",
"graphql": "^15.5.1",
"graphql-upload": "^12.0.0",
"luxon": "^1.27.0",
"md5": "^2.3.0",
"module-alias": "^2.2.2",
@ -34,8 +48,12 @@
"ramda": "^0.27.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-final-form": "^6.5.3",
"react-player": "^2.9.0",
"rethinkdb": "^2.4.2",
"tsconfig-paths": "^3.9.0"
"tsconfig-paths": "^3.9.0",
"uuid": "^8.3.2",
"vimond-replay": "^3.2.1"
},
"devDependencies": {
"@graphql-codegen/cli": "1.21.4",
@ -46,6 +64,7 @@
"@graphql-codegen/typescript-react-apollo": "^2.3.1",
"@graphql-codegen/typescript-resolvers": "1.19.1",
"@types/async": "^3.2.6",
"@types/classnames": "^2.3.1",
"@types/express": "^4.17.11",
"@types/express-graphql": "^0.9.0",
"@types/glob": "^7.1.3",

34
packages.nix

@ -1,34 +0,0 @@
with import <nixpkgs> {};
let
version = "0.1.0";
src = ./nix;
nodePkg = pkgs.nodejs-14_x;
yarnPkg = pkgs.yarn.override { nodejs = nodePkg; };
in mkYarnPackage rec {
name = "miracle-tv-packages";
inherit version src nodePkg yarnPkg;
packageJSON = "${src}/package.json";
yarnLock = "${src}/yarn.lock";
yarnNix = "${src}/yarn.nix";
doConfigure = false;
configurePhase = ''
true
'';
doBuild = false;
buildPhase = ''
true
'';
doDist = false;
distPhase = ''
true
'';
installPhase = ''
mkdir -p $out/modules/.bin
cp -R $node_modules/* $out/modules
cp -R $node_modules/.bin/* $out/modules/.bin
'';
}

BIN
public/game-example.png

After

Width: 1920  |  Height: 1080  |  Size: 3.1 MiB

BIN
public/videos/kill-the-night.mkv

BIN
public/videos/kill-the-night.mp4

BIN
public/yuuka-avatar.jpg

After

Width: 600  |  Height: 540  |  Size: 99 KiB

BIN
public/yuuka-header.jpg

After

Width: 850  |  Height: 549  |  Size: 98 KiB

BIN
public/yuuka-thumbnail.jpg

After

Width: 850  |  Height: 607  |  Size: 168 KiB

1
result

@ -0,0 +1 @@
/nix/store/bih2fw5ym2828pbnsnjwnrl27ywvikjk-miracle-tv

19
src/client/UserSettings/UserEditForm.tsx

@ -0,0 +1,19 @@
import { FormInput } from "miracle-tv-client/components/form/FormInput";
import { FormTextarea } from "miracle-tv-client/components/form/FormTextarea";
import { FormToggle } from "miracle-tv-client/components/form/FormToggle";
import React from "react";
export const UserEditForm = () => {
return (
<>
<FormInput name="displayName" label="Display Name" mb={2} />
<FormTextarea
name="bio"
label="About Me"
mb={2}
inputProps={{ rows: 5 }}
/>
<FormToggle name="singleUserMode" label="Single User Mode" />
</>
);
};

20
src/client/components/auth/Redirect.tsx

@ -0,0 +1,20 @@
import { useCurrentUser } from "miracle-tv-client/hooks/auth";
import { useRouter } from "next/dist/client/router";
import { useEffect } from "react";
type Props = {
roles?: string[];
};
export const AuthRedirect = (_props: Props): null => {
const { push } = useRouter();
const { user, isUserLoading } = useCurrentUser();
useEffect(() => {
if (!isUserLoading && !user) {
push("/auth/login");
}
}, [isUserLoading, user, push]);
return null;
};

47
src/client/components/form/FormGroup.tsx

@ -0,0 +1,47 @@
import {
FormControl,
FormControlProps,
FormHelperText,
FormLabel,
FormLabelProps,
HelpTextProps,
} from "@chakra-ui/form-control";
export type FormGroupChakraProps = {
labelProps?: FormLabelProps;
errorProps?: HelpTextProps;
};
type Props = {
name: string;
label?: string;
error?: string;
children: React.ReactNode | React.ReactNode[];
} & FormGroupChakraProps &
FormControlProps;
export const FormGroup = ({
name,
label,
error,
children,
labelProps,
errorProps,
...controlProps
}: Props) => {
return (
<FormControl id={name} {...controlProps} pt={0} mt={0}>
{!!label && (
<FormLabel {...labelProps} pt={0} mt={0}>
{label}
</FormLabel>
)}
{children}
{!!error && (
<FormHelperText {...errorProps} color="error">
{error}
</FormHelperText>
)}
</FormControl>
);
};

30
src/client/components/form/FormInput.tsx

@ -0,0 +1,30 @@
import React from "react";
import { FormControlProps } from "@chakra-ui/form-control";
import { useField } from "react-final-form";
import { FormGroup, FormGroupChakraProps } from "./FormGroup";
import { Input } from "@chakra-ui/input";
import { InputProps } from "@chakra-ui/react";
type Props = {
name: string;
label?: string;
error?: string;
type?: string;
inputProps?: InputProps;
} & FormControlProps &
FormGroupChakraProps;
export const FormInput = ({
name,
type: inputType,
inputProps,
...formGroupProps
}: Props) => {
const { input } = useField(name);
return (
<FormGroup name={name} {...formGroupProps}>
<Input {...input} type={inputType} {...inputProps} />
</FormGroup>
);
};

28
src/client/components/form/FormTextarea.tsx

@ -0,0 +1,28 @@
import React from "react";
import { FormControlProps } from "@chakra-ui/form-control";
import { useField } from "react-final-form";
import { FormGroup, FormGroupChakraProps } from "./FormGroup";
import { Textarea, TextareaProps } from "@chakra-ui/react";
type Props = {
name: string;
label?: string;
error?: string;
inputProps?: TextareaProps;
} & FormControlProps &
FormGroupChakraProps;
export const FormTextarea = ({
name,
type: inputType,
inputProps,
...formGroupProps
}: Props) => {
const { input } = useField(name);
return (
<FormGroup name={name} {...formGroupProps}>
<Textarea {...input} {...inputProps} />
</FormGroup>
);
};

28
src/client/components/form/FormToggle.tsx

@ -0,0 +1,28 @@
import React from "react";
import { FormControlProps } from "@chakra-ui/form-control";
import { useField } from "react-final-form";
import { FormGroup, FormGroupChakraProps } from "./FormGroup";
import { Switch, SwitchProps } from "@chakra-ui/react";
type Props = {
name: string;
label?: string;
error?: string;
inputProps?: SwitchProps;
} & FormControlProps &
FormGroupChakraProps;
export const FormToggle = ({ name, inputProps, ...formGroupProps }: Props) => {
const { input } = useField(name, { type: "checkbox" });
return (
<FormGroup name={name} {...formGroupProps}>
<Switch
{...input}
{...inputProps}
defaultChecked={input.checked}
isChecked={input.checked}
/>
</FormGroup>
);
};

10
src/client/components/icons/BurgerMenu.tsx

@ -0,0 +1,10 @@
import React from "react";
import { Icon, IconProps } from "@chakra-ui/icon";
export const BurgerMenuIcon = (props: IconProps) => (
<Icon viewBox="0 0 100 80" {...props}>
<rect width="100" height="20" fill="currentcolor"></rect>
<rect y="30" width="100" height="20" fill="currentcolor"></rect>
<rect y="60" width="100" height="20" fill="currentcolor"></rect>
</Icon>
);

77
src/client/components/player/Player.tsx

@ -0,0 +1,77 @@
import React, { useEffect, useRef, useState } from "react";
import { Box } from "@chakra-ui/layout";
import { css, Global } from "@emotion/react";
import "vimond-replay/index.css";
import ReactPlayer from "react-player";
import { Flex } from "@chakra-ui/react";
type PlayerKind = "preview" | "vod" | "live";
export type PlaybackActions = {
play: () => void;
pause: () => void;
stop: (pos: number) => void;
};
type Props = {
src: string;
w?: string;
h?: string;
kind?: PlayerKind;
autoplay?: boolean;
mute?: boolean;
onPlaybackActionsReady?: (actions: PlaybackActions) => void;
};
export const Player = ({
src,
w = "100%",
h = "100%",
autoplay = false,
onPlaybackActionsReady,
}: Props) => {
const playerRef = useRef<ReactPlayer>(null);
useEffect(() => {
if (playerRef.current !== null) {
const actions: PlaybackActions = {
play: () => playerRef.current?.getInternalPlayer()?.play(),
pause: () => playerRef.current?.getInternalPlayer()?.play(),
stop: () => {
playerRef.current?.getInternalPlayer()?.pause();
if (playerRef.current?.getInternalPlayer()) {
playerRef.current.getInternalPlayer().currentTime = 0;
}
},
};
onPlaybackActionsReady?.(actions);
}
}, [playerRef.current, onPlaybackActionsReady]);
return (
<>
<Global
styles={css`
.player-preview .replay-controls-bar {
display: none;
}
`}
/>
<Flex w={w} h={h}>
<ReactPlayer
url={src}
width="100%"
height="100%"
ref={playerRef}
playing={autoplay}
volume={0}
config={{
file: {
attributes: { preload: "metadata" },
},
}}
/>
</Flex>
</>
);
};

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

@ -0,0 +1,94 @@
import React, { useCallback, useState } from "react";
import {
Button,
Text,
Flex,
Heading,
IconButton,
MenuButton,
Menu,
MenuItem,
MenuList,
} 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 = () => {
const { push } = useRouter();
const defaultUrl: string = "/";
const [isSidebarOpen, setSidebarOpen] = useState<boolean>(false);
const toggleSidebar = useCallback(() => {
setSidebarOpen(!isSidebarOpen);
}, [isSidebarOpen, setSidebarOpen]);
const closeSidebar = useCallback(() => {
setSidebarOpen(false);
}, [setSidebarOpen]);
const goToDefault = useCallback(() => {
push(defaultUrl);
}, [push, defaultUrl]);
const { user, logout } = useCurrentUser();
return (
<>
<Flex
width="100%"
position="sticky"
top={0}
left={0}
align="center"
zIndex={9999999}
>
<Flex
width="100%"
align="center"
position="sticky"
bgColor="primary.500"
color="white"
p={5}
height={16}
boxShadow="0px 2px 26px -9px rgba(0,0,0,0.75)"
zIndex={9999999}
justify="space-between"
>
<Flex h="100%" align="center">
{user && (
<IconButton
aria-label="Side Menu"
variant="link"
color="white"
fontSize={35}
icon={<BurgerMenuIcon />}
onClick={toggleSidebar}
mr={5}
/>
)}
<Heading
as={(props) => <Button {...props} kind="ghost" />}
textTransform="none"
p={0}
m={0}
cursor="pointer"
onClick={goToDefault}
>
Miracle TV
</Heading>
</Flex>
<Flex h="100%" align="center">
{user && <UserMenu />}
{!user && <Link href="/auth/login">Login</Link>}
</Flex>
</Flex>
{user && <Sidebar isOpen={isSidebarOpen} onClose={closeSidebar} />}
</Flex>
</>
);
};

62
src/client/components/system/Sidebar/SidebarNav.tsx

@ -0,0 +1,62 @@
import { Flex, FlexProps } from "@chakra-ui/react";
import { useRouter } from "next/dist/client/router";
import { useCallback } from "react";
type LinkConfig = {
url: string;
label: string;
};
type Props = {
links?: LinkConfig[];
onClose?: () => void;
};
type SidebarLinkProps = { onClose?: () => void } & LinkConfig;
const SidebarLink = ({ url, label, onClose }: SidebarLinkProps) => {
const router = useRouter();
const isActive = router.asPath === url;
const flexStyle: FlexProps = {
w: "100%",
fontSize: "1.7rem",
px: 5,
py: 2,
bgColor: isActive ? "primary.500" : "none",
borderTopWidth: isActive ? 4 : 0,
borderBottomWidth: isActive ? 4 : 0,
borderStyle: "solid",
borderColor: "transparent",
// justify: "center",
_hover: {
bgColor: "secondary.600",
cursor: "pointer",
borderTopWidth: 4,
borderBottomWidth: 4,
borderStyle: "solid",
borderColor: "transparent",
transition: "all 0.1s ease-in-out",
},
transition: "all 0.1s ease",
color: "white",
};
const onClick = useCallback(() => {
router.push(url);
onClose?.();
}, [router, onClose, url]);
return (
<Flex {...flexStyle} onClick={onClick}>
{label}
</Flex>
);
};
export const SidebarNav = ({ links = [], onClose }: Props) => {
return (
<Flex width="100%" direction="column">
{links.map((link) => (
<SidebarLink key={link.url} {...link} onClose={onClose} />
))}
</Flex>
);
};

40
src/client/components/system/Sidebar/index.tsx

@ -0,0 +1,40 @@
import { Flex, FlexProps } from "@chakra-ui/react";
import React from "react";
import { SidebarNav } from "miracle-tv-client/components/system/Sidebar/SidebarNav";
type Props = {
isOpen?: boolean;
onClose?: () => void;
};
const links = [
{ url: "/home", label: "Home" },
{ url: "/feed", label: "Feed" },
{ url: "/profile", label: "Profile" },
{ url: "/subscriptions", label: "Subscriptions" },
{ url: "/dashboard", label: "Dashboard" },
];
export const Sidebar = ({ isOpen, onClose = () => {} }: Props) => {
const sidebarStyles: FlexProps = {
w: ["35vw", "20vw"],
left: isOpen ? 0 : ["-35vw", "-20vw"],
boxShadow: isOpen ? "2px 0px 26px -9px rgba(0,0,0,0.75)" : "none",
};
return (
<Flex
position="fixed"
overflowX="hidden"
top={16}
transition="all 0.3s ease-in-out"
h="calc(100vh - var(--chakra-sizes-16))"
bgColor="secondary.400"
color="black"
zIndex={9999998}
{...sidebarStyles}
>
<SidebarNav links={links} onClose={onClose} />
</Flex>
);
};

39
src/client/components/ui/Panel.tsx

@ -0,0 +1,39 @@
import React from "react";
import { BoxProps } from "@chakra-ui/layout";
import { Box } from "@chakra-ui/react";
type Props = {
colorScheme?: "primary" | "secondary";
} & BoxProps;
type Colors = Record<
Props["colorScheme"],
{
bgColor: string;
color: string;
}
>;
const colors: Colors = {
primary: {
bgColor: "primary.500",
color: "white",
},
secondary: {
bgColor: "secondary.400",
color: "white",
},
};
export const Panel = ({ colorScheme = "secondary", ...boxProps }: Props) => {
const styles = colors[colorScheme];
return (
<Box
p={4}
borderRadius="2px"
boxShadow="0px 0px 15px 10px rgba(0,0,0,0.3)"
{...styles}
{...boxProps}
/>
);
};

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

@ -0,0 +1,134 @@
import React, { useCallback, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { AspectRatio, Box, Flex, FlexProps, Text } from "@chakra-ui/react";
import {
PlaybackActions,
Player as PlayerType,
} from "miracle-tv-client/components/player/Player";
const Player = dynamic(
() =>
import("miracle-tv-client/components/player/Player").then(
({ Player }) => Player
) as unknown as Promise<typeof PlayerType>,
{ ssr: false }
);
type Props = {
name: string;
w?: string;
h?: string;
alwaysShowInfo?: boolean;
hideInfo?: boolean;
hideThumbnail?: boolean;
autoplay?: boolean;
} & FlexProps;
type Controls = {
play: PlaybackActions["play"];
pause: PlaybackActions["pause"];
stop: () => void;
};
export const StreamPreview = ({
name,
h = "100%",
w = "100%",
alwaysShowInfo = false,
hideInfo = false,
hideThumbnail = false,
autoplay = false,
...boxProps
}: Props) => {
const [showThumbnail, setShowThumbnail] = useState<boolean>(true);
const [playerControls, setPlayerControls] = useState<Controls | null>(null);
const enablePlayback = useCallback(() => {
if (!autoplay) {
playerControls?.play();
if (showThumbnail) {
setShowThumbnail(false);
}
}
}, [playerControls, setShowThumbnail, showThumbnail]);
const disablePlayback = useCallback(() => {
if (!autoplay) {
playerControls?.stop();
if (!showThumbnail) {
setShowThumbnail(true);
}
}
}, [playerControls, setShowThumbnail, showThumbnail]);
const onPlaybackActionReady = useCallback(
(actions: Controls) => {
setPlayerControls(actions);
},
[setPlayerControls]
);
return (
<AspectRatio width={w} maxW={w} h={h} maxH={h} ratio={16 / 9} {...boxProps}>
<Flex
w="15vw"
position="relative"
zIndex={1}
onMouseEnter={enablePlayback}
onMouseLeave={disablePlayback}
cursor="pointer"
>
{!hideThumbnail && (
<Box
w="100%"
h="100%"
bgImage="/game-example.png"
position="absolute"
top={0}
left={0}
bgSize="cover"
bgRepeat="no-repeat"
opacity={showThumbnail ? 1 : 0}
transition="opacity 0.2s ease"
/>
)}
{!hideInfo && (
<Flex
w="100%"
h="100%"
position="absolute"
top={0}
left={0}
zIndex={3}
direction="column"
justify="space-between"
transition="opacity 0.2s ease"
opacity={alwaysShowInfo ? 1 : 0}
_hover={{
opacity: 1,
}}
>
<Flex zIndex={3}>
<Box bgColor="secondary.400" px={2} py={1}>
<Text as="span" color="red">
</Text>
&nbsp;LIVE
</Box>
</Flex>
<Flex zIndex={3} justify="flex-end">
<Box bgColor="secondary.400" px={2} py={1}>
{name}
</Box>
</Flex>
</Flex>
)}
<Player
kind="preview"
src="/videos/kill-the-night.mp4"
autoplay={autoplay}
onPlaybackActionsReady={onPlaybackActionReady}
/>
</Flex>
</AspectRatio>
);
};

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

@ -0,0 +1,61 @@
import React from "react";
import { Button, Menu, MenuButton, MenuItem, MenuList } from "@chakra-ui/react";
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";
type LinkProps = {
url: string;
label: string;
icon: React.ReactNode;
};
const MenuLink = ({ url, label, icon }: LinkProps) => {
const { asPath } = useRouter();
const isActive = asPath === url;
return (
<Link href={url}>
<MenuItem bgColor={isActive ? "primary.500" : undefined}>
<>
{icon}
{label}
</>
</MenuItem>
</Link>
);
};
//
export const UserMenu = () => {
const { user, logout } = useCurrentUser();
return (
<>
<Menu variant="solid">
<MenuButton
as={Button}
rightIcon={<ChevronDownIcon />}
textTransform="none"
>
{user?.displayName || user?.username}
</MenuButton>
<MenuList>
<MenuLink
url="/profile"
label="Profile"
icon={<SettingsIcon aria-label="Settings" variant="ghost" mr={2} />}
/>
<MenuLink
url="/user/settings"
label="Settings"
icon={<SettingsIcon aria-label="Settings" variant="ghost" mr={2} />}
/>
<MenuItem onClick={logout}>
<LockIcon aria-label="Log Out" variant="ghost" mr={2} />
Sign Out
</MenuItem>
</MenuList>
</Menu>
</>
);
};

145
src/client/hooks/auth.tsx

@ -0,0 +1,145 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { gql } from "@apollo/client";
import { useCurrentUserFullLazyQuery } from "miracle-tv-shared/hooks";
import { CurrentUserFullQuery } from "miracle-tv-shared/graphql";
import { DateTime } from "luxon";
import { useRouter } from "next/dist/client/router";
type CurrentUserInfo = CurrentUserFullQuery["self"];
type LocalUserStorage = {
expiresAt: Date;
user: CurrentUserInfo;
};
type CurrentUserHookReturn = {
isUserLoading: boolean;
user: CurrentUserInfo;
logout: () => void;
updateUser: (user: CurrentUserInfo) => void;
};
export const useCurrentUser = (): CurrentUserHookReturn => {
const { push } = useRouter();
const [fakeLoading, setFakeLoading] = useState<boolean>(true);
const [currentUser, setCurrentUser] = useState<CurrentUserInfo | null>(null);
const updateUser = useCallback(
(user: CurrentUserInfo) => {
sessionStorage.setItem(
"user",
JSON.stringify({
expiresAt: DateTime.now().plus({ minutes: 30 }).toJSDate(),
user,
} as LocalUserStorage)
);
setCurrentUser(user);
setFakeLoading(false);
},
[setCurrentUser]
);
const [loadUser, { loading }] = useCurrentUserFullLazyQuery({
onCompleted: ({ self }) => {
updateUser(self);
},
});
useEffect(() => {
const token = localStorage.getItem("token");
if (token && !currentUser) {
let localUser: LocalUserStorage;
try {
localUser = JSON.parse(
sessionStorage.getItem("user")
) as LocalUserStorage;
} catch {
console.error("Couldn't get local user from storage");
}
if (!localUser || !localUser?.expiresAt) {
loadUser();
} else if (localUser) {
const isExpired =
DateTime.fromJSDate(localUser.expiresAt).diffNow("seconds").seconds >
0;
if (isExpired) {
loadUser();
} else {
setCurrentUser(localUser.user);
setFakeLoading(false);
}
}
}
}, [currentUser, loadUser, setFakeLoading]);
const logout = useCallback(() => {
localStorage.removeItem("token");
sessionStorage.removeItem("user");
setCurrentUser(null);
push("/");
}, [setCurrentUser]);
return useMemo(
() => ({
isUserLoading: loading || fakeLoading,
user: currentUser,
logout: logout,
updateUser,
}),
[currentUser, loading]
);
};
export const CurrentUserFullFragment = gql`
fragment CurrentUser on User {
id
username
displayName
emailHash
bio
singleUserMode
emailHash
roles {
id
parentId
name
access {
rights {
channels
streamKeys
users
activities
}
actions {
user {
silence
ban
warn
}
}
}
}
channels {
id
name
slug
description
activity {
id
icon
image
name
verb
}
}
}
`;
gql`
query CurrentUserFull {
self {
...CurrentUser
}
}
${CurrentUserFullFragment}
`;

14
src/client/theme/colors.ts

@ -0,0 +1,14 @@
const colors = {
white: "#FFFFFF",
black: "#000000",
primary: {
200: "#51db70",
500: "#2E633A",
},
secondary: {
400: "#455A75",
600: "#2D3B4C",
},
} as const;
export default colors;

9
src/client/theme/components/button.ts

@ -0,0 +1,9 @@
export const buttonStyles = {
baseStyle: {
textTransform: "uppercase",
borderRadius: "4px",
},
defaultProps: {
colorScheme: "primary",
},
};

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

@ -0,0 +1,15 @@
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;

30
src/client/theme/components/input.ts

@ -0,0 +1,30 @@
import cn from "classnames";
export const inputStyles = {
variants: {
solid: ({ colorScheme }: any) => {
return {
field: {
bgColor: cn({
"secondary.400": colorScheme === "secondary",
"secondary.600": colorScheme === "primary",
}),
borderRadius: "4px",
borderWidth: "2px",
borderStyle: "solid",
borderColor: cn({
"primary.500": colorScheme === "primary",</