From 1a88b1a5ba8951f533ddb3c86fe89255505546f8 Mon Sep 17 00:00:00 2001 From: Carl Kittelberger Date: Sat, 22 Aug 2020 22:25:57 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 3 + configs/nginx/cors.conf | 5 + configs/nginx/default.conf | 81 + configs/varnish/default.vcl | 7 + frontend/.dockerignore | 45 + frontend/.editorconfig | 11 + frontend/.eslintignore | 4 + frontend/.gitattributes | 4 + frontend/.gitignore | 41 + frontend/Dockerfile | 16 + frontend/README.md | 30 + frontend/components/CopyField.tsx | 75 + frontend/components/DarkToggler.tsx | 40 + frontend/components/DownloadButton.tsx | 18 + frontend/components/Filter.tsx | 35 + frontend/components/LocaleSwitcher.tsx | 61 + frontend/components/RelativeTime.tsx | 22 + frontend/components/UserContext.tsx | 10 + frontend/components/VideoList.tsx | 98 + frontend/components/VideoListItem.module.scss | 14 + frontend/components/VideoListItem.tsx | 100 + frontend/components/VideoPlayer.tsx | 38 + .../localization/FormattedDuration.tsx | 4 + .../localization/FormattedMessage.tsx | 6 + .../localization/FormattedNumber.tsx | 4 + .../localization/FormattedRelativeTime.tsx | 4 + .../components/localization/WrapReactIntl.tsx | 24 + frontend/components/localization/index.ts | 11 + frontend/custom.d.ts | 62 + frontend/images/favicon.ico | Bin 0 -> 94254 bytes frontend/images/favicon.svg | 1 + frontend/images/gdqlogo.png | Bin 0 -> 33347 bytes frontend/lang/de.json | 51 + frontend/lang/en.json | 47 + frontend/next-env.d.ts | 2 + frontend/next.config.js | 12 + frontend/package.json | 117 + frontend/pages/[id].tsx | 141 + frontend/pages/[id]/[vslug].tsx | 264 + frontend/pages/_app.tsx | 297 + frontend/pages/_document.tsx | 66 + frontend/pages/api/changePreferences.ts | 34 + frontend/pages/api/user.ts | 14 + frontend/pages/index.tsx | 76 + frontend/pages/robots.txt.ts | 19 + frontend/pages/sitemap.xml.ts | 62 + frontend/postcss.config.js | 23 + frontend/styles/Home.module.css | 124 + frontend/styles/bootstrap.scss | 96 + frontend/styles/main.scss | 19 + frontend/tsconfig.json | 43 + .../react-intl-formatted-duration.d.ts | 1 + frontend/typings/url-slug/index.d.ts | 32 + frontend/typings/video.js/index.d.ts | 6908 ++++++++++++ frontend/util/api.ts | 65 + frontend/util/datatypes/VideoList.ts | 13 + .../util/datatypes/VideoOnDemandIdentifier.ts | 13 + frontend/util/index.ts | 71 + frontend/util/localization.ts | 73 + frontend/util/parseBool.ts | 12 + frontend/util/sanitizeFileName.ts | 3 + frontend/util/sanitizeTitle.ts | 5 + frontend/util/session.ts | 52 + frontend/util/status.tsx | 26 + frontend/util/types.ts | 1 + frontend/yarn.lock | 9595 +++++++++++++++++ streamserver/.dockerignore | 4 + streamserver/Dockerfile | 59 + streamserver/example/docker-compose.yml | 8 + ...non-free-binary-with-fdk-aac-support.patch | 95 + 70 files changed, 19417 insertions(+) create mode 100644 .gitignore create mode 100644 configs/nginx/cors.conf create mode 100644 configs/nginx/default.conf create mode 100644 configs/varnish/default.vcl create mode 100644 frontend/.dockerignore create mode 100644 frontend/.editorconfig create mode 100644 frontend/.eslintignore create mode 100644 frontend/.gitattributes create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/components/CopyField.tsx create mode 100644 frontend/components/DarkToggler.tsx create mode 100644 frontend/components/DownloadButton.tsx create mode 100644 frontend/components/Filter.tsx create mode 100644 frontend/components/LocaleSwitcher.tsx create mode 100644 frontend/components/RelativeTime.tsx create mode 100644 frontend/components/UserContext.tsx create mode 100644 frontend/components/VideoList.tsx create mode 100644 frontend/components/VideoListItem.module.scss create mode 100644 frontend/components/VideoListItem.tsx create mode 100644 frontend/components/VideoPlayer.tsx create mode 100644 frontend/components/localization/FormattedDuration.tsx create mode 100644 frontend/components/localization/FormattedMessage.tsx create mode 100644 frontend/components/localization/FormattedNumber.tsx create mode 100644 frontend/components/localization/FormattedRelativeTime.tsx create mode 100644 frontend/components/localization/WrapReactIntl.tsx create mode 100644 frontend/components/localization/index.ts create mode 100644 frontend/custom.d.ts create mode 100644 frontend/images/favicon.ico create mode 100644 frontend/images/favicon.svg create mode 100644 frontend/images/gdqlogo.png create mode 100644 frontend/lang/de.json create mode 100644 frontend/lang/en.json create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.js create mode 100644 frontend/package.json create mode 100644 frontend/pages/[id].tsx create mode 100644 frontend/pages/[id]/[vslug].tsx create mode 100644 frontend/pages/_app.tsx create mode 100644 frontend/pages/_document.tsx create mode 100644 frontend/pages/api/changePreferences.ts create mode 100644 frontend/pages/api/user.ts create mode 100644 frontend/pages/index.tsx create mode 100644 frontend/pages/robots.txt.ts create mode 100644 frontend/pages/sitemap.xml.ts create mode 100644 frontend/postcss.config.js create mode 100644 frontend/styles/Home.module.css create mode 100644 frontend/styles/bootstrap.scss create mode 100644 frontend/styles/main.scss create mode 100644 frontend/tsconfig.json create mode 100644 frontend/typings/react-intl-formatted-duration.d.ts create mode 100644 frontend/typings/url-slug/index.d.ts create mode 100644 frontend/typings/video.js/index.d.ts create mode 100644 frontend/util/api.ts create mode 100644 frontend/util/datatypes/VideoList.ts create mode 100644 frontend/util/datatypes/VideoOnDemandIdentifier.ts create mode 100644 frontend/util/index.ts create mode 100644 frontend/util/localization.ts create mode 100644 frontend/util/parseBool.ts create mode 100644 frontend/util/sanitizeFileName.ts create mode 100644 frontend/util/sanitizeTitle.ts create mode 100644 frontend/util/session.ts create mode 100644 frontend/util/status.tsx create mode 100644 frontend/util/types.ts create mode 100644 frontend/yarn.lock create mode 100644 streamserver/.dockerignore create mode 100644 streamserver/Dockerfile create mode 100644 streamserver/example/docker-compose.yml create mode 100644 streamserver/patches/aports/0001-Build-non-free-binary-with-fdk-aac-support.patch diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a959898 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules + +_old diff --git a/configs/nginx/cors.conf b/configs/nginx/cors.conf new file mode 100644 index 0000000..bf6acf9 --- /dev/null +++ b/configs/nginx/cors.conf @@ -0,0 +1,5 @@ +add_header Access-Control-Allow-Headers '*'; +add_header Access-Control-Expose-Headers 'Server,range,Content-Length,Content-Range'; +add_header Access-Control-Allow-Methods 'GET, HEAD, OPTIONS'; +add_header Access-Control-Allow-Origin '*'; + diff --git a/configs/nginx/default.conf b/configs/nginx/default.conf new file mode 100644 index 0000000..90aa726 --- /dev/null +++ b/configs/nginx/default.conf @@ -0,0 +1,81 @@ +tcp_nopush on; +tcp_nodelay on; + +vod_segment_duration 2000; +vod_manifest_segment_durations_mode accurate; +vod_align_segments_to_key_frames on; +vod_output_buffer_pool 64k 32; + +vod_dash_fragment_file_name_prefix "seg"; +vod_hls_segment_file_name_prefix "seg"; + +# shared memory zones +vod_response_cache vod_response_cache 128m; +vod_metadata_cache vod_meta_cache 2048m; +vod_performance_counters perf_counters; + +reset_timedout_connection on; +send_timeout 20; + +# manifest compression +gzip on; +gzip_types application/vnd.apple.mpegurl video/f4m application/dash+xml text/xml text/vtt; +gzip_proxied any; + +open_file_cache max=1000 inactive=10m; +open_file_cache_valid 3m; +open_file_cache_min_uses 1; +open_file_cache_errors off; +aio on; + +server { + listen 80; + server_name gdq-a.hls.edge.streaminginter.net; + root /htdocs; + + include conf.d/cors.conf; + + vod_base_url https://gdq-a.hls.edge.streaminginter.net; + + location / { + vod hls; + } +} + +server { + listen 80; + server_name gdq-a.dash.edge.streaminginter.net; + root /htdocs; + + include conf.d/cors.conf; + + vod_base_url https://gdq-a.dash.edge.streaminginter.net; + + location / { + vod dash; + } +} + +server { + listen 80; + server_name thumb-gdq-a.edge.streaminginter.net; + root /htdocs; + + location / { + vod thumb; + + expires 2d; + access_log off; + add_header Cache-Control "public"; + } +} + +server { + listen 80; + server_name gdq-a.upstream.streaminginter.net; + root /htdocs; + + location ~ (.*\.(mp4|mkv|mp3|aac|ogg|mpg|ogv|oga|m4a|m4v|mov)) { + add_header Content-disposition "attachment"; + } +} diff --git a/configs/varnish/default.vcl b/configs/varnish/default.vcl new file mode 100644 index 0000000..8781529 --- /dev/null +++ b/configs/varnish/default.vcl @@ -0,0 +1,7 @@ +vcl 4.0; + +backend streamserver { + .host = "streamserver"; + .port = "80"; +} + diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..054b71d --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# react-intl +compiled-lang/ + +### + +.git* + +.docker* +Dockerfile +*.Dockerfile diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 0000000..020bd78 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = true + +[*.{js,jsx,ts,tsx}] +end_of_line = lf diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 0000000..92a2555 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,4 @@ +.next/ +/dist/ +/public/ +node_modules/ diff --git a/frontend/.gitattributes b/frontend/.gitattributes new file mode 100644 index 0000000..f08e365 --- /dev/null +++ b/frontend/.gitattributes @@ -0,0 +1,4 @@ +*.js text eol=lf +*.jsx text eol=lf +*.ts text eol=lf +*.tsx text eol=lf diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..05c5443 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# react-intl +compiled-lang/ + +# deployment +docker-compose.nostack.yml +docker-compose.final.yml diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..5ffbd3b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:lts AS build + +WORKDIR /usr/src/gdq-archive/frontend/ + +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile + +COPY ./ ./ +ARG NODE_ENV +ARG UPSTREAM_URL +ARG UPSTREAM_DIRECT_URL +RUN yarn build + +EXPOSE 3000 +STOPSIGNAL SIGTERM +CMD ["yarn", "start"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..4b5c883 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/frontend/components/CopyField.tsx b/frontend/components/CopyField.tsx new file mode 100644 index 0000000..fd40afd --- /dev/null +++ b/frontend/components/CopyField.tsx @@ -0,0 +1,75 @@ +import React, { useState, useRef } from 'react'; + +import InputGroup from 'react-bootstrap/InputGroup'; +import FormControl from 'react-bootstrap/FormControl'; +import Button from 'react-bootstrap/Button'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Overlay, Tooltip } from 'react-bootstrap'; +import type { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { FormattedMessage } from './localization'; + +const CopyField = ({ + children, + copyIcon = 'copy', + icon = null, +}: { + children: string, + copyIcon?: IconProp, + icon?: IconProp, +}) => { + const [show, setShow] = useState(false); + const target = useRef(null); + const textbox = useRef(null); + let timer: NodeJS.Timeout = null; + + function doCopy() { + if (timer !== null) { + clearTimeout(timer); + } + + textbox.current.select(); + document.execCommand('copy'); + + setShow(true); + timer = setTimeout(() => { + setShow(false); + }, 3000); + return false; + } + + return ( + + { + icon + ? ( + + + + + + ) + : '' + } + + + + + + ); +}; + +export default CopyField; diff --git a/frontend/components/DarkToggler.tsx b/frontend/components/DarkToggler.tsx new file mode 100644 index 0000000..f7354fb --- /dev/null +++ b/frontend/components/DarkToggler.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +// import useSWR from 'swr'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + Button, + // Spinner +} from 'react-bootstrap'; +import { FormattedMessage } from './localization'; + +export default function DarkToggler({ + isDarkEnabled = false, + onChangeDarkMode = null, + disabled = false, + showLoading = false, +}: { + isDarkEnabled?: boolean, + onChangeDarkMode?: (value: boolean) => void, + disabled?: boolean, + showLoading?: boolean, +} = {}) { + return ( + + ); +} diff --git a/frontend/components/DownloadButton.tsx b/frontend/components/DownloadButton.tsx new file mode 100644 index 0000000..409c424 --- /dev/null +++ b/frontend/components/DownloadButton.tsx @@ -0,0 +1,18 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React from 'react'; +import { Button } from 'react-bootstrap'; +import { getDownloadURL } from 'util/api'; +import { FormattedMessage } from './localization'; + +export default function DownloadButton({ id, fileName }: { id: string, fileName: string }) { + return ( + + ); +} diff --git a/frontend/components/Filter.tsx b/frontend/components/Filter.tsx new file mode 100644 index 0000000..a28956b --- /dev/null +++ b/frontend/components/Filter.tsx @@ -0,0 +1,35 @@ +import Fuse from 'fuse.js'; +import React from 'react'; + +export default function Filter({ + items, + query = '', + isCaseSensitive = false, + keys, + output, +}: { + items: Array, + query: string, + isCaseSensitive?: boolean, + keys: Array, + output: (filteredItems: Array>) => U, +}) { + if (query.length > 0) { + const fuse = new Fuse(items, { + isCaseSensitive, + keys, + }); + const filteredItems = fuse.search(query); + return ( + <>{output(filteredItems)} + ); + } + return ( + <> + {output(items.map((item, refIndex) => ({ + item, + refIndex, + })))} + + ); +} diff --git a/frontend/components/LocaleSwitcher.tsx b/frontend/components/LocaleSwitcher.tsx new file mode 100644 index 0000000..1268a0e --- /dev/null +++ b/frontend/components/LocaleSwitcher.tsx @@ -0,0 +1,61 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React from 'react'; +import { + ButtonGroup, Dropdown, DropdownButton, +} from 'react-bootstrap'; +import { FormattedMessage } from './localization'; +import { availableLocales, defaultLocale, localeDescriptions } from '../util/localization'; + +export default function LocaleSwitcher({ + locale = defaultLocale, + onChangeLocale = null, + disabled = false, + showLoading = false, +}: { + locale?: string, + onChangeLocale?: (value: string) => void, + disabled?: boolean, + showLoading?: boolean, +} = {}) { + return ( + + + + + + + )} + > + { + availableLocales.map((thisLocale) => ( + { onChangeLocale(thisLocale); } + : null + } + active={locale === thisLocale} + data-lang={thisLocale} + > + + + )) + } + + ); +} diff --git a/frontend/components/RelativeTime.tsx b/frontend/components/RelativeTime.tsx new file mode 100644 index 0000000..cf9303e --- /dev/null +++ b/frontend/components/RelativeTime.tsx @@ -0,0 +1,22 @@ +import { selectUnit } from '@formatjs/intl-utils'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import { FormattedRelativeTime } from './localization'; + +export default function RelativeTime({ + children: dateTimeValue, +}: { + children: Date, +}) { + const intl = useIntl(); + const { value, unit } = selectUnit(dateTimeValue, new Date()); + const title = `${intl.formatDate(dateTimeValue)} ${intl.formatTime(dateTimeValue)}`; + return ( + + ); +} diff --git a/frontend/components/UserContext.tsx b/frontend/components/UserContext.tsx new file mode 100644 index 0000000..08b2329 --- /dev/null +++ b/frontend/components/UserContext.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { defaultLocale } from '../util/localization'; + +const UserContext = React.createContext({ + enableDark: false, + locale: defaultLocale, + messages: [], +}); + +export default UserContext; diff --git a/frontend/components/VideoList.tsx b/frontend/components/VideoList.tsx new file mode 100644 index 0000000..74e9f41 --- /dev/null +++ b/frontend/components/VideoList.tsx @@ -0,0 +1,98 @@ +import React, { ChangeEvent } from 'react'; + +import FormControl from 'react-bootstrap/FormControl'; +import InputGroup from 'react-bootstrap/InputGroup'; +import ListGroup from 'react-bootstrap/ListGroup'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { injectIntl, IntlShape } from 'react-intl'; +import VideoListItem from './VideoListItem'; +import Filter from './Filter'; +import { VideoEntry } from '../util/datatypes/VideoList'; + +interface VideoListProps { + intl: IntlShape, + id: string, + thumbnailServerURL: string, + videos: Array, +} + +interface VideoListState { + query: string +} + +class VideoList extends React.Component { + constructor(props: VideoListProps) { + super(props); + + this.state = { + query: '', + }; + + this.onQueryChange = this.onQueryChange.bind(this); + } + + onQueryChange({ target }: ChangeEvent) { + this.setState({ + query: target.value, + }); + } + + render() { + const { query } = this.state; + const { + intl, + id, + thumbnailServerURL, + videos, + } = this.props; + return ( +
+ + + + + + + + + + filteredVideos.map(({ + item: { + duration, + fileName, + title, + sourceVideoStart, + sourceVideoEnd, + }, + refIndex: index, + }) => ( + + ))} + /> + +
+ ); + } +} + +export default injectIntl(VideoList); diff --git a/frontend/components/VideoListItem.module.scss b/frontend/components/VideoListItem.module.scss new file mode 100644 index 0000000..94de0af --- /dev/null +++ b/frontend/components/VideoListItem.module.scss @@ -0,0 +1,14 @@ +@import '~bootstrap/scss/functions'; +@import '~bootstrap/scss/variables'; +@import '~bootstrap/scss/mixins'; + +.thumbnail { + flex-grow: 0; + width: 96px; + @include media-breakpoint-up(md) { + width: 160px; + } + @include media-breakpoint-up(xl) { + width: 256px; + } +} diff --git a/frontend/components/VideoListItem.tsx b/frontend/components/VideoListItem.tsx new file mode 100644 index 0000000..c5d66b4 --- /dev/null +++ b/frontend/components/VideoListItem.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import ListGroup from 'react-bootstrap/ListGroup'; +import Media from 'react-bootstrap/Media'; +import Image from 'react-bootstrap/Image'; + +import Link from 'next/link'; + +import { getThumbnailURL } from '../util'; + +import FormattedDuration from './localization/FormattedDuration'; + +import style from './VideoListItem.module.scss'; +import sanitizeTitle from '../util/sanitizeTitle'; + +export default function VideoListItem({ + thumbnailServerURL, + duration, + id, + fileName, + title, + sourceVideoStart, + sourceVideoEnd, +}: { + thumbnailServerURL: string, + duration: number | string, + id: string, + fileName: string, + title: string, + sourceVideoStart: number | string, + sourceVideoEnd: number | string, +}) { + let displayDuration = null; + if (duration !== null) { + displayDuration = duration; + } else if ( + sourceVideoStart !== null + && sourceVideoEnd !== null + && sourceVideoStart !== sourceVideoEnd + ) { + let videoEnd: number; + if (typeof sourceVideoEnd === 'string') { + videoEnd = parseFloat(sourceVideoEnd); + } else { + videoEnd = sourceVideoEnd; + } + let videoStart: number; + if (typeof sourceVideoStart === 'string') { + videoStart = parseFloat(sourceVideoStart); + } else { + videoStart = sourceVideoStart; + } + displayDuration = videoEnd - videoStart; + } + const titleUrlSlug = sanitizeTitle(title); + return ( + + + + [ + getThumbnailURL(thumbnailServerURL, id, fileName, 90 * 1000, { + width, + }), + `${width}w`, + ]) + .map((item) => item.join(' ')) + .join(', ')} + alt={title} + /> + +
{title}
+

