reactors
Thomas Hintz 2 years ago
parent 94d2479dc9
commit 620ae4d444

@ -0,0 +1 @@
SITE_ROOT='https://www.thereactshow.com'

3717
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -29,7 +29,11 @@
"nodemailer": "^6.9.1",
"podcast": "^2.0.1",
"postcss-focus-visible": "^6.0.4",
"ra-data-json-server": "^4.9.0",
"ra-data-simple-rest": "^4.9.0",
"ra-input-rich-text": "^4.9.0",
"react": "18.2.0",
"react-admin": "^4.9.0",
"react-aria": "^3.19.0",
"react-dom": "18.2.0",
"react-stately": "^3.17.0",

@ -0,0 +1,200 @@
import * as React from "react";
import {
Admin,
Resource,
List,
Datagrid,
NumberField,
TextField,
DateField,
RichTextField,
UrlField,
ReferenceField,
FileField,
Show,
Edit,
Create,
SimpleForm,
TextInput,
NumberInput,
DateTimeInput,
RadioButtonGroupInput,
SelectInput,
SimpleShowLayout,
useGetList
} from 'react-admin';
import { RichTextInput } from 'ra-input-rich-text';
import simpleRestProvider from 'ra-data-simple-rest';
import { API_ADMIN_ROOT } from '@/paths';
export const UserList = () => (
<List>
<Datagrid>
<TextField source="id" />
<TextField source="email" />
</Datagrid>
</List>
);
export const SubscriptionList = () => (
<List>
<Datagrid>
<TextField source="id" />
<TextField source="uuid" />
<ReferenceField source="user_id" reference="users" label="Email" link="show">
<TextField source="email" />
</ReferenceField>
<DateField source="started_date" />
</Datagrid>
</List>
)
export const EpisodesList = () => (
<List>
<Datagrid rowClick="show">
<TextField source="id" />
<NumberField source="number" />
<TextField source="title" />
<NumberField source="episode" />
<TextField source="slug" />
<TextField source="episode_type" />
<TextField source="buzzsprout_id" />
<DateField source="pub_date" showTime={true} />
</Datagrid>
</List>
);
export const AudioFilesList = () => (
<List>
<Datagrid>
<TextField source="id" />
<FileField source="filename" title="filename" />
</Datagrid>
</List>
);
export const EpisodeShow = (props) => (
<Show {...props}>
<SimpleShowLayout>
<TextField source="id" />
<NumberField source="number" />
<TextField source="title" />
<NumberField source="season" />
<NumberField source="episode" />
<NumberField source="duration" />
<TextField source="slug" />
<TextField source="episode_type" />
<TextField source="buzzsprout_id" />
<UrlField source="buzzsprout_url" />
<UrlField source="youtube_url" />
<UrlField source="audio_url" />
<FileField source="transcript_filename" title="transcript_filename" />
<DateField source="pub_date" showTime={true} />
<RichTextField source="content" />
<TextField source="summary" />
</SimpleShowLayout>
</Show>
);
export const EpisodeEdit = () => {
return (
<Edit>
<SimpleForm>
<TextInput source="number" />
<TextInput source="title" />
<NumberInput source="season" />
<NumberInput source="episode" />
<TextInput source="slug" />
<TextInput source="episode_type" />
<TextInput source="buzzsprout_id" />
<TextInput source="buzzsprout_url" />
<TextInput source="youtube_url" />
<TextInput source="audio_url" />
<TextInput source="transcript_filename" />
<DateTimeInput source="pub_date" />
<RichTextInput source="content" />
<TextInput source="summary" />
</SimpleForm>
</Edit>
);
};
function listElement(arr, proc) {
return arr && arr.length > 0 ? proc(arr[0]) : undefined;
};
export const EpisodeCreate = () => {
const { data: lastNumber } = useGetList(
'episodes',
{
pagination: { page: 1, perPage: 2 },
sort: { field: 'number', order: 'DESC' }
}
);
const { data: lastEpisode } = useGetList(
'episodes',
{
pagination: { page: 1, perPage: 2 },
sort: { field: 'episode', order: 'DESC' }
}
);
const { data: transcriptFiles } = useGetList(
'transcript_files'
);
if (lastNumber && lastEpisode && audioFiles && transcriptFiles) {
return (
<Create>
<SimpleForm>
<TextInput source="number" defaultValue={listElement(lastNumber, x => x.number + 1)} />
<TextInput source="title" fullWidth />
<NumberInput source="season" defaultValue="1" />
<NumberInput source="episode" defaultValue={listElement(lastEpisode, x => x.episode + 1)} />
<TextInput source="slug" fullWidth />
<RadioButtonGroupInput
source="episode_type"
choices={[
{ id: 'full', name: 'full' },
{ id: 'bonus', name: 'bonus' }
]}
defaultValue="full"
/>
<TextInput
source="buzzsprout_id"
label="Buzzsprout ID"
format={v => v && v.split('-').length > 0 ? v.split('-')[1] : ''}
parse={v => `Buzzsprout-${v}`}
defaultValue=""
/>
<TextInput source="buzzsprout_url" fullWidth />
<TextInput source="youtube_url" fullWidth />
<TextInput source="audio_url" fullWidth />
<SelectInput
source="transcript_filename"
choices={transcriptFiles.map(x => { return { id: x.filename, name: x.filename } })}
/>
<DateTimeInput source="pub_date" />
<RichTextInput source="content" />
<TextInput source="summary" fullWidth />
</SimpleForm>
</Create>
);
} else {
return null;
}
};
const App = () => (
<Admin dataProvider={simpleRestProvider(API_ADMIN_ROOT)}>
<Resource name="users" list={UserList} />
<Resource name="subscriptions" list={SubscriptionList} />
<Resource name="episodes" list={EpisodesList} show={EpisodeShow} edit={EpisodeEdit} create={EpisodeCreate} />
<Resource name="audio_files" list={AudioFilesList} />
<Resource name="transcript_files" list={AudioFilesList} />
</Admin>
);
export default App;

