initial commit for GitHub
This commit is contained in:
33
.gitea/workflows/client.yml
Normal file
33
.gitea/workflows/client.yml
Normal 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
|
||||||
34
.gitea/workflows/server.yml
Normal file
34
.gitea/workflows/server.yml
Normal 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 .
|
||||||
36
.gitea/workflows/version.yml
Normal file
36
.gitea/workflows/version.yml
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.vscode
|
||||||
13
README.md
Normal file
13
README.md
Normal 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
2
client/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[api]
|
||||||
|
VITE_API_URL=http://localhost:8000/
|
||||||
18
client/.eslintrc.cjs
Normal file
18
client/.eslintrc.cjs
Normal 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
29
client/.gitignore
vendored
Normal 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
30
client/README.md
Normal 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
25
client/index.html
Normal 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>
|
||||||
36
client/meganjohns.com.conf
Normal file
36
client/meganjohns.com.conf
Normal 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
3696
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
client/package.json
Normal file
38
client/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
client/public/moonwish.svg
Normal file
1
client/public/moonwish.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 285 KiB |
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal 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 |
28
client/src/About/Homepage.tsx
Normal file
28
client/src/About/Homepage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
client/src/About/ProfessionalServices.tsx
Normal file
20
client/src/About/ProfessionalServices.tsx
Normal 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
32
client/src/Api.tsx
Normal 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
22
client/src/App.css
Normal 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
65
client/src/App.tsx
Normal 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;
|
||||||
45
client/src/ArtworkSection/ArtworkModal.tsx
Normal file
45
client/src/ArtworkSection/ArtworkModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
client/src/Discography/AlbumArtwork.tsx
Normal file
31
client/src/Discography/AlbumArtwork.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
client/src/Discography/AlbumModal.tsx
Normal file
43
client/src/Discography/AlbumModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
client/src/Discography/SourceList/SourceItem.tsx
Normal file
16
client/src/Discography/SourceList/SourceItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
client/src/Discography/SourceList/SourceList.tsx
Normal file
31
client/src/Discography/SourceList/SourceList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
client/src/Header/Header.css
Normal file
25
client/src/Header/Header.css
Normal 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;
|
||||||
|
}
|
||||||
20
client/src/Header/Header.tsx
Normal file
20
client/src/Header/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
client/src/Header/NavList.tsx
Normal file
28
client/src/Header/NavList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
client/src/Header/SocialBanner/SocialBanner.css
Normal file
14
client/src/Header/SocialBanner/SocialBanner.css
Normal 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);
|
||||||
|
}
|
||||||
48
client/src/Header/SocialBanner/SocialBanner.tsx
Normal file
48
client/src/Header/SocialBanner/SocialBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
client/src/Header/Title.tsx
Normal file
15
client/src/Header/Title.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
client/src/MjCard/MjCard.css
Normal file
4
client/src/MjCard/MjCard.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.mj-card {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
76
client/src/MjCard/MjCard.tsx
Normal file
76
client/src/MjCard/MjCard.tsx
Normal 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
33
client/src/MjSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
client/src/Quotes/Quote.tsx
Normal file
21
client/src/Quotes/Quote.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
client/src/Quotes/Quotes.css
Normal file
7
client/src/Quotes/Quotes.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.quote-giver {
|
||||||
|
color: var(--mj-dark-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-body {
|
||||||
|
color: var(--mj-dark-teal);
|
||||||
|
}
|
||||||
22
client/src/Quotes/Quotes.tsx
Normal file
22
client/src/Quotes/Quotes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
client/src/Videos/VideoModal.css
Normal file
0
client/src/Videos/VideoModal.css
Normal file
27
client/src/Videos/Videos.css
Normal file
27
client/src/Videos/Videos.css
Normal 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;
|
||||||
|
}
|
||||||
41
client/src/Videos/Videos.tsx
Normal file
41
client/src/Videos/Videos.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal 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
25
client/src/index.css
Normal 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
10
client/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
19
client/src/types/Album.tsx
Normal file
19
client/src/types/Album.tsx
Normal 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;
|
||||||
|
}
|
||||||
52
client/src/types/Artwork.tsx
Normal file
52
client/src/types/Artwork.tsx
Normal 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
16
client/src/types/Bio.tsx
Normal 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[];
|
||||||
|
}
|
||||||
15
client/src/types/MeganJohns.tsx
Normal file
15
client/src/types/MeganJohns.tsx
Normal 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;
|
||||||
|
}
|
||||||
6
client/src/types/Quote.tsx
Normal file
6
client/src/types/Quote.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Quote {
|
||||||
|
id: number;
|
||||||
|
body: string;
|
||||||
|
author: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
9
client/src/types/Video.tsx
Normal file
9
client/src/types/Video.tsx
Normal 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
1
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
31
client/tsconfig.json
Normal file
31
client/tsconfig.json
Normal 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
11
client/tsconfig.node.json
Normal 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
7
client/vite.config.ts
Normal 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
5
server/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[mysql]
|
||||||
|
DB_HOST=hostname
|
||||||
|
DB_USER=username
|
||||||
|
DB_PASSWORD=password
|
||||||
|
DB_DATABASE=database
|
||||||
167
server/.gitignore
vendored
Normal file
167
server/.gitignore
vendored
Normal 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
0
server/README.md
Normal file
30
server/api.meganjohns.com.conf
Normal file
30
server/api.meganjohns.com.conf
Normal 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
1
server/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from app.main import app
|
||||||
11
server/app/constants/__init__.py
Normal file
11
server/app/constants/__init__.py
Normal 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"
|
||||||
3
server/app/controller/__init__.py
Normal file
3
server/app/controller/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .controller import Controller
|
||||||
|
|
||||||
|
controller = Controller()
|
||||||
62
server/app/controller/controller.py
Normal file
62
server/app/controller/controller.py
Normal 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
64
server/app/db/DDL.sql
Normal 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;
|
||||||
0
server/app/db/__init__.py
Normal file
0
server/app/db/__init__.py
Normal file
37
server/app/db/conn.py
Normal file
37
server/app/db/conn.py
Normal 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
72
server/app/main.py
Normal 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()
|
||||||
5
server/app/model/__init__.py
Normal file
5
server/app/model/__init__.py
Normal 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
|
||||||
94
server/app/model/albums.py
Normal file
94
server/app/model/albums.py
Normal 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)
|
||||||
11
server/app/model/articles.py
Normal file
11
server/app/model/articles.py
Normal 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
|
||||||
85
server/app/model/artwork.py
Normal file
85
server/app/model/artwork.py
Normal 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
62
server/app/model/bio.py
Normal 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
|
||||||
59
server/app/model/model_object.py
Normal file
59
server/app/model/model_object.py
Normal 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
|
||||||
22
server/app/model/quotes.py
Normal file
22
server/app/model/quotes.py
Normal 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)]
|
||||||
7
server/app/model/response_object.py
Normal file
7
server/app/model/response_object.py
Normal 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
25
server/app/model/video.py
Normal 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
8
server/app/origins.py
Normal 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/",
|
||||||
|
]
|
||||||
0
server/app/routers/__init__.py
Normal file
0
server/app/routers/__init__.py
Normal file
20
server/app/routers/albums.py
Normal file
20
server/app/routers/albums.py
Normal 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)
|
||||||
20
server/app/routers/artwork.py
Normal file
20
server/app/routers/artwork.py
Normal 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
20
server/app/routers/bio.py
Normal 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()
|
||||||
20
server/app/routers/quotes.py
Normal file
20
server/app/routers/quotes.py
Normal 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)
|
||||||
20
server/app/routers/videos.py
Normal file
20
server/app/routers/videos.py
Normal 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)
|
||||||
0
server/app/scripts/__init__.py
Normal file
0
server/app/scripts/__init__.py
Normal file
4
server/app/scripts/bio.html
Normal file
4
server/app/scripts/bio.html
Normal 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
43
server/app/scripts/bump.py
Executable 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
12
server/app/scripts/run.py
Normal 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
3
server/app/scripts/run.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
uvicorn app:app --reload --port 8000
|
||||||
790
server/app/scripts/seed.py
Normal file
790
server/app/scripts/seed.py
Normal 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
13
server/meganjohns.service
Normal 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
1301
server/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
server/pyproject.toml
Normal file
25
server/pyproject.toml
Normal 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
0
server/tests/__init__.py
Normal file
Reference in New Issue
Block a user