Browse Source

Brought back the original development from when I wasn't using the git upstream/origin setup.

dev
Zed A. Shaw 1 month ago
parent
commit
e632e8b5db
  1. 26
      README.md
  2. 84
      api/questions.js
  3. 37
      api/related_streams.js
  4. 29
      api/series.js
  5. 28
      client/bando/demos/Code.svelte.md
  6. 83
      client/components/Schedule.svelte
  7. 115
      client/components/SeriesRelated.svelte
  8. 375
      client/pages/LiveStream.svelte
  9. 265
      client/pages/ReplayStream.svelte
  10. 105
      client/pages/Series.svelte
  11. 2
      client/pages/admin/Tests.svelte
  12. 2
      client/pages/admin/index.svelte
  13. BIN
      docs/Ac437_IBM_EGA_9x14-2x.ttf
  14. 35
      lib/models.js
  15. 26
      migrations/20210421194255_live_stream_page_tables.cjs
  16. 17
      migrations/20210422222507_questions.cjs
  17. 18214
      package-lock.json
  18. 5
      package.json
  19. 12
      rendered/pages/livecoding/container.html
  20. 42
      rendered/pages/livecoding/index.svelte
  21. 13
      scripts/install_deploy.sh
  22. 28
      scripts/install_root.sh
  23. 2
      scripts/templates/client/routes.js
  24. 1
      scripts/templates/rendered/index.svelte
  25. 26
      static/color.css
  26. 74
      static/colors.css
  27. 27
      static/global.css
  28. BIN
      static/images/1600x900.png
  29. BIN
      static/images/800x450.png
  30. 12
      static/livecoding/container.html
  31. BIN
      static/livecoding/cover.jpeg
  32. BIN
      static/livecoding/cover.png
  33. 2
      static/monochrome.css
  34. 8
      tests/fixtures/streams/spinner.json
  35. 36
      tests/models/livestream.js
  36. 66
      tests/models/questions.js
  37. 18
      tests/ui/chat.js
  38. 28
      tests/ui/livestream.js

26
README.md

@ -1,25 +1 @@
Install
===
You can install the project with a few commands:
```
git clone --depth 1 git@git.learnjsthehardway.com:zedshaw/ljsthw-project-template.git yourproject
cd yourproject
npm install .
./scripts/init.js
```
You can then either `rm -rf .git` and make your own git, or you can make a branch:
```
git checkout -b yourproject
```
You'll also want to disable push so you don't accidentally keep trying to push to the remote.
```
git config branch.master.pushRemote no_push
```
I'm actually not sure if this is needed but it should be an extra help to keep your from trying.
The code for zedshaw.games.

84
api/questions.js

@ -0,0 +1,84 @@
import { Question, User, LiveStream } from "../lib/models.js";
import * as queues from "../lib/queues.js";
import logging from '../lib/logging.js';
import assert from 'assert';
import { API } from '../lib/api.js';
const log = logging.create(import.meta.url);
export const get = async (req, res) => {
const api = new API(req, res);
try {
let stream_id = req.query.stream_id;
assert(stream_id !== undefined, "stream_id is undefined");
log.debug(`Finding questions for stream ${stream_id}`);
let questions = await Question.all({stream_id});
for(let question of questions) {
if(api.admin_authenticated) {
question.poster = await question.poster(['id', 'initials', 'full_name', 'admin']);
} else {
question.poster = await question.poster(['id', 'initials', 'admin']);
}
log.debug(`Poster of question ${question.id} with poster_id ${question.poster_id} is ${JSON.stringify(question.poster)}`);
}
api.reply(200, {questions});
} catch (error) {
log.error(error);
api.error(500, error);
}
}
export const post = async (req, res) => {
const api = new API(req, res);
try {
let stream_id = req.query.stream_id;
assert(stream_id !== undefined, "stream_id is undefined");
const stream = await LiveStream.first({id: stream_id});
assert(stream !== undefined, `no stream found for id ${stream_id}`);
const question = await Question.insert({
stream_id,
poster_id: req.user.id,
content: req.body.content
});
log.debug(question);
api.reply(200, question);
} catch(error) {
log.error(error);
api.error(500, error);
}
}
post.authenticated = true;
export const del = async (req, res) => {
const api = new API(req, res);
try {
let question_id = req.query.question_id;
assert(question_id !== undefined, "question_id is undefined");
assert(api.admin_authenticated, `Only administrators can delete questions. ${JSON.stringify(req.user)}`);
const count = await Question.delete({id: question_id});
if(count == 0) {
api.reply(404, {message: "question does not exist", id: question_id});
} else {
api.reply(200, {message: "OK", count});
}
} catch (error) {
log.error(error);
api.error(500, error);
}
}
del.authenticated = true;

37
api/related_streams.js