@ -33,6 +33,7 @@ export default async function Layout({ children }) {
</form>
</ul>
</nav>
{children}
</>
);
};

@ -1,6 +1,7 @@
import { cookies } from 'next/headers';
import db from '@/db';
import { accountFeedURL } from '@/paths';
import { Container } from '@/components/Container';
@ -19,14 +20,14 @@ async function getSession() {
export default async function Page() {
const userId = await getSession();
const { uuid } = await db.get('select uuid from subscriptions where user_id=?', userId);
return (
<div className="pt-16 pb-12 sm:pb-4 lg:pt-12">
<Container>
<h1 className="text-2xl font-bold leading-7 text-slate-900">
The Reactors
</h1>
<a href="https://buy.stripe.com/test_3cs01j768d65gso289">Level I</a>
{userId && <p>user: {userId}</p>}
{<p>feed URL: <a href={accountFeedURL(uuid)}>{accountFeedURL(uuid)}</a></p>}
</Container>
</div>
);

@ -212,7 +212,7 @@ Object.entries(episodeExtra).forEach(([id, { slug }]) => {
slugToEpisode[slug] = id
})
export async function getEpisodesReal() {
export async function getEpisodes() {
const feedRes = await fetch('https://feeds.buzzsprout.com/1764837.rss', { next: { revalidate: 60 * 10 } });
const feedString = await feedRes.text()
/* const feedString = fs.readFileSync('./feed.rss').toString() */
@ -280,9 +280,9 @@ export async function getEpisodesReal() {
: feedEntries;
}
export async function getEpisodes() {
export async function getEpisodesLocal() {
const dbEpisodes = await db.all('select * from episodes order by number desc;');
return dbEpisodes.map(({ title, pub_date, summary: description, content, slug, duration, filename, number, episode_type, buzzsprout_id, youtube_url, transcript_filename }) => {
return dbEpisodes.map(({ title, pub_date, summary: description, content, slug, duration, filename, number, episode_type, buzzsprout_id, buzzsprout_url, youtube_url, transcript_filename }) => {
const filepath = path.join(process.cwd(), 'public', 'files', 'episodes', filename);
return {
num: number,
@ -291,14 +291,13 @@ export async function getEpisodes() {
description,
content,
published: pub_date,
chapters: [],
chapters: [`https://feeds.buzzsprout.com/1764837/${buzzsprout_id}/chapters.json`],
youtube: youtube_url,
slug,
transcript: transcript_filename,
transcript: transcript_filename ? srtparsejs.parse(fs.readFileSync(path.join(process.cwd(), 'src', 'data', transcript_filename)).toString()) : undefined,
audio: {
src: '',
type: '',
length: '',
src: buzzsprout_url,
type: 'audio/mpeg'
},
};
});

@ -63,6 +63,16 @@ uuid text not null,
started_date text,
foreign key (user_id) references users (id)
)`]
},
{
key: 7,
name: 'set initial last_build_date',
sql: [`insert into feed (last_build_date) values ('Fri, 07 Apr 2000 14:00:00 GMT')`]
},
{
key: 8,
name: 'add audio url column',
sql: [`alter table episodes add column audio_url text;`]
}
];

@ -0,0 +1,8 @@
import dynamic from "next/dynamic"
const App = dynamic(() => import("@/admin/App"), { ssr: false })
const AdminPage = () => {
return <App />;
};
export default AdminPage;

@ -0,0 +1,14 @@
const fs = require('fs');
export default async function handler(req, res) {
if (req.method === 'GET') {
const files = fs.readdirSync('./public/files/episodes', {withFileTypes: true})
.filter(item => !item.isDirectory())
.map(item => item.name);
files.sort();
files.reverse();
res.setHeader('Content-Range', files.length);
res.status(200).json(files.map((f, i) => { return { id: i, filename: f } }));
}
}

@ -0,0 +1,30 @@
import db from '@/db';
const COLS = {};
const COLS_PREFIXED = {};
const COLS_LIST = ['id', 'number', 'content', 'summary', 'slug', 'season', 'episode', 'duration', 'filename', 'title', 'episode_type', 'buzzsprout_id', 'buzzsprout_url', 'pub_date', 'youtube_url', 'transcript_filename', 'audio_url'];
COLS_LIST.forEach((k) => COLS[k] = k)
COLS_LIST.forEach((k) => COLS_PREFIXED[k] = `$${k}`)
export default async function handler(req, res) {
const sessionId = req.cookies?.session;
if (!sessionId) { res.status(404).json({}); return; }
const sessionRes = await db.get('select email from sessions join users on users.id = sessions.user_id where session_id=?;', sessionId);
if (!sessionRes || sessionRes?.email != process.env.ADMIN_EMAIL) { res.status(404).json({}); return; }
const { id } = req.query;
if (req.method === 'GET') {
const episode = await db.get('select * from episodes where id = ?', id);
res.status(200).json(episode)
} else if (req.method === 'PUT') {
const changes = req.body;
const changesForSQL = {};
Object.keys(changes).forEach((k) => changesForSQL[COLS_PREFIXED[k]] = changes[k]);
const { id } = await db.get(`update episodes set ${Object.keys(changes).map((k) => `${COLS[k]} = ${COLS_PREFIXED[k]}`).join(', ')} where id = $id returning id;`, changesForSQL);
const episode = await db.get('select * from episodes where id = ?', id);
res.status(200).json(episode)
} else if (req.method = 'DELETE') {
const episode = await db.get('select * from episodes where id = ?', id);
await db.run('delete from episodes where id = ?', id);
res.status(200).json(episode);
}
}

@ -0,0 +1,42 @@
import db from '@/db';
const SORT_MAP = {
'ASC': 'asc',
'DESC': 'desc'
};
const COLUMN_MAP = {
'id': 'id',
'number': 'number',
'episode': 'episode'
};
const COLS_LIST = ['number', 'content', 'summary', 'slug', 'season', 'episode', 'duration', 'filename', 'title', 'episode_type', 'buzzsprout_id', 'buzzsprout_url', 'pub_date', 'youtube_url', 'transcript_filename', 'audio_url'];
export default async function handler(req, res) {
const sessionId = req.cookies?.session;
if (!sessionId) { res.status(404).json({}); return; }
const sessionRes = await db.get('select email from sessions join users on users.id = sessions.user_id where session_id=?;', sessionId);
if (!sessionRes || sessionRes?.email != process.env.ADMIN_EMAIL) { res.status(404).json({}); return; }
if (req.method === 'GET') {
const { sort, range, filter } = req.query;
const [sortColumn, sortDirection] = sort ? JSON.parse(sort) : [false, false];
const [rangeStart, rangeEnd] = range ? JSON.parse(range) : [false, false];
let rows;
if (sort && range) {
rows = await db.all(`select * from episodes order by ${COLUMN_MAP[sortColumn]} ${SORT_MAP[JSON.parse(sort)[1]]} limit ? offset ?;`, rangeEnd - rangeStart, rangeStart);
} else if (filter) {
const filterParsed = JSON.parse(filter);
rows = await db.all(`select * from episodes where id in (${filterParsed['id'].map(x => '?').join(',')})`, filterParsed['id']);
}
const { count } = await db.get('select count(id) as count from episodes;');
res.setHeader('Content-Range', count);
res.status(200).json(rows)
} else if (req.method === 'POST') {
await db.run(`insert into episodes (${COLS_LIST.join(', ')}) values (${COLS_LIST.map(() => '?').join(', ')});`,
COLS_LIST.map((c) => req.body[c]));
const episode = await db.get('select * from episodes where number = ? and title = ? and slug = ?', req.body['number'], req.body['title'], req.body['slug']);
res.status(200).json(episode);
}
}

@ -0,0 +1,14 @@
import db from '@/db';
export default async function handler(req, res) {
const sessionId = req.cookies?.session;
if (!sessionId) { res.status(404).json({}); return; }
const sessionRes = await db.get('select email from sessions join users on users.id = sessions.user_id where session_id=?;', sessionId);
if (!sessionRes || sessionRes?.email != process.env.ADMIN_EMAIL) { res.status(404).json({}); return; }
if (req.method === 'GET') {
const rows = await db.all('select id, user_id, uuid, started_date from subscriptions;');
res.setHeader('Content-Range', rows.length);
res.status(200).json(rows)
}
}

@ -0,0 +1,15 @@
const fs = require('fs');
export default async function handler(req, res) {
if (req.method === 'GET') {
const filesOrig = fs.readdirSync('./src/data', {withFileTypes: true})
.filter(item => !item.isDirectory())
.map(item => item.name);
const files = filesOrig.filter(f => f.includes('.srt'));
files.sort();
files.reverse();
res.setHeader('Content-Range', files.length);
res.status(200).json(files.map((f, i) => { return { id: i, filename: f } }));
}
}

@ -0,0 +1,16 @@
import db from '@/db';
export default async function handler(req, res) {
const sessionId = req.cookies?.session;
if (!sessionId) { res.status(404).json({}); return; }
const sessionRes = await db.get('select email from sessions join users on users.id = sessions.user_id where session_id=?;', sessionId);
if (!sessionRes || sessionRes?.email != process.env.ADMIN_EMAIL) { res.status(404).json({}); return; }
if (req.method === 'GET') {
const rows = await db.all('select id, email from users;');
res.setHeader('Content-Range', rows.length);
res.status(200).json(rows)
}
}

@ -2,9 +2,16 @@ import path from 'path';
import fs from 'fs';
import db from '@/db';
import {
ROOT,
REACTORS_ACCOUNT,
accountUnsubscribeURL,
accountFeedURL,
podcastPage,
episodeFile
} from '@/paths';
import { Podcast } from 'podcast';
import mp3Duration from 'mp3-duration';
import { getEpisodes } from '@/data/episodes';
@ -13,31 +20,24 @@ async function syncEpisodes() {
let newEpisodes = false;
const dbUpdates = episodes.map(async ({ title, published, description, content, slug, audio: { src, length }, num, id, youtube }) => {
const filename = `0${num}-mixed.mp3`; // TODO auto-add the 0 prefix
const filepath = path.join(process.cwd(), 'public', 'files', 'episodes', filename);
if (fs.existsSync(filepath)) {
const existsInDb = await db.get('select id from episodes where number=?', num);
if (!existsInDb) {
newEpisodes = true;
console.log('adding to db');
const duration = Math.round(await mp3Duration(filepath));
await db.run('insert into episodes (number, content, summary, slug, season, episode, filename, duration, title, episode_type, buzzsprout_id, buzzsprout_url, pub_date, youtube_url) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
num,
content,
description,
slug,
1,
num,
filename,
duration,
title,
'full',
id,
src,
published,
youtube);
console.log('added to db', num);
}
if (!existsInDb) {
newEpisodes = true;
console.log('adding to db');
await db.run('insert into episodes (number, content, summary, slug, season, episode, audio_url, title, episode_type, buzzsprout_id, buzzsprout_url, pub_date, youtube_url) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
num,
content,
description,
slug,
1,
num,
title,
'full',
id,
src,
published,
youtube);
console.log('added to db', num);
}
})
// if (newEpisodes) {
@ -49,22 +49,36 @@ async function syncEpisodes() {
export default async function handler(req, res) {
if (req.method === 'GET') {
// await syncEpisodes();
await syncEpisodes();
const { uuid: uuidRaw } = req.query;
const uuid = uuidRaw.split('.rss')[0];
const subExists = await db.get('select id from subscriptions where uuid=?', uuid);
if (subExists) {
const now = new Date();
const dbEpisodesRaw = await db.all('select * from episodes order by number desc;');
const dbEpisodes = dbEpisodesRaw.filter(e => new Date(e.pub_date) <= now);
const { last_build_date } = await db.get('select last_build_date from feed;');
const lastEpisode = dbEpisodes[0];
let lastBuilt = new Date(last_build_date);
if (lastBuilt < new Date(lastEpisode.pub_date)) {
console.log('rebuild!');
await db.run('update feed set last_build_date = ?;', now.toISOString());
lastBuilt = now;
}
const feed = new Podcast({
title: 'The React Show (Reactors)',
description: "Premium subscription to The React Show. Discussions about React, JavaScript, and web development by React experts with a focus on diving deep into learning React and discussing what it's like to work within the React industry.",
feedUrl: `https://www.thereactshow.com/api/${uuidRaw}`,
siteUrl: 'https://www.thereactshow.com',
title: 'The React Show Premium: The Reactors',
description: `<p>Premium subscription to The React Show: thank you so much for your support!</p>
<p>Manage your subscription here: <a href="${REACTORS_ACCOUNT}">${REACTORS_ACCOUNT}</a></p>
<p>Unsubscribe here: <a href="${accountUnsubscribeURL(uuid)}">${accountUnsubscribeURL(uuid)}</a></p>
<p>Discussions about React, JavaScript, and web development by React experts with a focus on diving deep into learning React and discussing what it's like to work within the React industry.</p>`,
feedUrl: accountFeedURL(uuid),
siteUrl: ROOT,
imageUrl: 'https://storage.buzzsprout.com/variants/d1tds1rufs5340fyq9mpyzo491qp/5cfec01b44f3e29fae1fb88ade93fc4aecd05b192fbfbc2c2f1daa412b7c1921.jpg',
author: 'The React Show',
copyright: '© 2023 Owl Creek',
language: 'en',
categories: ['Technology','Education','Business'],
pubDate: 'May 20, 2012 04:00:00 GMT',
pubDate:lastBuilt,
ttl: 60,
itunesAuthor: 'The React Show',
itunesOwner: { name: 'The React Show' },
@ -81,30 +95,25 @@ export default async function handler(req, res) {
itunesImage: 'https://storage.buzzsprout.com/variants/d1tds1rufs5340fyq9mpyzo491qp/5cfec01b44f3e29fae1fb88ade93fc4aecd05b192fbfbc2c2f1daa412b7c1921.jpg'
});
const dbEpisodes = await db.all('select * from episodes order by number desc;');
dbEpisodes.forEach(({ title, pub_date, summary: description, content, slug, duration, filename, number, episode_type }) => {
const filepath = path.join(process.cwd(), 'public', 'files', 'episodes', filename);
if (fs.existsSync(filepath)) {
feed.addItem({
title,
description: content,
content,
url: `https://www.thereactshow.com/podcast/${slug}`,
date: pub_date,
itunesTitle: title,
itunesExplicit: false,
itunesSummary: description,
itunesDuration: duration,
itunesAuthor: 'The React Show',
itunesSeason: 1,
itunesEpisode: number,
itunesEpisodeType: episode_type,
enclosure : {
url: `https://www.thereactshow.com/files/episodes/${filename}`,
file: filepath
},
});
}
dbEpisodes.forEach(({ title, pub_date, summary: description, content, slug, duration, audio_url, number, episode_type }) => {
feed.addItem({
title,
description: content,
content,
url: podcastPage(slug),
date: pub_date,
itunesTitle: title,
itunesExplicit: false,
itunesSummary: description,
itunesDuration: duration,
itunesAuthor: 'The React Show',
itunesSeason: 1,
itunesEpisode: number,
itunesEpisodeType: episode_type,
enclosure : {
url: audio_url || ''
},
});
})
const xml = feed.buildXml();

@ -0,0 +1,16 @@
// export const ROOT = 'https://www.thereactshow.com';
export const ROOT = process.env.SITE_ROOT;
export const FEED_ROOT = `${ROOT}/api/feed`;
export const REACTORS = `${ROOT}/reactors`;
export const REACTORS_ACCOUNT = `${REACTORS}/account`;
export const PODCAST_PAGE_ROOT = `${ROOT}/podcast`;
export const EPISODE_FILE_ROOT = `${ROOT}/files/episodes`;
export const API_ROOT = '/api';
export const API_ADMIN_ROOT = `${API_ROOT}/admin`;
export const API_USERS = `${API_ADMIN_ROOT}/users`;
export const accountFeedURL = (uuid) => `${FEED_ROOT}/${uuid}.rss`;
export const accountUnsubscribeURL = (uuid) => `${REACTORS_ACCOUNT}/${uuid}/unsubscribe`;
export const podcastPage = (slug) => `${PODCAST_PAGE_ROOT}/${slug}`;
export const episodeFile = (filename) => `${EPISODE_FILE_ROOT}/${filename}`;
Loading…
Cancel
Save