initial commit for GitHub

This commit is contained in:
Lucas Jensen
2024-12-01 19:15:25 -08:00
commit 925b334e4c
91 changed files with 8031 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
name: Client Build
on:
pull_request:
branches: ["main"]
jobs:
build:
runs-on: linux_amd64
strategy:
matrix:
node-version: [21.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Lint
working-directory: client
run: |
npm install
npm run prettier:check
- name: Build
working-directory: client
run: |
npm install
npm run build

View File

@@ -0,0 +1,34 @@
name: Server Build
on:
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
build:
runs-on: linux_amd64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v3
with:
python-version: "3.12"
- name: Install poetry
uses: abatilo/actions-poetry@v2
- name: Install dependencies
working-directory: server
run: |
poetry install
- name: Lint with black
working-directory: server
run: |
poetry run black --check .

View File

@@ -0,0 +1,36 @@
name: Auto Increment Version
on:
push:
branches:
- main
jobs:
bump_version:
runs-on: linux_amd64
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: List files in the repository
run: |
echo "Listing files in the current directory:"
ls -la
- name: Push new tag
run: |
git config user.name "gitea"
git config user.email "gitea@lucasjensen.me"
/usr/bin/env python3 --version
echo "Creating and pushing new tag"
./server/app/scripts/bump.py

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.vscode

13
README.md Normal file
View File

@@ -0,0 +1,13 @@
# Megan Johns Portfolio Website
## Client
The React client is built using TypeScript and communicated with the Python server using http requests. Static files are generally served from Cloudinary. Though there is not currently any "auth" component to this application, it will be introduced in the future using Google Oauth2.
## Server
The backend of this application is build using Python, FastAPI, and MariaDB. ORM is handled manually (for now), and dependencies are manage with [Poetry](https://python-poetry.org/).
## Deployment
This application, including the database, is hosted on a Linode Ubuntu server with reverse proxying handled with NGINX. SSL is handled with certbot, and the Python server is run with systemd.

2
client/.env.example Normal file
View File

@@ -0,0 +1,2 @@
[api]
VITE_API_URL=http://localhost:8000/

18
client/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
};

29
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# env
.env
/.vscode

30
client/README.md Normal file
View File

@@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: __dirname,
},
};
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

25
client/index.html Normal file
View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/moonwish.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
crossorigin="anonymous"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Megan Johns</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,36 @@
server {
server_name meganjohns.com www.meganjohns.com;
location / {
root /home/lucas/MeganJohns/client/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/www.meganjohns.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/www.meganjohns.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.meganjohns.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = meganjohns.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
server_name meganjohns.com www.meganjohns.com;
listen 80;
return 404; # managed by Certbot
}

3696
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
client/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"prettier:check": "npx prettier --check .",
"prettier:fix": "npx prettier --write ."
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"axios": "^1.7.2",
"bootstrap": "^5.3.3",
"react": "^18.2.0",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.2.0",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 285 KiB

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,28 @@
import { Bio, ProfessionalService } from "../types/Bio";
import Quotes from "../Quotes/Quotes";
import { Quote } from "../types/Quote";
import ProfessionalServices from "./ProfessionalServices";
interface AboutProps {
bio: Bio;
quotes: Quote[];
services: ProfessionalService[];
}
export default function About(props: AboutProps) {
const bio = props.bio;
const quotes = props.quotes;
const services = props.services;
const bioHTML = () => {
return { __html: bio.bio };
};
return (
<section id="about" className="content-section">
<h2>Bio</h2>
<div dangerouslySetInnerHTML={bioHTML()} />
<ProfessionalServices services={services} />
<Quotes quotes={quotes} />
</section>
);
}

View File

@@ -0,0 +1,20 @@
import { ListGroup } from "react-bootstrap";
import { ProfessionalService } from "../types/Bio";
interface ProfessionalServicesProps {
services: ProfessionalService[];
}
export default function ProfessionalServices(props: ProfessionalServicesProps) {
const services = props.services;
return (
<>
<h4>Professional Services</h4>
<ListGroup>
{services.map((service) => (
<ListGroup.Item>{service.service_name}</ListGroup.Item>
))}
</ListGroup>
</>
);
}

32
client/src/Api.tsx Normal file
View File

@@ -0,0 +1,32 @@
import axios from "axios";
import { MeganJohns } from "./types/MeganJohns";
import { Artwork } from "./types/Artwork";
import { Quote } from "./types/Quote";
import { Video } from "./types/Video";
import { Album } from "./types/Album";
import { Bio, ProfessionalService } from "./types/Bio";
const baseURL = import.meta.env.VITE_API_URL as string;
const api = axios.create({ baseURL: baseURL });
export const getMeganJohns = async (): Promise<MeganJohns> => {
const response = await api.get<MeganJohns>("/");
const albums = response.data.albums as Album[];
const artwork = response.data.artwork as Artwork[];
const quotes = response.data.quotes as Quote[];
const videos = response.data.videos as Video[];
const bio = response.data.bio as Bio;
const professional_services = response.data
.professional_services as ProfessionalService[];
const version = response.data.version as string;
return {
artwork,
videos,
albums,
quotes,
bio,
professional_services,
version,
} as MeganJohns;
};

22
client/src/App.css Normal file
View File

@@ -0,0 +1,22 @@
#root {
text-align: center;
}
#main-content {
max-width: 1100px;
background-color: var(--mj-light-blue);
padding-left: 0;
padding-right: 0;
}
.card {
width: 18rem;
background-color: inherit;
border: none;
}
.content-section {
padding-top: 10rem;
margin-top: -8rem;
margin-bottom: 10em;
}

65
client/src/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { getMeganJohns } from "./Api";
import { MeganJohns } from "./types/MeganJohns";
import { Album } from "./types/Album";
import { Artwork } from "./types/Artwork";
import { Quote } from "./types/Quote";
import { Video } from "./types/Video";
import { Bio, ProfessionalService } from "./types/Bio";
import MjSection from "./MjSection";
import Header from "./Header/Header";
import About from "./About/Homepage";
import { Container } from "react-bootstrap";
import Videos from "./Videos/Videos";
import "./App.css";
function App() {
const [mj, setMj] = useState<MeganJohns | undefined>(undefined);
const [albums, setAlbums] = useState<Album[]>([]);
const [artwork, setArtwork] = useState<Artwork[]>([]);
const [bio, setBio] = useState<Bio | undefined>(undefined);
const [quotes, setQuotes] = useState<Quote[]>([]);
const [videos, setVideos] = useState<Video[]>([]);
const [services, setServices] = useState<ProfessionalService[]>([]);
useEffect(() => {
getMeganJohns().then(
(mjData) => {
setMj(mjData);
},
(error) => {
console.error(error);
},
);
}, []);
useEffect(() => {
if (mj) {
setAlbums(mj.albums);
setArtwork(mj.artwork);
setBio(mj.bio);
setQuotes(mj.quotes);
setVideos(mj.videos);
setServices(mj.professional_services);
}
}, [mj]);
if (!mj || !bio) {
return <>Loading</>;
}
return (
<Container className="">
<Container id="main-content">
<Header mj={mj} />
<MjSection sectionTitle="discography" works={albums} />
<MjSection sectionTitle="artwork" works={artwork} />
<Videos videos={videos} />
<About bio={bio} quotes={quotes} services={services} />
<p>{mj.version}</p>
</Container>
</Container>
);
}
export default App;

View File

@@ -0,0 +1,45 @@
import Modal from "react-bootstrap/Modal";
import { Artwork } from "../types/Artwork";
import Image from "react-bootstrap/Image";
import { Container } from "react-bootstrap";
interface ArtworkModalProps {
artwork: Artwork;
show: boolean;
onHide: () => void;
}
export default function ArtworkModal(props: ArtworkModalProps) {
const artwork = props.artwork;
return (
<Modal
{...props}
size="lg"
aria-labelledby={`{artwork.artwork_name}-centered`}
centered
>
<Modal.Header closeButton>
<Modal.Title id={`{artwork.artwork_name}-modal`}>
{artwork.artwork_name}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Container className="d-flex justify-content-center">
<a href={artwork.source} target="_blank">
<Image src={artwork.source} thumbnail />
</a>
</Container>
</Modal.Body>
<Modal.Footer>
<p>
<em>
<small>
{artwork.medium?.medium_name} {artwork.size} (
{artwork.release_year})
</small>
</em>
</p>
</Modal.Footer>
</Modal>
);
}

View File

@@ -0,0 +1,31 @@
import Col from "react-bootstrap/Col";
import Container from "react-bootstrap/Container";
import Image from "react-bootstrap/Image";
import Row from "react-bootstrap/Row";
import { Album } from "../types/Album";
interface AlbumArtworkProps {
album: Album;
}
export default function AlbumArtwork(props: AlbumArtworkProps) {
const album = props.album;
const numArtworks =
(album.front_artwork_url ? 1 : 0) + (album.rear_artwork_url ? 1 : 0);
return (
<Container>
<Row>
{album.front_artwork_url && (
<Col xs={12} md={numArtworks === 1 ? 12 : 6}>
<Image src={album.front_artwork_url} thumbnail />
</Col>
)}
{album.rear_artwork_url && (
<Col xs={12} md={numArtworks === 1 ? 12 : 6}>
<Image src={album.rear_artwork_url} thumbnail />
</Col>
)}
</Row>
</Container>
);
}

View File

@@ -0,0 +1,43 @@
import { Modal } from "react-bootstrap";
import { Album } from "../types/Album";
import SourceList from "./SourceList/SourceList";
import AlbumArtwork from "./AlbumArtwork";
interface AlbumModalProps {
album: Album;
show: boolean;
onHide: () => void;
}
export default function AlbumModal(props: AlbumModalProps) {
const album = props.album;
const bandcampPlayer = album.bandcamp_player ? (
<div
dangerouslySetInnerHTML={{
__html: album.bandcamp_player,
}}
/>
) : null;
const handleClose = () => {
props.onHide();
};
return (
<Modal {...props} onHide={handleClose} size="lg" centered>
<Modal.Header closeButton>
<Modal.Title id={`{album.album_name}-modal`}>
{album.album_name}
</Modal.Title>
</Modal.Header>
<AlbumArtwork album={album} />
<Modal.Body>
<SourceList album={album} />
{bandcampPlayer}
</Modal.Body>
<Modal.Footer>
{album.artist.artist_name} ({album.release_year})
</Modal.Footer>
</Modal>
);
}