+ {displayDuration !== null ? ( + + + {' '} + + + ) : ( + '' + )} +

+
+
+
+ + ); +} diff --git a/frontend/components/VideoPlayer.tsx b/frontend/components/VideoPlayer.tsx new file mode 100644 index 0000000..2a63741 --- /dev/null +++ b/frontend/components/VideoPlayer.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from 'video.js'; +// import 'videojs-contrib-dash'; +import { ResponsiveEmbed } from 'react-bootstrap'; + +export default class VideoPlayer extends React.Component { + videoNode: HTMLVideoElement; + + player: VideoJsPlayer; + + componentDidMount() { + this.player = videojs(this.videoNode, this.props, function onPlayerReady() { + console.log('Video.js Ready', this); + }); + } + + componentWillUnmount() { + if (this.player) { + this.player.dispose(); + } + } + + render() { + return ( + +
+
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */} +
+
+
+ ); + } +} diff --git a/frontend/components/localization/FormattedDuration.tsx b/frontend/components/localization/FormattedDuration.tsx new file mode 100644 index 0000000..6038a19 --- /dev/null +++ b/frontend/components/localization/FormattedDuration.tsx @@ -0,0 +1,4 @@ +import FormattedDuration from 'react-intl-formatted-duration'; +import WrapReactIntl from './WrapReactIntl'; + +export default WrapReactIntl(FormattedDuration); diff --git a/frontend/components/localization/FormattedMessage.tsx b/frontend/components/localization/FormattedMessage.tsx new file mode 100644 index 0000000..ffde8bd --- /dev/null +++ b/frontend/components/localization/FormattedMessage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { Spinner } from 'react-bootstrap'; +import { FormattedMessage } from 'react-intl'; +import WrapReactIntl from './WrapReactIntl'; + +export default WrapReactIntl(FormattedMessage); diff --git a/frontend/components/localization/FormattedNumber.tsx b/frontend/components/localization/FormattedNumber.tsx new file mode 100644 index 0000000..ac96d02 --- /dev/null +++ b/frontend/components/localization/FormattedNumber.tsx @@ -0,0 +1,4 @@ +import { FormattedNumber } from 'react-intl'; +import WrapReactIntl from './WrapReactIntl'; + +export default WrapReactIntl(FormattedNumber); diff --git a/frontend/components/localization/FormattedRelativeTime.tsx b/frontend/components/localization/FormattedRelativeTime.tsx new file mode 100644 index 0000000..b74cf64 --- /dev/null +++ b/frontend/components/localization/FormattedRelativeTime.tsx @@ -0,0 +1,4 @@ +import { FormattedRelativeTime } from 'react-intl'; +import WrapReactIntl from './WrapReactIntl'; + +export default WrapReactIntl(FormattedRelativeTime); diff --git a/frontend/components/localization/WrapReactIntl.tsx b/frontend/components/localization/WrapReactIntl.tsx new file mode 100644 index 0000000..bf74726 --- /dev/null +++ b/frontend/components/localization/WrapReactIntl.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { Spinner } from 'react-bootstrap'; +import { isPolyfillPhaseDone } from 'util/localization'; + +export default function WrapReactIntl

