first working version

This commit is contained in:
Thomas Ruoff
2020-11-28 00:28:38 +01:00
parent 8762aef219
commit a17153759e
22 changed files with 1592 additions and 214 deletions

2
.gitignore vendored
View File

@@ -32,3 +32,5 @@ yarn-error.log*
# vercel
.vercel
.tool-versions
.log

View File

@@ -1,30 +1,7 @@
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).
# Bello's Adventskalender
## Getting Started
# Getting Youtube playlist data
First, run the development server:
Get data in the [Api Explorer](https://developers.google.com/youtube/v3/docs/playlistItems/list?apix_params=%7B%22part%22%3A%5B%22snippet%22%5D%2C%22maxResults%22%3A24%2C%22playlistId%22%3A%22PLrhIlImke6F4uwg70DQmzGFOKIZdTpwPv%22%7D#usage)
```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.
Then pipe it to `./scripts/getSongs.sh` and copy it into `./pages/api/songs.js`

42
components/calendar.js Normal file
View File

@@ -0,0 +1,42 @@
import React, { useContext } from "react";
import AppContext from "../context/app";
const Calendar = () => {
const { loading, songs, openDoor } = useContext(AppContext);
if (loading || !songs) {
return null;
}
return (
<div>
<ul className="cal">
{songs.map((song, index) => {
const classes = ["calcard", song.locked && "calcard__locked"]
.filter(Boolean)
.join(" ");
return (
<li
className={classes}
data-id={index}
key={song.id}
onClick={() => !song.locked && openDoor(index)}
title={
song.locked &&
`Es ist noch nicht der ${new Date(
song.lockedUntil
).toLocaleDateString()}! Geduld, nur Gedult!` || index + 1
}
>
<span className="cardnumber">{index + 1}</span>
</li>
);
})}
</ul>
</div >
);
};
export default Calendar;

36
components/controls.js vendored Normal file
View File

@@ -0,0 +1,36 @@
import React, { useEffect, useState, Fragment } from "react";
export const ENDED = 0;
export const PLAYING = 1;
export const PAUSED = 2;
const Controls = ({ playerState, onPause, onPlay, onRestart }) => {
return (
<Fragment>
<div className="player-controls">
{playerState === PLAYING && (
<a onClick={onPause} title="Pause">
</a>
)}
{playerState === PAUSED && (
<a onClick={onPlay} title="Play">
</a>
)}
{[PLAYING, PAUSED].includes(playerState) && (
<a onClick={onRestart} title="Von Vorne">
</a>
)}
{playerState === ENDED && (
<a onClick={onPlay} title="Play">
</a>
)}
</div>
</Fragment>
);
};
export default Controls;

27
components/header.js Normal file
View File

@@ -0,0 +1,27 @@
import React, { useEffect, useState } from "react";
const Header = () => {
const [gifUrl, setGifUrl] = useState(null);
useEffect(() => {
async function getGif() {
const response = await fetch(
"//api.giphy.com/v1/gifs/random?tag=christmas&api_key=3ziHSa4ptYJdv2dOuawgzpBhhiW09Ss1"
);
const { data } = await response.json();
setGifUrl(data.image_mp4_url);
}
getGif();
}, []);
return (
<div className="header">
<h1>Bello's Adventskalender 2020</h1>
{gifUrl && (
<video className="yay-gif-video" src={gifUrl} autoPlay loop muted />
)}
</div>
);
};
export default Header;

28
components/nav.js Normal file
View File

@@ -0,0 +1,28 @@
import React from "react";
const links = [
{ href: "https://youtube.com", label: "Youtube" },
{ href: "https://www.discogs.com/", label: "Discog" },
{ href: "https://findmusicbylyrics.com/", label: "Find music by Lyrics" },
{ href: "https://musicbrainz.org/", label: "MusicBrainz" }
].map(link => {
link.key = `nav-link-${link.href}-${link.label}`;
return link;
});
const Nav = () => (
<nav className="ad-nav">
<ul>
<li>Für die Recherche:</li>
{links.map(({ key, href, label }) => (
<li key={key}>
<a href={href} target="_blank">
{label}
</a>
</li>
))}
</ul>
</nav>
);
export default Nav;

64
components/player.js Normal file
View File

@@ -0,0 +1,64 @@
import React, { useEffect, useState, Fragment, useContext } from "react";
import youtubePlayer from "youtube-player";
import AppContext from "../context/app";
import Controls, { ENDED, PLAYING, PAUSED } from "./controls";
let player;
const Player = ({ hidden }) => {
const [playerState, setPlayerState] = useState(-1);
const { openSong } = useContext(AppContext);
const onPause = () => player && player.pauseVideo();
const onPlay = () => player && player.playVideo();
const onRestart = () => {
player && player.seekTo(openSong.startSeconds || 0);
player && [PAUSED, ENDED].includes(playerState) && player.playVideo();
};
useEffect(onRestart, [!hidden]);
useEffect(() => {
const setState = ({ data }) => setPlayerState(data);
player = youtubePlayer("player", {
height: "390",
width: "640"
});
player.on("stateChange", setState);
player.loadVideoById({
videoId: openSong.id,
startSeconds: openSong.startSeconds,
endSeconds: openSong.endSeconds
});
return () => {
player = null;
setPlayerState(null);
};
}, []);
return (
<Fragment>
<div className={hidden ? "hidden" : undefined}>
<div id="player" />
</div>
{hidden && playerState === -1 && <span>🎶 Titel laden...</span>}
{hidden && playerState > -1 && (
<Fragment>
<span>🎶 Titel spielt</span>
<Controls
playerState={playerState}
onPause={onPause}
onPlay={onPlay}
onRestart={onRestart}
/>
</Fragment>
)}
</Fragment>
);
};
export default Player;

View File

@@ -0,0 +1,57 @@
import React, { useState, useContext } from "react";
import AppContext from "../context/app";
import Player from "./player";
const ShowDoorDialog = () => {
const { loading, openDoor, openSong, openSongIndex } = useContext(AppContext);
const [showSolution, setShowSolution] = useState(false);
const [showHint, setShowHint] = useState(false);
const handleClose = () => {
openDoor(null);
setShowSolution(false);
};
if (loading || !openSong) {
return null;
}
const { title, hint, id } = openSong;
return (
<div className="door-mask" onClick={handleClose}>
<div className="door" onClick={event => event.stopPropagation()}>
<a className="door-close" onClick={handleClose}>
</a>
<h1>Türchen {openSongIndex + 1}</h1>
<Player hidden={!showSolution} />
{!showSolution && (
<div>
{hint && !showHint && (
<p>
<a className="link" onClick={() => setShowHint(true)}>
Ich brauche einen Tip!
</a>
</p>
)}
{hint && showHint && (
<div className="hint">
<h4>Tip:</h4>
<p>{hint}</p>
</div>
)}
<p>
<a className="solve" onClick={() => setShowSolution(true)}>
Zeig mir die Lösung!
</a>
</p>
</div>
)}
</div>
</div>
);
};
export default ShowDoorDialog;

5
context/app.js Normal file
View File

@@ -0,0 +1,5 @@
import React from "react";
const AppContext = React.createContext();
export default AppContext;

50
context/appProvider.js Normal file
View File

@@ -0,0 +1,50 @@
import React, { useEffect, useState } from "react";
import AppContext from "./app";
const AppProvider = ({ debug, children }) => {
const [songs, setSongs] = useState(null);
const [openSong, setOpenSong] = useState(null);
const URL = debug ? "/api/songs?unlock=true" : "/api/songs";
useEffect(() => {
async function getSongs() {
const response = await fetch(URL);
if (!response.ok) {
setError(`HTTP Status of youtube request: ${response.status}`);
return;
}
const { songs } = await response.json();
setSongs(songs);
}
getSongs();
}, []);
const openDoor = index => {
if (!Number.isInteger(index) || !songs[index]) {
setOpenSong(null);
}
setOpenSong(songs[index]);
};
return (
<AppContext.Provider
value={{
loading: !songs,
songs,
openSong,
openSongIndex: songs && songs.indexOf(openSong),
openDoor
}}
>
{children}
</AppContext.Provider>
);
};
export default AppProvider;

2
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/types/global" />

914
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,14 @@
"start": "next start"
},
"dependencies": {
"luxon": "^1.25.0",
"next": "10.0.3",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-nested": "^5.0.1",
"postcss-preset-env": "^6.7.0",
"react": "17.0.1",
"react-dom": "17.0.1"
"react-dom": "17.0.1",
"youtube-player": "^5.5.2"
},
"devDependencies": {
"@types/node": "^14.14.10",

View File

@@ -1,4 +1,5 @@
import '../styles/globals.css'
import "../styles/styles.css";
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />

View File

@@ -1,6 +0,0 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
export default (req, res) => {
res.statusCode = 200
res.json({ name: 'John Doe' })
}

125
pages/api/songs.js Normal file
View File

@@ -0,0 +1,125 @@
const { DateTime } = require("luxon");
const SONGS = [
{
id: "Bys-OE_C7lQ",
title: "A Christmas Twist - Si Cranstoun",
startSeconds: 4,
},
{
id: "lheIRyhsUSE",
title: "EAV - Fata Morgana 1986",
},
{
id: "Dqn01vCQnUs",
title: "Sheena Easton - My Baby Takes The Morning Train (with Lyric)HQ",
},
{
id: "b_BzMuOAKUs",
title: "Rod Stewart - Have I Told You Lately",
},
{
id: "3D-j9PcOPAk",
title: "Deifedanz",
},
{
id: "9HvpIgHBSdo",
title: 'Gregory Porter - "Be Good (Lion\'s Song)" Official Video',
},
{
id: "3FKA-3uRdQY",
title:
"Doris Day - Whatever Will Be Will Be Que Sera Sera (Best All Time Hits Forever 2014 / HQ) Mu©o",
},
{
id: "WIoHSu5v1Mo",
title: "The Specials - Message to you Rudy",
},
{
id: "6ZwjdGSqO0k",
title: "George Harrison - Got My Mind Set On You (Version I)",
},
{
id: "lAj-Q_W9AT4",
title: "George Strait - Write This Down (Closed Captioned)",
},
{
id: "YiadNVhaGwk",
title: "Chuck Berry - Run Rudolph Run (Official Video)",
},
{
id: "_6FBfAQ-NDE",
title: "Depeche Mode - Just Can't Get Enough (Official Video)",
},
{
id: "X22vAmpZSdY",
title: "MIEKE TELKAMP ★★★ TULPEN AUS AMSTERDAM ★★★ 1961",
},
{
id: "uvGvmsLQaHA",
title: "Elvis Presley - Green Green Grass Of Home (best video)",
},
{
id: "5vheNbQlsyU",
title:
"Lady Gaga - Always Remember Us This Way (from A Star Is Born) (Official Music Video)",
},
{
id: "4WM_R-6AKHE",
title: "Mockingbird - Carly Simon & James Taylor",
},
{
id: "MgIwLeASnkw",
title: "Elmo & Patsy - Grandma Got Run over by a Reindeer",
},
{
id: "SLErVV1TxUI",
title: "Gänsehaut - Karl der Käfer 1983",
},
{
id: "js-2cqqY1K8",
title: "The Beautiful South - Perfect 10 (Official Video)",
},
{
id: "S6nSpBiekzQ",
title: "Les Négresses Vertes - Zobi La Mouche (Official Music Video)",
},
{
id: "z0qW9P-uYfM",
title: "Elton John - Don't Go Breaking My Heart (with Kiki Dee)",
},
{
id: "Pgqa3cVOxUc",
title: "The Undertones - My Perfect Cousin (Official Video)",
},
{
id: "lLLL1KxpYMA",
title: "Madness - Night Boat to Cairo (Official HD Video)",
},
{
id: "qTx-sdR6Yzk",
title: 'Dropkick Murphys - "The Season\'s Upon Us" (Video)',
},
];
export default async (req, res) => {
const unlocked = !!req.query.unlock;
const NOW = DateTime.utc().setZone("Europe/Berlin");
const getLockedData = (index) => {
const dayString = ("00" + (index + 1)).substr(-2, 2);
const lockedUntilDateTime = DateTime.fromISO(
`${NOW.year}-12-${dayString}T00:00:00.000+01:00`
);
return {
locked: unlocked ? false : NOW < lockedUntilDateTime,
lockedUntil: lockedUntilDateTime.toISO(),
};
};
const songs = SONGS.map((item, index) => ({
...item,
...getLockedData(index),
}));
res.json({ songs });
};

8
pages/debug.js Normal file
View File

@@ -0,0 +1,8 @@
import React from "react";
import Home from "./index";
const HomeDebug = () => {
return <Home debug={true} />;
};
export default HomeDebug;

View File

@@ -1,65 +1,25 @@
import Head from 'next/head'
import styles from '../styles/Home.module.css'
import Head from "next/head";
export default function Home() {
import AppProvider from "../context/appProvider";
import Header from "../components/header";
import Calendar from "../components/calendar";
import ShowDoorDialog from "../components/showdoordialog";
import Nav from "../components/nav";
export default function Home({ debug = false }) {
return (
<div className={styles.container}>
<div>
<Head>
<title>Create Next App</title>
<title>Bello's Adventskalender 2020</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<p className={styles.description}>
Get started by editing{' '}
<code className={styles.code}>pages/index.js</code>
</p>
<div className={styles.grid}>
<a href="https://nextjs.org/docs" className={styles.card}>
<h3>Documentation &rarr;</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href="https://nextjs.org/learn" className={styles.card}>
<h3>Learn &rarr;</h3>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>
<a
href="https://github.com/vercel/next.js/tree/master/examples"
className={styles.card}
>
<h3>Examples &rarr;</h3>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>
<a
href="https://vercel.com/import?filter=next.js&utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
className={styles.card}
>
<h3>Deploy &rarr;</h3>
<p>
Instantly deploy your Next.js site to a public URL with Vercel.
</p>
</a>
<AppProvider debug={debug}>
<Header />
<Calendar />
<ShowDoorDialog />
<Nav />
</AppProvider>
</div>
</main>
<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<img src="/vercel.svg" alt="Vercel Logo" className={styles.logo} />
</a>
</footer>
</div>
)
);
}

18
postcss.config.json Normal file
View File

@@ -0,0 +1,18 @@
{
"plugins": [
"postcss-nested",
"postcss-flexbugs-fixes",
[
"postcss-preset-env",
{
"autoprefixer": {
"flexbox": "no-2009"
},
"stage": 3,
"features": {
"custom-properties": false
}
}
]
]
}

5
scripts/getSongs.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#
# Gets videoIds from a youtube playlistItems response provided on stdin.
jq -M '[.items[] | {id: .snippet.resourceId.videoId, title: .snippet.title }]' <&0

View File

@@ -1,122 +0,0 @@
.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;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}

180
styles/styles.css Normal file
View File

@@ -0,0 +1,180 @@
:root {
--bg-color: #1b5e20;
--text-color: #fff;
--link-color: var(--text-color);
--link-color--hover: #000;
--calcard-color: #fff;
--calcard-bg-color: #c62828;
--calcard-bg-color__locked: #003300;
--door-bg-color: #4c8c4a;
--door-color: #fff;
--player-controls-bg-color: #1b5e20;
--door-hint-bg-color: #c62828;
}
html {
font-family: "Charm", cursive;
font-size: 100%;
}
body {
margin: 0;
padding: 2rem 12rem;
color: var(--text-color);
background-color: var(--bg-color);
position: relative;
}
a {
color: var(--link-color);
cursor: pointer;
&:hover {
color: var(--link-color--hover);
}
}
.hidden {
position: absolute;
left: -5000px;
}
.header {
display: flex;
width: 100%;
h1 {
flex-grow: 1;
font-size: 3rem;
}
.yay-gif-video {
max-height: 12rem;
max-width: 20rem;
}
}
.cal {
padding-left: 0;
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-rows: repeat(4, 6vw);
grid-gap: 2rem;
.calcard {
color: var(--calcard-color);
background-color: var(--calcard-bg-color);
cursor: pointer;
border-radius: 6px;
box-shadow: 2px 2px 8px 3px #000;
display: flex;
justify-content: center;
&__locked {
background-color: var(--calcard-bg-color__locked);
cursor: not-allowed;
}
& .cardnumber {
font-size: 6vw;
}
}
}
.door-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.4);
}
.door {
background-color: var(--door-bg-color);
color: var(--door-color);
padding: 2rem;
text-align: center;
font-size: 1.7rem;
border-radius: 6px;
box-shadow: 2px 2px 8px 3px #000;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
min-width: 30rem;
.solve {
font-size: 2rem;
margin-top: 24px;
}
.hint {
margin-top: 12px;
padding: 12px;
text-align: left;
background-color: var(--door-hint-bg-color);
border-radius: 3px;
box-shadow: 2px 2px 8px 3px #000;
}
.door-close {
font-size: 1.5rem;
position: absolute;
top: 0.5rem;
right: 1rem;
}
.player-controls {
display: inline-flex;
a {
width: 2rem;
margin-left: 16px;
padding: 0 6px;
background-color: var(--player-controls-bg-color);
border-radius: 3px;
box-shadow: 2px 2px 3px 2px #000;
}
}
}
.ad-nav {
& nav {
text-align: center;
}
& ul {
display: flex;
}
& nav > ul {
padding: 4px 16px;
}
& li {
display: flex;
padding: 6px 8px;
}
}