View File

@@ -0,0 +1,16 @@
import { ListGroup } from "react-bootstrap";
interface SourceItemProps {
sourceName: string;
sourceUrl: string;
}
export default function SourceItem(props: SourceItemProps) {
return (
<ListGroup.Item>
<a href={props.sourceUrl} target="_blank">
{props.sourceName}
</a>
</ListGroup.Item>
);
}

View File

@@ -0,0 +1,31 @@
import { Album } from "../../types/Album";
import { ListGroup } from "react-bootstrap";
import SourceItem from "./SourceItem";
interface SourceListProps {
album: Album;
}
export default function SourceList(props: SourceListProps) {
const album = props.album;
return (
<ListGroup className="mb-2">
{album.apple_music_url && (
<SourceItem
sourceName="Apple Music"
sourceUrl={album.apple_music_url}
/>
)}
{album.spotify_url && (
<SourceItem sourceName="Spotify" sourceUrl={album.spotify_url} />
)}
{album.bandcamp_url && (
<SourceItem sourceName="Bandcamp" sourceUrl={album.bandcamp_url} />
)}
{album.itunes_url && (
<SourceItem sourceName="iTunes" sourceUrl={album.itunes_url} />
)}
</ListGroup>
);
}

View File

@@ -0,0 +1,25 @@
#global-header {
position: sticky;
top: 0;
z-index: 1000;
background-color: var(--mj-white);
margin-left: 0;
margin-right: 0;
padding-left: 0;
padding-right: 0;
}
.navbar-title {
text-transform: uppercase;
font-size: 90%;
}
.list-group-item,
.list-group-item.disabled {
background-color: transparent;
border: none;
}
.navbar-link {
background-color: transparent;
}

View File

@@ -0,0 +1,20 @@
import { MeganJohns } from "../types/MeganJohns";
import Title from "./Title";
import NavList from "./NavList";
import SocialBanner from "./SocialBanner/SocialBanner";
import { Container } from "react-bootstrap";
interface HeaderProps {
mj: MeganJohns;
}
export default function Header(props: HeaderProps) {
const mj = props.mj;
return (
<Container id="global-header" className="">
<SocialBanner socials={mj.bio.social_urls} />
<Title name={mj.bio.name} />
<NavList />
</Container>
);
}

View File

@@ -0,0 +1,28 @@
import { Col, Container, ListGroup, Row } from "react-bootstrap";
import "./Header.css";
export default function NavList() {
return (
<Container>
<Row className="justify-content-center">
<Col xs="auto">
<ListGroup horizontal className="justify-content-center navbar-title">
<ListGroup.Item>
<a href="#discography">Music</a>
</ListGroup.Item>
<ListGroup.Item>
<a href="#artwork">Art</a>
</ListGroup.Item>
<ListGroup.Item>
<a href="#videos">Videos</a>
</ListGroup.Item>
<ListGroup.Item>
<a href="#about">About</a>
</ListGroup.Item>
<ListGroup.Item disabled>News</ListGroup.Item>
</ListGroup>
</Col>
</Row>
</Container>
);
}

View File

@@ -0,0 +1,14 @@
.social-banner {
background-color: none;
border: none;
}
.social-icon {
border: inherit;
background-color: transparent;
color: var(--mj-gray);
}
.social-icon:hover {
color: var(--mj-dark-teal);
}

View File

@@ -0,0 +1,48 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SocialUrl } from "../../types/Bio";
import {
faItunesNote,
faFacebook,
faSoundcloud,
faYoutube,
faInstagram,
faSpotify,
faBandcamp,
IconDefinition,
} from "@fortawesome/free-brands-svg-icons";
import { Container, ListGroup } from "react-bootstrap";
import "./SocialBanner.css";
interface SocialBannerProps {
socials: SocialUrl[];
}
export default function SocialBanner(props: SocialBannerProps) {
const socials = props.socials;
const socialIconMap: { [key: string]: IconDefinition } = {
itunes: faItunesNote,
facebook: faFacebook,
soundcloud: faSoundcloud,
youtube: faYoutube,
instagram: faInstagram,
spotify: faSpotify,
bandcamp: faBandcamp,
};
return (
<Container>
<ListGroup horizontal className="justify-content-center">
{socials.map((social) => (
<ListGroup.Item key={social.id} className="social-icon">
<a href={social.social_url} target="_blank">
<FontAwesomeIcon
icon={socialIconMap[social.social_name]}
className="social-icon"
/>
</a>
</ListGroup.Item>
))}
</ListGroup>
</Container>
);
}

View File

@@ -0,0 +1,15 @@
import "./Header.css";
interface TitleProps {
name: string;
}
export default function Title(props: TitleProps) {
return (
<h2>
<a href="#root" className="title navbar-title" id="title">
{props.name}
</a>
</h2>
);
}

View File

@@ -0,0 +1,4 @@
.mj-card {
margin: 0 !important;
padding: 0 !important;
}

View File

@@ -0,0 +1,76 @@
/* eslint-disable no-case-declarations */
import { Button, Card } from "react-bootstrap";
import { Album } from "../types/Album";
import { Artwork } from "../types/Artwork";
import AlbumModal from "../Discography/AlbumModal";
import ArtworkModal from "../ArtworkSection/ArtworkModal";
import { useState } from "react";
import "./MjCard.css";
interface CardProps {
type: string;
work: Artwork | Album;
}
export default function MjCard(props: CardProps) {
let work = props.work;
const [show, setShow] = useState(false);
let thumbnail: string;
let title: string;
let subtitle: string;
switch (props.type) {
case "artwork":
work = work as Artwork;
thumbnail = work.thumbnail;
title = work.artwork_name;
const medium_name = work.medium?.medium_name || "";
const size = work?.size || "";
subtitle = `${medium_name} ${size}`;
break;
case "discography":
work = work as Album;
thumbnail = work.front_artwork_url;
title = work.album_name;
subtitle = work.artist.artist_name;
break;
default:
thumbnail = "";
title = "";
subtitle = "";
break;
}
return (
<>
<Card className="m-3 mj-card">
<Button
variant="none"
onClick={() => setShow(true)}
className="mj-card"
>
<Card.Img variant="top" src={thumbnail} />
<Card.Body>
<Card.Title>{title}</Card.Title>
<Card.Subtitle>{subtitle}</Card.Subtitle>
</Card.Body>
</Button>
</Card>
{props.type === "discography" && (
<AlbumModal
album={work as Album}
show={show}
onHide={() => setShow(false)}
/>
)}
{props.type === "artwork" && (
<ArtworkModal
artwork={work as Artwork}
show={show}
onHide={() => setShow(false)}
/>
)}
</>
);
}

33
client/src/MjSection.tsx Normal file
View File

@@ -0,0 +1,33 @@
// import { Album } from "./types/Album";
import { Col, Container, Row } from "react-bootstrap";
import { Artwork } from "./types/Artwork";
import MjCard from "./MjCard/MjCard";
import { Album } from "./types/Album";
interface MjSectionProps {
sectionTitle: string;
works: Artwork[] | Album[];
}
export default function MjSection(props: MjSectionProps) {
const sectionTitleAsTitle =
props.sectionTitle.charAt(0).toUpperCase() + props.sectionTitle.slice(1);
const works = props.works;
return (
<section id={props.sectionTitle} className="content-section">
<h2>{sectionTitleAsTitle}</h2>
<Container>
<Row>
{works.map((work) => (
<Col
key={`${props.sectionTitle}-${work.id}`}
className="d-flex justify-content-center align-items-center"
>
<MjCard type={props.sectionTitle} work={work} />
</Col>
))}
</Row>
</Container>
</section>
);
}

View File

@@ -0,0 +1,21 @@
import { Figure } from "react-bootstrap";
import "./Quotes.css";
interface QuoteProps {
body: string;
author: string;
source_url?: string;
}
export default function QuoteElement(props: QuoteProps) {
return (
<Figure>
<blockquote>
<p className="quote-body">{props.body}</p>
<figcaption className="blockquote-footer quote-giver">
{props.author}
</figcaption>
</blockquote>
</Figure>
);
}

View File

@@ -0,0 +1,7 @@
.quote-giver {
color: var(--mj-dark-gray);
}
.quote-body {
color: var(--mj-dark-teal);
}

View File

@@ -0,0 +1,22 @@
import { Quote } from "../types/Quote";
import QuoteElement from "./Quote";
interface QuotesProps {
quotes: Quote[];
}
export default function Quotes(props: QuotesProps) {
return (
<section id="quotes" className="content-section">
<h4>Quotes</h4>
{props.quotes.map((quote) => (
<QuoteElement
key={quote.id}
body={quote.body}
author={quote.author}
source_url={quote.source}
/>
))}
</section>
);
}

View File

View File