(Component: React.ComponentType

) { + return (props: P) => { + if (!isPolyfillPhaseDone()) { + return ( + + ); + } + + return ( + // eslint-disable-next-line react/jsx-props-no-spreading + + ); + }; +} diff --git a/frontend/components/localization/index.ts b/frontend/components/localization/index.ts new file mode 100644 index 0000000..91b257f --- /dev/null +++ b/frontend/components/localization/index.ts @@ -0,0 +1,11 @@ +import FormattedDuration from './FormattedDuration'; +import FormattedMessage from './FormattedMessage'; +import FormattedNumber from './FormattedNumber'; +import FormattedRelativeTime from './FormattedRelativeTime'; + +export { + FormattedDuration, + FormattedMessage, + FormattedNumber, + FormattedRelativeTime, +}; diff --git a/frontend/custom.d.ts b/frontend/custom.d.ts new file mode 100644 index 0000000..fc38ae8 --- /dev/null +++ b/frontend/custom.d.ts @@ -0,0 +1,62 @@ +declare module '*.svg' { + const content: string; + export default content; +} + +declare module '*.ico' { + const content: string; + export default content; +} + +declare module '*.png' { + const content: string; + export default content; +} + +declare module '*.jpg' { + const content: string; + export default content; +} + +declare module '*.css' { + const content: Record; + export default content; +} + +declare module '*.scss' { + const content: Record; + export default content; +} + +declare module '*.sass' { + const content: Record; + export default content; +} + +declare module '*.styl' { + const content: Record; + export default content; +} + +declare module '*.less' { + const content: Record; + export default content; +} + +declare module '@formatjs/intl-numberformat/locale-data/*' { + export default undefined; +} + +declare namespace Intl { + type NumberFormatPartTypes = 'currency' | 'decimal' | 'fraction' | 'group' | 'infinity' | 'integer' | 'literal' | 'minusSign' | 'nan' | 'plusSign' | 'percentSign'; + + interface NumberFormatPart { + type: NumberFormatPartTypes; + value: string; + } + + interface NumberFormat { + formatToParts(number?: number): NumberFormatPart[]; + static polyfilled?: boolean; + } +} diff --git a/frontend/images/favicon.ico b/frontend/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..792b2a922d6d98376b3f90d497f04c88a6326d9b GIT binary patch literal 94254 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 ji)in6ZzlZei7@i>epfFh`Fx*zHNNWW-7EUP \ No newline at end of file diff --git a/frontend/images/gdqlogo.png b/frontend/images/gdqlogo.png new file mode 100644 index 0000000000000000000000000000000000000000..770da7db57eba6b143c6cac71475f6d47ffe28de GIT binary patch literal 33347 zcmc#)WmjBX&mP>0+u*Ln-QC?<910Y7cXt?|xI4wQ=%B?4#ih8ryZbx$^C#Z0OT^nixe6Z?!-*J%h zlK+kk*~I_HuN{E-2LEVk`c~fp00137Lc<}u7;`0m6_vW>mAbu<;>wN^p`i4o6p)i* zk=m7&!jwu;l3J2-i;S{~F3*kfVIrpf#wbTuBq_xe8P#33mGyy(=`SZWA(bL8Rii^# zb#)o-Wz{q=EKJErT|`W&&^(ZsPg5I@Jxxlfrz(|?Ic-4sj86HiPx*{Z`Hbd42gsNS zxL=XFO;6(l?Hp<<8l1JBI(MfnDpC{~P%<_TSSHgLA(UclB$#&WL@!Q|7gO61nA2{QdMZ*cQNzhm+tKA=Q9g;2HpqCHqhBsV zKkRh_RWNM6dGrT~is0yP&qYSX>xt+w)l&xtMi`>8_440GO5Hk2-HKq^(-hrE;g~In z3c8W@ucakWsY|&HTx0GUM;(fqnL_~pDZ+e4G(}&ecCDUbzC?7+u*p#tk&T{Wi#Dx3 zbN6-f#9=B{)9G}_*R}d_f3Rcv<9ASfD{wuP#oJi31BY*A&$AH4uPKZw&wEh%#6`WM zbVANSY_<1_WE5989hsP_h>yCPCzOKn*#Mr*J3K1po62u!JY@>0ru3+FilUEb4q>q! z*Hhl;!waTXy7)8>O)XZxI0?lH=9T@Petn3rJQ1-r2cPbrVYDcg5e%1LDdQ($%4Mh< zC{5T4I+Zk7kRo#+iXcnD6#u>dsPdGFqA?nq`(@;$7^R-1)Ca61G~l-#!my~$$QE=| zIh19HwPSn$(~P49*0g^HT0K@~nn;wl)Gf^EaL|D3DQlEfp|sb|y`GU9R-$O2m}CM4 z0MOzUqy&8OJ42y9gnK?3*jx(JgY(w^9z-omNa0N>8G zu87j=ta)ZP*ts}llu5d@^j1__6$t4WT4N+MV~$LfNcj!_7EyKcTGh#N@beiLyT>;) zZR=3`x3gy8oqsCvY%#)nEDa?aH@HCU4ok1yk2TDz%e(B_ajEfE{a`cfF;un;ZT$Q0 z#^hiB8RNu^#&XHnhZvpg3`{vNoYcS7bge_Z#Q$fiw9#3D*jdQg04%$or<&XS
5*9O}p>*swr@%(9 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 +/// diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..8166e7b --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,12 @@ +const withPlugins = require('next-compose-plugins'); +const optimizedImages = require('next-optimized-images'); + +module.exports = withPlugins([ + [optimizedImages, { + /* config for next-optimized-images */ + inlineImageLimit: -1, + }], + + // your other plugins here + +]); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0f2e603 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,117 @@ +{ + "name": "gdq-archive-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "run-s i18n:build dev:next", + "dev:next": "next dev", + "build": "run-s i18n:build build:next", + "build:next": "next build", + "start": "next start", + "i18n:extract": "formatjs extract \"{public,components,pages}/**/*.{js,jsx,ts,tsx}\" --out-file .\\lang\\en.json --id-interpolation-pattern \"[sha512:contenthash:base64:6]\"", + "i18n:build:en": "formatjs compile lang/en.json --ast --out-file compiled-lang/en.json", + "i18n:build:de": "formatjs compile lang/de.json --ast --out-file compiled-lang/de.json", + "i18n:build": "run-p i18n:build:*", + "lint": "eslint . --ext .js,.jsx,.ts,.tsx" + }, + "dependencies": { + "@forevolve/bootstrap-dark": "^1.0.0-alpha.1091", + "@formatjs/intl-numberformat": "^5.5.4", + "@formatjs/intl-utils": "^3.8.3", + "@fortawesome/fontawesome-free": "^5.14.0", + "@fortawesome/fontawesome-svg-core": "^1.2.30", + "@fortawesome/free-brands-svg-icons": "^5.14.0", + "@fortawesome/free-regular-svg-icons": "^5.14.0", + "@fortawesome/free-solid-svg-icons": "^5.14.0", + "@fortawesome/react-fontawesome": "^0.1.11", + "bootstrap": "^4.5.2", + "cssnano": "^4.1.10", + "file-loader": "^6.0.0", + "fuse.js": "^6.4.1", + "imagemin-optipng": "^8.0.0", + "imagemin-svgo": "^8.0.0", + "intl-messageformat": ">= 5.1", + "intl-messageformat-parser": "^6.0.1", + "jquery": "~3", + "native-url": "^0.3.4", + "next": "9.5.2", + "next-compose-plugins": "^2.2.0", + "next-iron-session": "^4.1.8", + "next-optimized-images": "^2.6.2", + "nprogress": "^0.2.0", + "popper.js": "^1.16.1", + "raw-loader": "^4.0.1", + "react": "16.13.1", + "react-bootstrap": "^1.3.0", + "react-dom": "16.13.1", + "react-intl": "^5.6.3", + "react-intl-formatted-duration": "^4.0.0", + "sass": "^1.26.10", + "shaka-player": "^3.0.3", + "shaka-player-react": "^1.0.1", + "swr": "^0.3.0", + "url-loader": "^4.1.0", + "url-slug": "^2.3.2", + "video.js": "^7.8.4", + "videojs-contrib-dash": "^2.11.0", + "webpack": "^4.0.0", + "xmlbuilder2": "^2.3.1" + }, + "devDependencies": { + "@formatjs/cli": "^2.7.5", + "@types/node": "^14.6.0", + "@types/nprogress": "^0.2.0", + "@types/react": "^16.9.46", + "@typescript-eslint/eslint-plugin": "^3.9.1", + "@typescript-eslint/parser": "^3.9.1", + "eslint": "^7.7.0", + "eslint-config-airbnb-typescript": "^9.0.0", + "eslint-plugin-import": "2.21.2", + "eslint-plugin-jsx-a11y": "6.3.0", + "eslint-plugin-react": "7.20.0", + "eslint-plugin-react-hooks": "4", + "lint-staged": "^10.2.11", + "npm-run-all": "^4.1.5", + "postcss-flexbugs-fixes": "^4.2.1", + "postcss-preset-env": "^6.7.0", + "typescript": "^4.0.2" + }, + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "*.{js,ts,tsx}": [ + "eslint --fix" + ] + }, + "eslintConfig": { + "env": { + "browser": true, + "es2020": true + }, + "extends": [ + "airbnb-typescript" + ], + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 11, + "project": "./tsconfig.json", + "sourceType": "module" + }, + "plugins": [ + "react" + ], + "rules": { + "react/prop-types": "off" + }, + "settings": { + "react": { + "version": "detect" + } + } + } +} diff --git a/frontend/pages/[id].tsx b/frontend/pages/[id].tsx new file mode 100644 index 0000000..e2901fb --- /dev/null +++ b/frontend/pages/[id].tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; + +import { Breadcrumb } from 'react-bootstrap'; + +import Head from 'next/head'; +import Link from 'next/link'; + +import { useIntl } from 'react-intl'; +import { FormattedMessage } from '../components/localization'; + +import RelativeTime from '../components/RelativeTime'; +import VideoList from '../components/VideoList'; + +import { notFound } from '../util/status'; +import { getIndex, getVideos } from '../util/api'; +import { VideoEntry } from '../util/datatypes/VideoList'; + +export async function getServerSideProps({ params: { id } }: { params: { id: string } }) { + // Fetch URL to thumbnails server + const { + ids, + servers: { thumbnails: thumbnailServerURL }, + } = await getIndex(); + + const vodMeta = ids.find(({ + id: thisID, + }: { + id: string + }) => id === thisID); + + if (!vodMeta) { + return { + props: {}, + }; + } + + const { title } = vodMeta; + + // Fetch list of videos for this VOD ID + const vodInfo = await getVideos(id); + const { videos } = vodInfo; + let lastUpdatedAt = null; + lastUpdatedAt = vodInfo.lastUpdatedAt; + const finalVideos = videos + .map((video: VideoEntry) => ({ + ...video, + duration: typeof video.duration === 'string' ? parseFloat(video.duration) : video.duration || null, + sourceVideoStart: typeof video.sourceVideoStart === 'string' ? parseFloat(video.sourceVideoStart) : video.sourceVideoStart || null, + sourceVideoEnd: typeof video.sourceVideoEnd === 'string' ? parseFloat(video.sourceVideoEnd) : video.sourceVideoEnd || null, + })); + + // Pass data to the page via props + return { + props: { + id, + thumbnailServerURL, + title, + videos: finalVideos, + lastUpdatedAt, + }, + }; +} + +export default function VideoListPage({ + id, + lastUpdatedAt, + thumbnailServerURL, + title, + videos, +}: { + id: string, + lastUpdatedAt: string, + thumbnailServerURL: string, + title: string, + videos: Array +}) { + if (!id) { + return notFound(); + } + + let lastUpdatedDate = null; + if (typeof (lastUpdatedAt) === 'string') { + lastUpdatedDate = new Date(lastUpdatedAt); + } + + const intl = useIntl(); + + return ( +

+ + + {title} + {' '} + – + {' '} + {intl.formatMessage({ + id: 'App.title', + description: 'The full title of the website', + defaultMessage: 'Games Done Quick Instant Archive', + })} + + + + + + + + + + + + + {title} + + + + + + + { + lastUpdatedDate + ? ( +
+ Last updated + {' '} + {lastUpdatedDate} +
+ ) + : '' + } +
+ ); +} diff --git a/frontend/pages/[id]/[vslug].tsx b/frontend/pages/[id]/[vslug].tsx new file mode 100644 index 0000000..1032315 --- /dev/null +++ b/frontend/pages/[id]/[vslug].tsx @@ -0,0 +1,264 @@ +import * as React from 'react'; + +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; + +import { + Breadcrumb, Button, ButtonGroup, Col, Row, +} from 'react-bootstrap'; + +import { useIntl } from 'react-intl'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; +import { IncomingMessage } from 'http'; +import { VideoEntry } from 'util/datatypes/VideoList'; +import DownloadButton from 'components/DownloadButton'; +import { getHLSMasterURL } from '../../util'; + +import VideoPlayer from '../../components/VideoPlayer'; +import CopyField from '../../components/CopyField'; +import { FormattedMessage } from '../../components/localization'; +import sanitizeFileName from '../../util/sanitizeFileName'; +import sanitizeTitle from '../../util/sanitizeTitle'; +import { notFound } from '../../util/status'; +import { getIndex, getVideos } from '../../util/api'; + +export const getServerSideProps: GetServerSideProps = async (context) => { + const { req }: { req: IncomingMessage } = context; + const { id, vslug } = context.params || {}; + + if (typeof id !== 'string') { + throw new Error('only expected a single id'); + } + + if (typeof vslug !== 'string') { + throw new Error('only expected a single vslug'); + } + + // Fetch URL to thumbnails server + const { + ids, + servers: { hls: hlsServerURL, dash: dashServerURL }, + } = await getIndex(); + + const vodMeta = ids.find(({ + id: thisID, + }: { + id: string + }) => id === thisID); + if (!vodMeta) { + return { props: {} }; + } + + // Fetch list of videos for this VOD ID + const vodInfo = await getVideos(id); + let videos; + if (Array.isArray(vodInfo)) { + videos = vodInfo; + } else { + videos = vodInfo.videos; + } + + // Check if vslug is actually an index number (old app style) + /* + NOTE - parseInt will accept strings CONTAINING numbers. + This is supposed to reject strings that are not JUST numbers. + */ + const vindexNum = +vslug; + if (!Number.isNaN(vindexNum)) { + // Check if video exists + if (vindexNum < 0 || vindexNum >= videos.length) { + return { props: {} }; + } + const video = videos[vindexNum]; + return { + props: { + redirect: true, + id, + video, + vslug: sanitizeTitle(video.title), + }, + }; + } + + // Check if vslug is actually point to a file name + const sanitizedFileName = sanitizeFileName(vslug); + const realVIndex = videos.findIndex( + (video: VideoEntry) => video.fileName === sanitizedFileName, + ); + if (realVIndex >= 0) { + const video = videos[realVIndex]; + return { + props: { + redirect: true, + id, + video: realVIndex, + vslug: sanitizeTitle(video.title), + }, + }; + } + + // Check if we can find any video with matching vslug + const video = videos.find(({ title }: { title: string }) => sanitizeTitle(title) === vslug); + if (!video) { + return { props: {} }; + } + + // At this point we found the right video, just get more information at this point + const { title } = vodMeta; + + // let basePath = null; + // basePath = `${JSON.stringify(req.toString())}`; + const basePath = `https://${req.headers.host}`; + + // Pass data to the page via props + return { + props: { + id, + vslug, + video, + title, + hlsServerURL, + dashServerURL, + + basePath, + }, + }; +}; + +export default function VideoPlayerPage({ + id, + vslug, + video, + redirect, + title, + hlsServerURL, + // dashServerURL, + basePath, +}: InferGetServerSidePropsType) { + if (redirect) { + const router = useRouter(); + + React.useEffect(() => { + router.push(`/${id}/${vslug}`); + }); + + return ( +

+ You will be redirected + {' '} + + here + + … +

+ ); + } + + if (!video) { + return notFound(); + } + + const intl = useIntl(); + + const { + fileName, + title: videoTitle, + sourceVideoURL, + sourceVideoStart, + } = video; + + return ( +
+ + + {videoTitle} + {' '} + – + {' '} + {title} + {' '} + – + {' '} + {intl.formatMessage({ + id: 'App.title', + description: 'The full title of the website', + defaultMessage: 'Games Done Quick Instant Archive', + })} + + + + + + + + + + + + {title} + + + {videoTitle} + + + + + +

+ {title} + : + {' '} + {videoTitle} +

+ + + + + + {sourceVideoURL ? ( + + ) : ( + '' + )} + + + + + {[basePath, id, vslug].join('/')} + + + +
+ ); +} diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx new file mode 100644 index 0000000..59d3836 --- /dev/null +++ b/frontend/pages/_app.tsx @@ -0,0 +1,297 @@ +import * as React from 'react'; + +import { + config as fontawesomeSvgCoreConfig, + library, +} from '@fortawesome/fontawesome-svg-core'; +import { + faClock, + faSearch, + faShare, + faLightbulb, + faCopy, + faLanguage, +} from '@fortawesome/free-solid-svg-icons'; +import { faLightbulb as farLightbulb } from '@fortawesome/free-regular-svg-icons'; +import { faTwitch as fabTwitch } from '@fortawesome/free-brands-svg-icons'; +import '@fortawesome/fontawesome-svg-core/styles.css'; + +import Container from 'react-bootstrap/Container'; +import Navbar from 'react-bootstrap/Navbar'; + +import { IntlProvider, FormattedMessage } from 'react-intl'; + +import Link from 'next/link'; +import Router from 'next/router'; +import App, { AppContext } from 'next/app'; + +import useSWR, { SWRConfig } from 'swr'; + +import NProgress from 'nprogress'; +import 'nprogress/nprogress.css'; + +import '../styles/main.scss'; +import Image from 'react-bootstrap/Image'; +import Head from 'next/head'; +import { ButtonGroup } from 'react-bootstrap'; +import { shouldPolyfill } from '@formatjs/intl-numberformat/should-polyfill'; +import HomeStyle from '../styles/Home.module.css'; +import DarkToggler from '../components/DarkToggler'; + +import { fetchJson } from '../util/api'; +import { defaultLocale, loadLocaleData, loadLocalePolyfill } from '../util/localization'; + +import gdqLogo from '../images/gdqlogo.png'; +import favicon from '../images/favicon.svg'; +import LocaleSwitcher from '../components/LocaleSwitcher'; +import withSession from '../util/session'; + +fontawesomeSvgCoreConfig.autoAddCss = false; + +library.add( + faClock, + faCopy, + faLanguage, + faSearch, + faShare, + fabTwitch, + faLightbulb, + farLightbulb, +); + +Router.events.on('routeChangeStart', (url) => { + console.log(`Loading: ${url}`); + NProgress.start(); +}); +Router.events.on('routeChangeComplete', () => NProgress.done()); +Router.events.on('routeChangeError', () => NProgress.done()); + +export interface GDQArchiveAppPageProps extends Record { + locale: string, + messages: any, + enableDark: boolean, +} + +const polyfillRequired = shouldPolyfill(); + +function GDQArchiveApp({ + Component, + pageProps: { + initialEnableDark, + initialLocale, + initialMessages, + ...pageProps + }, +}: { + Component: React.ElementType>, + pageProps: GDQArchiveAppPageProps, +}) { + /* Component state */ + + const [currentMessages, setMessages] = React.useState(initialMessages); + const [isChangingLocale, setIsChangingLocale] = React.useState(false); + const [isChangingDarkMode, setIsChangingDarkMode] = React.useState(false); + const [{ + loadedPolyfill, + isLoadingPolyfill, + }, setPolyfillState] = React.useState({ + loadedPolyfill: null, + isLoadingPolyfill: false, + }); + + /* Synchronize via SWR */ + + const { + data: userData, + error, + isValidating, + mutate, + } = useSWR('/api/user', { + fetcher: fetchJson, + initialData: { + locale: initialLocale, + enableDark: initialEnableDark, + }, + onError(err) { + console.log(err); + }, + }); + if (error) { + console.log(error); + } + + const { + enableDark: enableDarkMode, + locale: currentLocale, + } = userData; + + /* Polyfill for locale */ + + if (!isLoadingPolyfill + && polyfillRequired + && currentLocale !== loadedPolyfill) { + console.log('load locale polyfill start'); + loadLocalePolyfill(currentLocale) + .then(() => { + console.log('set polyfill state end'); + setPolyfillState({ + loadedPolyfill: currentLocale, + isLoadingPolyfill: false, + }); + }); + } + + async function onChangeLocale(value: string) { + setIsChangingLocale(true); + + const formData = new URLSearchParams(); + formData.append('locale', value); + + await fetchJson('/api/changePreferences', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData, + }); + + mutate({ locale: value }); + + setMessages(await loadLocaleData(value)); + setIsChangingLocale(false); + } + + let darkToggleAnimationTimer: NodeJS.Timeout = null; + + function triggerDarkToggleAnimation() { + document.documentElement.setAttribute('data-toggled-dark', 'true'); + if (darkToggleAnimationTimer !== null) { + clearTimeout(darkToggleAnimationTimer); + } + darkToggleAnimationTimer = setTimeout(() => { + document.documentElement.removeAttribute('data-toggled-dark'); + }, 1200); + } + + /** + * @param {bool} value + */ + async function onChangeDarkMode(value: boolean) { + setIsChangingDarkMode(true); + + const formData = new URLSearchParams(); + formData.append('enableDark', value.toString()); + + await fetchJson('/api/changePreferences', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData, + }); + + mutate({ enableDark: value }); + + triggerDarkToggleAnimation(); + setIsChangingDarkMode(false); + } + + React.useEffect(() => { + if (enableDarkMode) { + document.documentElement.setAttribute('data-enable-dark', 'true'); + } else { + document.documentElement.removeAttribute('data-enable-dark'); + } + document.documentElement.setAttribute('lang', currentLocale); + }); + + return ( + + + + + + Games Done Quick + {' '} + + + + + + + + + + + + + + + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + + + ); +} + +const getInitialPropsFromReq = withSession(async (req) => { + const initialLocale = req.session.get('locale') || defaultLocale; + const initialMessages = await loadLocaleData(initialLocale); + const initialEnableDark = req.session.get('enable-dark') || false; + + return { + pageProps: { + initialEnableDark, + initialLocale, + initialMessages, + }, + }; +}); + +GDQArchiveApp.getInitialProps = async (appContext: AppContext) => { + const result = ({ + ...await App.getInitialProps(appContext), + ...await getInitialPropsFromReq(appContext.ctx.req, appContext.ctx.res), + }); + // console.log('getInitialProps for app:', result); + return result; +}; + +export default GDQArchiveApp; diff --git a/frontend/pages/_document.tsx b/frontend/pages/_document.tsx new file mode 100644 index 0000000..f8ed479 --- /dev/null +++ b/frontend/pages/_document.tsx @@ -0,0 +1,66 @@ +import Document, { + Html, + Head, + Main, + NextScript, + DocumentContext, + DocumentInitialProps, +} from 'next/document'; +import { SWRConfig } from 'swr'; +import * as React from 'react'; +import { IntlProvider } from 'react-intl'; +import { fetchJson } from '../util/api'; +import withSession, { IncomingMessageWithSession } from '../util/session'; +import { defaultLocale, loadLocaleData, LocaleData } from '../util/localization'; + +const getNewProps = withSession(async (req: IncomingMessageWithSession) => { + const enableDark = req.session.get('enable-dark') || false; + const locale = req.session.get('locale') || defaultLocale; + const messages = await loadLocaleData(locale); + + return { + enableDark, + locale, + messages, + }; +}); + +class GDQArchiveDocument extends Document<{ + enableDark: boolean, + locale: string, + messages: LocaleData, +}> { + static async getInitialProps(ctx: DocumentContext): Promise { + const initialProps = await Document.getInitialProps(ctx); + return { + ...initialProps, + ...await getNewProps(ctx.req, ctx.res), + }; + } + + render() { + const { enableDark, locale, messages } = this.props; + return ( + { + console.error(err); + }, + }} + > + + + + +
+ + + + + + ); + } +} + +export default GDQArchiveDocument; diff --git a/frontend/pages/api/changePreferences.ts b/frontend/pages/api/changePreferences.ts new file mode 100644 index 0000000..7df1139 --- /dev/null +++ b/frontend/pages/api/changePreferences.ts @@ -0,0 +1,34 @@ +import { availableLocales } from '../../util/localization'; +import parseBool from '../../util/parseBool'; +import withSession from '../../util/session'; + +export default withSession(async (req, res) => { + if (!req.body) { + res.status(400).json({ + success: false, + error: 'bad request', + }); + return; + } + + if (req.body.enableDark) { + const enableDark = parseBool(req.body.enableDark); + req.session.set('enable-dark', enableDark); + } + + if (req.body.locale) { + if (!availableLocales.includes(req.body.locale)) { + res.status(400).json({ + success: false, + error: 'wanted locale does not exist', + }); + } + req.session.set('locale', req.body.locale); + } + + await req.session.save(); + + res.json({ + success: true, + }); +}); diff --git a/frontend/pages/api/user.ts b/frontend/pages/api/user.ts new file mode 100644 index 0000000..c113aab --- /dev/null +++ b/frontend/pages/api/user.ts @@ -0,0 +1,14 @@ +import { defaultLocale } from '../../util/localization'; +import withSession from '../../util/session'; + +export default withSession(async (req, res) => { + const enableDark = req.session.get('enable-dark') || false; + const locale = req.session.get('locale') || defaultLocale; + + res.setHeader('cache-control', 'public, max-age=0, must-revalidate'); + + res.json({ + enableDark, + locale, + }); +}); diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx new file mode 100644 index 0000000..594e026 --- /dev/null +++ b/frontend/pages/index.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; + +import Head from 'next/head'; +import Link from 'next/link'; + +import ListGroup from 'react-bootstrap/ListGroup'; +import { Breadcrumb } from 'react-bootstrap'; +import { useIntl } from 'react-intl'; +import { FormattedMessage } from '../components/localization'; +import { VideoOnDemandIndex } from '../util/datatypes/VideoOnDemandIdentifier'; +import { getIndex } from '../util/api'; + +export async function getServerSideProps() { + // Fetch VOD IDs + const { ids } = await getIndex(); + + return { + props: { + ids, + }, + }; +} + +export default function Home({ ids }: VideoOnDemandIndex) { + const intl = useIntl(); + return ( +
+ + + {intl.formatMessage({ + id: 'App.title', + description: 'The full title of the website', + defaultMessage: 'Games Done Quick Instant Archive', + })} + + + + + + + + + + + + +

+ +

+ +

+ +

+ + + {ids.map(({ id, title }) => ( + + +
{title}
+
+ + ))} +
+
+ ); +} diff --git a/frontend/pages/robots.txt.ts b/frontend/pages/robots.txt.ts new file mode 100644 index 0000000..a6391c6 --- /dev/null +++ b/frontend/pages/robots.txt.ts @@ -0,0 +1,19 @@ +import { GetServerSideProps } from 'next'; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const { req, res } = ctx; + + const basePath = `https://${req.headers.host}`; + + res.setHeader('content-type', 'text-plain'); + res.write('User-agent: *\n'); + res.write(`Sitemap: ${basePath}/sitemap.xml\n`); + res.write('Disallow: /api/*\n'); + res.end(); + + return { + props: {}, + }; +}; + +export default () => {}; diff --git a/frontend/pages/sitemap.xml.ts b/frontend/pages/sitemap.xml.ts new file mode 100644 index 0000000..03e8b43 --- /dev/null +++ b/frontend/pages/sitemap.xml.ts @@ -0,0 +1,62 @@ +import { GetServerSideProps } from 'next'; +import { create } from 'xmlbuilder2'; +import { getIndex, getVideos } from '../util/api'; +import sanitizeTitle from '../util/sanitizeTitle'; + +const urlsetNamespace = 'http://www.sitemaps.org/schemas/sitemap/0.9'; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const { req, res } = ctx; + + const { ids } = await getIndex(); + + const basePath = `https://${req.headers.host}`; + + const url = [ + { + loc: basePath, + }, + ]; + + const results = await Promise.all( + ids.map(async ({ id }) => { + const vodInfo = await getVideos(id); + const { lastUpdatedAt, videos } = vodInfo; + return [ + { + loc: `${basePath}/${id}`, + lastmod: lastUpdatedAt, + }, + ...videos.map(({ + title, + }) => ({ + loc: `${basePath}/${id}/${sanitizeTitle(title)}`, + lastmod: lastUpdatedAt, + })), + ]; + }, url), + ); + + url.push( + ...(results + .reduce((old, current) => [...old, ...current], [])), + ); + + const root = create({ version: '1.0' }) + .ele(urlsetNamespace, 'urlset') + .ele({ + url, + }) + .doc(); + + // convert the XML tree to string + const xml = root.end({ prettyPrint: true }); + res.setHeader('Content-Type', 'text/xml'); + res.write(xml); + res.end(); + return { + props: {}, + }; +}; + +export default () => {}; diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..91049d5 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,23 @@ +module.exports = { + plugins: [ + 'postcss-flexbugs-fixes', + [ + 'postcss-preset-env', + { + autoprefixer: { + flexbox: 'no-2009', + }, + stage: 3, + features: { + 'custom-properties': false, + }, + }, + ], + [ + 'cssnano', + { + preset: 'default', + }, + ], + ], +}; diff --git a/frontend/styles/Home.module.css b/frontend/styles/Home.module.css new file mode 100644 index 0000000..27fb856 --- /dev/null +++ b/frontend/styles/Home.module.css @@ -0,0 +1,124 @@ +.container { + min-height: 100vh; + padding: 0 0.5rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.main { + padding: 5rem 0; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.footer { + width: 100%; + height: 100px; + border-top: 1px solid #eaeaea; + display: flex; + justify-content: center; + align-items: center; +} + +.footer img { + margin-left: 0.5rem; +} + +.footer a { + display: flex; + justify-content: center; + align-items: center; +} + +.title a { + color: #0070f3; + text-decoration: none; +} + +.title a:hover, +.title a:focus, +.title a:active { + text-decoration: underline; +} + +.title { + margin: 0; + line-height: 1.15; + font-size: 4rem; +} + +.title, +.description { + text-align: center; +} + +.description { + line-height: 1.5; + font-size: 1.5rem; +} + +.code { + background: #fafafa; + border-radius: 5px; + padding: 0.75rem; + font-size: 1.1rem; + font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, + Bitstream Vera Sans Mono, Courier New, monospace; +} + +.grid { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + + max-width: 800px; + margin-top: 3rem; +} + +.card { + margin: 1rem; + flex-basis: 45%; + padding: 1.5rem; + text-align: left; + color: inherit; + text-decoration: none; + border: 1px solid #eaeaea; + border-radius: 10px; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.card:hover, +.card:focus, +.card:active { + color: #0070f3; + border-color: #0070f3; +} + +.card h3 { + margin: 0 0 1rem 0; + font-size: 1.5rem; +} + +.card p { + margin: 0; + font-size: 1.25rem; + line-height: 1.5; +} + +.logo { + height: 1em; + vertical-align: baseline; +} + +@media (max-width: 600px) { + .grid { + width: 100%; + flex-direction: column; + } +} diff --git a/frontend/styles/bootstrap.scss b/frontend/styles/bootstrap.scss new file mode 100644 index 0000000..69d4416 --- /dev/null +++ b/frontend/styles/bootstrap.scss @@ -0,0 +1,96 @@ +/* + * BOOTSTRAP + */ + +html:not([data-enable-dark='true']) { + @import '~bootstrap/scss/functions'; + @import '~bootstrap/scss/variables'; + @import '~bootstrap/scss/mixins'; + @import '~bootstrap/scss/root'; + @import '~bootstrap/scss/reboot'; + @import '~bootstrap/scss/type'; + @import '~bootstrap/scss/images'; + // @import '~bootstrap/scss/code'; + @import '~bootstrap/scss/grid'; + // @import '~bootstrap/scss/tables'; + @import '~bootstrap/scss/forms'; + @import '~bootstrap/scss/buttons'; + @import '~bootstrap/scss/transitions'; + @import '~bootstrap/scss/dropdown'; + @import '~bootstrap/scss/button-group'; + @import '~bootstrap/scss/input-group'; + // @import '~bootstrap/scss/custom-forms'; + @import '~bootstrap/scss/nav'; + @import '~bootstrap/scss/navbar'; + // @import '~bootstrap/scss/card'; + @import '~bootstrap/scss/breadcrumb'; + // @import '~bootstrap/scss/pagination'; + // @import '~bootstrap/scss/badge'; + // @import '~bootstrap/scss/jumbotron'; + // @import '~bootstrap/scss/alert'; + // @import '~bootstrap/scss/progress'; + @import '~bootstrap/scss/media'; + @import '~bootstrap/scss/list-group'; + @import '~bootstrap/scss/close'; + // @import '~bootstrap/scss/toasts'; + // @import '~bootstrap/scss/modal'; + @import '~bootstrap/scss/tooltip'; + @import '~bootstrap/scss/popover'; + // @import '~bootstrap/scss/carousel'; + @import '~bootstrap/scss/spinners'; + @import '~bootstrap/scss/utilities'; + // @import '~bootstrap/scss/print'; +} + +/** + * BOOTSTRAP DARK + */ + +html[data-enable-dark='true'] { + @import '~bootstrap/scss/functions'; + @import '~bootstrap/scss/variables'; + @import '~@forevolve/bootstrap-dark/scss/dark-variables'; + @import '~bootstrap/scss/mixins'; + @import '~@forevolve/bootstrap-dark/scss/dark-mixins'; + @import '~bootstrap/scss/root'; + @import '~bootstrap/scss/reboot'; + @import '~bootstrap/scss/type'; + @import '~bootstrap/scss/images'; + // @import '~bootstrap/scss/code'; + @import '~bootstrap/scss/grid'; + // @import '~bootstrap/scss/tables'; + // @import '~@forevolve/bootstrap-dark/scss/dark-tables'; + @import '~bootstrap/scss/forms'; + @import '~bootstrap/scss/buttons'; + @import '~bootstrap/scss/transitions'; + @import '~bootstrap/scss/dropdown'; + @import '~bootstrap/scss/button-group'; + @import '~bootstrap/scss/input-group'; + @import '~@forevolve/bootstrap-dark/scss/dark-input-group'; + // @import '~bootstrap/scss/custom-forms'; + @import '~bootstrap/scss/nav'; + @import '~bootstrap/scss/navbar'; + // @import '~bootstrap/scss/card'; + @import '~bootstrap/scss/breadcrumb'; + // @import '~bootstrap/scss/pagination'; + // @import '~bootstrap/scss/badge'; + // @import '~bootstrap/scss/jumbotron'; + // @import '~bootstrap/scss/alert'; + // @import '~bootstrap/scss/progress'; + @import '~bootstrap/scss/media'; + @import '~bootstrap/scss/list-group'; + @import '~bootstrap/scss/close'; + // @import '~bootstrap/scss/toasts'; + // @import '~bootstrap/scss/modal'; + @import '~bootstrap/scss/tooltip'; + @import '~bootstrap/scss/popover'; + // @import '~bootstrap/scss/carousel'; + @import '~bootstrap/scss/spinners'; + @import '~bootstrap/scss/utilities'; + // @import '~bootstrap/scss/print'; + @import '~@forevolve/bootstrap-dark/scss/dark-styles'; +} + +.breadcrumb { + border-top: 0 !important; +} diff --git a/frontend/styles/main.scss b/frontend/styles/main.scss new file mode 100644 index 0000000..f9f0ff8 --- /dev/null +++ b/frontend/styles/main.scss @@ -0,0 +1,19 @@ +@import '~video.js/src/css/video-js'; +@import 'bootstrap'; + +$colorTransitionFunction: ease-out; +$colorTransitionDuration: 1s; + +html[data-toggled-dark='true'] * { + transition: background-color $colorTransitionDuration $colorTransitionFunction, + color $colorTransitionDuration $colorTransitionFunction, + border-color $colorTransitionDuration $colorTransitionFunction !important; +} + +html { + .btn.btn-twitch { + border-color: purple; + background-color: purple; + color: white; + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..843acb6 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "resolveJsonModule": true, + "noImplicitAny": true, + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "isolatedModules": true, + "typeRoots": [ + "./typings", + "./node_modules/@types" + ], + "baseUrl": ".", + "paths": { + "url-slug": [ + "typings/url-slug" + ], + "video.js": [ + "typings/video.js" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/frontend/typings/react-intl-formatted-duration.d.ts b/frontend/typings/react-intl-formatted-duration.d.ts new file mode 100644 index 0000000..a88ccaf --- /dev/null +++ b/frontend/typings/react-intl-formatted-duration.d.ts @@ -0,0 +1 @@ +declare module 'react-intl-formatted-duration'; diff --git a/frontend/typings/url-slug/index.d.ts b/frontend/typings/url-slug/index.d.ts new file mode 100644 index 0000000..07e9751 --- /dev/null +++ b/frontend/typings/url-slug/index.d.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-redeclare */ + +export type UrlSlugTransformer = ( + slugFragments: string[], + separator: UrlSlugSeparator +) => string; + +export type UrlSlugSeparator = '-'|'.'|'_'|'~'|''; + +export interface UrlSlugOptions { + camelCase?: boolean = true; + separator?: UrlSlugSeparator = '-'; + transformer: false|UrlSlugTransformer = false; +} + +declare function urlSlug( + string: string, + options?: urlSlug.Options): string; + +export default urlSlug; + +export as namespace urlSlug; + +declare namespace urlSlug { + type Options = UrlSlugOptions; + + static function revert(slug: string, options?: urlSlug.Options); + + declare namespace transformers { + static const lowercase: UrlSlugTransformer; + } +} diff --git a/frontend/typings/video.js/index.d.ts b/frontend/typings/video.js/index.d.ts new file mode 100644 index 0000000..46a0a42 --- /dev/null +++ b/frontend/typings/video.js/index.d.ts @@ -0,0 +1,6908 @@ +/* eslint-disable max-len */ + +// Type definitions for Video.js 7.8 +// Project: https://github.com/videojs/video.js, https://videojs.com +// Definitions by: Vincent Bortone +// Simon Clériot +// Sean Bennett +// Christoph Wagner +// Gio Freitas +// Grzegorz Błaszczyk +// Stéphane Roucheray +// Adam Eisenreich +// Mei Qingguang +// Joe Flateau +// KuanYu Chu +// Carl Kittelberger +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped +// TypeScript Version: 2.1 + +// The Video.js API allows you to interact with the video through +// Javascript, whether the browser is playing the video through HTML5 +// video, Flash, or any other supported playback technologies. + +/** + * Doubles as the main function for users to create a inplayer instance and also + * the main library object. + * The `videojs` function can be used to initialize or retrieve a player. + * + * @param id + * Video element or video element ID + * + * @param [options] + * Optional options object for config/settings + * + * @param [ready] + * Optional ready callback + * + * @return A player instance + */ +declare function videojs(id: any, options?: videojs.PlayerOptions, ready?: () => void): videojs.Player; +export default videojs; +export as namespace videojs; + +declare namespace videojs { + /** + * Adding languages so that they're available to all players. + * Example: `addLanguage('es', { 'Hello': 'Hola' });` + * + * @param code + * The language code or dictionary property + * + * @param data + * The data values to be translated + * + * @return The resulting language dictionary object + */ + function addLanguage(code: string, data: LanguageTranslations): LanguageTranslations; + + /** + * Bind (a.k.a proxy or Context). A simple method for changing the context of a function + * It also stores a unique id on the function so it can be easily removed from events. + * + * @param context + * The object to bind as scope. + * + * @param fn + * The function to be bound to a scope. + * + * @param [uid] + * An optional unique ID for the function to be set + * + * @return The new function that will be bound into the context given + */ + function bind any>(context: any, fn: F, uid?: number): F; + + /** + * Should create a fake `TimeRange` object which mimics an HTML5 time range instance. + * + * @param start + * The start of a single range or an array of ranges + * + * @param end + * The end of a single range. + */ + function createTimeRanges(start?: number | TimeRange[], end?: number): TimeRange; + + /** + * A suite of browser and device tests from {@link browser}. + * + */ + const browser: Browser; + + const dom: Dom; + + /** + * Format seconds as a time string, H:MM:SS or M:SS. Supplying a guide (in seconds) + * will force a number of leading zeros to cover the length of the guide. + * + * @param seconds + * Number of seconds to be turned into a string + * + * @param guide + * Number (in seconds) to model the string after + * + * @return Time formatted as H:MM:SS or M:SS + */ + function formatTime(seconds: number, guide: number): string; + + /** + * Returns an array of all current players. + * + * @return An array of all players. The array will be in the order that + * `Object.keys` provides, which could potentially vary between + * JavaScript engines. + * + */ + function getAllPlayers(): Player[]; + + /** + * Get a component class object by name + * + * @borrows Component.getComponent as getComponent + */ + const getComponent: typeof Component.getComponent; + + /** + * Get a single player based on an ID or DOM element. + * + * This is useful if you want to check if an element or ID has an associated + * Video.js player, but not create one if it doesn't. + * + * @param id + * An HTML element - `