Initial commit.
commit
1a88b1a5ba
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
_old
|
|
@ -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 '*';
|
||||||
|
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
vcl 4.0;
|
||||||
|
|
||||||
|
backend streamserver {
|
||||||
|
.host = "streamserver";
|
||||||
|
.port = "80";
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
||||||
|
.next/
|
||||||
|
/dist/
|
||||||
|
/public/
|
||||||
|
node_modules/
|
|
@ -0,0 +1,4 @@
|
||||||
|
*.js text eol=lf
|
||||||
|
*.jsx text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.tsx text eol=lf
|
|
@ -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
|
|
@ -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"]
|
|
@ -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.
|
|
@ -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 (
|
||||||
|
<InputGroup>
|
||||||
|
{
|
||||||
|
icon
|
||||||
|
? (
|
||||||
|
<InputGroup.Prepend>
|
||||||
|
<InputGroup.Text>
|
||||||
|
<FontAwesomeIcon icon="share" />
|
||||||
|
</InputGroup.Text>
|
||||||
|
</InputGroup.Prepend>
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
<FormControl ref={textbox} readOnly value={children} />
|
||||||
|
<InputGroup.Append>
|
||||||
|
<Button ref={target} onClick={doCopy}>
|
||||||
|
<FontAwesomeIcon icon={copyIcon} />
|
||||||
|
<Overlay target={target.current} show={show} placement="top">
|
||||||
|
{(props) => (
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
<Tooltip id="overlay-copy" {...props}>
|
||||||
|
<FormattedMessage
|
||||||
|
id="CopyField.copied"
|
||||||
|
defaultMessage="Copied!"
|
||||||
|
description="Tooltip shown when user clicks the Copy button."
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Overlay>
|
||||||
|
</Button>
|
||||||
|
</InputGroup.Append>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyField;
|
|
@ -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 (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onChangeDarkMode(!isDarkEnabled);
|
||||||
|
}}
|
||||||
|
disabled={disabled || showLoading}
|
||||||
|
variant="outline-secondary"
|
||||||
|
active={!isDarkEnabled}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={[isDarkEnabled ? 'far' : 'fas', 'lightbulb']} />
|
||||||
|
<span className="sr-only">
|
||||||
|
<FormattedMessage
|
||||||
|
id="DarkToggler.screenReaderText"
|
||||||
|
defaultMessage="Toggle dark mode"
|
||||||
|
description="Screen reader description of the dark mode toggle button"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<Button variant="success" href={getDownloadURL(id, fileName)}>
|
||||||
|
<FontAwesomeIcon icon="download" className="mr-2" />
|
||||||
|
<FormattedMessage
|
||||||
|
id="DownloadButton.download"
|
||||||
|
defaultMessage="Download"
|
||||||
|
description="Text of the download button"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
import Fuse from 'fuse.js';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Filter<T, U extends React.ReactNode>({
|
||||||
|
items,
|
||||||
|
query = '',
|
||||||
|
isCaseSensitive = false,
|
||||||
|
keys,
|
||||||
|
output,
|
||||||
|
}: {
|
||||||
|
items: Array<T>,
|
||||||
|
query: string,
|
||||||
|
isCaseSensitive?: boolean,
|
||||||
|
keys: Array<string>,
|
||||||
|
output: (filteredItems: Array<Fuse.FuseResult<T>>) => 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,
|
||||||
|
})))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<DropdownButton
|
||||||
|
disabled={showLoading || disabled}
|
||||||
|
as={ButtonGroup}
|
||||||
|
variant="outline-secondary"
|
||||||
|
alignRight
|
||||||
|
id="dropdown-locale"
|
||||||
|
title={(
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon="language" />
|
||||||
|
<span className="sr-only">
|
||||||
|
<FormattedMessage
|
||||||
|
id="LocaleSwitcher.screenReaderText"
|
||||||
|
defaultMessage="Switch language"
|
||||||
|
description="Screen reader description of the locale switcher button"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
availableLocales.map((thisLocale) => (
|
||||||
|
<Dropdown.Item
|
||||||
|
key={thisLocale}
|
||||||
|
onClick={
|
||||||
|
onChangeLocale
|
||||||
|
? () => { onChangeLocale(thisLocale); }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
active={locale === thisLocale}
|
||||||
|
data-lang={thisLocale}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id={`LocaleSwitcher.language.${thisLocale}`}
|
||||||
|
defaultMessage={localeDescriptions[thisLocale]}
|
||||||
|
/>
|
||||||
|
</Dropdown.Item>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</DropdownButton>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<time title={title} dateTime={dateTimeValue.toISOString()}>
|
||||||
|
<FormattedRelativeTime
|
||||||
|
value={value}
|
||||||
|
unit={unit}
|
||||||
|
/>
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
|
@ -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<VideoEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoListState {
|
||||||
|
query: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class VideoList extends React.Component<VideoListProps, VideoListState> {
|
||||||
|
constructor(props: VideoListProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
query: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onQueryChange = this.onQueryChange.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
onQueryChange({ target }: ChangeEvent<HTMLInputElement>) {
|
||||||
|
this.setState({
|
||||||
|
query: target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { query } = this.state;
|
||||||
|
const {
|
||||||
|
intl,
|
||||||
|
id,
|
||||||
|
thumbnailServerURL,
|
||||||
|
videos,
|
||||||
|
} = this.props;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroup.Prepend>
|
||||||
|
<InputGroup.Text id="search-prepend">
|
||||||
|
<FontAwesomeIcon icon="search" />
|
||||||
|
</InputGroup.Text>
|
||||||
|
</InputGroup.Prepend>
|
||||||
|
<FormControl
|
||||||
|
placeholder={intl.formatMessage({ id: 'VideoList.Search.Placeholder', defaultMessage: 'Type something to search here…' })}
|
||||||
|
aria-label={intl.formatMessage({ id: 'VideoList.Search.Label', defaultMessage: 'Search' })}
|
||||||
|
aria-describedby="search-prepend"
|
||||||
|
value={query}
|
||||||
|
onChange={this.onQueryChange}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
<ListGroup>
|
||||||
|
<Filter
|
||||||
|
items={videos}
|
||||||
|
query={query}
|
||||||
|
keys={['title', 'fileName']}
|
||||||
|
output={(filteredVideos) => filteredVideos.map(({
|
||||||
|
item: {
|
||||||
|
duration,
|
||||||
|
fileName,
|
||||||
|
title,
|
||||||
|
sourceVideoStart,
|
||||||
|
sourceVideoEnd,
|
||||||
|
},
|
||||||
|
refIndex: index,
|
||||||
|
}) => (
|
||||||
|
<VideoListItem
|
||||||
|
key={index}
|
||||||
|
duration={duration}
|
||||||
|
id={id}
|
||||||
|
thumbnailServerURL={thumbnailServerURL}
|
||||||
|
fileName={fileName}
|
||||||
|
title={title}
|
||||||
|
sourceVideoStart={sourceVideoStart}
|
||||||
|
sourceVideoEnd={sourceVideoEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default injectIntl(VideoList);
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<Link passHref href="/[id]/[vslug]" as={`/${id}/${titleUrlSlug}`}>
|
||||||
|
<ListGroup.Item action>
|
||||||
|
<Media>
|
||||||
|
<Image
|
||||||
|
className={['mr-3', style.thumbnail].join(' ')}
|
||||||
|
src={getThumbnailURL(thumbnailServerURL, id, fileName, 90 * 1000, {
|
||||||
|
width: 96,
|
||||||
|
})}
|
||||||
|
srcSet={[96, 96 * 2, 96 * 3]
|
||||||
|
.map((width) => [
|
||||||
|
getThumbnailURL(thumbnailServerURL, id, fileName, 90 * 1000, {
|
||||||
|
width,
|
||||||
|
}),
|
||||||
|
`${width}w`,
|
||||||
|
])
|
||||||
|
.map((item) => item.join(' '))
|
||||||
|
.join(', ')}
|
||||||
|
alt={title}
|
||||||
|
/>
|
||||||
|
<Media.Body>
|
||||||
|
<h5 className="mt-0 mb-3">{title}</h5>
|
||||||
|
<p>
|
||||||
|
{displayDuration !== null ? (
|
||||||
|
<span>
|
||||||
|
<FontAwesomeIcon icon="clock" />
|
||||||
|
{' '}
|
||||||
|
<FormattedDuration
|
||||||
|
seconds={displayDuration}
|
||||||
|
format="{hours} {minutes} {seconds}"
|
||||||
|
unitDisplay="short"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Media.Body>
|
||||||
|
</Media>
|
||||||
|
</ListGroup.Item>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<VideoJsPlayerOptions> {
|
||||||
|
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 (
|
||||||
|
<ResponsiveEmbed aspectRatio="16by9">
|
||||||
|
<div>
|
||||||
|
<div data-vjs-player>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
|
<video
|
||||||
|
ref={(node) => { this.videoNode = node; }}
|
||||||
|
className="video-js"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResponsiveEmbed>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
import FormattedDuration from 'react-intl-formatted-duration';
|
||||||
|
import WrapReactIntl from './WrapReactIntl';
|
||||||
|
|
||||||
|
export default WrapReactIntl(FormattedDuration);
|
|
@ -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);
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { FormattedNumber } from 'react-intl';
|
||||||
|
import WrapReactIntl from './WrapReactIntl';
|
||||||
|
|
||||||
|
export default WrapReactIntl(FormattedNumber);
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { FormattedRelativeTime } from 'react-intl';
|
||||||
|
import WrapReactIntl from './WrapReactIntl';
|
||||||
|
|
||||||
|
export default WrapReactIntl(FormattedRelativeTime);
|
|
@ -0,0 +1,24 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Spinner } from 'react-bootstrap';
|
||||||
|
import { isPolyfillPhaseDone } from 'util/localization';
|
||||||
|
|
||||||
|
export default function WrapReactIntl<P>(Component: React.ComponentType<P>) {
|
||||||
|
return (props: P) => {
|
||||||
|
if (!isPolyfillPhaseDone()) {
|
||||||
|
return (
|
||||||
|
<Spinner
|
||||||
|
as="span"
|
||||||
|
size="sm"
|
||||||
|
animation="border"
|
||||||
|
role="status"
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
<Component {...props} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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,
|
||||||
|
};
|
|
@ -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<string, string>;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.scss' {
|
||||||
|
const content: Record<string, string>;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.sass' {
|
||||||
|
const content: Record<string, string>;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.styl' {
|
||||||
|
const content: Record<string, string>;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.less' {
|
||||||
|
const content: Record<string, string>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 92 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 128 128" height="128px" id="Layer_1" version="1.1" viewBox="0 0 128 128" width="128px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g><g><path d="M84,20H44C19.699,20,0,39.703,0,64s19.699,44,44,44c7.211,0,13.996-1.766,20-4.844 C70.004,106.234,76.789,108,84,108c24.301,0,44-19.703,44-44S108.301,20,84,20z M84,100c-5.719,0-11.223-1.336-16.352-3.961 c-1.145-0.586-2.395-0.883-3.648-0.883s-2.504,0.297-3.648,0.883C55.223,98.664,49.719,100,44,100C24.148,100,8,83.852,8,64 s16.148-36,36-36h40c19.852,0,36,16.148,36,36S103.852,100,84,100z" fill="#B0BEC5"/></g></g><path d="M44,92c-6.617,0-12-5.383-12-12v-4h-4c-6.617,0-12-5.383-12-12s5.383-12,12-12h4v-4c0-6.617,5.383-12,12-12 s12,5.383,12,12v4h4c6.617,0,12,5.383,12,12s-5.383,12-12,12h-4v4C56,86.617,50.617,92,44,92z M28,60c-2.207,0-4,1.797-4,4 s1.793,4,4,4h8c2.211,0,4,1.789,4,4v8c0,2.203,1.793,4,4,4s4-1.797,4-4v-8c0-2.211,1.789-4,4-4h8c2.207,0,4-1.797,4-4s-1.793-4-4-4 h-8c-2.211,0-4-1.789-4-4v-8c0-2.203-1.793-4-4-4s-4,1.797-4,4v8c0,2.211-1.789,4-4,4H28z" fill="#546E7A"/><path d="M96,60c0,4.422-3.582,8-8,8s-8-3.578-8-8s3.582-8,8-8S96,55.578,96,60z" fill="#4CAF50"/><path d="M112,52c0,4.422-3.582,8-8,8s-8-3.578-8-8s3.582-8,8-8S112,47.578,112,52z" fill="#03A9F4"/><path d="M96,84c0,4.422-3.582,8-8,8s-8-3.578-8-8s3.582-8,8-8S96,79.578,96,84z" fill="#FF9800"/><path d="M112,76c0,4.422-3.582,8-8,8s-8-3.578-8-8s3.582-8,8-8S112,71.578,112,76z" fill="#F44336"/></svg>
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/types/global" />
|
|
@ -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
|
||||||
|
|
||||||
|
]);
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<VideoEntry>
|
||||||
|
}) {
|
||||||
|
if (!id) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastUpdatedDate = null;
|
||||||
|
if (typeof (lastUpdatedAt) === 'string') {
|
||||||
|
lastUpdatedDate = new Date(lastUpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
{title}
|
||||||
|
{' '}
|
||||||
|
–
|
||||||
|
{' '}
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'App.title',
|
||||||
|
description: 'The full title of the website',
|
||||||
|
defaultMessage: 'Games Done Quick Instant Archive',
|
||||||
|
})}
|
||||||
|
</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Breadcrumb>
|
||||||
|
<Link passHref href="/">
|
||||||
|
<Breadcrumb.Item>
|
||||||
|
<FormattedMessage
|
||||||
|
id="Breadcrumb.homeTitle"
|
||||||
|
defaultMessage="GDQ Instant Archive"
|
||||||
|
description="Root node text in breadcrumb"
|
||||||
|
/>
|
||||||
|
</Breadcrumb.Item>
|
||||||
|
</Link>
|
||||||
|
<Link passHref href="/[id]" as={`/${id}`}>
|
||||||
|
<Breadcrumb.Item active>
|
||||||
|
{title}
|
||||||
|
</Breadcrumb.Item>
|
||||||
|
</Link>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<VideoList
|
||||||
|
id={id}
|
||||||
|
thumbnailServerURL={thumbnailServerURL}
|
||||||
|
videos={videos}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{
|
||||||
|
lastUpdatedDate
|
||||||
|
? (
|
||||||
|
<footer className="pt-4 my-md-5 pt-md-5 border-top">
|
||||||
|
Last updated
|
||||||
|
{' '}
|
||||||
|
<RelativeTime>{lastUpdatedDate}</RelativeTime>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<typeof getServerSideProps>) {
|
||||||
|
if (redirect) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
router.push(`/${id}/${vslug}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
You will be redirected
|
||||||
|
{' '}
|
||||||
|
<Link href="/[id]/[vslug]" as={`/${id}/${vslug}`}>
|
||||||
|
<span>here</span>
|
||||||
|
</Link>
|
||||||
|
…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const {
|
||||||
|
fileName,
|
||||||
|
title: videoTitle,
|
||||||
|
sourceVideoURL,
|
||||||
|
sourceVideoStart,
|
||||||
|
} = video;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
{videoTitle}
|
||||||
|
{' '}
|
||||||
|
–
|
||||||
|
{' '}
|
||||||
|
{title}
|
||||||
|
{' '}
|
||||||
|
–
|
||||||
|
{' '}
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'App.title',
|
||||||
|
description: 'The full title of the website',
|
||||||
|
defaultMessage: 'Games Done Quick Instant Archive',
|
||||||
|
})}
|
||||||
|
</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Breadcrumb>
|
||||||
|
<Link passHref href="/">
|
||||||
|
<Breadcrumb.Item>
|
||||||
|
<FormattedMessage
|
||||||
|
id="Breadcrumb.homeTitle"
|
||||||
|
defaultMessage="GDQ Instant Archive"
|
||||||
|
description="Root node text in breadcrumb"
|
||||||
|
/>
|
||||||
|
</Breadcrumb.Item>
|
||||||
|
</Link>
|
||||||
|
<Link passHref href="/[id]" as={`/${id}`}>
|
||||||
|
<Breadcrumb.Item>{title}</Breadcrumb.Item>
|
||||||
|
</Link>
|
||||||
|
<Link passHref href="/[id]/[vslug]" as={`/${id}/${vslug}`}>
|
||||||
|
<Breadcrumb.Item active>{videoTitle}</Breadcrumb.Item>
|
||||||
|
</Link>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<VideoPlayer
|
||||||
|
autoplay
|
||||||
|
controls
|
||||||
|
sources={[
|
||||||
|
// getDASHManifestURL(dashServerURL, id, fileName),
|
||||||
|
{ src: getHLSMasterURL(hlsServerURL, id, fileName) },
|
||||||
|
]}
|
||||||
|
aspectRatio="16:9"
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
{title}
|
||||||
|
:
|
||||||
|
{' '}
|
||||||
|
{videoTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<Row className="mb-3">
|
||||||
|
<Col sm={12} md={7}>
|
||||||
|
<ButtonGroup>
|
||||||
|
<DownloadButton id={id} fileName={fileName} />
|
||||||
|
{sourceVideoURL ? (
|
||||||
|
<Button
|
||||||
|
href={
|
||||||
|
sourceVideoStart
|
||||||
|
? `${sourceVideoURL}?t=${Math.floor(sourceVideoStart / 60)}m${sourceVideoStart % 60
|
||||||
|
}s`
|
||||||
|
: sourceVideoURL
|
||||||
|
}
|
||||||
|
target="blank"
|
||||||
|
variant="twitch"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={['fab', 'twitch']} className="mr-2" />
|
||||||
|
<FormattedMessage
|
||||||
|
id="VideoPlayerPage.watchOnTwitch"
|
||||||
|
description="Button below video that links to the exact position in the archived stream to watch it there."
|
||||||
|
defaultMessage="Watch on Twitch"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
</Col>
|
||||||
|
<Col sm={12} md={5}>
|
||||||
|
<CopyField icon="share">
|
||||||
|
{[basePath, id, vslug].join('/')}
|
||||||
|
</CopyField>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<string, any> {
|
||||||
|
locale: string,
|
||||||
|
messages: any,
|
||||||
|
enableDark: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
const polyfillRequired = shouldPolyfill();
|
||||||
|
|
||||||
|
function GDQArchiveApp({
|
||||||
|
Component,
|
||||||
|
pageProps: {
|
||||||
|
initialEnableDark,
|
||||||
|
initialLocale,
|
||||||
|
initialMessages,
|
||||||
|
...pageProps
|
||||||
|
},
|
||||||
|
}: {
|
||||||
|
Component: React.ElementType<Record<string, any>>,
|
||||||
|
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 (
|
||||||
|
<SWRConfig
|
||||||
|
value={{
|
||||||
|
fetcher: fetchJson,
|
||||||
|
onError(err) {
|
||||||
|
console.error(err);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IntlProvider
|
||||||
|
messages={currentMessages}
|
||||||
|
locale={currentLocale}
|
||||||
|
defaultLocale={defaultLocale}
|
||||||
|
>
|
||||||
|
<Navbar bg="dark" variant="dark">
|
||||||
|
<Link passHref href="/">
|
||||||
|
<Navbar.Brand>
|
||||||
|
<Image
|
||||||
|
src={gdqLogo}
|
||||||
|
alt="Games Done Quick"
|
||||||
|
className={[
|
||||||
|
HomeStyle.logo,
|
||||||
|
'd-inline-block',
|
||||||
|
'align-middle',
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
{' '}
|
||||||
|
<FormattedMessage
|
||||||
|
id="Navbar.brandText"
|
||||||
|
defaultMessage="Instant Archive"
|
||||||
|
/>
|
||||||
|
</Navbar.Brand>
|
||||||
|
</Link>
|
||||||
|
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||||
|
<Navbar.Collapse
|
||||||
|
id="basic-navbar-nav"
|
||||||
|
className="justify-content-end"
|
||||||
|
>
|
||||||
|
<ButtonGroup>
|
||||||
|
<LocaleSwitcher
|
||||||
|
locale={currentLocale}
|
||||||
|
onChangeLocale={onChangeLocale}
|
||||||
|
disabled={isChangingLocale || isValidating}
|
||||||
|
showLoading={isChangingLocale}
|
||||||
|
/>
|
||||||
|
<DarkToggler
|
||||||
|
isDarkEnabled={enableDarkMode}
|
||||||
|
onChangeDarkMode={onChangeDarkMode}
|
||||||
|
disabled={isChangingDarkMode || isValidating}
|
||||||
|
showLoading={isChangingDarkMode}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Navbar.Collapse>
|
||||||
|
</Navbar>
|
||||||
|
<Head>
|
||||||
|
<link rel="icon" type="image/svg+xml" href={favicon} />
|
||||||
|
</Head>
|
||||||
|
<Container>
|
||||||
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</Container>
|
||||||
|
</IntlProvider>
|
||||||
|
</SWRConfig>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
|
@ -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<DocumentInitialProps> {
|
||||||
|
const initialProps = await Document.getInitialProps(ctx);
|
||||||
|
return {
|
||||||
|
...initialProps,
|
||||||
|
...await getNewProps(ctx.req, ctx.res),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { enableDark, locale, messages } = this.props;
|
||||||
|
return (
|
||||||
|
<SWRConfig
|
||||||
|
value={{
|
||||||
|
fetcher: fetchJson,
|
||||||
|
onError: (err) => {
|
||||||
|
console.error(err);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IntlProvider messages={messages} locale={locale} defaultLocale={defaultLocale}>
|
||||||
|
<Html lang={locale} data-enable-dark={enableDark}>
|
||||||
|
<Head />
|
||||||
|
<body>
|
||||||
|
<Main />
|
||||||
|
<NextScript />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
|
</IntlProvider>
|
||||||
|
</SWRConfig>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GDQArchiveDocument;
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'App.title',
|
||||||
|
description: 'The full title of the website',
|
||||||
|
defaultMessage: 'Games Done Quick Instant Archive',
|
||||||
|
})}
|
||||||
|
</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Breadcrumb>
|
||||||
|
<Link passHref href="/">
|
||||||
|
<Breadcrumb.Item active>
|
||||||
|
<FormattedMessage
|
||||||
|
id="Breadcrumb.homeTitle"
|
||||||
|
defaultMessage="GDQ Instant Archive"
|
||||||
|
description="Root node text in breadcrumb"
|
||||||
|
/>
|
||||||
|
</Breadcrumb.Item>
|
||||||
|
</Link>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
<FormattedMessage
|
||||||
|
id="Home.introText1"
|
||||||
|
defaultMessage="Instant access to your favorite VODs of Games Done Quick!"
|
||||||
|
/>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<FormattedMessage
|
||||||
|
id="Home.introText2"
|
||||||
|
defaultMessage="This website collects all the broadcasted runs and allows streaming and downloading them."
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ListGroup>
|
||||||
|
{ids.map(({ id, title }) => (
|
||||||
|
<Link key={id} passHref href="/[id]" as={`/${id}`}>
|
||||||
|
<ListGroup.Item action>
|
||||||
|
<h5>{title}</h5>
|
||||||
|
</ListGroup.Item>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 () => {};
|
|
@ -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 () => {};
|
|
@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
declare module 'react-intl-formatted-duration';
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,65 @@
|
||||||
|
import { VideoList } from './datatypes/VideoList';
|
||||||
|
import { VideoOnDemandIndex } from './datatypes/VideoOnDemandIdentifier';
|
||||||
|
|
||||||
|
const upstreamURL = process.env.UPSTREAM_URL;
|
||||||
|
const upstreamDirectURL = process.env.UPSTREAM_DIRECT_URL || upstreamURL;
|
||||||
|
// const apiURL = process.env.API_URL || `${upstreamURL}/api`;
|
||||||
|
const apiDirectURL = process.env.API_DIRECT_URL || `${upstreamDirectURL}/api`;
|
||||||
|
|
||||||
|
export class HTTPError extends Error {
|
||||||
|
response: Response;
|
||||||
|
|
||||||
|
data: any;
|
||||||
|
|
||||||
|
constructor(response: Response, data: any) {
|
||||||
|
super(`HTTP server responded with error: ${response.statusText}`);
|
||||||
|
|
||||||
|
this.response = response;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJson(
|
||||||
|
input: RequestInfo,
|
||||||
|
init?: RequestInit,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(input, init);
|
||||||
|
|
||||||
|
// if the server replies, there's always some data in json
|
||||||
|
// if there's a network error, it will throw at the previous line
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HTTPError(response, data);
|
||||||
|
} catch (error) {
|
||||||
|
if (!error.data) {
|
||||||
|
error.data = { message: error.message };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDirect(relativeURL: string) {
|
||||||
|
return fetchJson(`${apiDirectURL}/${relativeURL}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// async function get(relativeURL: any) {
|
||||||
|
// return fetchJson(`${apiURL}/${relativeURL}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
export async function getIndex(): Promise<VideoOnDemandIndex> {
|
||||||
|
return getDirect('index.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVideos(id: string): Promise<VideoList> {
|
||||||
|
return getDirect(`videos/${id}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDownloadURL(id: string, fileName: string): string {
|
||||||
|
return [upstreamURL, encodeURIComponent(id), encodeURIComponent(fileName)]
|
||||||
|
.join('/');
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface VideoEntry {
|
||||||
|
fileName:string
|
||||||
|
title: string
|
||||||
|
duration?: number | string,
|
||||||
|
sourceVideoURL: string
|
||||||
|
sourceVideoStart: number | string
|
||||||
|
sourceVideoEnd: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoList {
|
||||||
|
lastUpdatedAt: string
|
||||||
|
videos: Array<VideoEntry>
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface VideoOnDemandIdentifier {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoOnDemandIndex {
|
||||||
|
ids: Array<VideoOnDemandIdentifier>
|
||||||
|
servers: {
|
||||||
|
dash: string
|
||||||
|
hls: string
|
||||||
|
thumbnails: string
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
export function getThumbnailURL(
|
||||||
|
thumbnailServerURL: string,
|
||||||
|
id: string,
|
||||||
|
fileName: string,
|
||||||
|
offset: number|string,
|
||||||
|
resizeparams: {
|
||||||
|
width?: number,
|
||||||
|
height?: number,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
// Example: https://thumb-gdq-a.edge.streaminginter.net/agdq2020vods/000.%20Pre%2dpreshow.mp4/thumb-90000-w240.jpg
|
||||||
|
|
||||||
|
// thumb-<offset>[<resizeparams>].jpg
|
||||||
|
|
||||||
|
const resizeparamsStr = Object.entries(resizeparams)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
switch (key) {
|
||||||
|
case 'width':
|
||||||
|
return `w${value}`;
|
||||||
|
case 'height':
|
||||||
|
return `h${value}`;
|
||||||
|
default:
|
||||||
|
throw new Error(`unsupported resizeparam: ${key}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((v) => !!v)
|
||||||
|
.join('-');
|
||||||
|
|
||||||
|
return `${thumbnailServerURL}/${[
|
||||||
|
id,
|
||||||
|
fileName,
|
||||||
|
`thumb-${offset}${resizeparamsStr.length > 0 ? `-${resizeparamsStr}` : ''}.jpg`,
|
||||||
|
]
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHLSMasterURL(
|
||||||
|
hlsServerURL: string,
|
||||||
|
id: string,
|
||||||
|
fileName: string,
|
||||||
|
) {
|
||||||
|
return `${hlsServerURL}/${[
|
||||||
|
id,
|
||||||
|
fileName,
|
||||||
|
'master.m3u8',
|
||||||
|
]
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDASHManifestURL(
|
||||||
|
dashServerURL:string,
|
||||||
|
id:string,
|
||||||
|
fileName:string,
|
||||||
|
) {
|
||||||
|
return `${dashServerURL}/${[
|
||||||
|
id,
|
||||||
|
fileName,
|
||||||
|
'manifest.mpd',
|
||||||
|
]
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default null;
|
||||||
|
|
||||||
|
export * as api from './api';
|
||||||
|
export * as localization from './localization';
|
||||||
|
export * as session from './session';
|
||||||
|
export * as status from './status';
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { shouldPolyfill } from '@formatjs/intl-numberformat/should-polyfill';
|
||||||
|
import '@formatjs/intl-numberformat/polyfill';
|
||||||
|
import '@formatjs/intl-numberformat/locale-data/de';
|
||||||
|
import '@formatjs/intl-numberformat/locale-data/en';
|
||||||
|
import { UnboxPromise } from './types';
|
||||||
|
|
||||||
|
export const defaultLocale = 'en';
|
||||||
|
export const defaultLocaleDescription = 'English';
|
||||||
|
|
||||||
|
export const availableLocales: Array<string> = [
|
||||||
|
defaultLocale,
|
||||||
|
'de',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const localeDescriptions: Record<string, string> = {
|
||||||
|
[defaultLocale]: defaultLocaleDescription,
|
||||||
|
de: 'German',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadLocalePolyfill(
|
||||||
|
locale: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// if (shouldPolyfill()) {
|
||||||
|
// // Load the polyfill 1st BEFORE loading data
|
||||||
|
// console.log('Loading intl-numberformat polyfill');
|
||||||
|
// await import('@formatjs/intl-numberformat/polyfill');
|
||||||
|
// } else {
|
||||||
|
// console.log('Skipping intl-numberformat polyfill');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (Object.keys(Intl.NumberFormat).includes('polyfilled')) {
|
||||||
|
// console.log('Intl.NumberFormat is polyfilled, let\'s load intl-numberformat locale data');
|
||||||
|
// switch (locale) {
|
||||||
|
// case 'de':
|
||||||
|
// await import('@formatjs/intl-numberformat/locale-data/de');
|
||||||
|
// break;
|
||||||
|
// default:
|
||||||
|
// await import('@formatjs/intl-numberformat/locale-data/en');
|
||||||
|
// break;
|
||||||
|
// }
|
||||||
|
// console.log('intl-numberformat locale data loaded');
|
||||||
|
// } else {
|
||||||
|
// console.log('Intl.NumberFormat is native');
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLocaleData(
|
||||||
|
locale: string,
|
||||||
|
) {
|
||||||
|
let localeData;
|
||||||
|
|
||||||
|
switch (locale) {
|
||||||
|
case 'de':
|
||||||
|
localeData = await import('../compiled-lang/de.json');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
localeData = await import('../compiled-lang/en.json');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return localeData.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPolyfilled(): boolean {
|
||||||
|
return !Object.keys(Intl.NumberFormat).includes('polyfilled');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPolyfillPhaseDone(): boolean {
|
||||||
|
// return shouldPolyfill() && isPolyfilled();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocaleData = UnboxPromise<ReturnType<typeof loadLocaleData>>;
|
|
@ -0,0 +1,12 @@
|
||||||
|
export default function parseBool(value: string|number) {
|
||||||
|
switch (value.toString().toLowerCase()) {
|
||||||
|
case '':
|
||||||
|
case '0':
|
||||||
|
case 'false':
|
||||||
|
case 'n':
|
||||||
|
case 'no':
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
const sanitizeFileName = (name: string) => name
|
||||||
|
.replace(/[<>:"/\\|?*%() ]/g, '_');
|
||||||
|
export default sanitizeFileName;
|
|
@ -0,0 +1,5 @@
|
||||||
|
import urlSlug from 'url-slug';
|
||||||
|
|
||||||
|
const sanitizeTitle = urlSlug;
|
||||||
|
|
||||||
|
export default sanitizeTitle;
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { withIronSession } from 'next-iron-session';
|
||||||
|
import type { Session } from 'next-iron-session';
|
||||||
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import { NextApiResponse, NextPageContext } from 'next';
|
||||||
|
import { DocumentContext } from 'next/document';
|
||||||
|
|
||||||
|
export interface IncomingMessageWithSession extends IncomingMessage {
|
||||||
|
body: any;
|
||||||
|
session: Session
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HandlerWithDocumentContextAndSession<T> = (
|
||||||
|
ctx: DocumentContext & {
|
||||||
|
req: IncomingMessageWithSession
|
||||||
|
},
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
export type HandlerWithPageContextAndSession<T> = (
|
||||||
|
ctx: NextPageContext & {
|
||||||
|
req: IncomingMessageWithSession
|
||||||
|
},
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
export type HandlerWithReqResAndSession<T> = (
|
||||||
|
req: IncomingMessageWithSession,
|
||||||
|
res: NextApiResponse
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
export type HandlerWithSession<T, U> =
|
||||||
|
T extends IncomingMessageWithSession
|
||||||
|
? HandlerWithReqResAndSession<U>
|
||||||
|
: T extends NextPageContext
|
||||||
|
? HandlerWithPageContextAndSession<U>
|
||||||
|
: HandlerWithDocumentContextAndSession<U>;
|
||||||
|
|
||||||
|
export default function withSession<T>(
|
||||||
|
handler: (
|
||||||
|
req: IncomingMessageWithSession,
|
||||||
|
res: NextApiResponse,
|
||||||
|
...args: any) => Promise<T>,
|
||||||
|
): (...args: any) => Promise<T> {
|
||||||
|
return withIronSession(handler, {
|
||||||
|
cookieName: 'gdq-archive',
|
||||||
|
cookieOptions: {
|
||||||
|
// the next line allows to use the session in non-https environements like
|
||||||
|
// Next.js dev mode (http://localhost:3000)
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: true,
|
||||||
|
},
|
||||||
|
password: process.env.SECRET_PASSWORD || '0'.repeat(32),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import Head from 'next/head';
|
||||||
|
import DefaultErrorPage from 'next/error';
|
||||||
|
|
||||||
|
export function notFound() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
</Head>
|
||||||
|
<DefaultErrorPage statusCode={404} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forbidden() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<meta name="robots" content="noindex" />
|
||||||
|
</Head>
|
||||||
|
<DefaultErrorPage statusCode={403} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export type UnboxPromise<T extends Promise<any>> = T extends Promise<infer U> ? U: never;
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,4 @@
|
||||||
|
.docker*
|
||||||
|
Docker*
|
||||||
|
|
||||||
|
example/
|
|
@ -0,0 +1,59 @@
|
||||||
|
FROM icedream/nginx AS ffmpeg-build
|
||||||
|
|
||||||
|
RUN echo "@testing http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
|
||||||
|
RUN apk update
|
||||||
|
RUN apk add sudo alpine-sdk git fdk-aac@testing fdk-aac-dev@testing
|
||||||
|
|
||||||
|
# Prepare abuild
|
||||||
|
WORKDIR /usr/src
|
||||||
|
RUN chown 999:0 /usr/src
|
||||||
|
RUN adduser -h /usr/src -S -u 999 apk
|
||||||
|
RUN addgroup apk abuild
|
||||||
|
RUN echo "apk ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||||
|
USER apk
|
||||||
|
RUN abuild-keygen -a -i
|
||||||
|
|
||||||
|
# Check out aports
|
||||||
|
RUN git config --global user.name Docker
|
||||||
|
RUN git config --global user.email "$(whoami)@localhost"
|
||||||
|
RUN git clone --depth=1 -b $(. /etc/os-release && echo "$VERSION_ID" | grep -o '^[0-9]\+.[0-9]\+')-stable https://gitlab.alpinelinux.org/alpine/aports.git
|
||||||
|
WORKDIR /usr/src/aports
|
||||||
|
|
||||||
|
# Apply package changes
|
||||||
|
COPY patches/aports /patches/
|
||||||
|
RUN git am /patches/*.patch
|
||||||
|
|
||||||
|
WORKDIR /usr/src/aports/community/ffmpeg-serverkomplex
|
||||||
|
#RUN sed -i 's,^license=.\+$,license="non-free",g' APKBUILD
|
||||||
|
RUN sed -i 's,!check,!check !checkroot,g' APKBUILD
|
||||||
|
#RUN apkgrel -a .
|
||||||
|
RUN abuild -r
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
FROM alpine AS module-source
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
RUN git config --global user.name Docker
|
||||||
|
RUN git config --global user.email "$(whoami)@localhost"
|
||||||
|
WORKDIR /usr/src
|
||||||
|
RUN git clone --depth=1 --recursive https://github.com/kaltura/nginx-vod-module.git
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
FROM icedream/nginx
|
||||||
|
|
||||||
|
COPY --from=module-source /usr/src/ /usr/src/nginx-modules/
|
||||||
|
COPY --from=0 /usr/src/packages/ /packages/
|
||||||
|
COPY --from=0 /etc/apk/keys/ /etc/apk/keys/
|
||||||
|
RUN echo "http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
|
||||||
|
RUN sed -i '1s#^#/packages/community\n#' /etc/apk/repositories
|
||||||
|
RUN cat /etc/apk/repositories
|
||||||
|
RUN apk update
|
||||||
|
RUN apk add ffmpeg-serverkomplex ffmpeg-serverkomplex-dev openssl openssl-dev fdk-aac
|
||||||
|
RUN \
|
||||||
|
docker-nginx-download-source &&\
|
||||||
|
docker-nginx-build \
|
||||||
|
--add-dynamic-module=../nginx-modules/nginx-vod-module --with-file-aio &&\
|
||||||
|
rm -rf /packages
|
||||||
|
RUN sed -i '1s#^#load_module modules/ngx_http_vod_module.so;\n#' /etc/nginx/nginx.conf
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
build: ..
|
||||||
|
volumes:
|
||||||
|
- ./conf.d:/etc/nginx/conf.d/:ro
|
||||||
|
- /srv/nfs4/gdq-archive:/videos:ro
|
|
@ -0,0 +1,95 @@
|
||||||
|
From f79a9b7aee7894803aa6a754894a566d19b126ee Mon Sep 17 00:00:00 2001
|
||||||
|
From: Carl Kittelberger <icedream@icedream.pw>
|
||||||
|
Date: Mon, 17 Aug 2020 02:17:15 +0200
|
||||||
|
Subject: [PATCH] Build non-free binary with fdk-aac support.
|
||||||
|
|
||||||
|
---
|
||||||
|
...util-clean-up-unused-FF_SYMVER-macro.patch | 0
|
||||||
|
.../{ffmpeg => ffmpeg-serverkomplex}/APKBUILD | 20 ++++++++++++++++---
|
||||||
|
2 files changed, 17 insertions(+), 3 deletions(-)
|
||||||
|
rename community/{ffmpeg => ffmpeg-serverkomplex}/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch (100%)
|
||||||
|
rename community/{ffmpeg => ffmpeg-serverkomplex}/APKBUILD (93%)
|
||||||
|
|
||||||
|
diff --git a/community/ffmpeg/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch b/community/ffmpeg-serverkomplex/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch
|
||||||
|
similarity index 100%
|
||||||
|
rename from community/ffmpeg/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch
|
||||||
|
rename to community/ffmpeg-serverkomplex/0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch
|
||||||
|
diff --git a/community/ffmpeg/APKBUILD b/community/ffmpeg-serverkomplex/APKBUILD
|
||||||
|
similarity index 93%
|
||||||
|
rename from community/ffmpeg/APKBUILD
|
||||||
|
rename to community/ffmpeg-serverkomplex/APKBUILD
|
||||||
|
index 601dd210831..bf2505beadf 100644
|
||||||
|
--- a/community/ffmpeg/APKBUILD
|
||||||
|
+++ b/community/ffmpeg-serverkomplex/APKBUILD
|
||||||
|
@@ -2,19 +2,20 @@
|
||||||
|
# Contributor: Łukasz Jendrysik <scadu@yandex.com>
|
||||||
|
# Contributor: Jakub Skrzypnik <j.skrzypnik@openmailbox.org>
|
||||||
|
# Maintainer: Natanael Copa <ncopa@alpinelinux.org>
|
||||||
|
-pkgname=ffmpeg
|
||||||
|
+pkgname="ffmpeg-serverkomplex"
|
||||||
|
pkgver=4.3.1
|
||||||
|
pkgrel=0
|
||||||
|
pkgdesc="Complete and free Internet live audio and video broadcasting solution for Linux/Unix"
|
||||||
|
url="https://ffmpeg.org/"
|
||||||
|
arch="all"
|
||||||
|
-license="GPL-2.0-or-later AND LGPL-2.1-or-later"
|
||||||
|
+license="GPL-2.0-or-later AND LGPL-2.1-or-later AND non-free"
|
||||||
|
options="!check" # tests/data/hls-lists.append.m3u8 fails
|
||||||
|
subpackages="$pkgname-dev $pkgname-doc $pkgname-libs"
|
||||||
|
makedepends="
|
||||||
|
alsa-lib-dev
|
||||||
|
coreutils
|
||||||
|
bzip2-dev
|
||||||
|
+ fdk-aac-dev
|
||||||
|
gnutls-dev
|
||||||
|
imlib2-dev
|
||||||
|
lame-dev
|
||||||
|
@@ -132,6 +133,7 @@ build() {
|
||||||
|
--enable-gnutls \
|
||||||
|
--enable-gpl \
|
||||||
|
--enable-libass \
|
||||||
|
+ --enable-libfdk-aac \
|
||||||
|
--enable-libmp3lame \
|
||||||
|
--enable-libvorbis \
|
||||||
|
--enable-libvpx \
|
||||||
|
@@ -141,6 +143,7 @@ build() {
|
||||||
|
--enable-libtheora \
|
||||||
|
--enable-libv4l2 \
|
||||||
|
--enable-libdav1d \
|
||||||
|
+ --enable-nonfree \
|
||||||
|
--enable-postproc \
|
||||||
|
--enable-pic \
|
||||||
|
--enable-pthreads \
|
||||||
|
@@ -169,17 +172,28 @@ check() {
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
+ provides="ffmpeg"
|
||||||
|
make DESTDIR="$pkgdir" install install-man
|
||||||
|
install -D -m755 tools/qt-faststart "$pkgdir/usr/bin/qt-faststart"
|
||||||
|
# strip --strip-debug "$pkgdir"/usr/lib/*.a
|
||||||
|
}
|
||||||
|
|
||||||
|
+prepare() {
|
||||||
|
+ mv ffmpeg-*/ "${builddir}"
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
libs() {
|
||||||
|
pkgdesc="Libraries for ffmpeg"
|
||||||
|
- replaces="ffmpeg"
|
||||||
|
+ replaces="ffmpeg-serverkomplex"
|
||||||
|
+ provides="ffmpeg-libs"
|
||||||
|
mkdir -p "$subpkgdir"/usr
|
||||||
|
mv "$pkgdir"/usr/lib "$subpkgdir"/usr
|
||||||
|
}
|
||||||
|
|
||||||
|
+dev() {
|
||||||
|
+ provides="ffmpeg-dev"
|
||||||
|
+ default_dev
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
sha512sums="64e1052c45145e27726e43d4fe49c9a92058e55562d34fd3b3adf54d3506e6bd680f016b748828215e1bfc8ce19aa85b6f7e4eb05fafe21479118a4ad528a81f ffmpeg-4.3.1.tar.xz
|
||||||
|
1047a23eda51b576ac200d5106a1cd318d1d5291643b3a69e025c0a7b6f3dbc9f6eb0e1e6faa231b7e38c8dd4e49a54f7431f87a93664da35825cc2e9e8aedf4 0001-libavutil-clean-up-unused-FF_SYMVER-macro.patch"
|
||||||
|
--
|
||||||
|
2.28.0
|
||||||
|
|
Loading…
Reference in New Issue