@@ -0,0 +1,27 @@
.videos-container {
max-width: 600px;
}
.youtube-player-container {
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
height: 0;
overflow: hidden;
max-width: 100%;
background: #000;
}
.youtube-player-container iframe,
.youtube-player-container object,
.youtube-player-container embed {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.video-item {
background-color: inherit;
border: none;
}

View File

@@ -0,0 +1,41 @@
import { Container, ListGroup, Row } from "react-bootstrap";
import { Video } from "../types/Video";
import "./Videos.css";
interface VideosProps {
videos: Video[];
}
export default function Videos(props: VideosProps) {
const videos = props.videos;
return (
<section id="videos" className="content-section">
<h2>Videos</h2>
<Container className="videos-container">
<Row>
<ListGroup>
{videos.map((video) => (
<ListGroup.Item key={video.id} className="mb-4 video-item">
<h4>{video.title}</h4>
<h5>{video.subtitle}</h5>
<Container className="youtube-player-container">
<div
dangerouslySetInnerHTML={{
__html: video.embedded_player_iframe,
}}
/>
</Container>
<div
dangerouslySetInnerHTML={{
__html: video.description,
}}
></div>
</ListGroup.Item>
))}
</ListGroup>
</Row>
</Container>
</section>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

25
client/src/index.css Normal file
View File

@@ -0,0 +1,25 @@
:root {
--mj-white: #effffd;
--mj-gray: #a9a9a9;
--mj-dark-gray: #545454;
--mj-gold: #a46609;
--mj-teal: #72c6e5;
--mj-dark-teal: #006990;
--mj-light-blue: #a1dcf2;
--mj-light-blue-alt: #0088cb;
--mj-green: #79af13;
}
body {
font-family: nunito, system-ui, Avenir, Helvetica, Arial, sans-serif;
background-color: var(--mj-white);
}
a {
font-weight: 500;
color: var(--mj-gold);
text-decoration: inherit;
}
a:hover {
color: var(--mj-dark-teal);
}

10
client/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,19 @@
export interface Artist {
id: number;
artist_name: string;
artist_url?: string;
}
export interface Album {
id: number;
album_name: string;
release_year: number;
artist: Artist;
front_artwork_url: string;
spotify_url?: string;
itunes_url?: string;
bandcamp_url?: string;
apple_music_url?: string;
rear_artwork_url?: string;
bandcamp_player?: string;
}

View File

@@ -0,0 +1,52 @@
// class Medium {
// id: number;
// medium_name: string;
// constructor(id: number, medium_name: string) {
// this.id = id;
// this.medium_name = medium_name;
// }
// }
export interface Medium {
id: number;
medium_name: string;
}
export interface Artwork {
id: number;
artwork_name: string;
source: string;
thumbnail: string;
is_featured: boolean;
medium?: Medium;
release_year?: number;
size?: string;
}
// class Artwork {
// id: number;
// type: string = "artwork";
// artwork_name: string;
// medium: Medium;
// source_url: string;
// release_year: string;
// size?: string;
// constructor(
// id: number,
// artwork_name: string,
// medium: Medium,
// source_url: string,
// release_year: string,
// size?: string
// ) {
// this.id = id;
// this.type = "artwork";
// this.artwork_name = artwork_name;
// this.medium = medium;
// this.source_url = source_url;
// this.release_year = release_year;
// this.size = size;
// }
// }

16
client/src/types/Bio.tsx Normal file
View File

@@ -0,0 +1,16 @@
export interface ProfessionalService {
id: number;
service_name: string;
}
export interface SocialUrl {
id: number;
social_name: string;
social_url: string;
}
export interface Bio {
name: string;
bio: string;
social_urls: SocialUrl[];
}

View File

@@ -0,0 +1,15 @@
import { Album } from "./Album";
import { Artwork } from "./Artwork";
import { Bio, ProfessionalService } from "./Bio";
import { Quote } from "./Quote";
import { Video } from "./Video";
export interface MeganJohns {
artwork: Artwork[];
videos: Video[];
albums: Album[];
quotes: Quote[];
bio: Bio;
professional_services: ProfessionalService[];
version: string;
}

View File

@@ -0,0 +1,6 @@
export interface Quote {
id: number;
body: string;
author: string;
source?: string;
}

View File

@@ -0,0 +1,9 @@
export interface Video {
id: number;
title: string;
subtitle: string;
description: string;
source: string;
embedded_player_iframe: string;
website?: string;
}

1
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

31
client/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src",
"src/ArtworkSection/.ArtworkCard.tsx",
"src/Discography/.AlbumCard.tsx"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

11
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

7
client/vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
});

5
server/.env.example Normal file
View File

@@ -0,0 +1,5 @@
[mysql]
DB_HOST=hostname
DB_USER=username
DB_PASSWORD=password
DB_DATABASE=database

167
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,167 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# VS Code
.vscode/
.idea
dump.sql

0
server/README.md Normal file
View File

View File

@@ -0,0 +1,30 @@
server {
server_name api.meganjohns.com www.api.meganjohns.com;
location / {
proxy_pass http://localhost:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/api.meganjohns.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/api.meganjohns.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = api.meganjohns.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
server_name api.meganjohns.com www.api.meganjohns.com;
listen 80;
return 404; # managed by Certbot
}

1
server/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
from app.main import app

View File

@@ -0,0 +1,11 @@
# sql table names
ARTISTS_TABLE = "artists"
ALBUMS_TABLE = "albums"
ARTICLES_TABLE = "articles"
ARTWORK_TABLE = "artwork"
ART_MEDIUM_TABLE = "medium"
SOCIAL_TABLE = "social"
BIO_CONTENT_TABLE = "bio_content"
QUOTES_TABLE = "quotes"
VIDEOS_TABLE = "videos"
SERVICES_TABLE = "services"

View File

@@ -0,0 +1,3 @@
from .controller import Controller
controller = Controller()

View File

@@ -0,0 +1,62 @@
from pathlib import Path
from fastapi import status
from fastapi.exceptions import HTTPException
from git import Repo
from icecream import ic
from app.model import Album, Artwork, Bio, ProfessionalService, Quote, SocialUrl, Video
class Controller:
def __init__(self) -> None:
pass
async def get_version(self) -> str:
repo = Repo(Path.cwd().parent.joinpath(".git"))
tags = [tag.tag for tag in repo.tags if tag.tag is not None]
tags.sort(key=lambda t: t.tagged_date)
return tags[-1].tag
async def get_all_videos(self) -> list[Video]:
return Video.select_all()
def get_one_video(self, video_id: int) -> Video:
if (video := Video.select_one(video_id)) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return video
async def get_all_quotes(self) -> list[Quote]:
return Quote.select_all()
def get_one_quote(self, quote_id: int) -> Quote:
if (quote := Quote.select_one(quote_id)) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return quote
async def get_all_albums(self) -> list[Album]:
return Album.select_all()
def get_one_album(self, album_id: int) -> Album:
if (album := Album.select_one(album_id)) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return album
async def get_all_artwork(self) -> list[Artwork]:
return Artwork.select_all()
def get_one_artwork(self, artwork_id: int) -> Artwork:
if (artwork := Artwork.select_one(artwork_id)) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return artwork
async def get_bio(self) -> Bio:
return Bio.select_one()
async def get_all_professional_services(self) -> list[ProfessionalService]:
return ProfessionalService.select_all()
def get_one_professional_service(self, service_id) -> ProfessionalService:
if (service := ProfessionalService.select_one(service_id)) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return service

64
server/app/db/DDL.sql Normal file
View File

@@ -0,0 +1,64 @@
-- Auto generated by DBeaver
-- meganjohns.articles definition
CREATE TABLE `articles` (
`article_id` int(11) NOT NULL AUTO_INCREMENT,
`article_title` varchar(255) NOT NULL,
`body` varchar(255) NOT NULL,
`video_url` varchar(255) DEFAULT NULL,
`is_featured` tinyint(1) DEFAULT 0,
PRIMARY KEY (`article_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- meganjohns.artists definition
CREATE TABLE `artists` (
`artist_id` int(11) NOT NULL AUTO_INCREMENT,
`artist_name` varchar(255) NOT NULL,
`artist_url` varchar(255) NOT NULL,
PRIMARY KEY (`artist_id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- meganjohns.medium definition
CREATE TABLE `medium` (
`medium_id` int(11) NOT NULL AUTO_INCREMENT,
`medium_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`medium_id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- meganjohns.albums definition
CREATE TABLE `albums` (
`album_id` int(11) NOT NULL AUTO_INCREMENT,
`album_name` varchar(255) NOT NULL,
`year` int(11) NOT NULL,
`artist_id` int(11) NOT NULL,
`spotify_url` varchar(255) DEFAULT NULL,
`itunes_url` varchar(255) DEFAULT NULL,
`bandcamp_url` varchar(255) DEFAULT NULL,
`apple_music_url` varchar(255) DEFAULT NULL,
PRIMARY KEY (`album_id`),
KEY `artist_id` (`artist_id`),
CONSTRAINT `albums_ibfk_1` FOREIGN KEY (`artist_id`) REFERENCES `artists` (`artist_id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- meganjohns.artwork definition
CREATE TABLE `artwork` (
`artwork_id` int(11) NOT NULL AUTO_INCREMENT,
`medium_id` int(11) NOT NULL,
`artwork_name` varchar(255) NOT NULL,
`source_url` varchar(255) NOT NULL,
`year` int(11) NOT NULL,
`size` varchar(255) DEFAULT NULL,
PRIMARY KEY (`artwork_id`),
UNIQUE KEY `artwork_name` (`artwork_name`),
KEY `medium_id` (`medium_id`),
CONSTRAINT `artwork_ibfk_1` FOREIGN KEY (`medium_id`) REFERENCES `medium` (`medium_id`) ON DELETE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

View File

37
server/app/db/conn.py Normal file
View File

@@ -0,0 +1,37 @@
import os
import mysql.connector
from dotenv import load_dotenv
class DBException(Exception):
pass
def connect_db() -> mysql.connector.MySQLConnection:
"""
Connects to the MySQL database using credentials from the .env file.
Returns a MySQLConnection object which can be used by the database query layer.
Credential values are validated and an exception is raised if any are missing.
"""
load_dotenv()
host = os.getenv("DB_HOST")
user = os.getenv("DB_USER")
password = os.getenv("DB_PASSWORD")
database = os.getenv("DB_DATABASE")
if None in [host, user, password, database]:
raise DBException("Missing database credentials")
try:
return mysql.connector.connect(
host=host,
user=user,
password=password,
database=database,
auth_plugin="mysql_native_password",
) # type: ignore
except mysql.connector.Error as err:
raise DBException("Could not connect to database") from err

72
server/app/main.py Normal file
View File

@@ -0,0 +1,72 @@
from asyncio import gather
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from app.controller import controller
from app.model.albums import Album
from app.model.artwork import Artwork
from app.model.bio import Bio, ProfessionalService
from app.model.quotes import Quote
from app.model.video import Video
from app.routers.albums import router as albums_router
from app.routers.artwork import router as artwork_router
from app.routers.bio import router as bio_router
from app.routers.quotes import router as quotes_router
from app.routers.videos import router as videos_router
from .origins import origins
app = FastAPI()
app.include_router(albums_router)
app.include_router(artwork_router)
app.include_router(bio_router)
app.include_router(quotes_router)
app.include_router(videos_router)
# noinspection PyTypeChecker
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class MeganJohns(BaseModel):
albums: list[Album]
artwork: list[Artwork]
quotes: list[Quote]
videos: list[Video]
bio: Bio
professional_services: list[ProfessionalService]
version: str
@app.get("/")
async def root() -> MeganJohns:
albums, artwork, bio, quotes, videos, services = await gather(
controller.get_all_albums(),
controller.get_all_artwork(),
controller.get_bio(),
controller.get_all_quotes(),
controller.get_all_videos(),
controller.get_all_professional_services(),
)
return MeganJohns(
albums=albums,
artwork=artwork,
quotes=quotes,
videos=videos,
bio=bio,
professional_services=services,
version=await controller.get_version(),
)
@app.get("/version")
async def version() -> str:
return await controller.get_version()

View File

@@ -0,0 +1,5 @@
from .albums import Album, Artist
from .artwork import Artwork, Medium
from .bio import Bio, ProfessionalService, SocialUrl
from .quotes import Quote
from .video import Video

View File

@@ -0,0 +1,94 @@
from typing import Optional, Type
from icecream import ic
from pydantic import HttpUrl
from app.constants import ALBUMS_TABLE, ARTISTS_TABLE
from app.model.model_object import ModelObject
from app.model.response_object import ResponseObject
class Artist(ModelObject, ResponseObject):
artist_name: str
artist_url: Optional[HttpUrl] = None
class Album(ModelObject, ResponseObject):
album_name: str
release_year: int
artist: Artist
front_artwork_url: HttpUrl
spotify_url: Optional[HttpUrl] = None
itunes_url: Optional[HttpUrl] = None
bandcamp_url: Optional[HttpUrl] = None
apple_music_url: Optional[HttpUrl] = None
rear_artwork_url: Optional[HttpUrl] = None
bandcamp_player: Optional[str] = None
@classmethod
def select_one(
cls, album_id: int, table_name: str = ALBUMS_TABLE
) -> "Album | None":
cursor, conn = cls._get_cursor_and_conn()
cursor.execute(
f"""-- sql
SELECT
al.id,
al.album_name ,
al.release_year,
al.artist_id ,
al.spotify_url ,
al.itunes_url ,
al.bandcamp_url ,
al.apple_music_url ,
al.front_artwork_url ,
al.rear_artwork_url ,
al.bandcamp_player ,
ar.artist_name ,
ar.artist_url
FROM {table_name} al
LEFT JOIN {ARTISTS_TABLE} ar
ON al.artist_id = ar.id
WHERE al.id = {album_id};
"""
)
data: dict = cursor.fetchone() # type: ignore
cls._close_cursor_and_conn(cursor, conn)
return cls._construct(cls, data) if data else None
@classmethod
def select_all(cls, table_name: str = ALBUMS_TABLE) -> list["Album"]:
cursor, conn = cls._get_cursor_and_conn()
cursor.execute(
f"""-- sql
SELECT
al.id,
al.album_name ,
al.release_year,
al.artist_id ,
al.spotify_url ,
al.itunes_url ,
al.bandcamp_url ,
al.apple_music_url ,
al.front_artwork_url ,
al.rear_artwork_url ,
al.bandcamp_player ,
ar.artist_name ,
ar.artist_url
FROM {table_name} al
LEFT JOIN {ARTISTS_TABLE} ar
ON al.artist_id = ar.id;
"""
)
data: dict = cursor.fetchall() # type: ignore
cls._close_cursor_and_conn(cursor, conn)
return sorted(
[cls._construct(cls, row) for row in data],
key=lambda album: album.release_year,
reverse=True,
)
@classmethod
def _construct(cls, Obj: Type, data: dict) -> "Album":
data["artist"] = Artist(**data)
return Obj(**data)

View File

@@ -0,0 +1,11 @@
from typing import Optional
from pydantic import BaseModel, HttpUrl
class Article(BaseModel):
id: int
article_title: str
body: str
is_featured: Optional[bool] = False
video_url: Optional[HttpUrl] = None

View File

@@ -0,0 +1,85 @@
from typing import Optional, Type
from pydantic import HttpUrl
from app.constants import ART_MEDIUM_TABLE, ARTWORK_TABLE
from app.model.model_object import ModelObject
from app.model.response_object import ResponseObject
class Medium(ModelObject, ResponseObject):
medium_name: str
class Artwork(ModelObject, ResponseObject):
artwork_name: str
source: HttpUrl
thumbnail: HttpUrl
is_featured: bool = False
medium: Optional[Medium] = None
release_year: Optional[int] = None
size: Optional[str] = None
@classmethod
def select_one(
cls, artwork_id: int, table_name: str = ARTWORK_TABLE
) -> "Artwork | None":
cursor, conn = cls._get_cursor_and_conn()
cursor.execute(
f"""-- sql
SELECT
a.id,
a.medium_id ,
a.artwork_name ,
a.source ,
a.thumbnail ,
a.is_featured ,
a.release_year ,
a.`size` ,
m.id as medium_id,
m.medium_name
FROM {table_name} a
LEFT JOIN {ART_MEDIUM_TABLE} m ON a.medium_id = m.id
WHERE a.id = {artwork_id}
"""
)
row: dict = cursor.fetchone() # type: ignore
cls._close_cursor_and_conn(cursor, conn)
return cls._construct(cls, row) if row else None
@classmethod
def select_all(cls, table_name: str = ARTWORK_TABLE) -> list["Artwork"]:
cursor, conn = cls._get_cursor_and_conn()
cursor.execute(
f"""-- sql
SELECT
a.id,
a.medium_id ,
a.artwork_name ,
a.source ,
a.thumbnail ,
a.is_featured ,
a.release_year ,
a.`size` ,
m.id as medium_id,
m.medium_name
FROM {table_name} a
LEFT JOIN {ART_MEDIUM_TABLE} m ON a.medium_id = m.id
"""
)
rows: list[dict] = cursor.fetchall() # type: ignore
cls._close_cursor_and_conn(cursor, conn)
return sorted(
[cls._construct(cls, row) for row in rows],
key=lambda a: (a.is_featured, a.release_year if a.release_year else 0),
reverse=True,
)
@classmethod
def _construct(cls, Obj: Type, row: dict) -> "Artwork":
row["medium"] = (
Medium(id=row["medium_id"], medium_name=row["medium_name"])
if row.get("medium_id")
else None
)
return Obj(**row)

62
server/app/model/bio.py Normal file
View File

@@ -0,0 +1,62 @@
from pydantic import HttpUrl
from app.constants import BIO_CONTENT_TABLE, SERVICES_TABLE, SOCIAL_TABLE
from app.model.model_object import ModelObject
from app.model.response_object import ResponseObject
class ProfessionalService(ModelObject, ResponseObject):
service_name: str
@classmethod
def select_all(
cls, table_name: str = SERVICES_TABLE
) -> list["ProfessionalService"]:
return [
cls._construct(ProfessionalService, row)
for row in super().select_all(table_name)
]
@classmethod
def select_one(
cls, obj_id: int, table_name: str = SERVICES_TABLE
) -> "ProfessionalService | None":
return cls._construct(
ProfessionalService, super().select_one(obj_id, table_name)
)
class SocialUrl(ModelObject, ResponseObject):
social_name: str
social_url: HttpUrl
@classmethod
def select_one(
cls, obj_id: int, table_name: str = SOCIAL_TABLE
) -> "SocialUrl | None":
return cls._construct(SocialUrl, super().select_one(obj_id, table_name))
@classmethod
def select_all(cls, table_name: str = SOCIAL_TABLE) -> list["SocialUrl"]:
return [
cls._construct(SocialUrl, row) for row in super().select_all(table_name)
]
class Bio(ModelObject, ResponseObject):
name: str
bio: str
social_urls: list[SocialUrl]
@classmethod
def select_one(cls, table_name: str = BIO_CONTENT_TABLE) -> "Bio":
bio_data = super().select_one(1, table_name)
bio_content = bio_data.get("content", "") if bio_data else ""
socials = SocialUrl.select_all()
bio = Bio(bio=bio_content, social_urls=socials, name="Megan Johns")
del bio.id
return bio
@classmethod
def select_all(cls, **args) -> None:
raise NotImplemented

View File

@@ -0,0 +1,59 @@
from typing import Any, Optional, Type
from icecream import ic
from mysql.connector.connection import MySQLConnection
from mysql.connector.cursor import MySQLCursor
from pydantic import BaseModel
from app.db.conn import connect_db
class ModelObject:
@classmethod
def select_one(cls, obj_id: int, table_name: str = "") -> dict | None:
if not table_name:
raise Exception(
"table_name cannot be an empty string. Check default arguments."
)
cursor, conn = cls._get_cursor_and_conn()
cursor.execute(
f"""-- sql
SELECT *
FROM {table_name}
WHERE id = {obj_id};
"""
)
data: dict[Any, Any] | None = cursor.fetchone() # type: ignore
cls._close_cursor_and_conn(cursor, conn)
return data
@classmethod
def select_all(cls, table_name: str = "") -> list[dict]:
if not table_name:
raise Exception(
"table_name cannot be an empty string. Check default arguments."
)
cursor, conn = cls._get_cursor_and_conn()
cursor.execute(
f"""-- sql
SELECT *
FROM {table_name};
"""
)
data: list[dict] = cursor.fetchall() # type: ignore
cls._close_cursor_and_conn(cursor, conn)
return data
@classmethod
def _get_cursor_and_conn(cls) -> tuple[MySQLCursor, MySQLConnection]:
conn = connect_db()
return conn.cursor(dictionary=True), conn
@classmethod
def _close_cursor_and_conn(cls, cursor: MySQLCursor, conn: MySQLConnection) -> None:
cursor.close()
conn.close()
@classmethod
def _construct(cls, Obj: Type, data: dict | None) -> Any:
return Obj(**data) if data is not None else None

View File

@@ -0,0 +1,22 @@
from typing import Optional, Type
from icecream import ic
from pydantic import HttpUrl
from app.constants import QUOTES_TABLE
from app.model.model_object import ModelObject
from app.model.response_object import ResponseObject
class Quote(ModelObject, ResponseObject):
body: str
author: str
source: Optional[HttpUrl] = None
@classmethod
def select_one(cls, obj_id: int, table_name: str = QUOTES_TABLE) -> "Quote | None":
return cls._construct(cls, super().select_one(obj_id, table_name))
@classmethod
def select_all(cls, table_name: str = QUOTES_TABLE) -> list["Quote"]:
return [cls._construct(cls, row) for row in super().select_all(table_name)]

View File

@@ -0,0 +1,7 @@
from typing import Optional
from pydantic import BaseModel
class ResponseObject(BaseModel):
id: Optional[int] = None

25
server/app/model/video.py Normal file
View File

@@ -0,0 +1,25 @@
from typing import Optional, Type
from pydantic import HttpUrl
from app.constants import VIDEOS_TABLE
from app.model.model_object import ModelObject
from app.model.response_object import ResponseObject
class Video(ModelObject, ResponseObject):
title: str
subtitle: str
description: str # html
source: HttpUrl
embedded_player_iframe: str # an iframe from YouTube/Vimeo
website: Optional[HttpUrl] = None
@classmethod
def select_one(cls, obj_id: int, table_name: str = VIDEOS_TABLE) -> "Video | None":
data = super().select_one(obj_id, table_name)
return cls._construct(cls, data)
@classmethod
def select_all(cls, table_name: str = VIDEOS_TABLE) -> list["Video"]:
return [cls._construct(cls, row) for row in super().select_all(table_name)]

8
server/app/origins.py Normal file
View File

@@ -0,0 +1,8 @@
origins = [
"http://localhost:5173",
"http://127.0.0.1:5173/",
"https://mj.lucasjensen.me",
"https://meganjohns.com/",
"https://www.meganjohns.com",
"https://www.meganjohns.com/",
]

View File

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from app.controller import controller
from app.model import Album
router = APIRouter(
prefix="/albums",
tags=["albums"],
responses={404: {"description": "Not found"}},
)
@router.get("/")
async def all_albums() -> list[Album]:
return await controller.get_all_albums()
@router.get("/{album_id}")
async def album(album_id: int) -> Album:
return controller.get_one_album(album_id)

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from app.controller import controller
from app.model import Artwork
router = APIRouter(
prefix="/artwork",
tags=["artwork"],
responses={404: {"description": "Not found"}},
)
@router.get("/")
async def all_artwork() -> list[Artwork]:
return await controller.get_all_artwork()
@router.get("/{artwork_id}")
async def artwork(artwork_id: int) -> Artwork:
return controller.get_one_artwork(artwork_id)

20
server/app/routers/bio.py Normal file
View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from app.controller import controller
from app.model.bio import Bio, ProfessionalService
router = APIRouter(
prefix="/bio",
tags=["bio"],
responses={404: {"description": "Not found"}},
)
@router.get("/")
async def bio() -> Bio:
return await controller.get_bio()
@router.get("/services")
async def services() -> list[ProfessionalService]:
return await controller.get_all_professional_services()

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from app.controller import controller
from app.model import Quote
router = APIRouter(
prefix="/quotes",
tags=["quotes"],
responses={404: {"description": "Not found"}},
)
@router.get("/")
async def all_quotes() -> list[Quote]:
return await controller.get_all_quotes()
@router.get("/{id}")
async def quote(id: int) -> Quote:
return controller.get_one_quote(id)

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from app.controller import controller
from app.model import Video
router = APIRouter(
prefix="/videos",
tags=["videos"],
responses={404: {"description": "Not found"}},
)
@router.get("/")
async def all_videos() -> list[Video]:
return await controller.get_all_videos()
@router.get("/{video_id}")
async def video(video_id: int) -> Video:
return controller.get_one_video(video_id)

View File

View File

@@ -0,0 +1,4 @@
<p>American indie rock singer-songwriter, <strong>Megan Johns</strong>, originates from a small urban oasis in endless Illinois fields. At age 16, she recorded her first original folk pop album to local acclaim, written secretly in early adolescence.</p>
<p>Johns has performed hundreds of live original shows, including worldwide, supporting Johanna Warren, R. Ring (Kelley Deal, The Breeders), Jenny Owen Youngs, Swooning (Briana Marela), Zion I Crew (with Swords & The Struggle), Liz Cooper (& The Stampede), Lola Kirk, Said The Whale, Angie Heaton, King Washington, and many more.</p>
<p>She has musically collaborated with Zoot Suit Riot and Tori Amos Double-Platinum engineer, Billy Barnett (Gung Ho Studio), Allison Kraus, Hum, Ani DiFranco, and Ludacris engineer, Mark Rubel (Pogo Studio/Blackbird), Adam Schmitt, Mountain Goats bassist, Peter Hughes, Andy Lund, many loved ones, accomplished string and horn players, emcees, and bands.</p>
<p>Based in the Pacific Northwest for the last decade, Johns' mission is to share creativity. She now records her own music and alternatively performs as MoonWish.</p>

43
server/app/scripts/bump.py Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
from pathlib import Path
from subprocess import run
UTF8 = "utf-8"
def bump():
tags = [int(tag) for tag in find_tags() if tag.isnumeric()]
tag_name = max(tags) + 1
cmds = [f"git tag {tag_name}", f"git push origin tag {tag_name}"]
for cmd in cmds:
cmd = cmd.split(" ")
output = run(cmd, capture_output=True, encoding=UTF8)
if output.stdout:
print(output.stdout)
if output.stderr:
print(output.stderr)
def find_tags() -> list[str]:
dot_git = find_git_dir()
tags_dir = dot_git / "refs" / "tags"
for _, _, files in tags_dir.walk():
return files
raise Exception("Error parsing tags")
def find_git_dir() -> Path:
dot_git = ".git"
def _find_git_dir(cwd: Path = Path(__file__)) -> Path:
for _, dirs, _ in cwd.walk(top_down=False):
if dot_git in dirs:
return cwd / dot_git
return _find_git_dir(cwd.parent)
return _find_git_dir()
if __name__ == "__main__":
bump()

12
server/app/scripts/run.py Normal file
View File

@@ -0,0 +1,12 @@
import subprocess
from pathlib import Path
def main() -> None:
print("starting app in development mode")
curr_dir = Path(__file__).resolve().parent.absolute()
script = curr_dir / "run.sh"
try:
subprocess.run(["sh", script], check=True)
except KeyboardInterrupt:
return

3
server/app/scripts/run.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
uvicorn app:app --reload --port 8000

790
server/app/scripts/seed.py Normal file
View File

@@ -0,0 +1,790 @@
from pathlib import Path
from mysql.connector import MySQLConnection
from mysql.connector.cursor import MySQLCursor
from app.constants import (
ALBUMS_TABLE,
ART_MEDIUM_TABLE,
ARTICLES_TABLE,
ARTISTS_TABLE,
ARTWORK_TABLE,
BIO_CONTENT_TABLE,
QUOTES_TABLE,
SERVICES_TABLE,
SOCIAL_TABLE,
VIDEOS_TABLE,
)
from app.db.conn import connect_db
from app.model import Album, Artist, Artwork, Medium, Quote, Video
from app.model.articles import Article
from app.model.bio import Bio, ProfessionalService, SocialUrl
# videos
videos: list[Video] = [
Video(
title="Human",
subtitle="(Official Music Video)",
description="<p>Song and Video Written, Performed, Produced, Recorded, Filmed and Edited by Megan Johns.</p>",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/HiskLzZHX48?si=yomwnyr4FkupXU1e" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://youtu.be/HiskLzZHX48?si=WA30xIpf1iSfJvPt", # type: ignore
),
Video(
title="EQUALITY IS HERE".title(),
subtitle="(Dark Comedy)",
description="<p>Song by Megan Johns Video Written, Directed, Produced, Storyboarded, and Edited by Megan Johns</p>",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/TvXPCKv7eg4?si=AOrnPvhDS1NMD0tQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://youtu.be/TvXPCKv7eg4?si=kpUFyN3TM-FjJ67B", # type: ignore
),
Video(
title="Feathers",
subtitle="(34 Minute Dark Fantasy Drama)",
description="<p>Written, Directed, Produced, Storyboarded, Co-Scored and Co-Edited by Megan Johns Starring Sara Blythe Visual Effects by Dylan Keim Produced by MoonWish Productions, Tendrile Productions, BluryFilms, One Tree Productions LLC, and Oros Productions Director of Photography Walter King Score by Megan Johns and Shannon Swords (Bubble Bubble Gum Gum)</p>",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/tk8LlaAwgCg?si=olWVWRrhVEVxhFKp" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://youtu.be/tk8LlaAwgCg?si=hvREJdL6DZ3CkySb", # type: ignore
website="https://feathersmovie.weebly.com/", # type: ignore
),
Video(
title="Gemini",
subtitle="(Official Music Video) (From 2019 Movie 'Live Free Or Die')",
description="<p>Song Written by Megan Johns Video Directed by Chelsea Real Video Edited by Megan Johns (MoonWish Productions) and One Tree Productions LLC</p>",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/pWCTmCl9Im4?si=CORUdH3m5SS57nmU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://www.youtube.com/watch?v=pWCTmCl9Im4", # type: ignore
),
Video(
title="Talk of Dreams",
subtitle="(Official Music Video)",
description="<p>Video Directed and Edited by Rick Gates (Tendrile Productions) Camera Operated by Rick Gates, James Kaiser, and Nick Pemble</p>",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/yhXF5F_4F_k?si=o3kdxe9cRQXoHH0Y" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://youtu.be/yhXF5F_4F_k?si=MIi0uMHhg1kTYDYe", # type: ignore
),
Video(
title="Still",
subtitle="(Official Music Video)",
description="<p>Song Written by Megan Johns Video Directed and Edited by Matt HarsH & Sam Ambler Director of Photography Mark Spomer</p>",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/USNr1pJRXR4?si=DpHG_wR6BGaNlT3o" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://www.youtube.com/watch?v=USNr1pJRXR4", # type: ignore
),
Video(
title="By the Way",
subtitle="(EFS 72 Hr Music Video Contest Entry)",
description="<p>Song Written by Megan Johns Video Written, Directed and Edited Whitney Peterson Filmed by Whitney Peterson & Cody Van Roberts</p>",
embedded_player_iframe="""<iframe src="https://player.vimeo.com/video/167692299?h=40de8a4ade" width="640" height="360" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>""",
source="https://www.youtube.com/watch?v=RFad185zIYw", # type: ignore
),
Video(
title="Sunday Drive",
subtitle="(Official Music Video)",
description="<p>Song Written by Megan Johns Video Directed, Filmed and Edited by Garrick Nelson</p>",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/cX8K4BJbWWg?si=vSE8HNVJbBH54YQL" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://www.youtube.com/watch?v=cX8K4BJbWWg", # type: ignore
),
Video(
title="Hey, Lonely",
subtitle="(Official Music Video)",
description="<p>Song Written by Megan Johns Video Directed, Filmed and Edited by James Triechler</p>",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/AmrUTugnP2s?si=9XRy3D7hi7GHq_qV" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://youtu.be/AmrUTugnP2s?si=sGPhNkYynj2iyPnt", # type: ignore
),
Video(
title="Moonwish - Gemini",
subtitle="(Electronic Version)",
description="<p>Song Written by Megan Johns</p><p>Video Directed, Filmed and Edited by John Isberg</p><p>Video Produced by Matt HarsH, John Isberg and Megan Johns</p>",
embedded_player_iframe="""<iframe src="https://player.vimeo.com/video/143818518?h=2159fc0374" width="640" height="360" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>""",
source="https://vimeo.com/143818518?embedded=true&source=vimeo_logo&owner=31047464", # type: ignore
),
Video(
title="CAFÉ Song".title(),
subtitle="(Official Music Video)",
description="<p>Song Written, Performed, and Produced by Megan Johns Video Directed, Filmed and Edited by Matt HarsH</p>",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/llp2oi3zb_8?si=LrgxWYwmbHgBi_Es" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://www.youtube.com/watch?v=llp2oi3zb_8&t=10s", # type: ignore
),
Video(
title="THE BEAT WAS BURNT".title(),
subtitle="(Official Music Video)",
description="Song Written by Megan Johns Video Directed by Matt HarsH and Filmed by Sam Ambler",
embedded_player_iframe="""<iframe width="560" height="315" src="https://www.youtube.com/embed/5fDfH7XD92s?si=hULHJgCfe_iRrdiS" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>""",
source="https://www.youtube.com/watch?v=5fDfH7XD92s", # type: ignore
),
]
# quotes
quotes = [
Quote(
body="Something like Trent Reznor taking over the Smashing Pumpkins and replacing Corgan with Tori Amos.",
author="Middle Tennessee Music",
source="https://www.indiemusicdiscovery.com/megan-johns-says-hey-lonely/", # type: ignore
),
Quote(
body="Melodic and hypnotizing. | Best Local Singer-Songwriter 2013",
author="Smile Politely",
source="http://www.smilepolitely.com/music/best_music_2013/", # type: ignore
),
Quote(
body="Tight aggressive power chords underpin Johns's raspy yet melodic singing voice, telling a story in impressionistic fragments rather than a single narrative.",
author="Eugene Magazine",
),
Quote(
body="Johns creates music that is at once sweet and biting: the languid tone of her voice providing a perfect lace overlay to the harder-rocking arrangements beneath them.",
author="The News Gazette",
),
Quote(
body="She lures her listeners to unearth a deeper subconscious level.",
author="The Buzz Weekly",
),
]
# Social & Bio information
socials = [
SocialUrl(
social_name="itunes",
social_url="https://itunes.apple.com/us/artist/megan-johns/74351585", # type: ignore
),
SocialUrl(
social_name="facebook",
social_url="http://www.facebook.com/meganjohnsmusic", # type: ignore
),
SocialUrl(
social_name="soundcloud",
social_url="https://soundcloud.com/meganjohns", # type: ignore
),
SocialUrl(
social_name="youtube",
social_url="http://youtube.com/user/MeganJohnsVideos", # type: ignore
),
SocialUrl(
social_name="instagram",
social_url="https://instagram.com/meganjohnsmusic/", # type: ignore
),
SocialUrl(
social_name="spotify",
social_url="https://open.spotify.com/artist/3CTUWD06ndDSuuUUJHm1bf", # type: ignore
),
SocialUrl(
social_name="bandcamp",
social_url="https://meganjohns.bandcamp.com/track/i-am-old", # type: ignore
),
]
services = [
ProfessionalService(service_name="Music Composition & Performance"),
ProfessionalService(service_name="Audio Engineering"),
ProfessionalService(service_name="Voice Over & Acting"),
ProfessionalService(service_name="Videography / Filmmaking"),
ProfessionalService(service_name="Visual Art"),
ProfessionalService(service_name="Classes"),
]
bio_html_content = ""
ROOT_DIR = Path(__file__).parent
HTML_FILE = ROOT_DIR / "bio.html"
with open(HTML_FILE, "r") as html_file:
for line in html_file.readlines():
bio_html_content += line.strip()
bio = Bio(name="Megan Johns", bio=bio_html_content, social_urls=socials)
# art mediums
arcylic = Medium(medium_name="Acrylic on Canvas")
oil = Medium(medium_name="Oil on Canvas")
zinc = Medium(medium_name="Zinc Plate Etching")
charcol = Medium(medium_name="Charcol on Paper")
rice_paper = Medium(medium_name="Pen and Ink on Rice Paper")
watercolor = Medium(medium_name="Watercolor on Paper En Plein Air")
illustrator_poster = Medium(medium_name="Illustrator Poster")
graphic_design = Medium(medium_name="Graphic Design")
mediums = [
arcylic,
oil,
zinc,
charcol,
rice_paper,
watercolor,
illustrator_poster,
graphic_design,
]
# artwork
# https://docs.google.com/spreadsheets/d/1RMAGgpEAjEL6Kf-QGTKxuZXZD5Cqy7NVG2pnAPctcdw/edit?gid=0#gid=0
artwork: list[Artwork] = [
Artwork(
artwork_name="Gas Mask Study",
medium=oil,
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1721007132/MeganJohns/Art/Gaslit_Megan_Johns__Acrylic_on_Canvas_2009_ccge5s.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010768/MeganJohns/Art/thumbnails/Gaslit_Megan_Johns__Acrylic_on_Canvas_2009_ccge5s-400x311_aplzhd.jpg", # type: ignore
release_year=2009,
size='11"x14"',
),
Artwork(
artwork_name="Women's March",
is_featured=True,
medium=arcylic,
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1720988650/MeganJohns/Art/Women_s_March_Painting_24x36_Acrylic_on_Canvas_2017_Megan_Johns_dct1vp.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010773/MeganJohns/Art/thumbnails/Women_s_March_Painting_24x36_Acrylic_on_Canvas_2017_Megan_Johns_dct1vp-400x264_i3p4mx.jpg", # type: ignore
release_year=2017,
size='24"x36"',
),
Artwork(
artwork_name="Spilled Beans Study",
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1720992412/MeganJohns/Art/Spilled_Beans_Signature_2019_q81yvi.png", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010768/MeganJohns/Art/thumbnails/Spilled_Beans_Signature_2019_q81yvi-400x400_daovzb.png", # type: ignore
release_year=2019,
),
Artwork(
artwork_name="Captain",
medium=zinc,
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1718391476/MeganJohns/Art/Captain_9x12_Zinc_Plate_Etching_2009_zxh2wm.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010768/MeganJohns/Art/thumbnails/Captain_9x12_Zinc_Plate_Etching_2009_zxh2wm-400x267_few8in.jpg", # type: ignore
release_year=2009,
size='9"x12"',
),
Artwork(
artwork_name="Resting Chill Face",
medium=arcylic,
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1720992884/MeganJohns/Art/Resting_Chill_Face_16x20_Acrylic_on_Canvas_from_Life_2019_edited_no_edges_hfw28u.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010772/MeganJohns/Art/thumbnails/Resting_Chill_Face_16x20_Acrylic_on_Canvas_from_Life_2019_edited_no_edges_hfw28u-316x400_umgpus.jpg", # type: ignore
release_year=2019,
size='16"x12"',
),
Artwork(
artwork_name="Louis XIV",
medium=arcylic,
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1720988576/MeganJohns/Art/Louis_XIV_16x20_Acrylic_on_Canvas_2019_Megan_Johns_kmbirh.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010769/MeganJohns/Art/thumbnails/Louis_XIV_16x20_Acrylic_on_Canvas_2019_Megan_Johns_kmbirh-319x400_ixelam.jpg", # type: ignore
release_year=2019,
size='16"x20"',
),
Artwork(
artwork_name="Allerton",
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1720988504/MeganJohns/Art/Allerton_FU_Dog_Watercolor_on_Paper_En_Plein_Air_no_edges_cmkbjs.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010771/MeganJohns/Art/thumbnails/Allerton_FU_Dog_Watercolor_on_Paper_En_Plein_Air_no_edges_cmkbjs-300x400_odzydz.jpg", # type: ignore
),
Artwork(
artwork_name="Goat and Rooster",
medium=arcylic,
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1720988474/MeganJohns/Art/Goat_and_Rooster_16x20_Acrylic_on_Canvas_2019_Np_edges_d2y7hk.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010771/MeganJohns/Art/thumbnails/Goat_and_Rooster_16x20_Acrylic_on_Canvas_2019_Np_edges_d2y7hk-314x400_uhskcy.jpg", # type: ignore
release_year=2019,
size='16"x20"',
),
Artwork(
artwork_name="Feathers Poster",
medium=graphic_design,
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1718413105/MeganJohns/Art/Feathers_Poster_Graphic%20Design_2020_hcjej2.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010771/MeganJohns/Art/thumbnails/Feathers_Poster_Graphic_20Design_2020_hcjej2-270x400_zwzwd3.jpg", # type: ignore
release_year=2020,
),
Artwork(
artwork_name="Hey Lonely",
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1718391586/MeganJohns/Art/Hey_Lonely_Photoshopped_Photography_2012_h1aitf.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010773/MeganJohns/Art/thumbnails/Hey_Lonely_Photoshopped_Photography_2012_h1aitf-400x400_l0bghw.jpg", # type: ignore
release_year=2012,
),
Artwork(
artwork_name="Teapot and Teacup",
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1718391538/MeganJohns/Art/Teapot_and_Teacup_Acrylic_on_Canvas_2017_r6ivpo.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010769/MeganJohns/Art/thumbnails/Teapot_and_Teacup_Acrylic_on_Canvas_2017_r6ivpo-400x233_ztsjtv.jpg", # type: ignore
release_year=2017,
),
Artwork(
artwork_name="Polyphemus Moth",
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1718391523/MeganJohns/Art/Polyphemus_Moth_16x16_Acrylic_on_Canvas_Megan_Johns_hgqc1h.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010771/MeganJohns/Art/thumbnails/Polyphemus_Moth_16x16_Acrylic_on_Canvas_Megan_Johns_hgqc1h-400x400_vlnju2.jpg", # type: ignore
size='16"x16"',
),
Artwork(
artwork_name="Lou",
medium=arcylic,
release_year=2019,
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1718391496/MeganJohns/Art/Lou_Acrylic_on_Canvas_2019_fze7qc.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010769/MeganJohns/Art/thumbnails/Lou_Acrylic_on_Canvas_2019_fze7qc-320x400_cf5ed9.jpg", # type: ignore
),
Artwork(
artwork_name="Silver Teapot",
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1718349058/MeganJohns/Art/Silver_Teapot_12x12_Acrylic_on_Canvas_2017_Megan_Johns_zfrknd.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010771/MeganJohns/Art/thumbnails/Silver_Teapot_12x12_Acrylic_on_Canvas_2017_Megan_Johns_zfrknd-400x400_tz6ovb.jpg", # type: ignore
release_year=2017,
size='12"x12"',
),
Artwork(
artwork_name="Gemini",
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1718347980/MeganJohns/Art/Gemini_Single_Album_Cover_Photoshoped_Photography__2015_Megan_Johns_v4zwao.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010769/MeganJohns/Art/thumbnails/Gemini_Single_Album_Cover_Photoshoped_Photography__2015_Megan_Johns_v4zwao-400x400_a7kgs5.jpg", # type: ignore
release_year=2015,
),
Artwork(
artwork_name="Glass Ceiling",
source="https://res.cloudinary.com/dreftv0ue/image/upload/v1718347979/MeganJohns/Art/Glass_Cieling_Self_Portrait_Acrylic_on_Canvas_2009_Megan_Johns_jsgupl.jpg", # type: ignore
thumbnail="https://res.cloudinary.com/dreftv0ue/image/upload/v1721010769/MeganJohns/Art/thumbnails/Glass_Cieling_Self_Portrait_Acrylic_on_Canvas_2009_Megan_Johns_jsgupl-400x269_luwr5i.jpg", # type: ignore
release_year=2009,
),
]
# news articles
# TODO
articles: list[Article] = []
# album artists
megan_artist = Artist(
artist_name="Megan Johns",
artist_url="https://music.apple.com/us/artist/megan-johns/74351585", # type: ignore
)
greytones = Artist(
artist_name="The Greytones",
artist_url="https://open.spotify.com/artist/5JyA3JrRMinqhLslj7EyLl", # type: ignore
)
bubble_bubble_bum_bum = Artist(
artist_name="Bubble Bubble Gum Gum",
artist_url="https://music.apple.com/us/artist/bubble-bubble-gum-gum/1554007615", # type: ignore
)
artists: list[Artist] = [megan_artist, greytones, bubble_bubble_bum_bum]
# discography
albums: list[Album] = [
Album(
album_name="Dirty Shoes",
release_year=2005,
artist=megan_artist,
apple_music_url="https://music.apple.com/us/album/dirty-shoes/74351635", # type: ignore
bandcamp_url="https://meganjohns.bandcamp.com/track/fog", # type: ignore
front_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363942/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/Dirty_Shoes_Cover_2005_bnb8u0.jpg", # type: ignore
bandcamp_player='<iframe style="border: 0; width: 100%; height: 120px;" src="https://bandcamp.com/EmbeddedPlayer/track=2275072448/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/transparent=true/" seamless><a href="https://meganjohns.bandcamp.com/track/fog">Fog by Megan Johns</a></iframe>',
),
Album(
album_name="Hey, Lonely",
release_year=2012,
artist=megan_artist,
apple_music_url="https://music.apple.com/us/album/hey-lonely/565637660", # type: ignore
front_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363966/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/Hey_Lonely_Jacket_hires-2_kwo34f.jpg", # type: ignore
rear_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717364028/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/Hey_Lonely_Reverse2_esmjia.jpg", # type: ignore
bandcamp_player='<iframe style="border: 0; width: 100%; height: 120px;" src="https://bandcamp.com/EmbeddedPlayer/album=2623205502/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/transparent=true/" seamless><a href="https://meganjohns.bandcamp.com/album/hey-lonely">Hey, Lonely by Megan Johns</a></iframe>',
),
Album(
album_name="Gemini",
release_year=2015,
artist=megan_artist,
bandcamp_url="https://meganjohns.bandcamp.com/track/gemini", # type: ignore
front_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363956/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/Gemini_MoonWish_Single_2015_spgv2m.jpg", # type: ignore
bandcamp_player='<iframe style="border: 0; width: 100%; height: 120px;" src="https://bandcamp.com/EmbeddedPlayer/track=2493215358/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/transparent=true/" seamless><a href="https://meganjohns.bandcamp.com/track/gemini">Gemini by Moonwish</a></iframe>',
),
Album(
album_name="Inner Voice",
release_year=2019,
artist=megan_artist,
bandcamp_url="https://meganjohns.bandcamp.com/album/inner-voice-ep", # type: ignore
front_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363981/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/Inner_Voice_EP_2019_po0nbq.jpg", # type: ignore
rear_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363984/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/Inner_Voice_EP_Backcover_2019_ilenfq.jpg", # type: ignore
bandcamp_player='<iframe style="border: 0; width: 100%; height: 120px;" src="https://bandcamp.com/EmbeddedPlayer/album=1645073059/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/transparent=true/" seamless><a href="https://meganjohns.bandcamp.com/album/inner-voice-ep">Inner Voice EP by Megan Johns</a></iframe>',
),
Album(
album_name="MoonWish Recordings 2015",
release_year=2015,
artist=megan_artist,
bandcamp_url="https://meganjohns.bandcamp.com/album/moonwish-recordings-2015", # type: ignore
front_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363988/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/MoonWish_Recordings_2015_j3pzyd.jpg", # type: ignore
bandcamp_player='<iframe style="border: 0; width: 100%; height: 120px;" src="https://bandcamp.com/EmbeddedPlayer/album=3211709152/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/transparent=true/" seamless><a href="https://meganjohns.bandcamp.com/album/moonwish-recordings-2015">MoonWish Recordings 2015 by Megan Johns</a></iframe>',
),
Album(
album_name="Penumbra",
release_year=2007,
artist=greytones,
spotify_url="https://open.spotify.com/album/1FMhRBPjhCOe21JNwgYUAb", # type: ignore
front_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363997/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/The_Greytones_Penumbra_Cover_2007_rh02wu.jpg", # type: ignore
),
Album(
album_name="Feathers",
release_year=2021,
artist=bubble_bubble_bum_bum,
spotify_url="https://music.apple.com/us/album/feathers-original-motion-picture-score/1554024529", # type: ignore
front_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363949/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/Feathers_Score_Bubble_Bubble_Gum_Gum_2021_itjio0.jpg", # type: ignore
),
Album(
album_name="Human",
release_year=2022,
artist=megan_artist,
bandcamp_url="https://meganjohns.bandcamp.com/track/human", # type: ignore
front_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363973/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/Human_Single_2022_lknlpc.png", # type: ignore
bandcamp_player='<iframe style="border: 0; width: 100%; height: 120px;" src="https://bandcamp.com/EmbeddedPlayer/track=3620590740/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/transparent=true/" seamless><a href="https://meganjohns.bandcamp.com/track/human">Human by Megan Johns</a></iframe>',
),
Album(
album_name="I Am Old",
release_year=2022,
artist=megan_artist,
bandcamp_url="https://meganjohns.bandcamp.com/track/i-am-old", # type: ignore
front_artwork_url="https://res.cloudinary.com/dreftv0ue/image/upload/v1717363976/MeganJohns/Discography%20-%20Album%20%2B%20Single%20Covers/I_Am_Old_Single_2022_mao2yw.jpg", # type: ignore
bandcamp_player='<iframe style="border: 0; width: 100%; height: 120px;" src="https://bandcamp.com/EmbeddedPlayer/track=2078638263/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/transparent=true/" seamless><a href="https://meganjohns.bandcamp.com/track/i-am-old">I Am Old by Megan Johns</a></iframe>',
),
]
def get_db_cursor() -> tuple[MySQLConnection, MySQLCursor]:
db = connect_db()
cursor = db.cursor()
return db, cursor
def close_db_cursor(db: MySQLConnection, cursor: MySQLCursor) -> None:
db.commit()
cursor.close()
db.close()
def drop_tables():
db, cursor = get_db_cursor()
for table in [
ALBUMS_TABLE,
ARTISTS_TABLE,
ARTICLES_TABLE,
ARTWORK_TABLE,
ART_MEDIUM_TABLE,
BIO_CONTENT_TABLE,
SOCIAL_TABLE,
QUOTES_TABLE,
VIDEOS_TABLE,
SERVICES_TABLE,
]:
print(f"dropping table: {table}")
cursor.execute(
f"""-- sql
DROP TABLE IF EXISTS {table};
"""
)
close_db_cursor(db, cursor)
print("")
def seed_albums() -> None:
print(f"seeding {len(albums)} albums")
db, cursor = get_db_cursor()
cursor.execute(
f"""-- sql
CREATE TABLE {ALBUMS_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
album_name VARCHAR(255) NOT NULL,
release_year YEAR,
artist_id INT,
spotify_url VARCHAR(255),
itunes_url VARCHAR(255),
bandcamp_url VARCHAR(255),
apple_music_url VARCHAR(255),
front_artwork_url VARCHAR(255),
rear_artwork_url VARCHAR(255),
bandcamp_player VARCHAR(1024),
PRIMARY KEY (id),
FOREIGN KEY (artist_id) REFERENCES {ARTISTS_TABLE}(id) ON DELETE CASCADE
);
"""
)
for album in albums:
cursor.execute(
f"""-- sql
INSERT INTO {ALBUMS_TABLE} (album_name, release_year, artist_id, spotify_url, itunes_url, bandcamp_url, apple_music_url, front_artwork_url, rear_artwork_url, bandcamp_player)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s);
""",
(
album.album_name,
album.release_year,
album.artist.id,
str(album.spotify_url) if album.spotify_url else None,
str(album.itunes_url) if album.itunes_url else None,
str(album.bandcamp_url) if album.bandcamp_url else None,
str(album.apple_music_url) if album.apple_music_url else None,
str(album.front_artwork_url) if album.front_artwork_url else None,
str(album.rear_artwork_url) if album.rear_artwork_url else None,
album.bandcamp_player,
),
)
if cursor.lastrowid:
album.id = cursor.lastrowid
db.commit()
cursor.close()
db.close()
def seed_quotes() -> None:
print(f"seeding {len(quotes)} quotes")
db, cursor = get_db_cursor()
cursor.execute(
f"""-- sql
CREATE TABLE {QUOTES_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
body VARCHAR(1000) NOT NULL,
author VARCHAR(255) NOT NULL,
source VARCHAR(255),
PRIMARY KEY (id)
);
"""
)
for quote in quotes:
cursor.execute(
f"""-- sql
INSERT INTO {QUOTES_TABLE} (body, author, source)
VALUES (%s, %s, %s);
""",
(quote.body, quote.author, str(quote.source) if quote.source else None),
)
if cursor.lastrowid:
quote.id = cursor.lastrowid
close_db_cursor(db, cursor)
def seed_artists() -> None:
print(f"seeding {len(artists)} artists")
db, cursor = get_db_cursor()
cursor.execute(
f"""-- sql
CREATE TABLE {ARTISTS_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
artist_name VARCHAR(255) NOT NULL,
artist_url VARCHAR(255) NOT NULL,
PRIMARY KEY (id)
);
"""
)
for artist in artists:
cursor.execute(
f"""-- sql
INSERT INTO {ARTISTS_TABLE} (artist_name, artist_url)
VALUES (%s, %s);
""",
(artist.artist_name, str(artist.artist_url)),
)
if cursor.lastrowid:
artist.id = cursor.lastrowid
close_db_cursor(db, cursor)
def seed_articles():
print(f"seeding {len(articles)} articles")
db = connect_db()
cursor = db.cursor()
cursor.execute(
f"""-- sql
CREATE TABLE articles (
id INT NOT NULL AUTO_INCREMENT,
article_title VARCHAR(255) NOT NULL,
body VARCHAR(255) NOT NULL,
video_url VARCHAR(255),
is_featured BOOLEAN DEFAULT FALSE,
PRIMARY KEY (id)
);
"""
)
for article in articles:
cursor.execute(
f"""-- sql
INSERT INTO {ARTICLES_TABLE} (article_title, body, video_url, is_featured)
VALUES (%s, %s, %s, %s)
""",
(
article.article_title,
article.body,
str(article.video_url),
article.is_featured,
),
)
if cursor.lastrowid:
article.id = cursor.lastrowid
db.commit()
cursor.close()
db.close()
def seed_artwork():
print(f"seeding {len(artwork)} pieces of art")
db = connect_db()
cursor = db.cursor()
cursor.execute(
f"""-- sql
CREATE TABLE {ARTWORK_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
medium_id INT,
artwork_name VARCHAR(255) UNIQUE NOT NULL,
source VARCHAR(255) NOT NULL,
thumbnail VARCHAR(255) NOT NULL,
is_featured BOOLEAN DEFAULT FALSE,
release_year YEAR,
size VARCHAR(255),
PRIMARY KEY (id),
FOREIGN KEY (medium_id) REFERENCES {ART_MEDIUM_TABLE}(id) ON DELETE CASCADE
);
"""
)
for work in artwork:
cursor.execute(
f"""-- sql
INSERT INTO {ARTWORK_TABLE} (medium_id, artwork_name, source, thumbnail, is_featured, release_year, size)
VALUES (%s, %s, %s, %s, %s, %s, %s);
""",
(
work.medium.id if work.medium is not None else None,
work.artwork_name,
str(work.source),
str(work.thumbnail),
work.is_featured,
work.release_year,
work.size,
),
)
if cursor.lastrowid:
work.id = cursor.lastrowid
close_db_cursor(db, cursor)
def seed_mediums():
print(f"seeding {len(mediums)} art mediums")
db, cursor = get_db_cursor()
cursor.execute(
f"""-- sql
CREATE TABLE {ART_MEDIUM_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
medium_name VARCHAR(255),
PRIMARY KEY (id)
);
"""
)
for medium in mediums:
cursor.execute(
f"""-- sql
INSERT INTO {ART_MEDIUM_TABLE} (medium_name)
VALUES (%s);
""",
(medium.medium_name,),
)
if cursor.lastrowid:
medium.id = cursor.lastrowid
close_db_cursor(db, cursor)
def seed_social_info():
print(f"seeding {len(socials)} social urls")
db, cursor = get_db_cursor()
cursor.execute(
f"""-- sql
CREATE TABLE {SOCIAL_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
social_name VARCHAR(255) NOT NULL UNIQUE,
social_url VARCHAR(255) NOT NULL UNIQUE,
primary key (id)
);
"""
)
for s in socials:
cursor.execute(
f"""-- sql
INSERT INTO {SOCIAL_TABLE} (social_name, social_url)
VALUES (%s, %s);
""",
(s.social_name, str(s.social_url)),
)
if cursor.lastrowid:
s.id = cursor.lastrowid
close_db_cursor(db, cursor)
def seed_professional_services():
print(f"seeding {len(services)} professional services")
db, cursor = get_db_cursor()
cursor.execute(
f"""-- sql
CREATE TABLE {SERVICES_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
service_name VARCHAR(255) NOT NULL UNIQUE,
primary key (id)
);
"""
)
for s in services:
cursor.execute(
f"""-- sql
INSERT INTO {SERVICES_TABLE} (service_name)
VALUES (%s);
""",
(s.service_name,),
)
if cursor.lastrowid:
s.id = cursor.lastrowid
close_db_cursor(db, cursor)
def seed_bio():
print(f"seeding single bio")
db, cursor = get_db_cursor()
cursor.execute(
f"""-- sql
CREATE TABLE {BIO_CONTENT_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
content TEXT NOT NULL,
primary key (id)
);
"""
)
cursor.execute(
f"""-- sql
INSERT INTO {BIO_CONTENT_TABLE} (content)
VALUES (%s);
""",
(bio.bio,),
)
close_db_cursor(db, cursor)
def seed_videos() -> None:
print(f"seeding single bio")
db, cursor = get_db_cursor()
cursor.execute(
f"""-- sql
CREATE TABLE {VIDEOS_TABLE} (
id INT NOT NULL AUTO_INCREMENT,
title VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
source VARCHAR(255) NOT NULL,
embedded_player_iframe TEXT,
website VARCHAR(255),
primary key (id)
);
"""
)
for video in videos:
cursor.execute(
f"""-- sql
INSERT INTO {VIDEOS_TABLE} (title, subtitle, description, source, embedded_player_iframe, website)
VALUES (%s, %s, %s, %s, %s, %s);
""",
(
video.title,
video.subtitle,
video.description,
str(video.source),
video.embedded_player_iframe,
str(video.website) if video.website else None,
),
)
if cursor.lastrowid:
video.id = cursor.lastrowid
close_db_cursor(db, cursor)
def main() -> None:
drop_tables()
seed_artists()
seed_albums()
seed_articles()
seed_mediums()
seed_artwork()
seed_social_info()
seed_bio()
seed_quotes()
seed_videos()
seed_professional_services()

13
server/meganjohns.service Normal file
View File

@@ -0,0 +1,13 @@
[Unit]
Description=uvicorn service for api.meganjohns.com
After=network.target
[Service]
User=lucas
Group=www-data
WorkingDirectory=/home/lucas/MeganJohns/server
ExecStart=/home/lucas/MeganJohns/server/.venv/bin/uvicorn app:app --host 0.0.0.0 --port 8080 --workers 2
Restart=always
[Install]
WantedBy=multi-user.target

1301
server/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
server/pyproject.toml Normal file
View File

@@ -0,0 +1,25 @@
[tool.poetry]
name = "meganjohns"
version = "1.0.0"
package-mode = false
description = "FastAPI backend for meganjohns.com"
authors = ["Lucas Jensen <lucas@lucasjensen.me>"]
readme = "README.md"
packages = [{ include = "app" }]
[tool.poetry.dependencies]
python = "^3.12"
fastapi = "^0.111.0"
mysql-connector-python = "^8.4.0"
python-dotenv = "^1.0.1"
icecream = "^2.1.3"
black = "^24.4.2"
gitpython = "^3.1.43"
[tool.poetry.scripts]
dev = "app.scripts.run:main"
seed = "app.scripts.seed:main"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

0
server/tests/__init__.py Normal file
View File