commit 1a88b1a5ba8951f533ddb3c86fe89255505546f8 Author: Carl Kittelberger Date: Sat Aug 22 22:25:57 2020 +0200 Initial commit. 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 0000000..792b2a9 Binary files /dev/null and b/frontend/images/favicon.ico differ diff --git a/frontend/images/favicon.svg b/frontend/images/favicon.svg new file mode 100644 index 0000000..62a49ab --- /dev/null +++ b/frontend/images/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/images/gdqlogo.png b/frontend/images/gdqlogo.png new file mode 100644 index 0000000..770da7d Binary files /dev/null and b/frontend/images/gdqlogo.png differ diff --git a/frontend/lang/de.json b/frontend/lang/de.json new file mode 100644 index 0000000..66b558a --- /dev/null +++ b/frontend/lang/de.json @@ -0,0 +1,51 @@ +{ + "App.title": { + "defaultMessage": "Games Done Quick Instant Archive", + "description": "The full title of the website" + }, + "Breadcrumb.homeTitle": { + "defaultMessage": "GDQ Instant Archive", + "description": "Root node text in breadcrumb" + }, + "CopyField.copied": { + "defaultMessage": "Kopiert!", + "description": "Tooltip shown when user clicks the Copy button." + }, + "DarkToggler.screenReaderText": { + "defaultMessage": "Dunkelmodus umschalten", + "description": "Screen reader description of the dark mode toggle button" + }, + "Home.introText1": { + "defaultMessage": "Greife sofort auf deine Lieblings-Runs von GDQ zu!" + }, + "Home.introText2": { + "defaultMessage": "Diese Website sammelt alle Runs sehr bald nachdem sie ausgestrahlt wurden und stellt sie zum Streamen zur Verfügung." + }, + "LocaleSwitcher.screenReaderText": { + "defaultMessage": "Sprache ändern", + "description": "Screen reader description of the locale switcher button" + }, + "LocaleSwitcher.language.de": { + "defaultMessage": "Deutsch" + }, + "LocaleSwitcher.language.en": { + "defaultMessage": "Englisch" + }, + "Navbar.brandText": { + "defaultMessage": "Instant Archive" + }, + "VideoList.Search.Label": { + "defaultMessage": "Suche" + }, + "VideoList.Search.Placeholder": { + "defaultMessage": "Gib hier etwas zum Suchen ein…" + }, + "VideoPlayerPage.cutFrom": { + "defaultMessage": "Geschnitten von {url}", + "description": "Text below video that describes where a video was cut from" + }, + "VideoPlayerPage.watchOnTwitch": { + "defaultMessage": "Auf Twitch anschauen", + "description": "Button below video that links to the exact position in the archived stream to watch it there." + } +} diff --git a/frontend/lang/en.json b/frontend/lang/en.json new file mode 100644 index 0000000..9450458 --- /dev/null +++ b/frontend/lang/en.json @@ -0,0 +1,47 @@ +{ + "App.title": { + "defaultMessage": "Games Done Quick Instant Archive", + "description": "The full title of the website" + }, + "Breadcrumb.homeTitle": { + "defaultMessage": "GDQ Instant Archive", + "description": "Root node text in breadcrumb" + }, + "CopyField.copied": { + "defaultMessage": "Copied!", + "description": "Tooltip shown when user clicks the Copy button." + }, + "DarkToggler.screenReaderText": { + "defaultMessage": "Toggle dark mode", + "description": "Screen reader description of the dark mode toggle button" + }, + "Home.introText1": { + "defaultMessage": "Instant access to your favorite VODs of Games Done Quick!" + }, + "Home.introText2": { + "defaultMessage": "This website collects all the broadcasted runs and allows streaming and downloading them." + }, + "LocaleSwitcher.screenReaderText": { + "defaultMessage": "Switch language", + "description": "Screen reader description of the locale switcher button" + }, + "LocaleSwitcher.language.de": { + "defaultMessage": "German" + }, + "LocaleSwitcher.language.en": { + "defaultMessage": "English" + }, + "Navbar.brandText": { + "defaultMessage": "Instant Archive" + }, + "VideoList.Search.Label": { + "defaultMessage": "Search" + }, + "VideoList.Search.Placeholder": { + "defaultMessage": "Type something to search here…" + }, + "VideoPlayerPage.watchOnTwitch": { + "defaultMessage": "Watch on Twitch", + "description": "Button below video that links to the exact position in the archived stream to watch it there." + } +} diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..7b7aa2c --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// 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 - `