@ -0,0 +1,37 @@
import * as models from "../lib/models.js";
import * as queues from "../lib/queues.js";
import logging from '../lib/logging.js';
import assert from 'assert';
import { API } from '../lib/api.js';
import { series_data } from '../temp_data.js';
import _ from 'lodash';
const log = logging.create(import.meta.url);
export const get = async (req, res) => {
const api = new API(req, res);
let { series_id, limit } = req.query;
let reply;
try {
series_id = parseInt(series_id);
limit = parseInt(limit);
assert(!isNaN(limit), "A string is expected for limit.");
if(series_id !== undefined && !isNaN(series_id)) {
// series given so exclude that one
let excluded = series_data.filter(s => s.id != series_id);
reply = _.flatten(excluded.map(s => s.streams)).slice(0, limit);
} else {
// no series given so don't exclude, just return a batch
reply = _.flatten(series_data.map(s => s.streams)).slice(0, limit);
}
api.reply(200, reply);
} catch (error) {
log.error(error);
api.error(500, error);
}
}

29
api/series.js

@ -0,0 +1,29 @@
import * as models from "../lib/models.js";
import * as queues from "../lib/queues.js";
import logging from '../lib/logging.js';
import assert from 'assert';
import { API } from '../lib/api.js';
import { series_data } from '../temp_data.js';
const log = logging.create(import.meta.url);
export const get = async (req, res) => {
const api = new API(req, res);
let { series_id } = req.query;
try {
if(series_id !== undefined) {
series_id = parseInt(series_id, 10);
assert(!isNaN(series_id), "series_id must be a number");
let reply = series_data[series_id];
api.reply(200, reply);
} else {
api.reply(200, series_data);
}
} catch (error) {
log.error(error);
api.error(500, error);
}
}

28
client/bando/demos/Code.svelte.md

