Repository: henb13/jre-missing
Branch: main
Commit: 8ace00ee0e62
Files: 78
Total size: 92.8 KB
Directory structure:
gitextract_5r8ltyem/
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── codeql.yml
│ └── main.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── client/
│ ├── .eslintrc.json
│ ├── index.html
│ ├── package.json
│ ├── public/
│ │ ├── ads.txt
│ │ └── robots.txt
│ ├── src/
│ │ ├── components/
│ │ │ ├── AmountInfo.jsx
│ │ │ ├── AmountInfo.module.css
│ │ │ ├── App.css
│ │ │ ├── App.jsx
│ │ │ ├── ChangeDetails.jsx
│ │ │ ├── ChangeDetails.module.css
│ │ │ ├── Coffee.jsx
│ │ │ ├── Coffee.module.css
│ │ │ ├── Contact.jsx
│ │ │ ├── Contact.module.css
│ │ │ ├── Disclosure.jsx
│ │ │ ├── Disclosure.module.css
│ │ │ ├── Episode.jsx
│ │ │ ├── Episode.module.css
│ │ │ ├── EpisodeList.jsx
│ │ │ ├── EpisodeList.module.css
│ │ │ ├── Error.jsx
│ │ │ ├── Error.module.css
│ │ │ ├── Github.jsx
│ │ │ ├── Github.module.css
│ │ │ ├── Header.jsx
│ │ │ ├── Header.module.css
│ │ │ ├── ListTabs.jsx
│ │ │ ├── ListTabs.module.css
│ │ │ ├── ScrollButton.jsx
│ │ │ ├── ScrollButton.module.css
│ │ │ ├── Searchbox.jsx
│ │ │ ├── Searchbox.module.css
│ │ │ ├── Sort.jsx
│ │ │ ├── Sort.module.css
│ │ │ ├── Sponsor.jsx
│ │ │ ├── Sponsor.module.css
│ │ │ ├── Tag.jsx
│ │ │ └── Tag.module.css
│ │ ├── hooks/
│ │ │ ├── useFetch.js
│ │ │ ├── useMinLoadingTime.js
│ │ │ └── useScroll.js
│ │ ├── index.css
│ │ ├── index.jsx
│ │ ├── skeletons/
│ │ │ ├── SkeletonList.jsx
│ │ │ ├── SkeletonStyles.module.css
│ │ │ └── SkeletonText.jsx
│ │ ├── utils.js
│ │ └── utils.test.js
│ ├── tsconfig.json
│ ├── vite-env.d.ts
│ └── vite.config.ts
└── server/
├── .eslintrc.json
├── api/
│ ├── __mocks__/
│ │ └── mockResponse.js
│ ├── dev-api.js
│ └── index.js
├── app.js
├── db/
│ ├── connect.js
│ ├── db.js
│ ├── mapQueries.js
│ └── migration/
│ ├── missingEpisodesPerNow.js
│ ├── schema.sql
│ ├── seeds.sql
│ └── setup.js
├── lib/
│ ├── getEpisodeNumber.js
│ ├── getEpisodeNumber.test.js
│ └── getSpotifyEpisodes.js
├── package.json
├── utils/
│ └── utils.js
└── worker/
├── tasks/
│ └── refreshDb.js
└── worker.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
github: [cotterjd]
================================================
FILE: .github/workflows/codeql.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "main", **/** ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "main" ]
schedule:
- cron: '37 1 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"
================================================
FILE: .github/workflows/main.yml
================================================
name: Run Tests
on:
push:
branches:
- "*"
pull_request:
branches:
- "*"
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
directory: ["./server", "./client"]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
cache-dependency-path: ${{ matrix.directory }}/package-lock.json
- name: Install dependencies
run: |
cd ${{ matrix.directory }}
npm ci
- name: Build project
run: |
cd ${{ matrix.directory }}
npm run build --if-present
- name: Run tests
run: |
cd ${{ matrix.directory }}
npm run test
================================================
FILE: .gitignore
================================================
.vscode
.DS_Store
**/.DS_Store
# client
## dependencies
client/node_modules
client/.pnp
client/.pnp.js
## testing
client/coverage
## production
client/build
client/dist
## misc
client/.DS_Store
client/.env
client/.env.local
client/.env.development.local
client/.env.test.local
client/.env.production.local
client/npm-debug.log*
client/yarn-debug.log*
client/yarn-error.log*
# server
server/.env
server/node_modules
server/coverage
================================================
FILE: .prettierrc
================================================
{
"printWidth": 95,
"tabWidth": 2,
"bracketSameLine": true
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 HenB13
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
================================================
FILE: client/.eslintrc.json
================================================
{
"env": {
"browser": true,
"es2021": true,
"jest": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": ["react", "react-hooks"],
"rules": {
"react/prop-types": 0,
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "warn",
"prefer-const": [
"warn",
{
"destructuring": "all",
"ignoreReadBeforeAssign": false
}
],
"prefer-template": "warn"
},
"ignorePatterns": ["node_modules/", "coverage/", "build/"]
}
================================================
FILE: client/index.html
================================================
JRE Missing
You need to enable JavaScript to run this app.
================================================
FILE: client/package.json
================================================
{
"name": "client",
"version": "0.1.0",
"private": true,
"dependencies": {
"@types/node": "^20.8.9",
"@types/react": "^18.2.33",
"@vitejs/plugin-react-swc": "^3.4.0",
"classnames": "^2.3.2",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-text-transition": "^3.1.0",
"react-tooltip": "^5.21.6",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vite-plugin-svgr": "^4.1.0",
"vite-tsconfig-paths": "^4.2.1",
"web-vitals": "^3.5.0"
},
"scripts": {
"start": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"test": "vitest",
"test:coverage": "vitest run --coverage --watch=false"
},
"devDependencies": {
"@vitest/coverage-v8": "^0.34.6",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"jsdom": "^22.1.0",
"vitest": "^0.34.6"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
================================================
FILE: client/public/ads.txt
================================================
google.com, pub-2393608933506016, DIRECT, f08c47fec0942fa0
================================================
FILE: client/public/robots.txt
================================================
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
================================================
FILE: client/src/components/AmountInfo.jsx
================================================
import classnames from "classnames";
import styles from "./AmountInfo.module.css";
import { getClientLocalTime, formatMinutesToTimeAmountString } from "../utils";
import Checkmark from "../icons/AmountInfoIcon.svg";
import SkeletonText from "../skeletons/SkeletonText.jsx";
const AmountInfo = ({ data, showSkeleton, setListShown }) => {
if (showSkeleton) return ;
if (!data || !data.missingEpisodes || !data.shortenedEpisodes) return null;
const { missingEpisodes, shortenedEpisodes } = data;
const lastChecked = data.lastCheckedInMs;
const lastCheckedMinutes = lastChecked
? Math.floor((new Date() - new Date(lastChecked)) / 60000)
: 0;
const lastCheckedString = formatMinutesToTimeAmountString(lastCheckedMinutes);
const lastCheckedDate = getClientLocalTime(lastChecked, "PP HH:mm");
const dateTimeHTMLAttribute = getClientLocalTime(lastChecked, "yyyy-MM-dd HH:mm:ss.sss");
return (
setListShown("removed")} className={styles.AmountInfoItem}>
{missingEpisodes.length}
{" "}
episodes are missing from Spotify.
setListShown("shortened")} className={styles.AmountInfoItem}>
{shortenedEpisodes.length}
{" "}
episode
{shortenedEpisodes.length === 1 ? "" : "s"}{" "}
{shortenedEpisodes.length == 1 ? "has" : "have"} been shortened.
Last checked: {lastCheckedString} ago
({lastCheckedDate})
);
};
export default AmountInfo;
================================================
FILE: client/src/components/AmountInfo.module.css
================================================
.AmountInfo {
font-size: 2rem;
}
.AmountInfoItem {
display: block;
white-space: nowrap;
}
.AmountInfoItem .count {
color: var(--color-red);
font-weight: 700;
position: relative;
}
.AmountInfoItem .count:after {
content: "";
position: absolute;
width: 100%;
height: 0;
right: 0;
bottom: 1px;
border-bottom: 2px solid var(--color-red);
}
.AmountInfoItem .count.NoAmount:after {
border-color: var(--color-green);
}
.LastChecked {
font-size: 1.4rem;
display: flex;
text-align: center;
margin-top: 1px;
color: var(--color-grey);
margin-bottom: 3rem;
}
.count.NoAmount {
color: var(--color-green);
}
.Checkmark {
margin: -1px 0 0 6px;
color: var(--color-green);
}
@media (max-width: 1350px) {
.AmountInfo {
font-size: 1.8rem;
margin: 4.5rem 0 1.75rem;
text-align: center;
}
.AmountInfoItem {
margin: 0 auto;
}
.AmountInfo .count:after {
bottom: 0;
width: 95%;
}
.LastChecked {
justify-content: center;
margin-left: 1.25rem;
margin-top: 0.7rem;
}
.LastChecked time {
display: none;
}
}
================================================
FILE: client/src/components/App.css
================================================
.App {
display: flex;
position: relative;
}
.left {
position: fixed;
top: 26rem;
bottom: 19rem;
left: 35rem;
}
.right {
display: flex;
align-items: flex-start;
margin: 9rem 0rem 9rem 49vw;
}
@media (max-width: 1700px) {
.left {
left: 20rem;
top: 17rem;
}
}
@media (max-width: 1450px) {
.left {
left: 14rem;
}
}
@media (max-width: 1350px) {
.App {
flex-direction: column;
align-items: center;
margin-bottom: 6rem;
}
.left,
.right {
position: unset;
display: flex;
flex-direction: column;
align-items: center;
margin: unset;
}
.right {
margin-top: 2rem;
}
}
@media (max-width: 750px) {
.App {
padding: 0 5rem;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
================================================
FILE: client/src/components/App.jsx
================================================
import "./App.css";
import { useState, useEffect } from "react";
import useFetch from "../hooks/useFetch";
import useMinLoadingTime from "../hooks/useMinLoadingTime";
import Error from "./Error";
import Github from "./Github";
import Header from "./Header";
import AmountInfo from "./AmountInfo";
import EpisodeList from "./EpisodeList";
import Sort from "./Sort";
import Searchbox from "./Searchbox";
import ScrollButton from "./ScrollButton";
import Contact from "./Contact";
import Sponsor from "./Sponsor";
import Coffee from "./Coffee";
import useScroll from "../hooks/useScroll";
function App() {
const { data, error, isPending } = useFetch(
`${import.meta.env.VITE_API_BASE_URL}/api/episodes`
);
const minLoadingTimeElapsed = useMinLoadingTime(400);
const [shouldShakeEpisodes, setShouldShakeEpisodes] = useState(false);
const [missingEpisodesShown, setMissingEpisodesShown] = useState([]);
const [shortenedEpisodesShown, setShortenedEpisodesShown] = useState([]);
const [listShown, setListShown] = useState("removed");
const [searchText, setSearchText] = useState("");
const listMap = {
removed: {
episodes: missingEpisodesShown,
allEpisodes: data?.missingEpisodes || [],
setEpisodes: setMissingEpisodesShown,
},
shortened: {
episodes: shortenedEpisodesShown,
allEpisodes: data?.shortenedEpisodes || [],
setEpisodes: setShortenedEpisodesShown,
},
};
const currentList = listMap[listShown];
useEffect(() => {
setMissingEpisodesShown(data?.missingEpisodes || []);
setShortenedEpisodesShown(data?.shortenedEpisodes || []);
}, [data]);
const shakeEpisodes = () => {
setShouldShakeEpisodes(true);
setTimeout(() => {
setShouldShakeEpisodes(false);
}, 1000);
};
const { scrollTarget, scrollable } = useScroll({
refreshOnChange: [missingEpisodesShown, shortenedEpisodesShown, listShown, searchText],
});
const showSkeleton = isPending || !minLoadingTimeElapsed;
const resetCurrentEpisodes = () => {
currentList.setEpisodes(currentList.allEpisodes);
setSearchText("");
};
return (
);
}
export default App;
================================================
FILE: client/src/components/ChangeDetails.jsx
================================================
import { useState } from "react";
import classnames from "classnames";
import Disclosure from "./Disclosure";
import styles from "./ChangeDetails.module.css";
import Chavron from "../icons/chavron.svg";
const ChangeDetails = ({ episode }) => {
const [open, setOpen] = useState(false);
const disclosureId = `${episode.full_name}-toggle`;
const [latestChange, ...restOfChanges] = episode.changes;
const disclosureProps = {
isOpen: open,
onClick: () => {
setOpen((open) => !open);
},
ariaControls: `${episode.full_name}-change-history-wrapper`,
id: disclosureId,
};
return (
{latestChange.old_duration_string}
>
{latestChange.new_duration_string}
{restOfChanges.length > 0 && (
Has been changed {restOfChanges.length} {!episode.isOriginalLength && "more"}{" "}
time
{restOfChanges.length === 1 ? "" : "s"} previously.
View change history
{open && (
{[latestChange, ...restOfChanges].map((change) => {
return (
);
})}
)}
)}
);
};
const ChangeDisplay = ({ change }) => {
const {
date: { formatted, htmlAttribute },
old_duration_string,
new_duration_string,
} = change;
return (
{formatted}
{old_duration_string}
>
{new_duration_string}
);
};
export default ChangeDetails;
================================================
FILE: client/src/components/ChangeDetails.module.css
================================================
.ChangeDetails {
display: flex;
flex-direction: column;
font-size: var(--font-size-small);
color: var(--color-grey-secondary);
margin-top: 0.75rem;
}
.headingWrapper {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
background: var(--color-black-secondary);
padding: 2px 8px 4px;
border-radius: 5px;
}
.heading {
color: var(--color-white);
font-size: var(--font-size-small);
font-weight: 400;
white-space: nowrap;
}
.ChangeDisplay {
display: flex;
}
.ChangeDisplayWrapper {
display: flex;
flex-direction: column;
}
.ChangeDisplay {
display: flex;
flex-direction: row;
gap: 1rem;
}
.historyToggle {
color: var(--color-grey);
white-space: nowrap;
}
.historyToggle svg {
width: 20px;
height: 20px;
}
.ChangeDisplayDate {
color: var(--color-grey);
margin-bottom: 2px;
}
.restOfChanges {
width: fit-content;
margin-top: 1rem;
}
.restOfChangesItems {
display: flex;
flex-direction: column;
background-color: var(--color-black-secondary);
gap: 1.75rem;
padding: 1.5rem;
margin-top: -4px;
}
.Chavron {
transform: rotate(180deg) scale(0.65);
margin-top: 3px;
transition: transform 0.25s ease-out;
}
.Chavron.open {
transform: rotate(0deg) scale(0.65);
margin-top: 3px;
}
@media (max-width: 1350px) {
.ChangeDetails {
align-items: center;
}
}
@media (max-width: 400px) {
.restOfChanges {
margin-top: 1.75rem;
}
.headingWrapper {
flex-direction: column;
gap: 0;
padding: 6px 32px;
}
}
================================================
FILE: client/src/components/Coffee.jsx
================================================
import styles from "./Coffee.module.css";
const Coffee = () => {
return (
<>
>
);
};
export default Coffee;
================================================
FILE: client/src/components/Coffee.module.css
================================================
.Coffee {
position: fixed;
top: 3.65rem;
left: 36rem;
font-weight: 500;
font-size: 16px;
cursor: pointer;
}
.CoffeeLogo {
max-height: 30px;
}
@media (max-width: 1350px) {
.Coffee {
left: 24rem;
position: absolute;
}
}
@media (max-width: 750px) {
.Coffee {
top: 1.65rem;
left: 21rem;
}
}
@media (max-width: 380px) {
.Coffee {
top: 1.5rem;
left: 15rem;
}
}
================================================
FILE: client/src/components/Contact.jsx
================================================
import styles from "./Contact.module.css";
import EmailIcon from "../icons/email.svg";
const Contact = () => {
return (
contact me
);
};
export default Contact;
================================================
FILE: client/src/components/Contact.module.css
================================================
.contact {
position: fixed;
display: flex;
align-items: center;
font-size: var(--font-size-medium);
top: 4rem;
right: 5rem;
}
.contactIcon {
height: 2rem;
width: 2rem;
margin-left: 1rem;
transition: transform 0.2s;
}
.contact:hover .contactIcon {
transform: scale(1.2);
cursor: pointer;
}
.contact:active .contactIcon {
transform: scale(1);
}
@media (max-width: 1350px) {
.contact {
position: absolute;
}
}
@media (max-width: 750px) {
.contact {
top: 2rem;
right: 2rem;
}
}
@media (max-width: 450px) {
.contact span {
display: none;
}
}
================================================
FILE: client/src/components/Disclosure.jsx
================================================
import styles from "./Disclosure.module.css";
import classnames from "classnames";
const Disclosure = ({ isOpen, onClick, className, ariaControls, id, children }) => {
return (
{children}
);
};
export default Disclosure;
================================================
FILE: client/src/components/Disclosure.module.css
================================================
.Disclosure {
display: flex;
align-items: center;
gap: 1px;
transition: all 0.25s;
cursor: pointer;
color: var(--color-grey-secondary);
font-size: var(--font-size-small);
}
.Disclosure:hover {
color: var(--color-white-secondary);
}
================================================
FILE: client/src/components/Episode.jsx
================================================
import styles from "./Episode.module.css";
import Tag from "./Tag";
const Episode = ({ variant, name, number, date, isNew, isOriginalLength }) => {
// eslint-disable-next-line no-unused-vars
let [_, ...guest] = name.split("-");
guest = guest.join("-");
return (
{isNew && new }
{variant === "shortened" && isOriginalLength && (
original length
)}
{number ? (
<>
#{number}
{guest}
>
) : (
name
)}
{date && (
{variant === "removed" ? "Removed" : "Shortened"} on{" "}
{date.formatted}
)}
);
};
export default Episode;
================================================
FILE: client/src/components/Episode.module.css
================================================
.epContent {
display: flex;
flex-direction: column;
}
.tagsWrapper {
display: flex;
gap: 9px;
margin-bottom: 8px;
}
.epName {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.epNumber {
color: var(--color-orange);
}
.epGuest {
text-align: center;
}
.timeDetail {
font-size: var(--font-size-small);
letter-spacing: 0.1px;
color: var(--color-grey);
}
@media (max-width: 1350px) {
.EpisodeItem {
display: unset;
text-align-last: center;
align-items: unset;
width: 100%;
}
.epContent {
align-items: center;
}
.epName {
justify-content: center;
gap: 0.5rem;
}
.timeDetail {
display: block;
margin: 4px 0 0 0;
font-size: var(--font-size-small);
}
}
================================================
FILE: client/src/components/EpisodeList.jsx
================================================
import classnames from "classnames";
import styles from "./EpisodeList.module.css";
import Episode from "./Episode";
import SkeletonList from "../skeletons/SkeletonList.jsx";
import ListTabs from "./ListTabs";
import ChangeDetails from "./ChangeDetails";
const EpisodeList = ({
missingEpisodesShown,
shortenedEpisodesShown,
shouldShake,
showSkeleton,
searchText,
listShown,
setListShown,
resetCurrentEpisodes,
}) => {
if (showSkeleton) return ;
if (!missingEpisodesShown && !shortenedEpisodesShown) return null;
const classesEpList = classnames(styles.EpisodeList, {
shake: shouldShake,
});
const listIdRemoved = "episode-list-removed";
const listIdShortened = "episode-list-shortened";
const tabIdRemoved = "tab-removed";
const tabIdShortened = "tab-shortened";
return (
<>
{listShown === "removed" ? (
) : (
)}
>
);
};
const RemovedList = ({ episodes, searchText, className, id, ariaLabelledBy }) => {
return (
);
};
const ShortenedList = ({ episodes, searchText, className, id, ariaLabelledBy }) => {
return (
);
};
const Border = ({ visible }) => {
return (
);
};
export default EpisodeList;
================================================
FILE: client/src/components/EpisodeList.module.css
================================================
.wrapper .EpisodeList {
position: unset;
font-size: 1.4rem;
list-style-type: none;
display: flex;
flex-direction: column;
gap: 4.5rem;
}
.EpisodeItem {
font-size: var(--font-size-medium);
hyphens: auto;
position: relative;
display: flex;
flex-direction: column;
}
.NoEpisodesMessage {
color: var(--color-grey);
}
.Border {
display: initial;
background-color: var(--color-grey);
height: 1px;
width: 75%;
opacity: 0.25;
margin-bottom: 4rem;
}
.Border:not(.visible) {
display: none;
}
@media (max-width: 1350px) {
.EpisodeList {
gap: 3.5rem;
justify-items: center;
}
.EpisodeItem {
flex-direction: column;
align-items: center;
}
.Border:not(.visible) {
display: initial;
}
}
@media (max-width: 600px) {
.EpisodeList {
font-size: 1.2rem;
margin-right: 0.6rem;
}
}
================================================
FILE: client/src/components/Error.jsx
================================================
import AlertIcon from "../icons/alertIcon.svg";
import styles from "./Error.module.css";
const Error = ({ error }) => {
return (
);
};
export default Error;
================================================
FILE: client/src/components/Error.module.css
================================================
.error {
display: flex;
align-items: center;
font-size: var(--font-size-medium);
}
.icon {
margin-right: 1rem;
width: 2.4rem;
height: 2.4rem;
}
================================================
FILE: client/src/components/Github.jsx
================================================
import GitHubLogo from "../icons/github-1.png";
import styles from "./Github.module.css";
const Github = () => {
return (
<>
{" "}
{"<--"} View the code!
>
);
};
export default Github;
================================================
FILE: client/src/components/Github.module.css
================================================
.Github {
position: fixed;
top: 4rem;
left: 5rem;
display: flex;
align-items: center;
}
.GithubLogo {
transition: transform 0.2s;
height: 2.4rem;
width: 2.4rem;
margin-right: 1rem;
}
.Github:hover .GithubLogo {
transform: scale(1.2);
cursor: pointer;
}
.Github:active .GithubLogo {
transform: scale(1);
}
.GithubText {
font-size: var(--font-size-medium);
font-style: italic;
color: var(--color-white);
}
@media (max-width: 1350px) {
.Github {
position: absolute;
}
.GithubText {
display: none;
}
}
@media (max-width: 750px) {
.Github {
top: 2rem;
left: 2rem;
}
}
@media (max-width: 380px) {
.Github {
display: none;
}
}
================================================
FILE: client/src/components/Header.jsx
================================================
import styles from "./Header.module.css";
const Header = () => {
return (
);
};
export default Header;
================================================
FILE: client/src/components/Header.module.css
================================================
.Header {
margin-bottom: 10rem;
}
h1 {
font-size: 8rem;
font-family: "Teko", sans-serif;
font-style: italic;
letter-spacing: -3px;
font-weight: 900;
margin-left: -2px;
color: var(--color-red);
}
h1 span {
color: var(--color-white);
}
.intro {
font-size: var(--font-size-medium);
max-width: 47rem;
}
.intro span {
color: var(--color-orange);
font-weight: 600;
transition: color 0.75s;
}
.intro span {
position: relative;
}
.intro span:after {
content: "";
position: absolute;
bottom: 0.05em;
left: 0;
width: 100%;
height: 1px;
min-height: 1px;
z-index: -1;
background-color: var(--color-orange);
transform: scale(0, 1);
transition: transform 0.25s ease;
}
.intro span:hover:after {
transform: scale(1, 1);
}
.brMobile {
display: none;
}
@media (max-width: 1350px) {
.Header {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 15rem;
margin-bottom: 0;
}
h1 {
font-size: clamp(3rem, 5rem, 5vw);
letter-spacing: unset;
margin-bottom: 2rem;
}
.intro {
text-align: center;
font-size: clamp(1.4rem, 1.6rem, 2.5vw);
max-width: 40rem;
}
.intro a {
display: block;
width: max-content;
margin: 0 auto;
}
br {
display: none;
}
}
@media (max-width: 600px), (orientation: landscape) AND (max-height: 650px) {
.Header {
margin-top: 10rem;
}
}
@media (orientation: landscape) AND (max-height: 650px) {
.Header {
margin-top: 11rem;
}
}
================================================
FILE: client/src/components/ListTabs.jsx
================================================
import styles from "./ListTabs.module.css";
import classnames from "classnames";
const ListTabs = ({
listShown,
setListShown,
resetCurrentEpisodes,
listIdRemoved,
listIdShortened,
tabIdRemoved,
tabIdShortened,
}) => {
return (
{
setListShown("removed");
resetCurrentEpisodes();
}}
id={tabIdRemoved}
ariaControls={listIdRemoved}
/>
{
setListShown("shortened");
resetCurrentEpisodes();
}}
id={tabIdShortened}
ariaControls={listIdShortened}
/>
);
};
const Option = ({ title, onClick, isSelected, ariaControls, id }) => {
return (
{title}
);
};
export default ListTabs;
================================================
FILE: client/src/components/ListTabs.module.css
================================================
.ListTab {
font-size: var(--font-size-small);
display: flex;
gap: 2rem;
margin-bottom: 4rem;
}
.option {
transition: color, border-color 0.25s;
padding-bottom: 0.5rem;
color: var(--color-grey);
transition: all 0.25s ease-out;
border-bottom: 1px solid;
border-color: var(--color-black);
}
.option.selected {
border-color: var(--color-white);
}
.option.selected,
.option:hover {
color: var(--color-white);
}
.divider {
max-height: 50%;
width: 1px;
background-color: var(--color-grey);
opacity: 0.3;
}
@media (max-width: 1350px) {
.ListTab {
justify-content: center;
margin-bottom: 5rem;
}
}
================================================
FILE: client/src/components/ScrollButton.jsx
================================================
import TextTransition, { presets } from "react-text-transition";
import classnames from "classnames";
import ArrowDown from "../icons/ScrollButtonIcon.svg";
import styles from "./ScrollButton.module.css";
const ScrollButton = ({ dataPending, minLoadingTimeElapsed, scrollTarget, scrollable }) => {
const shouldHide = !scrollable || dataPending || !minLoadingTimeElapsed;
function handleClick() {
window.scroll({
top: scrollTarget === "top" ? 0 : document.body.clientHeight,
left: 0,
behavior: "smooth",
});
}
return (
To{" "}
{scrollTarget}
);
};
export default ScrollButton;
================================================
FILE: client/src/components/ScrollButton.module.css
================================================
.ScrollButton {
background: transparent;
outline: none;
color: var(--color-white);
font-family: var(--font-family-primary);
position: fixed;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
bottom: 9rem;
right: var(--number-right-placement);
width: 9.5rem;
height: 9.5rem;
border: 1px solid var(--color-red);
border-radius: 100%;
font-size: 2rem;
cursor: pointer;
transition: transform 0.7s ease-out 0.2s, right 0.3s ease-out;
}
.ScrollButton.hidden {
right: -10rem;
}
.ScrollText {
z-index: 1;
}
.arrow {
transition: all 0.5s ease-out;
margin-bottom: 14px;
}
.arrow.up {
transform: rotateZ(-180deg);
}
.ScrollButton:hover .arrow,
.ScrollButton:hover,
.ScrollButton:active {
transform: translateY(5px);
}
.ScrollButton:focus-visible {
outline: -webkit-focus-ring-color auto 1px;
}
.ScrollButton.up:hover .arrow {
transform: translateY(-5px) rotateZ(-180deg);
}
.ScrollButton.up:hover,
.ScrollButton.up:active {
transform: translateY(-5px);
}
@media (max-width: 1750px) {
.ScrollButton {
right: 4vw;
}
}
@media (max-width: 1350px) {
.ScrollButton {
right: 10%;
}
}
@media (max-width: 750px), (orientation: landscape) AND (max-height: 650px) {
.ScrollButton {
justify-content: center;
width: 6.5rem;
height: 6.5rem;
bottom: 5%;
right: 4%;
}
.ScrollButton .ScrollText {
display: none;
}
.arrow {
margin: 0;
}
.arrow path {
stroke-width: 2px;
}
}
@media (max-width: 600px), (orientation: landscape) AND (max-height: 650px) {
.ScrollButton {
justify-content: center;
width: 5rem;
height: 5rem;
bottom: 3%;
right: 5%;
}
.arrow {
width: 3rem;
height: 3rem;
}
}
================================================
FILE: client/src/components/Searchbox.jsx
================================================
import classnames from "classnames";
import { useState } from "react";
import styles from "./Searchbox.module.css";
import SearchIcon from "../icons/SearchboxIcon.svg";
//Rewrite the Searchbox component below but without forwardRef
const Searchbox = ({
episodes,
setEpisodes,
allEpisodes,
shakeEpisodes,
searchText,
setSearchText,
}) => {
const [placeholder, setPlaceholder] = useState("Search for episode or guest");
// TODO: useeffect isteden som setter episode ved tab change
const handleSearch = (e) => {
setEpisodes(() => {
return allEpisodes.filter((ep) =>
ep.full_name?.toLowerCase().includes(e.target.value.toLowerCase())
);
});
setSearchText(e.target.value);
};
const classesSearchIcon = classnames(styles.SearchIcon, {
[styles.hoverCursor]: searchText,
});
return (
<>
setPlaceholder(null)}
onBlur={() => setPlaceholder("Search for episode or guest")}
onKeyUp={(e) => {
if (e.key === "Enter") shakeEpisodes();
}}
spellCheck="false"
autoComplete="off"
/>
{
if (searchText) {
shakeEpisodes();
navigator.vibrate();
}
}}
/>
{searchText && (
{episodes.length} result
{episodes.length != 1 && "s"} found
)}
>
);
};
export default Searchbox;
================================================
FILE: client/src/components/Searchbox.module.css
================================================
.Searchbox {
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--color-grey-secondary);
border-radius: 1.6rem;
padding: 0 1.5rem;
width: 23.3rem;
height: 4.6rem;
transition: background-color 0.35s;
position: relative;
}
.Searchbox:focus-within {
background-color: var(--color-black-secondary);
}
input {
flex: 1;
background-color: transparent;
border: none;
font-size: var(--font-size-small);
outline: none;
color: var(--color-grey-secondary);
caret-color: var(--color-white);
font-family: var(--font-family-primary);
}
input::-webkit-input-placeholder {
position: absolute;
color: var(--color-grey-secondary);
opacity: 1;
font-family: var(--font-family-primary);
font-size: 1.2rem;
transform: translateY(-12%);
cursor: text;
transition: all 0.15s ease-out;
}
.searchResult {
color: var(--color-grey-secondary);
font-size: 1.2rem;
margin-top: 1rem;
margin-left: 1.5rem;
}
.SearchIcon.hoverCursor {
cursor: pointer;
}
================================================
FILE: client/src/components/Sort.jsx
================================================
import { useEffect } from "react";
import Arrow from "../icons/arrow.svg";
import { useState } from "react";
import classnames from "classnames";
import styles from "./Sort.module.css";
import Disclosure from "./Disclosure";
import Chavron from "../icons/chavron.svg";
const options = {
removed: ["episode number", "date removed"],
shortened: ["episode number", "date shortened"],
};
const initialState = { name: "episode number", reverse: false };
const Sort = ({ setEpisodes, episodes, listShown }) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState(initialState);
useEffect(() => {
setSelected(initialState);
}, [listShown]);
const handleSort = (option, isReversed) => {
let nulls;
let nonNulls;
if (option === "date shortened") {
nulls = [];
nonNulls = nonNulls = episodes.sort((a, b) => {
[a, b] = isReversed ? [a, b] : [b, a];
return a.changes[0].date.ms - b.changes[0].date.ms;
});
} else if (option === "date removed") {
nulls = episodes.filter((ep) => !ep.date);
nonNulls = episodes
.filter((ep) => ep.date)
.sort((a, b) => {
[a, b] = isReversed ? [a, b] : [b, a];
return a.date.ms - b.date.ms;
});
// episode number
} else {
nulls = episodes.filter((ep) => !ep.episode_number);
nonNulls = episodes
.filter((ep) => ep.episode_number)
.sort((a, b) => {
[a, b] = isReversed ? [a, b] : [b, a];
return a.episode_number - b.episode_number;
});
}
setEpisodes([...nonNulls, ...nulls]);
};
const disclosureId = "sort-by-toggle";
const optionsWrapperId = "sort-by-content";
return (
setOpen((open) => !open)}
id={disclosureId}
ariaControls={optionsWrapperId}>
Sort by
{options[listShown]?.map((option) => {
return (
);
})}
);
};
function Option({ optionName, selected, setSelected, handleSort }) {
const isSelected = selected.name === optionName;
const isReversed = isSelected && selected.reverse;
function handleClick() {
const newReverse = isSelected ? !isReversed : isReversed;
setSelected({ name: optionName, reverse: newReverse });
handleSort(optionName, newReverse);
}
return (
{optionName
.split(" ")
.map((word) => word[0].toUpperCase() + word.slice(1))
.join(" ")}
);
}
export default Sort;
================================================
FILE: client/src/components/Sort.module.css
================================================
.sort {
display: flex;
flex-direction: column;
align-items: flex-end;
color: var(--color-grey-secondary);
margin-right: 2.5rem;
margin-top: 6rem;
font-size: var(--font-size-small);
}
.sortDisclosure {
gap: 3px;
}
.optionsWrapper {
display: flex;
flex-direction: column;
margin-top: 1.5rem;
}
.sort.open .optionsWrapper {
cursor: pointer;
}
.option {
font-size: 11px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--color-grey-secondary);
padding: 0.3em 0.6em 0.3em 1em;
border: 1px solid var(--color-grey-secondary);
transition: color, border-color 0.25s;
}
.option {
visibility: hidden;
}
.sort.open .option {
visibility: visible;
}
.option .label {
margin-right: 2px;
transition: color 0.5s;
}
.option.selected {
border-color: var(--color-white);
}
.option:not(.selected) {
transition: background-color 0.5s;
}
.option:hover:not(.selected) {
background-color: var(--color-black-secondary);
}
.icon {
transform: scale(0.6);
transition: color, transform 0.25s ease-out;
}
.icon path {
color: var(--color-grey);
transition: color 0.5s;
transition-delay: 0.25;
}
.option.selected .label,
.option.selected:hover .icon path {
color: var(--color-white);
}
.option.selected:hover .label {
color: var(--color-grey-secondary);
}
.iconReverse {
transform: rotate(180deg) scale(0.6);
}
.Chavron {
transform: rotate(180deg) scale(0.65);
margin-top: 3px;
transition: transform 0.25s ease-out;
}
.Chavron.open {
transform: rotate(0deg) scale(0.65);
margin-top: 3px;
}
@media (max-width: 1350px) {
.sort {
margin: 0;
justify-content: center;
align-items: center;
}
.sort.open {
margin-bottom: 4rem;
}
}
================================================
FILE: client/src/components/Sponsor.jsx
================================================
import styles from "./Sponsor.module.css";
const Sponsor = () => {
return (
);
};
export default Sponsor;
================================================
FILE: client/src/components/Sponsor.module.css
================================================
.githubSponsorButton {
position: fixed;
top: 3.55rem;
left: 20rem;
height: 35px;
width: 116px;
border: 0;
margin-left: 2rem;
cursor: pointer;
}
@media (max-width: 1350px) {
.githubSponsorButton {
left: 8rem;
position: absolute;
}
}
@media (max-width: 750px) {
.githubSponsorButton {
top: 1.6rem;
left: 5rem;
}
}
@media (max-width: 380px) {
.githubSponsorButton {
top: 1.5rem;
left: 0;
}
}
================================================
FILE: client/src/components/Tag.jsx
================================================
import classnames from "classnames";
import { Tooltip } from "react-tooltip";
import styles from "./Tag.module.css";
const variantClasses = {
new: styles.new,
originalLength: styles.originalLength,
};
const Tag = ({ className, variant, children, toolTip }) => (
{children}
{toolTip && (
<>
?
>
)}
);
export default Tag;
================================================
FILE: client/src/components/Tag.module.css
================================================
.tag {
display: flex;
justify-content: center;
align-items: center;
font-size: 11px;
color: var(--color-black);
font-weight: 700;
line-height: 100%;
width: min-content;
}
.tagName {
white-space: nowrap;
border-radius: 10px;
padding: 2px 8px 4px;
}
.new .tagName {
background-color: var(--color-green);
}
.originalLength .tagName {
background-color: var(--color-orange);
}
.toolTip {
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
background-color: transparent;
border: 1px solid;
width: 2px;
height: 2px;
padding: 7px;
cursor: pointer;
margin-left: 6px;
margin-bottom: 1px;
color: var(--color-grey);
border-color: var(--color-grey);
}
.toolTipElement {
max-width: 250px;
font-family: var(--font-family-secondary);
line-height: 16px;
font-weight: normal;
opacity: 0.95 !important;
}
================================================
FILE: client/src/hooks/useFetch.js
================================================
import { useState, useEffect } from "react";
const useFetch = (url) => {
const [data, setData] = useState(null);
const [isPending, setIsPending] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortCont = new AbortController();
fetch(url, { signal: abortCont.signal })
.then((res) => {
if (!res.ok) {
throw Error(
res.status == 429
? "You are making too many requests, please try again later."
: "Something went wrong, please try again later."
);
}
return res.json();
})
.then((data) => {
setIsPending(false);
setError(null);
setData(data);
})
.catch((err) => {
if (err.name === "AbortError") {
console.warn("fetch aborted");
} else {
setIsPending(false);
console.error(`Something went wrong with fetching episodes: ${err}`);
setError(err.message);
}
});
return () => abortCont.abort();
}, [url]);
return { data, isPending, error };
};
export default useFetch;
================================================
FILE: client/src/hooks/useMinLoadingTime.js
================================================
import { useState, useEffect } from "react";
const useMinLoadingTime = (minTime) => {
const [minLoadingTimeElapsed, setMinLoadingTimeElapsed] = useState(true);
useEffect(() => {
setMinLoadingTimeElapsed(false);
const timer = setTimeout(() => {
setMinLoadingTimeElapsed(true);
}, minTime);
return () => {
clearTimeout(timer);
};
}, [minTime]);
return minLoadingTimeElapsed;
};
export default useMinLoadingTime;
================================================
FILE: client/src/hooks/useScroll.js
================================================
import { useState, useEffect } from "react";
import _ from "lodash";
const useScroll = ({
refreshOnChange: [missingEpisodesShown, shortenedEpisodesShown, listShown, searchText],
}) => {
const [scrollTarget, setScrollTarget] = useState("bottom");
const [scrollable, setScrollable] = useState(null);
useEffect(() => {
setScrollable(document.body.clientHeight > window.innerHeight);
}, [missingEpisodesShown, shortenedEpisodesShown, listShown, searchText, setScrollable]);
useEffect(() => {
const handleScroll = _.throttle(() => {
setScrollTarget(
window.pageYOffset + window.innerHeight / 2 > document.body.clientHeight / 2
? "top"
: "bottom"
);
}, 200);
const handleResize = _.throttle(() => {
handleScroll();
setScrollable(document.body.clientHeight > window.innerHeight);
}, 200);
window.addEventListener("scroll", handleScroll, {
passive: true,
});
window.addEventListener("resize", handleResize, {
passive: true,
});
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};
}, [scrollTarget]);
return { scrollTarget, scrollable, setScrollable };
};
export default useScroll;
================================================
FILE: client/src/index.css
================================================
@import url("https://fonts.googleapis.com/css2?family=Oswald:wght@200;300;400;500;600;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Teko:wght@700&display=swap");
:root {
--color-black-secondary: #0f0f0f;
--color-black: #151515;
--color-grey: #424242;
--color-grey-secondary: #6b6b6b;
--color-white: #fafafa;
--color-white-secondary: #e8e6e6;
--color-orange: #ee7d2c;
--color-red: #a51f21;
--color-green: #1ed760;
--font-size-large: 2.2rem;
--font-size-medium: 1.6rem;
--font-size-small: 1.2rem;
--font-size-xs: 1rem;
--font-family-primary: Oswald, sans-serif;
--font-family-secondary: Roboto, sans-serif;
--number-right-placement: 6%;
}
*,
:after,
:before {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
html {
font-size: 62.5%;
font-family: var(--font-family-primary);
overflow-y: scroll;
}
body {
background-color: var(--color-black);
color: var(--color-white);
min-height: 100vh;
}
a,
a:link,
a:visited {
color: inherit;
text-decoration: none;
}
input,
textarea,
button,
select,
a {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
input,
button,
textarea,
select {
font: inherit;
}
button {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
button:hover {
cursor: pointer;
}
button:focus-visible {
outline: -webkit-focus-ring-color auto 2px;
}
.shake {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
================================================
FILE: client/src/index.jsx
================================================
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./components/App";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
);
================================================
FILE: client/src/skeletons/SkeletonList.jsx
================================================
import { useRef } from "react";
import styles from "./SkeletonStyles.module.css";
const SkeletonList = () => {
const stylesArr = useRef(
Array.apply(null, Array(42)).map(() => {
const random = Math.random();
const type =
random > 90
? styles.extraLarge
: random > 0.85
? styles.large
: random > 0.75
? styles.medium
: random > 0.15
? styles.small
: styles.extraSmall;
return type;
})
);
return (
{stylesArr.current?.map((st, i) => {
return (
);
})}
);
};
export default SkeletonList;
================================================
FILE: client/src/skeletons/SkeletonStyles.module.css
================================================
.SkeletonList {
position: unset;
font-size: 1.4rem;
list-style-type: none;
display: flex;
flex-direction: column;
gap: 4.5rem;
margin-top: 6.5rem;
}
.SkeletonElement {
position: relative;
overflow: hidden;
background-color: #212121;
border-radius: 2px;
}
.hidden {
visibility: hidden;
}
.listElement {
height: 20px;
}
.SkeletonElement.extraSmall {
width: 105px;
}
.SkeletonElement.small {
width: 150px;
}
.SkeletonElement.medium {
width: 220px;
}
.SkeletonElement.large {
width: 260px;
}
.SkeletonElement.extraLarge {
width: 325px;
}
.SkeletonShimmerWrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.SkeletonShimmer {
width: 50%;
height: 100%;
background: rgba(0, 0, 0, 0.1);
transform: skewX(-50deg);
box-shadow: 0 0 60px 60px rgba(0, 0, 0, 0.1);
animation: loading 2s infinite;
}
@keyframes loading {
0% {
transform: translateX(-20vw);
}
50% {
transform: translateX(3vw);
}
100% {
transform: translateX(20vw);
}
}
.skeletonText {
display: flex;
flex-direction: column;
margin-top: 10.5rem;
margin-bottom: 2.5rem;
}
.amount {
position: relative;
height: 24px;
width: 280px;
}
.shortened {
margin-top: 10px;
height: 22px;
width: 240px;
}
.lastChecked {
position: relative;
margin-top: 7px;
height: 19px;
width: 250px;
}
@media (max-width: 1350px) {
.SkeletonList {
justify-content: center;
align-items: center;
margin-top: 0;
gap: 9.15rem;
}
.SkeletonElement.extraSmall,
.SkeletonElement.small,
.SkeletonElement.medium,
.SkeletonElement.large,
.SkeletonElement.extraLarge {
width: 130px;
height: 25px;
}
.skeletonText {
margin-top: 4.7rem;
align-items: center;
margin-bottom: 48px;
}
.amount {
width: 250px;
height: 22px;
margin-left: 2px;
}
.shortened {
width: 220px;
margin-top: 7px;
height: 21px;
}
.lastChecked {
height: 18px;
width: 150px;
margin-top: 10px;
}
}
================================================
FILE: client/src/skeletons/SkeletonText.jsx
================================================
import classnames from "classnames";
import styles from "./SkeletonStyles.module.css";
const SkeletonElement = ({ elStyle }) => {
return (
);
};
const SkeletonText = () => {
return (
);
};
export default SkeletonText;
================================================
FILE: client/src/utils.js
================================================
import { zonedTimeToUtc, utcToZonedTime, format as formatTz } from "date-fns-tz";
export const getClientLocalTime = (date, pattern) => {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const utcDate = zonedTimeToUtc(date, userTimezone);
const zonedDate = utcToZonedTime(utcDate, userTimezone);
const lastCheckedDate = formatTz(zonedDate, pattern, {
timeZone: userTimezone,
});
return lastCheckedDate;
};
export const getDateString = (time) => {
return getClientLocalTime(time, "PPP");
};
export const formatMinutesToTimeAmountString = (minutes) => {
if (minutes < 1) {
return "less than a minute ago";
}
if (minutes < 60) {
const minutesString = setPlurality("minute", minutes);
return `${minutes} ${minutesString}`;
}
if (minutes < 1440) {
const hours = Math.floor(minutes / 60);
const hoursString = setPlurality("hour", hours);
const minutesRest = minutes % 60;
const minutesString = setPlurality("minute", minutesRest);
return `${hours} ${hoursString}${
minutesRest === 0 ? "" : ` and ${minutesRest} ${minutesString}`
}`;
}
const days = Math.floor(minutes / (60 * 24));
const daysString = days === 1 ? "day" : "days";
return `${days} ${daysString}`;
};
const setPlurality = (text, number) => {
return `${text}${number === 1 ? "" : "s"}`;
};
================================================
FILE: client/src/utils.test.js
================================================
import { formatMinutesToTimeAmountString } from "./utils";
describe("get correct timestring given a minute amount as input", () => {
test("return 'less than a minute ago'", () => {
expect(formatMinutesToTimeAmountString(0)).toBe("less than a minute ago");
expect(formatMinutesToTimeAmountString(0.5)).toBe("less than a minute ago");
});
test("return minutes", () => {
expect(formatMinutesToTimeAmountString(1)).toBe("1 minute");
expect(formatMinutesToTimeAmountString(30)).toBe("30 minutes");
expect(formatMinutesToTimeAmountString(59)).toBe("59 minutes");
});
test("return hours", () => {
expect(formatMinutesToTimeAmountString(60)).toBe("1 hour");
expect(formatMinutesToTimeAmountString(60 * 2)).toBe("2 hours");
expect(formatMinutesToTimeAmountString(60 * 23)).toBe("23 hours");
});
test("return days", () => {
expect(formatMinutesToTimeAmountString(60 * 24)).toBe("1 day");
expect(formatMinutesToTimeAmountString(60 * 24 * 2)).toBe("2 days");
expect(formatMinutesToTimeAmountString(60 * 24 * 3)).toBe("3 days");
expect(formatMinutesToTimeAmountString(60 * 24 * 76)).toBe("76 days");
});
});
describe("get correct minutes addition to time string when returning hours (i.e. '2 hours and 5 minutes')", () => {
test("return hours and minutes", () => {
expect(formatMinutesToTimeAmountString(60 + 1)).toBe("1 hour and 1 minute");
expect(formatMinutesToTimeAmountString(60 + 59)).toBe("1 hour and 59 minutes");
expect(formatMinutesToTimeAmountString(60 * 2 + 5)).toBe("2 hours and 5 minutes");
expect(formatMinutesToTimeAmountString(60 * 5 + 25)).toBe("5 hours and 25 minutes");
expect(formatMinutesToTimeAmountString(60 * 6 + 1)).toBe("6 hours and 1 minute");
expect(formatMinutesToTimeAmountString(60 * 6 + 2)).toBe("6 hours and 2 minutes");
});
});
================================================
FILE: client/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noFallthroughCasesInSwitch": true,
"jsx": "react-jsx",
"types": ["vite/client", "vite-plugin-svgr/client"]
},
"include": ["src"]
}
================================================
FILE: client/vite-env.d.ts
================================================
/// ///
================================================
FILE: client/vite.config.ts
================================================
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import tsconfigPaths from "vite-tsconfig-paths";
import svgrPlugin from "vite-plugin-svgr";
// https://vitejs.dev/config/
export default defineConfig({
base: "/",
plugins: [
react(),
tsconfigPaths(),
svgrPlugin({ svgrOptions: { icon: true }, include: "**/*.svg" }),
],
server: {
port: 3000,
},
test: {
globals: true,
environment: "jsdom",
css: true,
reporters: ["verbose"],
coverage: {
reporter: ["text", "json", "html"],
include: ["src/**/*"],
exclude: [],
},
},
});
================================================
FILE: server/.eslintrc.json
================================================
{
"env": {
"commonjs": true,
"node": true,
"es2021": true,
"jest": true
},
"extends": ["eslint:recommended", "prettier"],
"parserOptions": {
"ecmaVersion": 12
},
"rules": {
"prefer-const": [
"warn",
{
"destructuring": "all",
"ignoreReadBeforeAssign": false
}
],
"prefer-template": "warn"
},
"ignorePatterns": ["node_modules/", "coverage/"]
}
================================================
FILE: server/api/__mocks__/mockResponse.js
================================================
const missingEpisodes = [
{
full_name: "#374 - Marc Maron",
episode_number: 374,
isNew: false,
date: {
ms: 1643984238000,
formatted: "February 4th, 2022",
htmlAttribute: "2022-02-04",
},
},
{
full_name: "#320 - Tim Ferriss",
episode_number: 320,
isNew: false,
date: {
ms: 1644006629000,
formatted: "February 4th, 2022",
htmlAttribute: "2022-02-04",
},
},
];
const shortenedEpisodes = [
{
id: 2124,
episode_number: 1877,
full_name: "#1877 - Jann Wenner",
isNew: true,
isOriginalLength: false,
changes: [
{
date: {
ms: 1665010388000,
formatted: "October 6th, 2022",
htmlAttribute: "2022-10-06",
},
new_duration_string: "2 hr 51 min 39 sec",
old_duration_string: "2 hr 51 min 46 sec",
},
],
},
{
id: 1943,
episode_number: 1330,
full_name: "#1330 - Bernie Sanders",
isNew: false,
isOriginalLength: false,
changes: [
{
date: {
ms: 1664486810000,
formatted: "September 29th, 2022",
htmlAttribute: "2022-09-29",
},
new_duration_string: "1 hr 17 min 9 sec",
old_duration_string: "1 hr 51 min 46 sec",
},
],
},
{
id: 1389,
episode_number: 1159,
full_name: "#1159 - Neil deGrasse Tyson",
isNew: true,
isOriginalLength: true,
changes: [
{
date: {
ms: 1665005849000,
formatted: "October 5th, 2022",
htmlAttribute: "2022-10-05",
},
new_duration_string: "3 hr 21 min 8 sec",
old_duration_string: "3 hr 21 min 3 sec",
},
{
date: {
ms: 1665005755000,
formatted: "October 5th, 2022",
htmlAttribute: "2022-10-05",
},
new_duration_string: "3 hr 21 min 3 sec",
old_duration_string: "3 hr 21 min 8 sec",
},
{
date: {
ms: 1664919458000,
formatted: "October 4th, 2022",
htmlAttribute: "2022-10-04",
},
new_duration_string: "3 hr 21 min 6 sec",
old_duration_string: "3 hr 21 min 7 sec",
},
{
date: {
ms: 1664919253000,
formatted: "October 4th, 2022",
htmlAttribute: "2022-10-04",
},
new_duration_string: "3 hr 21 min 7 sec",
old_duration_string: "3 hr 21 min 8 sec",
},
],
},
{
id: 1340,
episode_number: 1678,
full_name: "#1678 - Michael Pollen",
isNew: true,
isOriginalLength: false,
changes: [
{
date: {
ms: 1665005849000,
formatted: "October 5th, 2022",
htmlAttribute: "2022-10-05",
},
new_duration_string: "3 hr 21 min 1 sec",
old_duration_string: "3 hr 21 min 3 sec",
},
{
date: {
ms: 1665005755000,
formatted: "October 5th, 2022",
htmlAttribute: "2022-10-05",
},
new_duration_string: "3 hr 21 min 3 sec",
old_duration_string: "3 hr 21 min 8 sec",
},
{
date: {
ms: 1664919458000,
formatted: "October 4th, 2022",
htmlAttribute: "2022-10-04",
},
new_duration_string: "3 hr 21 min 6 sec",
old_duration_string: "3 hr 21 min 7 sec",
},
{
date: {
ms: 1664919253000,
formatted: "October 4th, 2022",
htmlAttribute: "2022-10-04",
},
new_duration_string: "3 hr 21 min 7 sec",
old_duration_string: "3 hr 21 min 8 sec",
},
],
},
];
const lastCheckedInMs = 1665691453175;
const mockResponse = {
missingEpisodes,
shortenedEpisodes,
lastCheckedInMs,
};
module.exports = { mockResponse };
================================================
FILE: server/api/dev-api.js
================================================
const express = require("express");
const router = express.Router();
const { mockResponse } = require("./__mocks__/mockResponse");
router.use(express.json());
require("dotenv").config();
router.get("/api/episodes", async (_, res) => {
res.header({
"Access-Control-Allow-Origin": "http://localhost:3000",
});
return res.json(mockResponse);
//TODO: Implement dev database
});
module.exports = router;
================================================
FILE: server/api/index.js
================================================
const express = require("express");
const router = express.Router();
const DB = require("../db/db");
const pool = require("../db/connect");
const { mockResponse } = require("./__mocks__/mockResponse");
router.use(express.json());
require("dotenv").config();
let missingEpisodesCache;
let shortenedEpisodesCache;
let lastCheckedCache;
const { CRON_INTERVAL, USE_MOCK_DATA, NODE_ENV } = process.env;
const isDev = NODE_ENV === "development";
//TODO: Change when client in prod
const allowOrigin = isDev ? "http://localhost:3000" : "https://not-yet-prod-domain.com";
router.get("/api/episodes", async (_, res) => {
if (isDev && USE_MOCK_DATA === "true") {
return res.json(mockResponse);
}
const timeSinceLastCheckedDbInMins =
lastCheckedCache && (Date.now() - lastCheckedCache) / 1000 / 60;
const cacheTimeLeftSecs = Math.floor((CRON_INTERVAL - timeSinceLastCheckedDbInMins) * 60);
const buffer = 120;
res.header({
"cache-control": `no-transform, max-age=${cacheTimeLeftSecs + buffer || 1}`,
"Access-Control-Allow-Origin": allowOrigin,
});
if (
!missingEpisodesCache ||
!shortenedEpisodesCache ||
timeSinceLastCheckedDbInMins > CRON_INTERVAL
) {
await (async () => {
const client = await pool.connect();
const db = DB(client);
try {
missingEpisodesCache = await db.getMissingEpisodes();
shortenedEpisodesCache = await db.getShortenedEpisodes();
lastCheckedCache = await db.getLastChecked();
console.info("db queried and cache updated");
} finally {
client.release();
}
})().catch((err) => console.error(err.message));
}
console.info("request fired");
res.json({
missingEpisodes: missingEpisodesCache,
shortenedEpisodes: shortenedEpisodesCache,
lastCheckedInMs: lastCheckedCache,
});
});
module.exports = router;
================================================
FILE: server/app.js
================================================
const express = require("express");
const app = express();
const api = require("./api/index.js");
const devApi = require("./api/dev-api.js");
const rateLimit = require("express-rate-limit");
const slowDown = require("express-slow-down");
const helmet = require("helmet");
require("dotenv").config();
// eslint-disable-next-line no-undef
const port = process.env.PORT || 3001;
const useDevApi =
process.env.NODE_ENV === "development" && process.env.USE_MOCK_DATA === "true";
app.set("trust proxy", 1);
const rateLimiter = rateLimit({
windowMs: 15 * 1000,
max: 7,
});
const speedLimiter = slowDown({
windowMs: 15 * 1000,
delayAfter: 3,
delayMs: (hits) => hits * 100,
});
app.use(express.json());
app.use(helmet());
app.use(rateLimiter);
app.use(speedLimiter);
app.use(useDevApi ? devApi : api);
app.listen(port, () => {
console.log(`Server listening on port ${port}!`);
});
module.exports = app;
================================================
FILE: server/db/connect.js
================================================
const pg = require("pg");
pg.types.setTypeParser(1184, (str) => str);
pg.defaults.poolSize = 25;
require("dotenv").config();
const pool = new pg.Pool();
module.exports = pool;
================================================
FILE: server/db/db.js
================================================
const { mapMissingEpisodes, mapShortenedEpisodes, mapLastChecked } = require("./mapQueries");
const getEpisodeNumber = require("../lib/getEpisodeNumber");
const DB = (client) => {
return {
getAllEpisodes: async function () {
const { rows } = await client.query("SELECT * from all_eps");
return rows;
},
getMissingEpisodes: async function () {
const { rows } = await client.query(
`SELECT full_name, EXTRACT(EPOCH FROM date_removed at time zone 'UTC') * 1000 AS date_removed, episode_number
FROM all_eps
LEFT JOIN (
SELECT id, MAX(date_removed) AS date_removed
from date_removed
group by id
) AS t2
ON all_eps.id = t2.id
WHERE on_spotify = false
ORDER BY episode_number DESC NULLS LAST, all_eps.id`
);
return mapMissingEpisodes(rows);
},
getShortenedEpisodes: async function () {
const { rows } = await client.query(
`SELECT all_eps.id AS id, episode_number, full_name, EXTRACT(EPOCH FROM date_changed at time zone 'UTC') * 1000 AS date_changed, new_duration, old_duration
FROM all_eps
JOIN (
SELECT id, episode_id, new_duration, old_duration, date AS date_changed
FROM duration_changes
GROUP BY episode_id, id, old_duration
) AS t2
ON all_eps.id = t2.episode_id
ORDER BY date_changed DESC`
);
return mapShortenedEpisodes(rows);
},
insertNewEpisode: async function (episode) {
const epNumber = getEpisodeNumber(episode.name);
await client.query("INSERT INTO all_eps VALUES(DEFAULT, $1, $2, $3, $4)", [
epNumber,
episode.name,
true,
episode.duration,
]);
},
updateEpisodeName: async function (name, id) {
await client.query("UPDATE all_eps SET full_name=($1) WHERE id=($2)", [name, id]);
},
updateEpisodeDuration: async function (newDuration, id) {
await client.query("UPDATE all_eps SET duration=($1) WHERE id=($2)", [newDuration, id]);
},
setSpotifyStatus: async function ({ id }, bool) {
await client.query(`UPDATE all_eps SET on_spotify=($1) WHERE id=($2)`, [bool, id]);
},
setLastCheckedNow: async function () {
await client.query("UPDATE all_eps_log SET last_checked=now()");
},
getLastChecked: async function () {
const { rows } = await client.query(
`SELECT EXTRACT(EPOCH FROM last_checked at time zone 'UTC') * 1000 AS miliseconds
FROM all_eps_log`
);
return mapLastChecked(rows);
},
};
};
module.exports = DB;
================================================
FILE: server/db/mapQueries.js
================================================
const { differenceInDays, parseISO } = require("date-fns");
const {
formatMsToTimeString,
getDateString,
getDateTimeHTMLAttribute,
} = require("../utils/utils");
const DAYS_THRESHOLD_NEW = 14;
const getIsEpisodeNewlyReleased = (time) =>
time &&
differenceInDays(new Date(), parseISO(new Date(time).toISOString())) < DAYS_THRESHOLD_NEW;
const mapMissingEpisodes = (missingEpisodes) => {
return missingEpisodes.map((ep) => {
const { full_name, episode_number, date_removed } = ep;
const ms = parseInt(date_removed);
return {
full_name,
episode_number,
isNew: getIsEpisodeNewlyReleased(ms),
date: ms
? {
ms,
formatted: getDateString(ms),
htmlAttribute: getDateTimeHTMLAttribute(ms),
}
: null,
};
});
};
const mapShortenedEpisodes = (shortenedEpisodes) => {
return shortenedEpisodes
.reduce((acc, curr) => {
const { id, episode_number, full_name, date_changed, new_duration, old_duration } = curr;
const ms = parseInt(date_changed);
const changeItem = {
date: {
ms,
formatted: getDateString(ms),
htmlAttribute: getDateTimeHTMLAttribute(ms),
},
new_duration_string: formatMsToTimeString(new_duration),
old_duration_string: formatMsToTimeString(old_duration),
old_duration: parseInt(old_duration),
new_duration: parseInt(new_duration),
};
const index = acc.findIndex((item) => item.id === curr.id);
if (index === -1) {
acc.push({
id,
episode_number,
full_name,
isNew: getIsEpisodeNewlyReleased(ms),
changes: [changeItem],
});
} else {
acc[index].changes.push(changeItem);
}
return acc;
}, [])
.filter((episode) =>
episode.changes.some((change) => change.new_duration < change.old_duration)
)
.map((episode) => {
const { changes } = episode;
const oldestChange = changes[changes.length - 1];
const newestChange = changes[0];
const isOriginalLength =
oldestChange.old_duration_string === newestChange.new_duration_string;
const episodeWithoutUnusedProperties = {
...episode,
changes: episode.changes.map(({ date, new_duration_string, old_duration_string }) => ({
date,
new_duration_string,
old_duration_string,
})),
};
return {
...episodeWithoutUnusedProperties,
isOriginalLength,
};
});
};
const mapLastChecked = (rows) => {
return parseInt(rows[0]?.miliseconds);
};
module.exports = {
mapMissingEpisodes,
mapShortenedEpisodes,
mapLastChecked,
};
================================================
FILE: server/db/migration/missingEpisodesPerNow.js
================================================
const missingEpisodesPerNow = [
"#1458 - Chris D’Elia",
"#1255 - Alex Jones Returns",
"#1218 - Gad Saad",
"#1206 - Mike Ward & Pantelis",
"#1197 - Michael Malice",
"#1187 - Kyle Kulinski",
"#1141 - Theo Von",
"#1103 - Tom Segura",
"#1093 - Owen Benjamin, Kurt Metzger",
"#1036 - Ari Shaffir, Bert Kreischer & Tom Segura",
"#1033 - Owen Benjamin",
"#998 - Owen Benjamin",
"#980 - Chris D’Elia",
"#979 - Sargon of Akkad",
"#963 - Michael Malice",
"#925 - Theo Von",
"#920 - Gavin McInnes",
"#912 - Pete Holmes",
"#911 - Alex Jones, Eddie Bravo",
"#820 - Milo Yiannopoulos",
"#810 - Big Jay Oakerson",
"#750 - Kip Andersen, Keegan Kuhn, producers of Conspiracy",
"#746 - TJ Kirk",
"#742 - Aubrey Marcus",
"#710 - Gavin McInnes",
"#702 - Milo Yiannopoulos",
"#674 - Brian Redban",
"#664 - Tom Segura & Christina Pazsitzky",
"#640 - Charles C. Johnson",
"#594 - Russell Peters",
"#582 - David Seaman",
"#570 - Ryan Parsons",
"#538 - Stefan Molyneux",
"#537 - Rich Vos",
"#533 - Chris D’elia",
"#525 - Bert Kreischer",
"#520 - David Seaman",
"#512 - Dan Savage",
"#506 - Moshe Kasher",
"#488 - Iliza Shlesinger",
"#487 - David Seaman",
"#463 - Louis Theroux",
"#461 - David Seaman",
"#454 - War Machine",
"#443 - Neal Brennan",
"#441 - Brian Dunning",
"#411 - Dave Asprey",
"#375 - Shane Smith",
"#374 - Marc Maron",
"#368 - David Seaman",
"#361 - Dave Asprey, Tait Fletcher",
"#358 - Bert Kreischer",
"#354 - Ari Shaffir, Amy Schumer",
"#349 - Greg Fitzsimmons",
"#344 - Stanley Krippner, Christopher Ryan",
"#340 - JD Kelley",
"#331 - Dr. Steven Greer",
"#324 - Sam Sheridan",
"#320 - Tim Ferriss",
"#314 - Ian Edwards",
"#303 - Matt Vengrin, Brian Redban",
"#294 - Ari Shaffir",
"#276 - David Seaman, Abby Martin, Dell Cameron, Brian Redban",
"#275 - Dave Asprey",
"#256 - David Seaman",
"#246 - Maynard J Keenan (Part 1)",
"#246 - Maynard J Keenan (Part 2)",
"#239 - Adam Kokesh",
"#232 - Giorgio Tsoukalos",
"#227 - Ari Shaffir",
"#213 - Eddie Bravo",
"#198 - Brody Stevens",
"#182 - Bryan Callen, Jimmy Burke, Brian Redban",
"#176 - Steven Rinella",
"#159 - Nick Thune",
"#155 - Dave Attell",
"#152 - Brian Redban",
"#149 - LIVE FROM THE ICEHOUSE (PART ONE)",
"#134 - Kevin Smith (Part 4)",
"#132 - Bert Kreischer",
"#128 - Joey Diaz, Brian Redban",
"#122 - Jamie Kilstein",
"#119 - Jan Irvin",
"#118 - Ari Shaffir",
"#116 - Russell Peters, Junior Simpson",
"#114 - Neal Brennan",
"#113 - Brian Posehn",
"#112 - Cliffy B, Johnny Cristo",
"#110 - Duncan Trussell",
"#108 - Joey Diaz, Brian Redban",
"#107 - Doug Benson",
"#102 - John Heffron",
"#98 - Daryl Wright, Brian Whitaker",
"#97 - Freddy Lockhart, Brian Redban",
"#92 - Jim Norton",
"#91 - Bill Burr",
"#88 - Andy Dick",
"#82 - Dave Foley",
"#81 - Pete Johansson",
"#75 - Sam Tripoli",
"#72 - Ari Shaffir",
"#66 - Nick Swardson",
"#57 - Jayson Thibault, Brian Redban",
"#55 - Duncan Trussell",
"#50 - Little Esther",
"#48 - Brian Redban",
"#42 - Duncan Trussell",
"#40 - Tyler Knight",
"#27 - Sam Tripoli",
"#21 - Brian Redban",
"#20 - Tom Segura",
"#4 - Brian Redban",
"Fight Companion - February 19, 2017",
];
module.exports = missingEpisodesPerNow;
================================================
FILE: server/db/migration/schema.sql
================================================
CREATE DATABASE jre_missing;
DROP TABLE IF EXISTS all_eps, test_table, date_removed, date_re_added, all_eps_log;
CREATE TABLE all_eps(
id SERIAL NOT NULL UNIQUE PRIMARY KEY,
episode_number INTEGER,
full_name VARCHAR(255) NOT NULL,
on_spotify BOOLEAN NOT NULL,
duration INTEGER
);
CREATE TABLE test_table(
id SERIAL NOT NULL UNIQUE PRIMARY KEY,
episode_number INTEGER,
full_name VARCHAR(255) NOT NULL,
on_spotify BOOLEAN NOT NULL,
duration INTEGER
);
CREATE TABLE date_removed(
id INTEGER NOT NULL,
date_removed timestamptz(0) NOT NULL,
PRIMARY KEY (id, date_removed),
CONSTRAINT fk_id
FOREIGN KEY(id)
REFERENCES all_eps(id)
);
CREATE TABLE date_re_added(
id INTEGER NOT NULL,
re_added timestamptz(0) NOT NULL,
PRIMARY KEY (id, re_added),
CONSTRAINT fk_id
FOREIGN KEY(id)
REFERENCES all_eps(id)
);
CREATE TABLE all_eps_log(
last_checked timestamptz(0),
last_modified timestamptz(0)
);
CREATE TABLE duration_changes(
id SERIAL NOT NULL UNIQUE PRIMARY KEY,
episode_id INTEGER NOT NULL,
date timestamptz(0) NOT NULL,
old_duration INTEGER,
new_duration INTEGER,
CONSTRAINT fk_id
FOREIGN KEY(episode_id)
REFERENCES all_eps(id)
);
CREATE OR REPLACE FUNCTION change_duration()
RETURNS TRIGGER AS $cl$
BEGIN
INSERT INTO duration_changes VALUES(DEFAULT, NEW.id, now(), OLD.duration, NEW.duration);
RETURN NEW;
END;
$cl$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION last_modified()
RETURNS TRIGGER AS $lm$
BEGIN
UPDATE all_eps_log SET last_modified=now();
RETURN NEW;
END;
$lm$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION spotify_status()
RETURNS TRIGGER AS $ss$
BEGIN
IF (new.on_spotify = true) THEN
INSERT INTO date_re_added VALUES(NEW.id, now());
RETURN NEW;
ELSEIF (new.on_spotify = false) THEN
INSERT INTO date_removed VALUES(NEW.id, now());
RETURN NEW;
END IF;
RETURN NULL;
END;
$ss$ LANGUAGE plpgsql;
CREATE TRIGGER change_duration
AFTER UPDATE ON all_eps
FOR EACH ROW
WHEN (NEW.duration IS DISTINCT FROM OLD.duration)
EXECUTE PROCEDURE change_duration();
CREATE TRIGGER spotify_status
AFTER UPDATE ON all_eps
FOR EACH ROW
WHEN (OLD.on_spotify IS DISTINCT FROM NEW.on_spotify)
EXECUTE PROCEDURE spotify_status();
CREATE TRIGGER last_modified
AFTER UPDATE OR INSERT OR DELETE ON all_eps
FOR EACH ROW
EXECUTE PROCEDURE last_modified();
================================================
FILE: server/db/migration/seeds.sql
================================================
INSERT INTO all_eps_log VALUES(now(), NULL);
================================================
FILE: server/db/migration/setup.js
================================================
const missingEpisodesPerNow = require("./missingEpisodesPerNow");
const getSpotifyEpisodes = require("../../lib/getSpotifyEpisodes");
const getEpisodeNumber = require("../../lib/getEpisodeNumber");
const pool = require("../connect");
require("dotenv").config();
getSpotifyEpisodes().then(async (episodes) => {
const allEpisodes = episodes.concat(missingEpisodesPerNow);
allEpisodes.sort((a, b) => getEpisodeNumber(a.name) - getEpisodeNumber(b.name));
await (async () => {
for (const ep of allEpisodes) {
const client = await pool.connect();
const epNumber = getEpisodeNumber(ep.name);
try {
const onSpotify = !missingEpisodesPerNow.includes(ep.name);
await client.query(`INSERT INTO all_eps VALUES(DEFAULT, $1, $2, $3, $4)`, [
epNumber,
ep.name,
onSpotify,
ep.duration,
]);
} finally {
client.release();
}
}
})().catch((err) => console.error(err.message));
console.info("inserts done");
});
================================================
FILE: server/lib/getEpisodeNumber.js
================================================
// Matches episodes starting with a number, either with or without preceding hashtag (#)
function getEpisodeNumber(name) {
return parseInt(name?.match(/^(\d+)|(?<=^#)(\d+)/)) || null;
}
module.exports = getEpisodeNumber;
================================================
FILE: server/lib/getEpisodeNumber.test.js
================================================
const getEpisodeNumber = require("./getEpisodeNumber");
describe("Return number from regular episode title where the episode name starts with the number", () => {
test("Return number when hashtag precedes number (i.e. #42)", () => {
expect(getEpisodeNumber("#1789 - Tom Papa")).toBe(1789);
});
test("Return number when no hashtag precedes number", () => {
expect(getEpisodeNumber("32 - Duncan Trussell")).toBe(32);
});
});
describe("Do not return episode number when the episode name do not start with a number", () => {
test("return null when episode number is not at the beginning of the title", () => {
expect(getEpisodeNumber("JRE #1789 - Tom Papa")).toBeNull();
expect(getEpisodeNumber("JRE 1789 - Tom Papa")).toBeNull();
expect(getEpisodeNumber("Fight Companion #32 - Joey Diaz")).toBeNull();
});
test("return null when there is no episode number in the title", () => {
expect(getEpisodeNumber("Fight Companion - February 19, 2017 (Part 2")).toBeNull();
});
});
describe("Is type of number", () => {
test("return a number and not a string when episode name start with an episode number", () => {
expect(typeof getEpisodeNumber("#1789 - Tom Papa")).toBe("number");
});
});
================================================
FILE: server/lib/getSpotifyEpisodes.js
================================================
/* eslint-disable no-undef */
const SpotifyWebApi = require("spotify-web-api-node");
require("dotenv").config();
const JRE_SHOW_ID = "4rOoJ6Egrf8K2IrywzwOMk";
async function getSpotifyEpisodes() {
try {
const spotifyApi = new SpotifyWebApi({
clientId: process.env.SPOTIFY_CLIENT_ID,
clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
});
const tokenData = await spotifyApi.clientCredentialsGrant();
console.info(`The access token expires in ${tokenData.body["expires_in"]}`);
spotifyApi.setAccessToken(tokenData.body["access_token"]);
const spotifyEpisodes = [];
const episodes = await spotifyApi.getShowEpisodes(JRE_SHOW_ID, {
market: "US",
limit: 50,
offset: spotifyEpisodes.length,
});
console.info(`Started fetching episodes from Spotify at ${new Date().toString()}`);
spotifyEpisodes.push(
...episodes.body.items.map((ep) => {
return { name: ep.name, duration: ep.duration_ms };
})
);
const totalEpisodes = episodes.body.total;
while (spotifyEpisodes.length < totalEpisodes) {
const episodes = await spotifyApi.getShowEpisodes(JRE_SHOW_ID, {
market: "US",
limit: 50,
offset: spotifyEpisodes.length,
});
spotifyEpisodes.push(
...episodes.body.items.map((ep) => {
return { name: ep.name, duration: ep.duration_ms };
})
);
}
console.log(
`${spotifyEpisodes.length} out of ${totalEpisodes} JRE episodes on Spotify successfully fetched`
);
return spotifyEpisodes;
} catch (err) {
console.error(err.message);
}
}
module.exports = getSpotifyEpisodes;
================================================
FILE: server/package.json
================================================
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "cross-env NODE_ENV=development npm run dev-server",
"dev:all": "cross-env NODE_ENV=development run-p dev-server worker",
"dev-server": "nodemon app.js",
"worker": "node worker/worker.js",
"refresh-db": "node worker/tasks/refreshDb.js",
"setup": "node db/migration/setup.js",
"test": "jest",
"test:coverage": "jest --coverage"
},
"author": "",
"dependencies": {
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.3",
"express-slow-down": "^2.0.0",
"helmet": "^7.0.0",
"node-schedule": "^2.1.1",
"npm-run-all": "^4.1.5",
"pg": "^8.11.3",
"spotify-web-api-node": "^5.0.2"
},
"devDependencies": {
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"jest": "^29.7.0",
"nodemon": "^3.0.1"
}
}
================================================
FILE: server/utils/utils.js
================================================
const { zonedTimeToUtc, utcToZonedTime, format: formatTz } = require("date-fns-tz");
const getClientLocalTime = (date, pattern) => {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const utcDate = zonedTimeToUtc(date, userTimezone);
const zonedDate = utcToZonedTime(utcDate, userTimezone);
const lastCheckedDate = formatTz(zonedDate, pattern, {
timeZone: userTimezone,
});
return lastCheckedDate;
};
const getDateString = (time) => {
return getClientLocalTime(time, "PPP");
};
const getDateTimeHTMLAttribute = (time) => {
return getClientLocalTime(time, "yyyy-MM-dd");
};
const formatMsToTimeString = (time) => {
const hours = Math.floor(time / 1000 / 60 / 60);
const minutesRest = (time / 1000 / 60) % 60;
const seconds = (time / 1000) % 60;
return `${Math.floor(hours)} hr ${Math.floor(minutesRest)} min ${Math.floor(seconds)} sec`;
};
module.exports = {
getClientLocalTime,
formatMsToTimeString,
getDateString,
getDateTimeHTMLAttribute,
};
================================================
FILE: server/worker/tasks/refreshDb.js
================================================
const pg = require("pg");
const pool = new pg.Pool();
const DB = require("../../db/db");
const getEpisodeNumber = require("../../lib/getEpisodeNumber");
const getSpotifyEpisodes = require("../../lib/getSpotifyEpisodes");
async function refreshDb() {
await (async () => {
const client = await pool.connect();
const db = DB(client);
try {
console.info("worker running");
const spotifyEpisodes = await getSpotifyEpisodes();
if (!spotifyEpisodes) {
throw new Error("No spotify episodes got fetched!");
}
const spotifyEpisodeNames = spotifyEpisodes.map((ep) => ep.name);
let allEpisodes = await db.getAllEpisodes();
let someEpisodeNameGotUpdated = false;
for (const dbEpisode of allEpisodes) {
const correspondingSpotifyEpisode = spotifyEpisodes.find(
(ep) => ep.name === dbEpisode.full_name
);
if (correspondingSpotifyEpisode && !dbEpisode.duration) {
console.info(
`Inserting missing duration for episode ${dbEpisode.full_name} (duration: ${correspondingSpotifyEpisode.duration}) `
);
await db.updateEpisodeDuration(correspondingSpotifyEpisode.duration, dbEpisode.id);
} else if (correspondingSpotifyEpisode) {
if (correspondingSpotifyEpisode.duration !== dbEpisode.duration) {
await db.updateEpisodeDuration(correspondingSpotifyEpisode.duration, dbEpisode.id);
console.info(
` \n\n Spotify has changed the duration of episode: ${dbEpisode.full_name} \n
from: ${dbEpisode.duration} \n
to: ${correspondingSpotifyEpisode.duration} \n\n`
);
}
}
}
for (const spotifyEpisode of spotifyEpisodes) {
for (const dbEpisode of allEpisodes) {
if (
dbEpisode.episode_number &&
didEpisodeChangeName(spotifyEpisode.name, dbEpisode)
) {
await db.updateEpisodeName(spotifyEpisode.name, dbEpisode.id);
someEpisodeNameGotUpdated = true;
console.info(
` \n\n spotify updated the name of an episode! \n
from: ${dbEpisode.full_name} \n
to: ${spotifyEpisode.name} \n\n`
);
break;
}
}
const isNewRelease = !allEpisodes.some((ep) => ep.full_name === spotifyEpisode.name);
if (isNewRelease) {
console.info(`New episode released: ${spotifyEpisode.name}`);
await db.insertNewEpisode(spotifyEpisode);
}
}
if (someEpisodeNameGotUpdated) allEpisodes = await db.getAllEpisodes();
for (const dbEpisode of allEpisodes) {
if (!spotifyEpisodeNames.includes(dbEpisode.full_name)) {
if (dbEpisode.on_spotify) {
await db.setSpotifyStatus(dbEpisode, false);
console.info(`\n\nNew episode removed!: ${dbEpisode.full_name} \n\n`);
}
} else if (!dbEpisode.on_spotify) {
await db.setSpotifyStatus(dbEpisode, true);
console.info(`\n\nNew episode re-added: ${dbEpisode.full_name} \n\n`);
}
}
await db.setLastCheckedNow();
console.info("Worker ran successfully");
} finally {
client.release();
}
})().catch((err) => console.warn(`Worker failed to run: ${err.message}`));
}
function didEpisodeChangeName(spotifyEpisodeName, dbEpisode) {
const epNumber = getEpisodeNumber(spotifyEpisodeName);
const { full_name, episode_number } = dbEpisode;
return (
full_name !== spotifyEpisodeName &&
epNumber == episode_number &&
!full_name.toLowerCase().includes("(part") &&
!spotifyEpisodeName.toLowerCase().includes("(part")
);
}
refreshDb();
module.exports = refreshDb;
================================================
FILE: server/worker/worker.js
================================================
const schedule = require("node-schedule");
const refreshDb = require("./tasks/refreshDb");
require("dotenv").config();
// eslint-disable-next-line no-undef
const { CRON_INTERVAL } = process.env;
schedule.scheduleJob(`*/${CRON_INTERVAL} * * * *`, refreshDb);