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