@ -2,7 +2,9 @@ The `Code` component simplifies displaying code with line numbers and letting pe
The CSS that makes this magic happen is:
<pre><code data-language="css">pre code span::before {
<pre>
<code data-language="css">
pre code span::before {
counter-increment: line;
content: counter(line);
display: inline-block;
@ -21,27 +23,3 @@ The CSS that makes this magic happen is:
This uses the `::before`` selector to add a line number counter to the front of each span, and then create a thin line. This is why you can use your mouse to select the lines of code but the _line numbers_ won't be selected (even if some browsers show you they are being selected).
To make it easier on people it will also just copy the code when you click on it and then do a `Toast` alert.
CodeFormatter
===
If you want your code to have color then you need to import `CodeFormatter` and at the bottom add `<CodeFormatter />`. This triggers the [rainbow](https://craig.is/making/rainbows) colorization code and CSS. It's done this way so you don't have to include it on every page even if it's not used.
<pre><code data-language="javascript">
&lt;script>
import CodeFormatter from "$/client/components/CodeFormatter.svelte";
&lt;/script>
&lt;CodeFormatter />
</code></pre>
Once you do that you can use a pre/code combination like this:
<pre><code data-language="html">
&lt;pre>&lt;code data-language="javascript">
&lt;script>
import CodeFormatter from "$/client/components/CodeFormatter.svelte";
&lt;/script>
&lt;CodeFormatter />
&lt;/code>&lt;/pre>
</code></pre>

83
client/components/Schedule.svelte

@ -0,0 +1,83 @@
<script>
import { user } from '../stores.js';
export let series_list = [];
export let short = false;
</script>
<style>
schedule {
display: flex;
justify-content: space-evenly;
align-items: center;
flex-direction: column;
}
schedule h1 {
margin-block-start: 0em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
}
schedule table tbody tr td.stream-time {
background-color: var(--color-bg-tertiary);
}
schedule table tfoot {
background-color: var(--color-bg-tertiary);
}
schedule table tfoot tr td {
border-top: 1px solid var(--color-accent);
padding: 2rem;
text-align: center;
border-radius: 0px 0px 5px 5px;
}
</style>
<schedule id="schedule">
{#if short}
<h3>Streaming Schedule</h3>
{:else}
<h1>Streaming Schedule</h1>
<p>This schedule is fake just for the UI design.</p>
{/if}
<table>
<thead>
<tr>
<th>Game</th>
<th>Mon</th>
<th>Tue</th>
<th>Wed</th>
<th>Thu</th>
<th>Fri</th>
<th>Sat</th>
<th>Sun</th>
</tr>
</thead>
<tbody>
{#each series_list as series}
<tr>
<th>{series.headline}</th>
{#each [0,1,2,3,4,5,6] as day}
{#if series.stream_day === day}
<td class="stream-time">{series.stream_time}</td>
{:else}
<td></td>
{/if}
{/each}
</tr>
{/each}
</tbody>
{#if !$user.authenticated}
<tfoot>
<tr>
<td colspan="8">
<a href="/client/#/register/"><button>Register for Notifications</button></a>
<h5>If you want to be notified when I stream then register and I'll send you an email to remind you.</h5>
</td>
</tr>
</tfoot>
{/if}
</table>
</schedule>

115
client/components/SeriesRelated.svelte

@ -0,0 +1,115 @@
<script>
import api from "../api.js";
import Spinner from "../components/Spinner.svelte";
import { link } from 'svelte-spa-router';
export let series = {}
export let limit = 12;
let related_streams = [];
const series_url = () => {
if(series.id) {
// a series was given so related it to that
return `/api/related_streams?limit=${limit}`;
} else {
// no series given so just get a list of other videos
return `/api/related_streams?series_id=${series.id}&limit=${limit}`;
}
}
const load_related_streams = async (series_id) => {
let [status, data] = await api.get(series_url());
if(status == 200) {
related_streams = data;
} else {
related_streams = [];
}
}
let related_streams_promise = load_related_streams(series.id);
</script>
<style>
series {
display: flex;
flex-direction: column;
}
series hr {
margin-top: 1.5rem;
margin-bottom: 1rem;
}
series h4 {
margin-top: 0px;
margin-bottom: 0px;
}
series stream {
display: flex;
margin-top: 0.3rem;
}
series stream img {
min-width: 168px;
max-width: 168px;
}
series stream info {
padding-left: 0.2rem;
}
series stream info h4 {
line-height: 1.1em;
margin: 0px;
font-size: 1.2em;
}
series a {
text-decoration: none;
}
series stream info span {
color: var(--color-inactive);
}
</style>
<series>
{#if series.headline && series.id !== undefined}
<h4>More <em>{ series.headline }</em> Videos</h4>
{#each series.streams as stream}
<a use:link href="/series/{ stream.series_id }/stream/{ stream.id }/">
<stream>
<figure>
<img src="{ stream.thumbnail }">
</figure>
<info>
<h4>{ stream.headline }</h4>
<span>{ series.headline }</span>
</info>
</stream>
</a>
{/each}
<hr>
{/if}
<h4>Videos You Might Enjoy</h4>
{#await related_streams_promise}
<Spinner color="var(--color-accent)" size={ 64 } />
{:then}
{#each related_streams as stream}
<a use:link href="/series/{ stream.series_id }/stream/{ stream.id }/">
<stream>
<figure>
<img src="{ stream.thumbnail }">
</figure>
<info>
<h4>{ stream.headline }</h4>
</info>
</stream>
</a>
{/each}
{/await}
</series>

375
client/pages/LiveStream.svelte

@ -0,0 +1,375 @@
<script>
import Video from "$/client/components/Video.svelte";
import Chat from "$/client/components/Chat.svelte";
import Icon from "$/client/components/Icon.svelte";
import Modal from "$/client/components/Modal.svelte";
import Spinner from "$/client/components/Spinner.svelte";
import Login from "$/client/components/Login.svelte";
import Layout from "$/client/Layout.svelte";
import { onMount } from "svelte";
import { fade } from "svelte/transition";
import SeriesRelated from "$/client/components/SeriesRelated.svelte";
import Schedule from "$/client/components/Schedule.svelte";
import { defer } from "$/client/helpers.js";
import { user, socket, reconnect_socket } from "../stores";
import api from "../api.js";
// use simple default values rather than an await block
let stream = {
"video_source": "",
"video_poster": "",
"video_background": "var(--color-accent)",
"headline": "",
"stream_id": 0,
"series_id": 1,
"starts_on": false
}
let question_input="";
let login_pulse = false;
let login_dialog = false;
let no_stream = false;
let questions_promise = defer("questions_promise");
let video_stream_promise = defer("video_stream_promise");
let questions = [];
const load_series_list = async () => {
let [status, data] = await api.get('/api/series');
return status == 200 ? data : [];
}
// just a simple load of a .json file for now
const load_stream = async () => {
let [status, data] = await api.get(`/streams/dev.json`);
if(status == 200) {
stream = data;
// convert the stream start on date
let starts_on = new Date(stream.starts_on);
// check that it's formatted correctly or set, this test is odd
// because new Date(false) will produce a valid date since false is truthy for 0
if(!stream.starts_on) {
// nothing was set in the stream spec so no stream
no_stream = true
} else if(isNaN(starts_on)) {
// invalid date format so warn and no stream
console.log("Invalid date format", stream.starts_on);
stream.starts_on = false;
no_stream = true;
} else {
// all good, set the date to the conversion
stream.starts_on = starts_on;
no_stream = false;
}
} else {
console.log("Error getting stream data", status, data);
}
}
const load_questions = async () => {
let [status, data] = await api.get(`/api/questions?stream_id=${stream.stream_id}`);
if(status == 200) {
questions = data.questions;
} else {
console.log("ERROR getting questions", status, data);
}
}
const post_question = async (message) => {
let [status, data] = await api.post(`/api/questions?stream_id=${stream.stream_id}`, message);
console.log("/api/questions", status, data);
}
const ask_question = async () => {
if(question_input.trim() !== "") {
let msg = { user_id: $user.id, content: question_input};
// TODO: kind of weird to post it and also message it so review this
await post_question(msg);
$socket.emit('/chat/question', msg);
question_input = "";
}
}
$socket.on("/chat/question", (data) => {
questions.push(data);
questions = questions;
});
const delete_question = async (question) => {
questions = questions.filter(q => q !== question);
let [status, data] = await api.del(`/api/questions?question_id=${question.id}`);
console.log("/api/questions", status, data);
}
const user_authenticated = () => {
login_dialog = false;
// need to reconnect the socket after they auth to upgrade it
reconnect_socket();
}
onMount(async () => {
// have to block on loading the stream first *then* we can load the qeustions
await load_stream();
video_stream_promise.resolve();
await load_questions();
questions_promise.resolve();
});
</script>
<style>
main {
display: flex;
flex-direction: row;
}
left {
display: flex;
flex-direction: column;
width: 100%;
min-width: 770px;
margin-right: 0.5rem;
}
right {
display: flex;
min-width: 300px;
max-width: 300px;
flex-direction: column;
margin-left: 0.5rem;
}
description {
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: 0.8rem;
width: 100%;
margin-bottom: 1rem;
padding-bottom: 1rem;
padding-top: 1rem;
border-bottom: 1px solid var(--color-border);
}
description streamer {
display: flex;
flex-direction: row;
}
description streamer avatar {
background-color: var(--color-bg-secondary);
border-radius: 50%;
height: 40px;
width: 40px;
padding: 8px;
margin-right: 1rem;
}
description info name {
font-weight: bold;
}
description info {
display: flex;
flex-direction: column;
}
description buttons {
}
questions {
display: flex;
flex-direction: column;
margin-right: 0.5rem;
margin-top: 0.5rem;
}
questions ask {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: baseline;
justify-content: space-evenly;
}
questions ask button {
display: flex;
flex-wrap: nowrap;
padding-left: 0.5em;
padding-right: 0.5em;
padding-top: 0.1em;
padding-bottom: 0.1em;
margin-left: 0.5rem;
border: 1px solid var(--color-bg-secondary);
font-size: 1.2em;
}
questions ask input {
border: 1px solid var(--color-bg-tertiary);
}
questions question {
font-size: 1.1em;
padding: 0.3rem;
margin-bottom: 0.4rem;
margin-top: 1rem;
}
questions question top {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
}
questions question top from {
font-style: italic;
color: var(--color-accent);
font-size: 0.9rem;
}
questions question p {
margin: unset;
}
@media only screen and (max-width: 900px) {
left {
min-width: unset;
margin-right: 0;
}
left questions {
display: none;
}
right {
max-width: unset;
}
main {
display: flex;
flex-direction: column;
}
series {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
}
}
series a:hover {
text-decoration: none;
}
@media only screen and (max-width: 600px) {
series {
display: flex;
flex-direction: column;
margin-top: 0.5rem;
}
}
</style>
<Layout testid="livestream-page">
<main data-testid="home-page">
<left>
{#await video_stream_promise}
<Spinner color="var(--color-bg)" size="128" />
{:then}
{#if no_stream}
<h1 data-testid="no-stream">No live stream scheduled.</h1>
<p>There is no live stream scheduled in the near future. Feel free to check out some of the
clips of past streams and to look at the to see when I
stream next.</p>
{#await load_series_list() then series_list}
<Schedule series_list={ series_list } short={ true } />
{/await}
{:else}
<Video starts_on={ stream.starts_on } poster={ stream.video_poster } background_color={ stream.video_background } source={ stream.video_source }/>
<description>
<streamer>
<avatar><Icon size="40" name="user" /></avatar>
<info>
<name>Zed A. Shaw</name>
<headline>{ stream.headline }</headline>
</info>
</streamer>
<buttons><Icon name="heart" /></buttons>
</description>
<questions>
{#if $user.authenticated}
<ask>
<input type="text" name="question_input" placeholder="Ask a question..." bind:value={ question_input }>
<button on:click={ ask_question }>
Ask <Icon name="help-circle" color="var(--color-text-inverted)" />
</button>
</ask>
{:else}
<ask on:mouseover={ () => login_pulse = true } on:mouseout={ () => login_pulse = false }>
<input type="text" name="question_input" placeholder="To ask a question please register or..." disabled>
<button class:pulse={ login_pulse } on:click={ () => login_dialog = true }>
Login...
</button>
</ask>
{/if}
{#await questions_promise}
<h1>Loading questions...</h1>
{:then}
{#each questions as question, i}
<question in:fade>
<top>
{#if $user.authenticated && $user.admin}
<icon on:click={ () => delete_question(question) }><Icon name="trash"/></icon>
{:else}
<icon><Icon name="help-circle"/></icon>
{/if}
{#if $user.admin && question.poster.full_name}
<from>{question.poster.full_name} ({question.poster.initials})</from>
{:else}
<from>Asked by {question.poster.initials}</from>
{/if}
</top>
<p>{question.content}</p>
</question>
{/each}
{:catch}
<h1>Error loading questions. Sorry.</h1>
{/await}
</questions>
{/if}
{/await}
</left>
<right>
<Chat />
<SeriesRelated limit={6} />
</right>
{#if login_dialog}
<Modal on:close={ () => login_dialog = false }>
<Login on:authenticated={ user_authenticated }
on:canceled={ () => login_dialog = false }
/>
</Modal>
{/if}
</main>
</Layout>

265
client/pages/ReplayStream.svelte

@ -0,0 +1,265 @@
<script>
import Video from '../components/Video.svelte';
import Layout from "$/client/Layout.svelte";
import Icon from '../components/Icon.svelte';
import Spinner from "$/client/components/Spinner.svelte";
import SeriesRelated from "$/client/components/SeriesRelated.svelte";
import { fade } from 'svelte/transition';
import { user } from '$/client/stores.js';
import api from "$/client/api.js";
export let params = {};
let video_background = "var(--color-bg-secondary)";
let questions = [];
// need a bit of defaults to deal with the loading delay
let this_series = { streams: [] };
let this_stream = { };
let this_series_promise;
const load_stream = (series, stream_id) => series.streams.filter(s => s.id == stream_id)[0];
const load_this_series = async (series_id) => {
let [status, data] = await api.get(`/api/series?series_id=${series_id}`);
if(status == 200) {
this_series = data;
this_stream = load_stream(this_series, params.stream_id);
// i get this sometimes so I have to log it until I find the cause
if(this_stream === undefined) {
console.error(data, "Should have found stream stream_id", params.stream_id);
}
const colors = this_stream.colors;
video_background = `linear-gradient(120deg, rgb(${colors[0].join(',')}) 32%, rgb(${colors[2].join(',')}) 63%)`
} else {
console.log("ERROR getting series", status, data);
}
}
$: {
this_series_promise = load_this_series(params.series_id);
}
const load_questions = async () => {
let [status, data] = await api.get(`/api/questions?stream_id=${params.stream_id}`);
questions = [];
if(status == 200) {
questions = data.questions;
} else {
console.log("ERROR getting questions", status, data);
}
}
this_series_promise = load_this_series(params.series_id);
</script>
<style>
main {
display: flex;
flex-direction: row;
}
left {
display: flex;
flex-direction: column;
width: 100%;
min-width: 770px;
margin-right: 0.5rem;
}
right {
display: flex;
min-width: 300px;
max-width: 300px;
flex-direction: column;
margin-left: 0.5rem;
}
description {
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: 0.8rem;
width: 100%;
margin-bottom: 1rem;
padding-bottom: 1rem;
padding-top: 1rem;
border-bottom: 1px solid var(--color-border);
}
description streamer {
display: flex;
flex-direction: row;
}
description streamer avatar {
background-color: var(--color-bg-secondary);
border-radius: 50%;
height: 40px;
width: 40px;
padding: 8px;
margin-right: 1rem;
}
description info name {
font-weight: bold;
}
description info {
display: flex;
flex-direction: column;
}
description buttons {
}
questions {
display: flex;
flex-direction: column;
margin-right: 0.5rem;
margin-top: 0.5rem;
}
questions ask {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: baseline;
justify-content: space-evenly;
}
questions ask button {
display: flex;
flex-wrap: nowrap;
padding-left: 0.5em;
padding-right: 0.5em;
padding-top: 0.1em;
padding-bottom: 0.1em;
margin-left: 0.5rem;
border: 1px solid var(--color-bg-secondary);
font-size: 1.2em;
}
questions ask input {
border: 1px solid var(--color-bg-tertiary);
}
questions question {
font-size: 1.1em;
padding: 0.3rem;
margin-bottom: 0.4rem;
margin-top: 1rem;
}
questions question top {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
}
questions question top from {
font-style: italic;
color: var(--color-accent);
font-size: 0.9rem;
}
questions question p {
margin: unset;
}
@media only screen and (max-width: 900px) {
left {
min-width: unset;
margin-right: 0;
}
left questions {
display: none;
}
right {
max-width: unset;
}
main {
display: flex;
flex-direction: column;
}
series {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
}
}
series a:hover {
text-decoration: none;
}
@media only screen and (max-width: 600px) {
series {
display: flex;
flex-direction: column;
margin-top: 0.5rem;
}
}
</style>
<Layout>
<main data-testid="replaystream-page">
<left>
{#await this_series_promise}
<Spinner color="var(--color-bg)" size="128" />
{:then}
<Video poster={ this_stream.thumbnail } background_color={ video_background } source={ this_stream.video }/>
<description>
<streamer>
<avatar><Icon size="40" name="user" /></avatar>
<info>
<name>Zed A. Shaw</name>
<headline>{ this_series.headline }</headline>
</info>
</streamer>
<buttons><Icon name="heart" /></buttons>
</description>
{/await}
<questions>
{#await load_questions()}
<h1>Loading questions...</h1>
{:then}
{#each questions as question, i}
<question in:fade>
<top>
<icon><Icon name="help-circle"/></icon>
{#if $user.admin && question.poster.full_name}
<from>{question.poster.full_name} ({question.poster.initials})</from>
{:else}
<from>Asked by {question.poster.initials}</from>
{/if}
</top>
<p>{question.content}</p>
</question>
{:else}
<h1>No questions were asked during this stream.</h1>
<p>During a live stream you can ask questions and they'll be shown here with the answers. Next time there's a stream consider asking a question.</p>
{/each}
{:catch}
<h1>Error loading questions. Sorry.</h1>
{/await}
</questions>
</left>
<right>
<SeriesRelated series={ this_series} limit={12} />
</right>
</main>
</Layout>

105
client/pages/Series.svelte

@ -0,0 +1,105 @@
<script>
import { user } from "../stores";
import { link } from "svelte-spa-router";
import SnapImage from '../components/SnapImage.svelte';
import { onMount } from "svelte";
import Icon from "../components/Icon.svelte";
import api from "../api.js";
export let params = {};
let series = {
streams: []
}
onMount(async () => {
// need get params but mock doesn't care
let [status, data] = await api.get(`/api/series?series_id=${params.series_id}`);
console.log(data.streams);
if(status == 200) {
series = data;
} else {
console.error(stats, "Failed to load series", data);
}
});
</script>
<style>
main {
display: flex;
flex-direction: row;
}
info {
display: flex;
flex-direction: column;
font-size: 1.5em;
flex-basis: 40%;
padding: 1rem;
}
info description {
margin-top: 1rem;
}
posts {
flex-basis: 60%;
}
posts post {
display: flex;
flex-direction: column;
margin-top: 1em;
}
post info {
display: flex;
flex-direction: column;
background-color: var(--color-bg-secondary);
}
post info h3 {
margin-top: 0;
margin-bottom: 0;
}
@media only screen and (max-width: 900px) {
main {
flex-direction: column;
font-size: 0.9em;
}
}
@media only screen and (max-width: 600px) {
main {
flex-direction: column;
font-size: 0.8em;
}
}
</style>
<main>
<info>
<h1>{ series.headline }</h1>
<name>Zed A. Shaw</name>
<description>
{ series.description }
</description>
</info>
<posts>
{#each series.streams as stream, stream_id}
<post>
<a href="/series/{ series.id }/stream/{ stream.id }" use:link>
<SnapImage width="900" height="450" colors={ stream.colors } src={ stream.thumbnail } />
</a>
<info>
<h3>{ stream.headline }</h3>
</info>
</post>
{/each}
</posts>
</main>

2
client/pages/admin/Tests.svelte

@ -38,7 +38,7 @@
<callout class="info">
<span>
<b>NOTE:</b> This page basically does nothing except provide access to
<b>NOTE:</b> This page basically does nothing except provide access to
things for the unit test in tests/ui/admin_tests.js. It has a mock
on purpose to test that they keep working, and does asserts as well.
You can delete this if you also delete the test.

2
client/pages/admin/index.svelte

@ -51,7 +51,7 @@
}
</style>
<Layout authenticated={ true } testid="page-admin-table-index">
<Layout testid="page-admin-table-index">
<buttons>
<span on:click={ () => show_all = !show_all }><Icon name={ show_all ? "eye-off" : "eye" } /> <span>
</buttons>

BIN
docs/Ac437_IBM_EGA_9x14-2x.ttf

Binary file not shown.

35
lib/models.js

@ -102,8 +102,13 @@ export class User extends Model.from_table('user') {
return 1;
}
}
}
async livestreams() {
return this.has_many(LiveStream, {streamer_id: this.id});
}
}
export class Payment extends Model.from_table('payment') {
user() {
@ -163,3 +168,31 @@ export class Site extends Model.from_table("site") {
return await knex(this.table_name).where({key}).decrement("value", count);
}
}
export class Series extends Model.from_table('series') {
async user() {
return this.has_one(User, {id: this.user_id});
}
}
export class LiveStream extends Model.from_table('livestream') {
async series() {
return this.has_one(Series, {id: this.series_id});
}
async questions(columns) {
return this.has_many(Question, {stream_id: this.id}, columns);
}
}
export class Question extends Model.from_table('question') {
async poster(columns) {
assert(this.poster_id !== undefined, "The Question object doesn't have a poster_id field. Did you use columns param to remove it?");
let poster = this.has_one(User, {id: this.poster_id}, columns);
return poster;
}
async stream() {
return this.has_one(LiveStream, {id: this.stream_id});
}
}

26
migrations/20210421194255_live_stream_page_tables.cjs

@ -0,0 +1,26 @@
exports.up = function(knex) {
const series = knex.schema.createTable('series', (table) => {
table.increments('id');
table.text('headline');
table.integer('user_id');
table.foreign('user_id').references('id').inTable('user');
})
const livestream = knex.schema.createTable('livestream', (table) => {
table.increments('id');
table.integer('series_id');
table.foreign('series_id').references('id').inTable('series');
table.text('headline');
table.integer('likes').default(0);
table.datetime('scheduled_for').defaultTo(knex.fn.now())
});
return Promise.all([series, livestream]);
};
exports.down = function(knex) {
return knex.schema.dropTable('livestream');
return knex.schema.dropTable('series');
};

17
migrations/20210422222507_questions.cjs

@ -0,0 +1,17 @@
exports.up = function(knex) {
return knex.schema.createTable('question', (table) => {
table.increments('id');
table.integer('poster_id').notNullable();
table.foreign('poster_id').references('id').inTable('user');
table.text('content').notNullable();
table.integer('stream_id').notNullable();
table.foreign('stream_id').references('id').inTable('stream');
table.datetime('posted_on').defaultTo(knex.fn.now())
})
};
exports.down = function(knex) {
return knex.schema.dropTable('question');
};

18214
package-lock.json

File diff suppressed because it is too large

5
package.json

@ -1,5 +1,5 @@
{
"name": "ljsthw-test",
"name": "zedshaw.games",
"version": "0.3.0",
"private": true,
"watch": {
@ -46,14 +46,13 @@
"browser-sync": "^2.26.14",
"codejar": "^3.4.0",
"colorthief": "^2.3.2",
"dainte": "^0.1.5",
"eslint": "^7.32.0",
"eslint-plugin-svelte3": "^3.2.0",
"html-minifier-terser": "^5.1.1",
"istanbul-lib-coverage": "^3.0.2",
"istanbul-lib-report": "^3.0.0",
"istanbul-reports": "^3.0.5",
"normalize-path": "^3.0.0",
"html-minifier-terser": "^5.1.1",
"npm-run-all": "^4.1.5",
"random": "^3.0.6",
"rollup": "^2.39.0",

12
rendered/pages/livecoding/container.html

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<body>
<style type="text/css">
</style>
<script id='player_embed' src='//player.cloud.wowza.com/hosted/ysdqtzjw/wowza.js' type='text/javascript'></script>
<div id='wowza_player'></div>
</body>
</html>

42
rendered/pages/livecoding/index.svelte

@ -0,0 +1,42 @@
<svelte:head>
<title>Zed's Gaming Vlog: Meta Live Coding</title>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
<meta name="twitter:card" content="player" />
<meta name="twitter:site" content="@lzsthw" />
<meta name="twitter:title" content="Live Streaming my Development of My Gaming Vlog" />
<meta name="twitter:description" content="This is a coding live stream of Zed Shaw creating zedshaw.games in JavaScript." />
<meta name="twitter:image" content="https://zedshaw.games/livecoding/cover.jpeg" />
<meta name="twitter:player" content="https://zedshaw.games/livecoding/container.html" />
<meta name="twitter:player:width" content="480" />
<meta name="twitter:player:height" content="480" />
<script id='player_embed' src='//player.cloud.wowza.com/hosted/ysdqtzjw/wowza.js' type='text/javascript'></script>
</svelte:head>
<style>
player {
width: 100%;
background-color: black;
}
</style>
<script>
import Layout from '$/rendered/Layout.svelte';
</script>
<Layout>
<header>
<player>
<div id='wowza_player'></div>
</player>
</header>
<main>
<h1>The Reliable Static Viewer</h1>
<p>You are viewing the static page that will always work. If you want to try the app as I work on it, go to <a href="/client/#/watch/">/client/#/watch/</a> and expect it to restart often. You can watch without registering, you just can't chat.</p>
<h2>Rephresh if "Stream Unavailable"</h2>
<p>This page is very stripped down to keep it working all the time, which means you have to refresh it if the video says "Stream Unavailable".</p>
</main>
</Layout>

13
scripts/install_deploy.sh

@ -0,0 +1,13 @@
# deploy user script
ssh-keygen
cat ~/.ssh/id_rsa.pub
# pause to copy key into gitea or, include a default id_rsa set
read -p "Add this key to the gitea."
git clone git@git.learnjsthehardway.com:zedshaw/zedshaw.games.git
cd zedshaw.games/
npm install .
npx knex --knexfile knexfile.cjs migrate:latest

28
scripts/install_root.sh

@ -0,0 +1,28 @@
set -x
apt install git redis vim
# install into debian
apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo apt-key add -
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee -a /etc/apt/sources.list.d/caddy-stable.list
apt update
apt install caddy
# copy over the Caddyfile
cat << EOF > /etc/caddy/Caddyfile
http://zedshaw.games {
reverse_proxy /socket.io/* 127.0.0.1:5001
reverse_proxy /* 127.0.0.1:5001
}
EOF
# install node
curl -LO https://nodejs.org/dist/v14.16.1/node-v14.16.1-linux-x64.tar.xz
tar -xf node-v14.16.1-linux-x64.tar.xz
cd node-v14.16.1-linux-x64/
cp -r bin include lib share /usr/local/
# setup the deploy user
adduser --disabled-password --gecos "" deploy

2
scripts/templates/client/routes.js

@ -4,7 +4,6 @@ import AdminIndex from './pages/admin/index.svelte';
import AdminCreate from './pages/admin/Create.svelte';
import AdminTable from './pages/admin/Table.svelte';
import AdminReadUpdate from './pages/admin/ReadUpdate.svelte';
import AdminTests from './pages/admin/Tests.svelte';
import UserProfile from './pages/UserProfile.svelte';
import ResetPassword from './pages/ResetPassword.svelte';
import Unsubscribe from './pages/Unsubscribe.svelte';
@ -29,7 +28,6 @@ export default {
'/admin/table/:table/': AdminTable,
'/admin/table/:table/:row_id/': AdminReadUpdate,
'/admin/table/': AdminIndex,
'/admin/tests/': AdminTests,
/* #if process.env.DANGER_ADMIN
'/admin/email/': EmailConfig,
'/bando/components/:name?': Components,

1
scripts/templates/rendered/index.svelte

@ -1,7 +1,6 @@
<script>
import Layout from '../Layout.svelte';
import IconImage from '$/client/components/IconImage.svelte';
import OGPreview from "$/client/components/OGPreview.svelte";
import { developer_admin } from "$/lib/api.js";
const components = [

26
static/color.css

@ -5,21 +5,21 @@
:root {
--value9: hsl(0, 0%, 100%); /* not used too often */
--value8: #f8f9fa;
--value7: #e9ecef;
--value6: #dee2e6;
--value5: #ced4da;
--value4: #adb5bd;
--value3: #6c757d;
--value2: #495057;
--value1: #343a40;
--value0: #212529;
--value8: #faffff;
--value7: #dfe3e4;
--value6: #c5c8c8;
--value5: #aaacad;
--value4: #757576;
--value3: #5a5a5a;
--value2: #3f3e3f;
--value1: #252323;
--value0: #0a0708;
--orange: #fb5607;
--yellow: #ffbe0b;
--red: #ff006e;
--purple: #8338ec;
--blue: #3a86ff;
--green: #6a994e;
--red: #d00000;
--purple: #9649cb;
--blue: #3f88c5;
--green: #136f63;
--border-radius: 5px;
--box-shadow: 0 2px 2px;
--color: var(--value0);

74
static/colors.css

@ -0,0 +1,74 @@
:root {
--border-radius: 5px;
--box-shadow: 0px 2px 2px;
--color: hsl(220, 16%, 22%);
--color-accent: hsl(220, 16%, 7%);
--color-inactive: hsl(218, 27%, 60%);
--color-inactive-secondary: hsl(219, 28%, 100%);
--color-bg: hsl(218, 27%, 99%);
--color-bg-secondary: hsl(218, 27%, 94%);
--color-bg-tertiary: hsl(219, 28%, 88%);
--color-secondary: hsl(220, 17%, 32%);
--color-secondary-accent: hsl(220, 17%, 40%);
--color-shadow: hsl(218, 27%, 87%);
--color-shadow-secondary: hsl(218, 27%, 95%);
--color-shadow-dark: hsl(220, 16%, 53%);
--color-bg-inverted: hsl(220, 16%, 33%);
--color-text: hsl(220, 16%, 22%);
--color-text-inverted: hsl(218, 0%, 95%);
--color-bright: hsl(0, 0%, 95%);
--color-input-button: hsl(193, 43%, 87%);
--color-input-border: hsl(210, 34%, 63%);
--color-input-bg: hsl(218, 27%, 94%);
--color-border: hsl(0, 0%, 90%);
--color-error: hsl(354, 32%, 46%);
--color-warning: hsl(14, 41%, 53%);
--color-good: hsl(92, 18%, 55%);
--color-info: hsl(311, 10%, 53%);
--color-pulse-1: hsla(311, 20%, 53%, 1);
--color-pulse-2: hsla(311, 20%, 53%, 0.7);
--color-pulse-3: hsla(311, 20%, 53%, 0.1);
--color-pulse-4: hsla(311, 20%, 53%, 0);
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
--font-button-size: 1.4em;
--hover-brightness: 1.2;
--justify-important: center;
--justify-normal: left;
--line-height: 1.5;
--width-card: 285px;
--width-card-medium: 460px;
--width-card-wide: 800px;
--width-content: 1080px;
}
[data-theme="dark"] {
--color: hsl(218, 27%, 99%);
--color-accent: hsl(210, 34%, 63%);
--color-bg: hsl(220, 16%, 22%);
--color-bg-secondary: hsl(222, 16%, 33%);
--color-bg-tertiary: hsl(222, 16%, 50%);
--color-shadow-secondary: hsl(222, 16%, 20%);
--color-secondary: hsl(218, 27%, 87%);
--color-secondary-accent: hsl(218, 27%, 87%);
--color-shadow: hsl(222, 16%, 5%);
--color-text: hsl(218, 16%, 98%);
--color-text-secondary: hsl(218, 27%, 67%);
--color-text-inverted: hsl(222, 16%, 20%);
--color-inactive: hsl(222, 16%, 40%);
--color-bg-inverted: hsl(218, 27%, 95%);
--color-input-border: hsl(193, 43%, 87%);
--color-input-button: hsl(210, 34%, 63%);
--color-input-bg: hsl(219, 28%, 88%);
--color-border: hsl(0, 0%, 33%);
--color-error: hsl(354, 52%, 66%);
--color-warning: hsl(14, 61%, 73%);
--color-good: hsl(92, 38%, 75%);
--color-info: hsl(311, 30%, 73%);
--color-pulse-1:hsla(222, 16%, 80%, 1);
--color-pulse-2: hsla(222, 16%, 80%, 0.7);
--color-pulse-3: hsla(222, 16%, 80%, 0.1);
--color-pulse-4: hsla(222, 16%, 80%, 0);
}

27
static/global.css

@ -1,5 +1,5 @@
/* Heavily modified from the wonderful MVP.css v1.6.2 - https://github.com/andybrewer/mvp */
@import 'monochrome.css';
@import 'color.css';
/* You can remove this if you don't do any code in your project. */
@import '/css/rainbow/theme.css';
@ -97,10 +97,6 @@ section aside {
width: var(--width-card);
}
section aside:hover {
box-shadow: var(--box-shadow) var(--color-bg-secondary);
}
section aside img {
max-width: 100%;
}
@ -1150,6 +1146,10 @@ hero figure {
padding: 0;
}
hero:hover figure {
z-index: -1;
}
hero > cover {
display: flex;
flex-direction: column;
@ -1164,6 +1164,12 @@ hero > cover {
font-size: 4vw;
color: var(--color-overlay-text);
transition: background 0.5s ease-out;
opacity: 0;
}
hero:hover cover {
opacity: 1;
z-index: 1;
}
hero cover h1 {
@ -1171,9 +1177,16 @@ hero cover h1 {
margin-bottom: 0;
}
hero cover a i {
hero cover:hover {
background: none;
}
hero cover:hover a i {
opacity: 100%;
}
hero over a i {
color: var(--color-text-inverted);
background-color: var(--color-bg-inverted);
border: 2px solid var(--value0);
text-shadow: none;
font-size: 0.5em;

BIN
static/images/1600x900.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
static/images/800x450.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

12
static/livecoding/container.html

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<body>
<style type="text/css">
</style>
<script id='player_embed' src='//player.cloud.wowza.com/hosted/ysdqtzjw/wowza.js' type='text/javascript'></script>