initial commit

This commit is contained in:
Lucas Jensen
2024-05-01 09:19:01 -07:00
commit 5d67c0c2b2
117 changed files with 9917 additions and 0 deletions

0
README.md Normal file
View File

5
client/.env.example Normal file
View File

@@ -0,0 +1,5 @@
[api]
VITE_API_URL=http://localhost:8000/
[oauth2-google]
VITE_GOOGLE_CLIENT_ID=some-value.apps.googleusercontent.com

18
client/.eslintrc.cjs Normal file
View File

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

33
client/.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: build app
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
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
run: |
npm install
npm run prettier:check
- name: Build
run: |
npm install
npm run build

27
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# 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
.info.txt

4
client/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# Ignore artifacts:
build
coverage
dist

1
client/.prettierrc Normal file
View File

@@ -0,0 +1 @@
{}

46
client/README.md Normal file
View File

@@ -0,0 +1,46 @@
# The Grapefruits Duo
Frontend client for Eugene, OR based chamber music duo, The Grapefruits Duo. Publicly available at [thegrapefruitsduo.com](https://thegrapefruitsduo.com/). This client consumes a RESTful API built with FastAPI and publicly available at [api.thegrapefruitsduo.com](https://api.thegrapefruitsduo.com/). Back-end source code available on [GitHub](https://github.com/ljensen505/thegrapefruitsduo-back).
## Features
The customer-facing page for this SPA is relatively simple. It includes sections for the group itself, each memeber, upcoming events, and a contact form. The admin portal is where the real magic happens. Once authenticated, the user (a member of the group, or myself) can utlilze full CRUD operations on most entities including events, bios, and headshots.
### Getting started
```bash
git clone https://github.com/ljensen505/thegrapefruitsduo-front
```
```bash
cd thegrapefruitsduo-front
```
```bash
npm install
npm run dev
```
### Technologies Used
- React
- TypeScript
- Bootstrap 5
- Axios
- oauth2
- Font Awesome
- Cloudinary
Initialized with Vite.
### Deployment Info
- Hosted on a Linode server running Ubuntu 22.04
- Reverse proxy managed with Nginx on port 6001
- SSL certificate provided by Let's Encrypt
- Managed with systemd
- DNS managed with Google Domains (for now...)
### Development Notes
When running locally, it is expected that the API is running on localhost:8000. This can be changed in the `.env` file (this file may need to be created). See `.env.example` for an example and more info.

27
client/index.html Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link
rel="icon"
type="image/x-icon"
href="https://thegrapefruitsduo.com/favicon.ico"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Grapefruits Duo</title>
<meta property="og:title" content="The Grapefruits Duo" />
<meta property="og:site_name" content="The Grapefruits Duo" />
<meta property="og:description" content="Eugene based piano duo" />
<meta property="og:image" content="https://thegrapefruitsduo.com/bg.jpeg" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://thegrapefruitsduo.com" />
<meta
name="google-site-verification"
content="5vWNnaYeRQiigTJpR9ZrOKBRbwdxkTT-2reoERHrpE4"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3819
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
client/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "thegrapefruitsduo",
"private": true,
"version": "0.3.6",
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"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": {
"@cloudinary/react": "^1.11.2",
"@cloudinary/url-gen": "^1.16.0",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@react-oauth/google": "^0.12.1",
"bootstrap": "^5.3.2",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"react": "^18.2.0",
"react-axios": "^2.0.6",
"react-bootstrap": "^2.10.0",
"react-dom": "^18.2.0",
"vite-plugin-package-version": "^1.1.0"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"prettier": "3.2.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

BIN
client/public/bg.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

@@ -0,0 +1,68 @@
body {
margin: 0;
padding: 0;
&::before {
content: " ";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(
rgba(252, 171, 83, 0.3),
rgba(252, 171, 83, 0.3)
),
url("/bg.jpeg");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
will-change: transform;
z-index: -1;
background-color: rgba(249, 255, 63, 0.3);
}
@media (max-width: 600px) {
&::before {
background-image: none;
background-color: var(--grapefruit-yellow-washed-out);
}
}
}
.btn-primary {
background-color: var(--group-info-color);
border-color: var(--group-info-color);
}
.btn-primary:hover {
background-color: var(--group-info-lighter-color);
border-color: var(--group-info-lighter-color);
}
.btn-primary:disabled {
background-color: var(--group-info-disabled-color);
border-color: var(--group-info-disabled-color);
}
.btn-primary:focus {
background-color: var(--group-info-darker-color);
border-color: var(--group-info-darker-color);
box-shadow: 0 0 0 0.2rem var(--group-info-darker-color);
}
.error-text {
background-color: rgb(250, 201, 222);
display: block;
padding: 5px;
border-radius: 5px;
font-weight: bold;
}
.card {
background-color: transparent !important;
backdrop-filter: blur(15px);
border: 0px solid transparent !important;
color: var(--group-info-color) !important;
}

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

@@ -0,0 +1,86 @@
import "./App.css";
import Musicians from "./Musicians/Musicians";
import NavBar from "./NavBar/NavBar";
import ContactForm from "./Forms/Contact/ContactForm";
import { Container } from "react-bootstrap";
import Group, { GroupObj } from "./Group/Group";
import SeriesList, { EventSeriesObj } from "./Series/SeriesList";
import { useState, useEffect } from "react";
import Footer from "./Footer/Footer";
import { getRoot } from "./api";
import { MusicianObj } from "./Musicians/Musician/Musician";
import ErrorModal from "./ErrorModal/ErrorModal";
import Cookies from "js-cookie";
function App() {
const tokenCookie = Cookies.get("token");
const [group, setGroup] = useState<GroupObj>();
const [update, setUpdate] = useState<boolean>(false);
const [musicians, setMusicians] = useState<MusicianObj[]>([]);
const [seriesList, setSeriesList] = useState<EventSeriesObj[]>([]);
const [apiVersion, setApiVersion] = useState<string>("");
const [error, setError] = useState<string>("");
const [errorModalShow, setErrorModalShow] = useState<boolean>(false);
const [errorEntity, setErrorEntity] = useState<string>("");
const [token, setToken] = useState<string>(tokenCookie ? tokenCookie : "");
const appVersion = import.meta.env.PACKAGE_VERSION;
const handleGroupBioChange = () => {
setUpdate(!update);
};
const handleError = (error: string, entity: string) => {
console.error(error);
setError(error);
setErrorEntity(entity);
setErrorModalShow(true);
};
useEffect(() => {
getRoot()
.then((tgd): void => {
setGroup(tgd.group);
setMusicians(tgd.musicians);
setSeriesList(tgd.events);
setApiVersion(tgd.version);
})
.catch((error) => {
handleError(error.message, "root");
});
}, []);
return (
<div id="home">
<NavBar
musicians={musicians}
apiVersion={apiVersion}
appVersion={appVersion}
token={token}
setToken={setToken}
/>
<Container id="content" style={{ maxWidth: "1200px", margin: "0 auto" }}>
<Group
group={group}
onBioChange={handleGroupBioChange}
setGroup={setGroup}
token={token}
/>
<Musicians
musicians={musicians}
setMusicians={setMusicians}
token={token}
/>
<SeriesList
seriesList={seriesList}
setSeriesList={setSeriesList}
token={token}
/>
<ContactForm />
</Container>
<Footer />
<ErrorModal error={error} show={errorModalShow} entity={errorEntity} />
</div>
);
}
export default App;

View File

@@ -0,0 +1,30 @@
import { Container } from "react-bootstrap";
import { JwtPayload, jwtDecode } from "jwt-decode";
interface GoogleJwtPayload extends JwtPayload {
picture?: string;
name?: string;
email?: string;
}
interface ProfileProps {
token: string;
}
const Profile = (props: ProfileProps) => {
const decoded = jwtDecode<GoogleJwtPayload>(props.token);
const name = decoded.name;
const email = decoded.email;
return (
props.token && (
<Container className="text-end">
<p>
Logged in as: <strong>{name}</strong>
</p>
<p>{email}</p>
</Container>
)
);
};
export default Profile;

13
client/src/Auth/User.tsx Normal file
View File

@@ -0,0 +1,13 @@
export class UserObj {
name: string;
email: string;
id: number;
sub?: string;
constructor(name: string, email: string, id: number, sub?: string) {
this.name = name;
this.email = email;
this.id = id;
this.sub = sub;
}
}

View File

@@ -0,0 +1,31 @@
const bgFilter = "rgb(255, 180, 89,0.5)";
const url = "d18xMDAwLGFyXzE2OjksY19maWxsLGdfYXV0byxlX3NoYXJwZW4=.jpeg";
const BGStyleMobile = {
backgroundColor: bgFilter,
};
const BGStyleDesktop = {
backgroundImage: `linear-gradient(${bgFilter}, ${bgFilter}),` + `url(${url})`,
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundAttachment: "fixed",
};
function isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
}
function isFixedBackgroundSupported() {
const testEl = document.createElement("div");
testEl.style.backgroundAttachment = "fixed";
return testEl.style.backgroundAttachment === "fixed";
}
const BGStyleFinal =
!isMobile() && isFixedBackgroundSupported() ? BGStyleDesktop : BGStyleMobile;
// const BGStyleFinal = BGStyleDesktop;
export default BGStyleFinal;

View File

@@ -0,0 +1,34 @@
.delete-icon {
/* position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); */
font-size: 4rem;
color: rgb(255, 0, 0);
}
.delete-icon:hover {
color: darkred;
cursor: pointer;
}
.btn-delete {
background-color: transparent;
border: none;
}
.btn-delete:hover {
background-color: transparent;
border: none;
}
.btn-delete:focus {
background-color: transparent;
border: none;
color: lightcoral;
}
.btn-delete:active {
background-color: transparent;
border: none;
}

View File

@@ -0,0 +1,40 @@
import { Button } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { deleteSeries } from "../../api";
import "./DeleteButton.css";
import { EventSeriesObj } from "../../Series/SeriesList";
interface DeleteButtonProps {
series: EventSeriesObj;
setSeriesList: React.Dispatch<React.SetStateAction<EventSeriesObj[]>>;
seriesList: EventSeriesObj[];
actionName?: string;
token: string;
}
function DeleteButton(props: DeleteButtonProps) {
const handleDelete = () => {
console.log(props.series.series_id);
deleteSeries(props.series.series_id, props.token)
.then(() => {
props.setSeriesList(
props.seriesList.filter(
(series) => series.series_id !== props.series.series_id,
),
);
})
.catch((error) => {
console.error(error);
});
};
return (
<Button className="delete-icon btn-delete" onClick={handleDelete}>
<FontAwesomeIcon icon={faTrash} />
{props.actionName}
</Button>
);
}
export default DeleteButton;

View File

@@ -0,0 +1,34 @@
.edit-icon {
/* position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); */
font-size: 4rem;
color: green;
}
.edit-icon:hover {
color: darkgreen;
cursor: pointer;
}
.btn-edit {
background-color: transparent;
border: none;
}
.btn-edit:hover {
background-color: transparent;
border: none;
}
.btn-edit:focus {
background-color: transparent;
border: none;
color: var(--group-info-darker-color);
}
.btn-edit:active {
background-color: transparent;
border: none;
}

View File

@@ -0,0 +1,26 @@
import { Button } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import "./EditButton.css";
interface EditButtonProps {
setModalShow: React.Dispatch<React.SetStateAction<boolean>>;
faIcon: IconDefinition;
actionName?: string;
}
function EditButton(props: EditButtonProps) {
return (
<Button
className="edit-icon btn-edit"
onClick={() => {
props.setModalShow(true);
}}
>
<FontAwesomeIcon icon={props.faIcon} />
{props.actionName}
</Button>
);
}
export default EditButton;

View File

@@ -0,0 +1,42 @@
import {
CredentialResponse,
GoogleLogin,
googleLogout,
} from "@react-oauth/google";
import { postUser } from "../api";
import Cookies from "js-cookie";
interface LoginProps {
setToken: React.Dispatch<React.SetStateAction<string>>;
}
function Login(props: LoginProps) {
const handleSuccess = async (credentialResponse: CredentialResponse) => {
if (credentialResponse.credential) {
const token = credentialResponse.credential;
props.setToken(token);
Cookies.set("token", token, { expires: 1 });
postUser(token)
.then((user) => {
console.log(`Welcome, ${user.name}!`);
})
.catch(() => {
console.error("Nice try, but you can't do that. Logging you out.");
googleLogout();
props.setToken("");
});
} else {
console.error("Login Failed");
}
};
return (
<GoogleLogin
onSuccess={handleSuccess}
onError={() => {
console.error("Login Failed");
}}
/>
);
}
export default Login;

View File

@@ -0,0 +1,25 @@
import { googleLogout } from "@react-oauth/google";
import { Button } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faRightFromBracket } from "@fortawesome/free-solid-svg-icons";
import Cookies from "js-cookie";
interface LogoutProps {
setToken: React.Dispatch<React.SetStateAction<string>>;
}
function Logout(props: LogoutProps) {
const handleLogout = () => {
googleLogout();
props.setToken("");
Cookies.remove("token");
};
return (
<Button onClick={handleLogout}>
<FontAwesomeIcon icon={faRightFromBracket} />
</Button>
);
}
export default Logout;

View File

@@ -0,0 +1,9 @@
import { Cloudinary } from "@cloudinary/url-gen/index";
const cld = new Cloudinary({
cloud: {
cloudName: "dreftv0ue",
},
});
export default cld;

View File

@@ -0,0 +1,33 @@
import Modal from "react-bootstrap/Modal";
import { MusicianProps } from "../Musicians/Musician/Musician";
import { GroupObj } from "../Group/Group";
import { EventSeriesObj } from "../Series/SeriesList";
interface EditModalProps {
title: string;
show: boolean;
onHide: () => void;
form: JSX.Element;
entity?: MusicianProps | GroupObj | EventSeriesObj;
error?: string;
}
function EditModal(props: EditModalProps) {
return (
<Modal
{...props}
size="lg"
aria-labelledby="contained-modal-title-vcenter"
centered
>
<Modal.Header closeButton>
<Modal.Title id="contained-modal-title-vcenter">
{props.title}
</Modal.Title>
</Modal.Header>
<Modal.Body>{props.form}</Modal.Body>
</Modal>
);
}
export default EditModal;

View File

@@ -0,0 +1,12 @@
#email-signup-button {
background-color: var(--grapefruit-yellow);
color: var(--group-info-darker);
}
#email-signup-button:hover {
background-color: var(--grapefruit-yellow-washed-out);
}
#email-signup-button:focus {
background-color: var(--grapefruit-yellow-washed-out);
}

View File

@@ -0,0 +1,19 @@
import { Button } from "react-bootstrap";
import "./EmailSignupButton.css";
function EmailSignupButton() {
const signupUrl = "http://eepurl.com/iNpJz-/";
return (
<Button
id="email-signup-button"
variant="primary"
href={signupUrl}
target="_self"
rel="noopener noreferrer"
>
Sign up for our newsletter
</Button>
);
}
export default EmailSignupButton;

View File

@@ -0,0 +1,8 @@
.error-modal {
color: rgb(174, 0, 0);
backdrop-filter: blur(10px);
}
.error-content {
background-color: rgba(255, 201, 201, 0.8);
}

View File

@@ -0,0 +1,31 @@
import Modal from "react-bootstrap/Modal";
import "./ErrorModal.css";
interface ErrorModalProps {
error: string;
entity: string;
show: boolean;
}
function ErrorModal(props: ErrorModalProps) {
return (
<Modal
{...props}
size="lg"
aria-labelledby="contained-modal-title-vcenter"
centered
className="error-modal"
>
<Modal.Header className="error-content">
<Modal.Title id="contained-modal-title-vcenter">API Error</Modal.Title>
</Modal.Header>
<Modal.Body className="error-content">
<p>{props.error}</p>
<p>error occurred while fetching {props.entity}</p>
<p>Try again later or contact the site administrator</p>
</Modal.Body>
</Modal>
);
}
export default ErrorModal;

View File

@@ -0,0 +1,16 @@
.footer {
background-image: linear-gradient(
to top,
rgba(139, 215, 210, 0.4) 60%,
transparent
) !important;
color: var(--group-info-color) !important;
}
.nav-link {
color: var(--group-info-color) !important;
}
.nav-link:hover {
color: var(--group-info-lighter-color) !important;
}

View File

@@ -0,0 +1,56 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faInstagram,
faFacebook,
faYoutube,
} from "@fortawesome/free-brands-svg-icons";
import "./Footer.css";
import { Container, Row, Col } from "react-bootstrap";
import { Nav } from "react-bootstrap";
const currentYear = new Date().getFullYear();
const copyright = (
<p className="text-center">&copy; {currentYear} The Grapefruits Duo</p>
);
function Footer() {
return (
<footer className="footer py-3">
<Container>
<Row>
<Col className="text-center">
<Nav className="justify-content-center">
<Nav.Link
href="https://www.instagram.com/thegrapefruitsduo/"
className="m-2"
target="_blank"
>
<FontAwesomeIcon icon={faInstagram} />
</Nav.Link>
<Nav.Link
href="https://www.facebook.com/thegrapefruitsduo"
className="m-2"
target="_blank"
>
<FontAwesomeIcon icon={faFacebook} />
</Nav.Link>
<Nav.Link
href="https://www.youtube.com/channel/UCzc-ds_awbx3RpGmLWEetKw"
className="m-2"
target="_blank"
>
<FontAwesomeIcon icon={faYoutube} />
</Nav.Link>
</Nav>
</Col>
</Row>
<Row>
<Col className="text-center">{copyright}</Col>
</Row>
</Container>
</footer>
);
}
export default Footer;

View File

@@ -0,0 +1,110 @@
import { Button, Container, Form } from "react-bootstrap";
import { MusicianObj } from "../../Musicians/Musician/Musician";
import { GroupObj } from "../../Group/Group";
import { patchMusician, patchGroup } from "../../api";
import { useState } from "react";
interface EditBioFormProps {
entity: MusicianObj | GroupObj;
hideModal: React.Dispatch<React.SetStateAction<boolean>>;
onBioChange: () => void;
setGroup?: React.Dispatch<React.SetStateAction<GroupObj | undefined>>;
setMusician?: React.Dispatch<React.SetStateAction<MusicianObj>>;
token: string;
}
function EditBioForm(props: EditBioFormProps) {
const [formBio, setFormBio] = useState<string>(props.entity.bio);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const handleBioChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setFormBio(event.target.value);
setCanSubmit(true);
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (props.entity instanceof MusicianObj) {
updateMusician(props.token, props.entity);
} else if (props.entity instanceof GroupObj) {
updateGroup(props.token, props.entity);
} else {
console.error("Invalid entity type");
}
props.onBioChange();
props.hideModal(false);
};
const updateMusician = async (
accessToken: string,
musician: MusicianObj,
): Promise<void> => {
patchMusician(
musician.id,
formBio,
musician.name,
musician.headshot_id,
accessToken,
)
.then((patchedMusician) => {
if (props.setMusician) {
props.setMusician(patchedMusician);
}
})
.catch((error) => {
console.error(error);
setError("Failed to update bio: " + error.response.data.detail);
});
};
const updateGroup = async (
accessToken: string,
group: GroupObj,
): Promise<void> => {
patchGroup(group.id, formBio, group.name, accessToken)
.then((patchedGroup) => {
if (props.setGroup) {
props.setGroup(patchedGroup);
}
})
.catch((error) => {
console.error(error);
setError("Failed to update bio: " + error.response.data.detail);
});
};
const SubmitButton = canSubmit ? (
<Button variant="primary" type="submit">
Submit
</Button>
) : (
<Button variant="primary" type="submit" disabled>
Submit
</Button>
);
return (
<Form onSubmit={handleSubmit}>
<Form.Group controlId="formBio">
<Form.Label>Bio</Form.Label>
<Form.Control
as="textarea"
rows={10}
required
value={formBio}
autoFocus
onChange={handleBioChange}
/>
{error && (
<Form.Text className="text-danger error-text">{error}</Form.Text>
)}
</Form.Group>
<Container className="d-flex justify-content-end mt-3">
{SubmitButton}
</Container>
</Form>
);
}
export default EditBioForm;

View File

@@ -0,0 +1,32 @@
import Button from "react-bootstrap/Button";
import Modal from "react-bootstrap/Modal";
interface ConfirmationModalProps {
name: string;
show: boolean;
onHide: () => void;
}
function ConfirmationModal(props: ConfirmationModalProps) {
return (
<Modal
{...props}
size="sm"
aria-labelledby="contained-modal-title-vcenter"
centered
>
<Modal.Body className="d-flex flex-column align-items-center justify-content-center">
<p>Thank you for your message, {props.name}!</p>
<Button
className="contact-button"
variant="primary"
onClick={props.onHide}
>
Close
</Button>
</Modal.Body>
</Modal>
);
}
export default ConfirmationModal;

View File

@@ -0,0 +1,13 @@
#contact {
padding-top: 70px;
margin-top: -70px;
margin-bottom: 200px;
}
.contact-title {
margin-bottom: 6rem;
}
.contact-text {
color: var(--group-info-color);
}

View File

@@ -0,0 +1,105 @@
import Button from "react-bootstrap/Button";
import Form from "react-bootstrap/Form";
import { postMessage } from "../../api";
import { useState } from "react";
import { Col, Container, Row } from "react-bootstrap";
import "./ContactForm.css";
import ConfirmationModal from "./Confirmation/ConfirmationModal";
import EmailSignupButton from "../../EmailSignupButton/EmailSignupButton";
function ContactForm() {
const [confirmationModalShow, setConfirmationModalShow] = useState(false);
const [form, setForm] = useState<{ [key: string]: string }>({
name: "",
email: "",
message: "",
});
const handleFormChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setForm({ ...form, [event.target.name]: event.target.value });
};
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
postMessage(form.name, form.email, form.message)
.then(() => {
setConfirmationModalShow(true);
})
.catch((error) => {
console.error(error);
});
};
const handleFormReset = () => {
setForm({ name: "", email: "", message: "" });
setConfirmationModalShow(false);
};
return (
<Container id="contact">
<Row className="justify-content-center text-end">
<Col xs={12} md={8} lg={6}>
<Form
className="contact-text"
id="contact-form"
onSubmit={handleSubmit}
>
<h3 className="display-3 contact-title">Contact Us</h3>
<Form.Group className="mb-3" controlId="formBasicName">
<Form.Control
type="text"
placeholder="Enter name"
required
name="name"
value={form.name}
onChange={handleFormChange}
autoComplete="name"
/>
</Form.Group>
<Form.Group className="mb-3" controlId="formBasicEmail">
<Form.Control
type="email"
placeholder="Enter email"
required
name="email"
value={form.email}
onChange={handleFormChange}
autoComplete="email"
/>
</Form.Group>
<Form.Group className="mb-3" controlId="formBasicMessage">
<Form.Label>Message</Form.Label>
<Form.Control
as="textarea"
rows={3}
required
name="message"
placeholder="Enter message"
value={form.message}
onChange={handleFormChange}
/>
</Form.Group>
<Button variant="primary" type="submit">
Submit
</Button>
</Form>
<ConfirmationModal
show={confirmationModalShow}
onHide={handleFormReset}
name={form.name}
/>
</Col>
</Row>
<Row className="justify-content-center">
<Container className="mt-5 text-center">
<EmailSignupButton />
</Container>
</Row>
</Container>
);
}
export default ContactForm;

View File

@@ -0,0 +1,5 @@
.event-form-container {
border: 2px solid var(--group-info-color);
border-radius: 5px;
margin-top: 5px;
}

View File

@@ -0,0 +1,246 @@
import { useState } from "react";
import { Button, Container, Form } from "react-bootstrap";
import { postSeries, putSeries } from "../../api";
import { EventSeriesObj } from "../../Series/SeriesList";
import { EventObj } from "../../Series/Events.tsx/Event/Event";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import "./EventForm.css";
interface AddEventFormProps {
setModalShow: React.Dispatch<React.SetStateAction<boolean>>;
setSeriesList: React.Dispatch<React.SetStateAction<EventSeriesObj[]>>;
seriesList: EventSeriesObj[];
isNewSeries: boolean;
series?: EventSeriesObj;
setSeries?: React.Dispatch<React.SetStateAction<EventSeriesObj>>;
token: string;
}
function EventForm(props: AddEventFormProps) {
/*
this form serves two purposes:
1. Create a new series
2. Edit an existing series
if isNewSeries is true,an empty form will be rendered to create a new series and post it to the server
if isNewSeries is false, the form will be populated with existing series data and will be used to edit the series
*/
if (props.series && props.isNewSeries) {
throw new Error("series provided for new series form");
}
const [formEvents, setFormEvents] = useState<(EventObj | undefined)[]>(
props.series === undefined
? [undefined]
: props.series.events.map((event) => event),
);
const [postError, setPostError] = useState<string>("");
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formElements = form.elements as HTMLFormControlsCollection;
const seriesNameElement = formElements.namedItem(
"formSeriesName",
) as HTMLInputElement;
const seriesName = seriesNameElement.value;
const seriesDescriptionElement = formElements.namedItem(
"formSeriesDescription",
) as HTMLInputElement;
const seriesDescription = seriesDescriptionElement.value;
const eventObjects: EventObj[] = findEventElements(formElements);
const series = new EventSeriesObj(
(props.series && props.series.series_id) || 0,
seriesName,
seriesDescription,
eventObjects,
(props.series && props.series.poster_id) || undefined,
);
if (!props.token) {
console.error("no access token");
return;
}
if (props.isNewSeries) {
postSeries(series, props.token)
.then((newSeries) => {
props.setSeriesList([...props.seriesList, newSeries]);
props.setModalShow(false);
})
.catch((error) => {
console.error(error);
setPostError(
"Failed to create series: " + error.response.data.detail,
);
});
} else {
putSeries(series, props.token)
.then((updatedSeries) => {
props.setSeries?.(updatedSeries);
props.setModalShow(false);
})
.catch((error) => {
console.error(error);
setPostError(
"Failed to update series: " + error.response.data.detail,
);
});
}
};
function findEventElements(
formElements: HTMLFormControlsCollection,
): EventObj[] {
const events: EventObj[] = [];
for (let idx = 0; idx < formEvents.length; idx++) {
const locationElement = formElements.namedItem(
`formEvent${idx}Location`,
) as HTMLInputElement;
const timeElement = formElements.namedItem(
`formEvent${idx}Time`,
) as HTMLInputElement;
const ticketUrlElement = formElements.namedItem(
`formEvent${idx}TicketUrl`,
) as HTMLInputElement;
const mapUrlElement = formElements.namedItem(
`formEvent${idx}MapUrl`,
) as HTMLInputElement;
const location = locationElement.value;
const time = timeElement.value;
const ticketUrl = ticketUrlElement.value
? ticketUrlElement.value
: undefined;
const mapUrl = mapUrlElement.value ? mapUrlElement.value : undefined;
const event = new EventObj(0, location, time, ticketUrl, mapUrl);
events.push(event);
}
return events;
}
const AddEventButton = (
<Container className="d-flex justify-content-end mt-2">
<Button
variant="primary"
onClick={() => setFormEvents([...formEvents, undefined])}
>
Add Event to Series
</Button>
</Container>
);
const SubmitButton = (
<Button variant="primary" type="submit">
Submit
</Button>
);
return (
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3" controlId="formSeriesName">
<Form.Label>Series Name *</Form.Label>
<Form.Control
type="text"
placeholder="Enter series name"
required
name="name"
{...(props.series && { defaultValue: props.series.name })}
/>
</Form.Group>
<Form.Group className="mb-3" controlId="formSeriesDescription">
<Form.Label>Series Description *</Form.Label>
<Form.Control
as="textarea"
rows={3}
placeholder="Enter series description"
required
name="description"
{...(props.series && { defaultValue: props.series.description })}
/>
</Form.Group>
{formEvents.map((event, idx) => (
<Container
key={event ? event.event_id : `temp-${idx}`}
className="event-form-container"
>
<Form.Text className="mb-3">{`Event ${idx}`}</Form.Text>
<Form.Group className="mb-3" controlId={`formEvent${idx}Location`}>
<Form.Label>Event Location *</Form.Label>
<Form.Control
type="text"
placeholder="Enter event location"
required
name="location"
{...(event && {
defaultValue: event.location,
})}
/>
</Form.Group>
<Form.Group className="mb-3" controlId={`formEvent${idx}Time`}>
<Form.Label>Event Time *</Form.Label>
<Form.Control
type="datetime-local"
required
name="time"
{...(event && {
defaultValue: event.time,
})}
/>
</Form.Group>
<Form.Group className="mb-3" controlId={`formEvent${idx}TicketUrl`}>
<Form.Label>Event Ticket URL</Form.Label>
<Form.Control
type="url"
name="ticket_url"
{...(event && {
defaultValue: event.ticket_url,
})}
/>
</Form.Group>
<Form.Group className="mb-3" controlId={`formEvent${idx}MapUrl`}>
<Form.Label>Event Map URL</Form.Label>
<Form.Control
type="url"
name="map_url"
{...(event && {
defaultValue: event.map_url,
})}
/>
</Form.Group>
<Container className="d-flex justify-content-end mb-1">
<Button
variant="danger"
onClick={() => {
if (event) {
setFormEvents(
formEvents.filter((e) => e?.event_id !== event.event_id),
);
} else {
setFormEvents(formEvents.filter((_, i) => i !== idx));
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</Container>
</Container>
))}
{AddEventButton}
<Form.Text>
A poster can be added after returning to the homepage.
</Form.Text>
{postError && (
<Form.Text className="text-danger error-text">{postError}</Form.Text>
)}
<Container className="d-flex justify-content-end">
{SubmitButton}
</Container>
</Form>
);
}
export default EventForm;

View File

@@ -0,0 +1,8 @@
.headshot-preview {
max-width: 400px;
max-height: 400px;
}
#headshot-upload {
display: block;
}

View File

@@ -0,0 +1,123 @@
import { Form, Image, Button, Container } from "react-bootstrap";
import { MusicianObj } from "../../Musicians/Musician/Musician";
import { useState } from "react";
import { postHeadshot } from "../../api";
import "./HeadshotUpload.css";
export const sizeLimit = 1000000; // one megabyte
interface HeadshotUploadProps {
currentHeadshot: string;
musician: MusicianObj;
onHeadshotChange?: () => void;
hideModal?: React.Dispatch<React.SetStateAction<boolean>>;
setMusician?: React.Dispatch<React.SetStateAction<MusicianObj>>;
token: string;
}
function HeadshotUpload(props: HeadshotUploadProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [fileError, setFileError] = useState<string>("");
const [preview, setPreview] = useState<string>(props.currentHeadshot);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const allowedTypes = ["image/jpeg", "image/png"];
const file = event.target.files?.[0];
const fileSize = file?.size; // bytes
const fileType = file?.type; // MIME type
if (fileSize && fileSize > sizeLimit) {
console.error("file too large");
setFileError("file too large");
setCanSubmit(false);
return;
}
if (fileType && !allowedTypes.includes(fileType)) {
console.error("invalid file type");
setFileError("invalid file type");
setCanSubmit(false);
return;
}
if (file) {
setSelectedFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
setCanSubmit(true);
setFileError("");
}
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!props.token) {
console.error("no access token");
return;
}
if (props.musician && selectedFile) {
uploadHeadshot(props.token, props.musician, selectedFile);
props.hideModal?.(false);
return;
}
console.error("no file selected");
};
const uploadHeadshot = async (
accessToken: string,
musician: MusicianObj,
file: File,
) => {
postHeadshot(musician.id, file, accessToken)
.then((updatedMusician) => {
props.onHeadshotChange?.();
props.setMusician?.(updatedMusician);
})
.catch((error) => {
console.error(error);
});
};
const SubmitButton = canSubmit ? (
<Button variant="primary" type="submit">
Submit
</Button>
) : (
<Button variant="primary" type="submit" disabled>
Submit
</Button>
);
return (
<Form onSubmit={handleSubmit}>
<Form.Group controlId="formFile" className="mb-3">
<Container className="d-flex justify-content-center">
<Image
src={preview}
className="img-fluid rounded-circle headshot-preview"
alt={`${props.musician.name} headshot`}
/>
</Container>
<Form.Label id="headshot-upload">Upload Headshot</Form.Label>
<Form.Control type="file" onChange={handleFileChange} />
<Form.Text className="text-muted">
size limit: {sizeLimit / 1000000} MB
</Form.Text>
{fileError && (
<Form.Text className="text-danger error-text">{fileError}</Form.Text>
)}
</Form.Group>
<Container className="d-flex justify-content-end">
{SubmitButton}
</Container>
</Form>
);
}
export default HeadshotUpload;

View File

@@ -0,0 +1,117 @@
import { Button, Container, Form, Image } from "react-bootstrap";
import { EventSeriesObj } from "../../Series/SeriesList";
import { useState } from "react";
import { sizeLimit } from "../HeadshotUpload/HeadshotUploadForm";
import { postSeriesPoster } from "../../api";
interface PosterUploadFormProps {
series: EventSeriesObj;
currentPoster: string | undefined;
setModalShow: React.Dispatch<React.SetStateAction<boolean>>;
setSeries: React.Dispatch<React.SetStateAction<EventSeriesObj>>;
token: string;
}
function PosterUploadForm(props: PosterUploadFormProps) {
const [preview, setPreview] = useState<string | undefined>(
props.currentPoster,
);
const [fileError, setFileError] = useState<string>("");
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const allowedTypes = ["image/jpeg", "image/png"];
const file = event.target.files?.[0];
const fileSize = file?.size; // bytes
const fileType = file?.type; // MIME type
if (fileSize && fileSize > sizeLimit) {
console.error("file too large");
setFileError("file too large");
setCanSubmit(false);
return;
}
if (fileType && !allowedTypes.includes(fileType)) {
console.error("invalid file type");
setFileError("invalid file type");
setCanSubmit(false);
return;
}
if (file) {
setSelectedFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(file);
setCanSubmit(true);
setFileError("");
}
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!props.token) {
console.error("no access token");
return;
}
if (selectedFile) {
postSeriesPoster(props.series.series_id, selectedFile, props.token)
.then((updatedEventObj) => {
props.setSeries(updatedEventObj);
props.setModalShow(false);
})
.catch((error) => {
console.error(error);
setFileError(
"Failed to upload poster: " + error.response.data.detail,
);
});
return;
}
console.error("no file selected");
};
const SubmitButton = canSubmit ? (
<Button variant="primary" type="submit">
Submit
</Button>
) : (
<Button variant="primary" type="submit" disabled>
Submit
</Button>
);
return (
<Form onSubmit={handleSubmit}>
<Form.Group controlId="formFile" className="mb-3">
<Container className="d-flex justify-content-center">
{preview && (
<Image
src={preview}
className="img-fluid rounded-circle poster-preview"
alt={`${props.series.name} poster`}
/>
)}
</Container>
<Form.Label id="poster-upload">Upload Poster</Form.Label>
<Form.Control type="file" onChange={handleFileChange} />
<Form.Text className="text-muted">
size limit: {sizeLimit / 1000000} MB
</Form.Text>
{fileError && (
<Form.Text className="text-danger error-text">{fileError}</Form.Text>
)}
</Form.Group>
<Container className="d-flex justify-content-end">
{SubmitButton}
</Container>
</Form>
);
}
export default PosterUploadForm;

View File

@@ -0,0 +1,13 @@
.group-info {
color: var(--group-info-color);
}
.group-bio {
color: var(--group-info-color);
font-weight: 600 !important;
}
#about {
padding-top: 120px;
margin-top: -70px;
}

View File

@@ -0,0 +1,79 @@
import { Card, Container } from "react-bootstrap";
import "./Group.css";
import EditModal from "../EditModals/EditModal";
import { useState } from "react";
import EditBioForm from "../Forms/Bio/BioForm";
import { faPen } from "@fortawesome/free-solid-svg-icons";
import EditButton from "../Buttons/EditButton/EditButton";
export class GroupObj {
id: number;
name: string;
bio: string;
constructor(id: number, name: string, bio: string) {
this.id = id;
this.name = name;
this.bio = bio;
}
}
interface GroupProps {
group?: GroupObj;
onBioChange: () => void;
setGroup: React.Dispatch<React.SetStateAction<GroupObj | undefined>>;
token: string;
}
function Group(props: GroupProps) {
const [modalShow, setModalShow] = useState(false);
if (!props.group) {
return null;
}
const group = props.group;
const EditTitle = `Edit ${group.name}'s Bio`;
const EditIcon = (
<Container>
<EditButton
setModalShow={setModalShow}
faIcon={faPen}
actionName=" Group Bio"
/>
<EditModal
show={modalShow}
onHide={() => setModalShow(false)}
title={EditTitle}
entity={group}
form={
<EditBioForm
entity={group}
hideModal={setModalShow}
onBioChange={props.onBioChange}
setGroup={props.setGroup}
token={props.token}
/>
}
/>
</Container>
);
return (
<section id="about">
<Container className="vh-100 d-flex align-items-center justify-content-center text-center">
<Card className="group-info">
<Card.Body>
<Card.Title>
<h1>{group.name}</h1>
</Card.Title>
{props.token && EditIcon}
<Card.Text className="lead group-bio">{group.bio}</Card.Text>
</Card.Body>
</Card>
</Container>
</section>
);
}
export default Group;

View File

@@ -0,0 +1,63 @@
import EditButton from "../../../Buttons/EditButton/EditButton";
import { useEffect, useState } from "react";
import { Col, Card, Container } from "react-bootstrap";
import EditBioForm from "../../../Forms/Bio/BioForm";
import EditModal from "../../../EditModals/EditModal";
import { MusicianObj } from "../Musician";
import { faPen } from "@fortawesome/free-solid-svg-icons";
interface BioProps {
musician: MusicianObj;
textPosition: string;
onBioChange: () => void;
setMusician: React.Dispatch<React.SetStateAction<MusicianObj>>;
token: string;
}
function MusicianBio(props: BioProps) {
const [modalShow, setModalShow] = useState(false);
const EditTitle = `Edit ${props.musician.name}'s Bio`;
useEffect(() => {
props.setMusician(props.musician);
}, [props]);
const Editable = (
<Container>
<EditButton
setModalShow={setModalShow}
faIcon={faPen}
actionName=" Bio"
/>
<EditModal
show={modalShow}
onHide={() => setModalShow(false)}
title={EditTitle}
entity={props.musician}
form={
<EditBioForm
entity={props.musician}
hideModal={setModalShow}
onBioChange={props.onBioChange}
setMusician={props.setMusician}
token={props.token}
/>
}
/>
</Container>
);
return (
<Col md={6} key="bioCard">
<Card className={`${props.textPosition}`}>
<Card.Header className="display-6">{props.musician.name}</Card.Header>
<Card.Body>
{props.token && Editable}
<Card.Text>{props.musician.bio}</Card.Text>
</Card.Body>
</Card>
</Col>
);
}
export default MusicianBio;

View File

@@ -0,0 +1,64 @@
import { faUpload } from "@fortawesome/free-solid-svg-icons";
import EditButton from "../../../Buttons/EditButton/EditButton";
import { Col, Container, Image } from "react-bootstrap";
import HeadshotUpload from "../../../Forms/HeadshotUpload/HeadshotUploadForm";
import EditModal from "../../../EditModals/EditModal";
import { useState } from "react";
import { MusicianObj } from "../Musician";
import "./Headshot.css";
export interface HeadshotProps {
src: string;
musician: MusicianObj;
onHeadshotChange?: () => void;
setMusician?: React.Dispatch<React.SetStateAction<MusicianObj>>;
token: string;
}
function Headshot(props: HeadshotProps) {
const [modalShow, setModalShow] = useState(false);
const EditableHeadshot = (
<Container>
<EditButton
setModalShow={setModalShow}
faIcon={faUpload}
actionName=" Headshot"
/>
<EditModal
show={modalShow}
onHide={() => setModalShow(false)}
title="Edit Headshot"
entity={props.musician}
form={
<HeadshotUpload
currentHeadshot={props.src}
musician={props.musician}
onHeadshotChange={props?.onHeadshotChange}
hideModal={setModalShow}
setMusician={props?.setMusician}
token={props.token}
/>
}
/>
</Container>
);
return (
<Col
key="headshot"
className="d-flex align-items-center justify-content-center position-relative"
>
<Container className="d-flex align-items-center justify-content-center flex-column">
<Image
src={props.src}
className="img-fluid rounded-circle"
alt={props.musician.name}
/>
{props.token && EditableHeadshot}
</Container>
</Col>
);
}
export default Headshot;

View File

@@ -0,0 +1,9 @@
.musician-container {
margin-bottom: 6rem;
padding-top: 70px;
margin-top: -70px;
}
/*
.musician-card {
background-color: transparent !important;
} */

View File

@@ -0,0 +1,68 @@
import { Container, Row } from "react-bootstrap";
import cld from "../../Cld/CloudinaryConfig";
import "./Musician.css";
import Headshot from "./Headshot/Headshot";
import MusicianBio from "./Bio/Bio";
import { useState } from "react";
export class MusicianObj {
id: number;
name: string;
bio: string;
headshot_id: string;
constructor(id: number, name: string, bio: string, headshot_id: string) {
this.id = id;
this.name = name;
this.bio = bio;
this.headshot_id = headshot_id;
}
}
export interface MusicianProps {
musician: MusicianObj;
onBioChange: () => void;
onHeadshotChange?: () => void;
token: string;
}
function Musician(props: MusicianProps) {
const [musician, setMusician] = useState<MusicianObj>(props.musician);
const textPosition = musician.id % 2 === 0 ? "text-end" : "text-start";
const image = cld.image(musician.headshot_id);
const imgUrl = image.toURL();
const musicianID = musician.name.split(" ").join("-").toLowerCase();
const key = `musician-${musician.id}`;
const bioCard = (
<MusicianBio
key={key}
musician={musician}
textPosition={textPosition}
onBioChange={props.onBioChange}
setMusician={setMusician}
token={props.token}
/>
);
const headshot = (
<Headshot
src={imgUrl}
key="headshot"
musician={musician}
onHeadshotChange={props?.onHeadshotChange}
setMusician={setMusician}
token={props.token}
/>
);
return (
<Container id={musicianID} className="musician-container">
<Row className="row-spacing">
{musician.id % 2 === 0 ? [bioCard, headshot] : [headshot, bioCard]}
</Row>
</Container>
);
}
export default Musician;

View File

@@ -0,0 +1,10 @@
.musicians-title {
color: var(--group-info-color);
margin-bottom: 6rem;
}
#musicians {
padding-top: 70px;
margin-top: -70px;
margin-bottom: 400px;
}

View File

@@ -0,0 +1,47 @@
import { useState } from "react";
import Musician, { MusicianObj } from "./Musician/Musician";
import { Col, Container } from "react-bootstrap";
import "./Musicians.css";
interface MusiciansProps {
musicians: MusicianObj[];
setMusicians: React.Dispatch<React.SetStateAction<MusicianObj[]>>;
token: string;
}
function Musicians(props: MusiciansProps) {
const [update, setUpdate] = useState<boolean>(false);
const musicians = props.musicians;
const handleBioChange = () => {
setUpdate(!update);
};
const handleHeadshotChange = () => {
setUpdate(!update);
};
const musicianList = musicians.map((musician) => (
<Musician
key={musician.id}
musician={musician}
onBioChange={handleBioChange}
onHeadshotChange={handleHeadshotChange}
token={props.token}
/>
));
return (
<section id="musicians">
<Col>
<Container>
<h3 className="display-3 text-end musicians-title">
Meet the Musicians
</h3>
</Container>
{musicianList}
</Col>
</section>
);
}
export default Musicians;

View File

@@ -0,0 +1,7 @@
#api-container {
font-size: smaller;
}
#auth-row {
background-color: var(--grapefruit-yellow-washed-out);
}

View File

@@ -0,0 +1,49 @@
import { ButtonGroup, Container } from "react-bootstrap";
import Dropdown from "react-bootstrap/Dropdown";
import DropdownButton from "react-bootstrap/DropdownButton";
import Profile from "../../Auth/Profile";
import Login from "../../Buttons/Login";
import Logout from "../../Buttons/Logout";
import "./AdminDropdown.css";
interface AdminDropdownProps {
appVersion: string;
apiVersion: string;
token: string;
setToken: React.Dispatch<React.SetStateAction<string>>;
}
function AdminDropdown(props: AdminDropdownProps) {
const AuthButton = () => {
return props.token ? (
<Logout setToken={props.setToken} />
) : (
<Login setToken={props.setToken} />
);
};
return (
<>
<DropdownButton
as={ButtonGroup}
align={{ lg: "end" }}
title="Admin"
id="admin-dropdown"
variant="link"
className="navbar-text-color"
>
{props.token && <Profile token={props.token} />}
<Dropdown.Item eventKey="1" className="text-end" id="auth-row">
<AuthButton />
</Dropdown.Item>
<Dropdown.Divider />
<Container className="text-end text-muted" id="api-container">
<p>APP Version: {props.appVersion}</p>
<p>API Version: {props.apiVersion}</p>
</Container>
</DropdownButton>
</>
);
}
export default AdminDropdown;

View File

@@ -0,0 +1,36 @@
.navbar-color {
backdrop-filter: blur(10px);
transition: background-color 0.5s ease;
}
.navbar-text-color .dropdown-toggle {
color: var(--group-info-color);
}
.navbar-scrolled {
background-color: var(--grapefruit-yellow);
}
.logo {
height: 1.7rem;
width: auto;
}
#nav-contact {
margin-right: 8px;
}
#admin-dropdown {
text-decoration: none;
text-align: left;
padding-left: 0;
}
#admin-dropdown:hover {
text-decoration: none;
color: var(--group-info-lighter-color);
}
.dropdown-menu {
background-color: var(--grapefruit-yellow-washed-out);
}

View File

@@ -0,0 +1,91 @@
import Container from "react-bootstrap/Container";
import Nav from "react-bootstrap/Nav";
import Navbar from "react-bootstrap/Navbar";
import "./NavBar.css";
import { useState, useEffect } from "react";
import AdminDropdown from "./AdminDropdown/AdminDropdown";
import { Image, NavDropdown } from "react-bootstrap";
import { MusicianObj } from "../Musicians/Musician/Musician";
interface NavBarProps {
musicians: MusicianObj[];
appVersion: string;
apiVersion: string;
token: string;
setToken: React.Dispatch<React.SetStateAction<string>>;
}
function NavBar(props: NavBarProps) {
const [scrolled, setScrolled] = useState(false);
const MusicianLinks = props.musicians.map((musician) => (
<NavDropdown.Item
className="navbar-text-color"
href={"#" + musician.name.split(" ").join("-").toLowerCase()}
key={musician.id}
>
{musician.name}
</NavDropdown.Item>
));
useEffect(() => {
const handleScroll = () => {
const isScrolled = window.scrollY > 0;
if (isScrolled !== scrolled) {
setScrolled(!scrolled);
}
};
document.addEventListener("scroll", handleScroll);
return () => {
document.removeEventListener("scroll", handleScroll);
};
}, [scrolled]);
return (
<Navbar
className={`navbar-color ${scrolled ? "navbar-scrolled" : ""}`}
expand="lg"
sticky="top"
>
<Container>
<Navbar.Brand href="#root" className="navbar-text-color">
<Image src="/favicon.ico" alt="TGD Logo" className="logo" />
</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse className="justify-content-end">
<Nav>
<Nav.Link href="#home" className="navbar-text-color">
About
</Nav.Link>
<NavDropdown title="Musicians" className="">
<NavDropdown.Item href="#musicians" className="navbar-text-color">
All
</NavDropdown.Item>
<NavDropdown.Divider />
{MusicianLinks}
</NavDropdown>
<Nav.Link href="#events" className="navbar-text-color">
Events
</Nav.Link>
<Nav.Link
href="#contact"
className="navbar-text-color"
id="nav-contact"
>
Contact
</Nav.Link>
<AdminDropdown
apiVersion={props.apiVersion}
appVersion={props.appVersion}
token={props.token}
setToken={props.setToken}
/>
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
);
}
export default NavBar;

View File

@@ -0,0 +1,5 @@
.event-list-item {
background-color: transparent !important;
color: inherit;
border: 0.15rem solid var(--group-info-color);
}

View File

@@ -0,0 +1,70 @@
import { ListGroup } from "react-bootstrap";
import "./Event.css";
export class EventObj {
event_id: number;
location: string;
time: string; // ISO 8601 formatted date-time string
ticket_url?: string;
map_url?: string;
constructor(
event_id: number,
location: string,
time: string,
ticket_url?: string,
map_url?: string,
) {
this.event_id = event_id;
this.location = location;
this.time = time;
this.ticket_url = ticket_url;
this.map_url = map_url;
}
}
interface EventProps {
event: EventObj;
}
function Event(props: EventProps) {
const event = props.event;
const date = new Date(event.time);
const dateString = date.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
weekday: "long",
});
const location = event.map_url ? (
<>
<a href={event.map_url} target="_blank" rel="noreferrer">
{event.location}
</a>
</>
) : (
event.location
);
const tickets = event.ticket_url ? (
<>
|{" "}
<a href={event.ticket_url} target="_blank" rel="noreferrer">
Tickets
</a>
</>
) : null;
const timeString = date.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
return (
<ListGroup.Item className="event-list-item">
<p>
{dateString} {timeString.toLowerCase()} | {location} {tickets}
</p>
</ListGroup.Item>
);
}
export default Event;

View File

@@ -0,0 +1,3 @@
.event-list {
margin-top: 2.5rem;
}

View File

@@ -0,0 +1,23 @@
import { ListGroup } from "react-bootstrap";
import Event, { EventObj } from "./Event/Event";
import "./Events.css";
interface EventsProps {
events: EventObj[];
}
function Events(props: EventsProps) {
const events = props.events;
return (
<>
<ListGroup className="event-list">
{events.map((event) => (
<Event key={event.event_id} event={event} />
))}
</ListGroup>
</>
);
}
export default Events;

View File

@@ -0,0 +1,90 @@
import { Card, Col, Container, Row } from "react-bootstrap";
import { EventSeriesObj } from "../SeriesList";
import Events from "../Events.tsx/Events";
import SeriesPoster from "./SeriesPoster";
import EditButton from "../../Buttons/EditButton/EditButton";
import DeleteButton from "../../Buttons/DeleteButton/DeleteButton";
import { useEffect, useState } from "react";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import EditModal from "../../EditModals/EditModal";
import EventForm from "../../Forms/Event/EventForm";
interface SeriesProps {
series: EventSeriesObj;
setSeriesList: React.Dispatch<React.SetStateAction<EventSeriesObj[]>>;
seriesList: EventSeriesObj[];
token: string;
}
function Series(props: SeriesProps) {
const [series, setSeries] = useState<EventSeriesObj>(props.series);
const [modalShow, setModalShow] = useState(false);
useEffect(() => {
setSeries(props.series);
}, [props.series]);
const EditableSeries = (
<Container>
<Row>
<Col>
<Container className="text-center">
<EditButton setModalShow={setModalShow} faIcon={faEdit} />
<EditModal
show={modalShow}
onHide={() => setModalShow(false)}
title="Edit Concert Series"
entity={props.series}
form={
<EventForm
setModalShow={setModalShow}
setSeriesList={props.setSeriesList}
seriesList={props.seriesList}
series={series}
isNewSeries={false}
setSeries={setSeries}
token={props.token}
/>
}
/>
</Container>
</Col>
<Col>
<Container className="text-center">
<DeleteButton
series={series}
setSeriesList={props.setSeriesList}
seriesList={props.seriesList}
token={props.token}
/>
</Container>
</Col>
</Row>
</Container>
);
return (
<Row id={`series-${series.series_id}-row`}>
<Col>
<SeriesPoster
series={series}
setSeries={setSeries}
token={props.token}
/>
</Col>
<Col>
<Container>
<Card>
<h4>{series.name}</h4>
{props.token && EditableSeries}
<p>{series.description}</p>
<Events events={series.events} />
</Card>
</Container>
</Col>
</Row>
);
}
export default Series;

View File

@@ -0,0 +1,57 @@
import { Container, Image } from "react-bootstrap";
import { EventSeriesObj } from "../SeriesList";
import cld from "../../Cld/CloudinaryConfig";
import { useState } from "react";
import EditButton from "../../Buttons/EditButton/EditButton";
import { faUpload } from "@fortawesome/free-solid-svg-icons";
import EditModal from "../../EditModals/EditModal";
import PosterUploadForm from "../../Forms/PosterUpload/PosterUploadForm";
interface SeriesPosterProps {
series: EventSeriesObj;
setSeries: React.Dispatch<React.SetStateAction<EventSeriesObj>>;
token: string;
}
function SeriesPoster(props: SeriesPosterProps) {
const series = props.series;
const imgUrl = cld.image(series.poster_id).toURL();
const imgSrc = imgUrl ? imgUrl : undefined;
const [modalShow, setModalShow] = useState(false);
const EditablePoster = (
<Container className="">
<EditButton
setModalShow={setModalShow}
faIcon={faUpload}
actionName=" Poster"
/>
<EditModal
show={modalShow}
onHide={() => setModalShow(false)}
title="Edit Poster"
entity={props.series}
form={
<PosterUploadForm
series={series}
currentPoster={imgSrc}
setModalShow={setModalShow}
setSeries={props.setSeries}
token={props.token}
/>
}
/>
</Container>
);
return (
<Container>
{series.poster_id ? (
<Image src={imgSrc} alt={series.name} className="img-fluid" />
) : null}
{props.token && EditablePoster}
</Container>
);
}
export default SeriesPoster;

View File

@@ -0,0 +1,19 @@
.events-title {
color: var(--group-info-color);
margin-bottom: 6rem;
}
hr {
border: 0;
height: 4px; /* This controls the thickness of the divider */
background: rgba(124, 1, 68); /* This sets the color of the divider */
margin-top: 10rem;
margin-bottom: 6rem;
opacity: 0.9;
}
#events {
padding-top: 70px;
margin-top: -70px;
margin-bottom: 400px;
}

View File

@@ -0,0 +1,106 @@
import { Col, Container } from "react-bootstrap";
import { EventObj } from "./Events.tsx/Event/Event";
import Series from "./Series/Series";
import "./SeriesList.css";
import EditButton from "../Buttons/EditButton/EditButton";
import { useState } from "react";
import { faAdd } from "@fortawesome/free-solid-svg-icons";
import EditModal from "../EditModals/EditModal";
import EventForm from "../Forms/Event/EventForm";
export class EventSeriesObj {
series_id: number;
name: string;
description: string;
events: EventObj[];
poster_id?: string; // Cloudinary public ID
constructor(
series_id: number,
name: string,
description: string,
events: EventObj[],
poster_id?: string,
) {
this.series_id = series_id;
this.name = name;
this.description = description;
this.events = events;
this.poster_id = poster_id;
}
}
interface SeriesListProps {
seriesList: EventSeriesObj[];
setSeriesList: React.Dispatch<React.SetStateAction<EventSeriesObj[]>>;
token: string;
}
function SeriesList(props: SeriesListProps) {
const seriesList = props.seriesList;
const [modalShow, setModalShow] = useState(false);
const AddableSeries = (
<Container className="text-end">
<EditButton
setModalShow={setModalShow}
faIcon={faAdd}
actionName=" Event"
/>
<EditModal
show={modalShow}
onHide={() => setModalShow(false)}
title="Add Concert Series"
form={
<EventForm
setModalShow={setModalShow}
setSeriesList={props.setSeriesList}
seriesList={props.seriesList}
isNewSeries={true}
token={props.token}
/>
}
/>
</Container>
);
if (seriesList.length === 0) {
return (
<section id="events">
<Col>
<Container>
<h3 className="display-3 text-end events-title">Upcoming Events</h3>
{props.token && AddableSeries}
</Container>
<Container>
<h3 className="display-4 text-center events-title">Stay tuned!</h3>
</Container>
</Col>
</section>
);
}
return (
<section id="events">
<Col>
<Container>
<h3 className="display-3 text-end events-title">Upcoming Events</h3>
{props.token && AddableSeries}
</Container>
{seriesList.map((series, idx) => (
<Container key={series.series_id}>
<Series
series={series}
setSeriesList={props.setSeriesList}
seriesList={props.seriesList}
token={props.token}
/>
{idx < seriesList.length - 1 && <hr className="series-divider" />}
</Container>
))}
</Col>
</section>
);
}
export default SeriesList;

293
client/src/api.tsx Normal file
View File

@@ -0,0 +1,293 @@
import axios from "axios";
import { GroupObj } from "./Group/Group";
import { MusicianObj } from "./Musicians/Musician/Musician";
import { EventSeriesObj } from "./Series/SeriesList";
import { EventObj } from "./Series/Events.tsx/Event/Event";
import { UserObj } from "./Auth/User";
const baseURL = import.meta.env.VITE_API_URL as string;
class TheGrapefruitsDuoAPI {
version: string;
group: GroupObj;
musicians: MusicianObj[];
events: EventSeriesObj[];
constructor(
version: string,
group: GroupObj,
musicians: MusicianObj[],
events: EventSeriesObj[],
) {
this.version = version;
this.group = group;
this.musicians = musicians;
this.events = events;
}
}
const api = axios.create({
baseURL: baseURL,
});
export const getRoot = async (): Promise<TheGrapefruitsDuoAPI> => {
const response = await api.get("/");
const tgd = new TheGrapefruitsDuoAPI(
response.data.version,
new GroupObj(
response.data.group.id,
response.data.group.name,
response.data.group.bio,
),
response.data.musicians.map(
(musician: MusicianObj) =>
new MusicianObj(
musician.id,
musician.name,
musician.bio,
musician.headshot_id,
),
),
response.data.events.map(
(series: EventSeriesObj) =>
new EventSeriesObj(
series.series_id,
series.name,
series.description,
series.events.map(
(event: EventObj) =>
new EventObj(
event.event_id,
event.location,
event.time,
event.ticket_url,
event.map_url,
),
),
series.poster_id,
),
),
);
console.log(tgd);
return tgd;
};
export const getUsers = async (): Promise<EventObj[]> => {
const response = await api.get("/users/");
return response.data.map(
(user: UserObj) => new UserObj(user.name, user.email, user.id, user.sub),
);
};
export const getUser = async (id: number): Promise<UserObj> => {
const response = await api.get(`/users/${id}/`);
return new UserObj(
response.data.name,
response.data.email,
response.data.id,
response.data.sub,
);
};
export const postUser = async (token: string): Promise<UserObj> => {
const response = await api.post(
"/users/",
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
return new UserObj(
response.data.name,
response.data.email,
response.data.id,
response.data.sub,
);
};
export const getGroup = async (): Promise<GroupObj> => {
const response = await api.get("/group/");
return new GroupObj(response.data.id, response.data.name, response.data.bio);
};
export const getMusicians = async (): Promise<MusicianObj[]> => {
const response = await api.get("/musicians/");
return response.data.map(
(musician: MusicianObj) =>
new MusicianObj(
musician.id,
musician.name,
musician.bio,
musician.headshot_id,
),
);
};
export const patchMusician = async (
id: number,
bio: string,
name: string,
headshot_id: string,
user_token: string,
): Promise<MusicianObj> => {
const response = await api.patch(
`/musicians/${id}/`,
{ id, bio, name, headshot_id },
{ headers: { Authorization: `Bearer ${user_token}` } },
);
return new MusicianObj(
response.data.id,
response.data.name,
response.data.bio,
response.data.headshot_id,
);
};
export const patchGroup = async (
id: number,
bio: string,
name: string,
user_token: string,
): Promise<GroupObj> => {
const response = await api.patch(
`/group/`,
{ id, bio, name },
{ headers: { Authorization: `Bearer ${user_token}` } },
);
return new GroupObj(response.data.id, response.data.name, response.data.bio);
};
export const postHeadshot = async (
id: number,
file: File,
user_token: string,
): Promise<MusicianObj> => {
const formData = new FormData();
formData.append("file", file);
const response = await api.post(`/musicians/${id}/headshot`, formData, {
headers: { Authorization: `Bearer ${user_token}` },
});
return new MusicianObj(
response.data.id,
response.data.name,
response.data.bio,
response.data.headshot_id,
);
};
export const postMessage = async (
name: string,
email: string,
message: string,
): Promise<void> => {
await api.post("/contact/", { name, email, message });
return;
};
export const postSeriesPoster = async (
series_id: number,
poster: File,
user_token: string,
): Promise<EventSeriesObj> => {
const formData = new FormData();
formData.append("poster", poster);
const response = await api.post(`/events/${series_id}/poster`, formData, {
headers: { Authorization: `Bearer ${user_token}` },
});
return new EventSeriesObj(
response.data.series_id,
response.data.name,
response.data.description,
response.data.events.map(
(event: EventObj) =>
new EventObj(
event.event_id,
event.location,
event.time,
event.ticket_url,
event.map_url,
),
),
response.data.poster_id,
);
};
export const getSeriesList = async (): Promise<EventSeriesObj[]> => {
const response = await api.get("/events/");
return response.data.map(
(series: EventSeriesObj) =>
new EventSeriesObj(
series.series_id,
series.name,
series.description,
series.events.map(
(event: EventObj) =>
new EventObj(
event.event_id,
event.location,
event.time,
event.ticket_url,
event.map_url,
),
),
series.poster_id,
),
);
};
export const postSeries = async (
series: EventSeriesObj,
user_token: string,
): Promise<EventSeriesObj> => {
const response = await api.post("/events/", series, {
headers: { Authorization: `Bearer ${user_token}` },
});
return new EventSeriesObj(
response.data.series_id,
response.data.name,
response.data.description,
response.data.events.map(
(event: EventObj) =>
new EventObj(
event.event_id,
event.location,
event.time,
event.ticket_url,
event.map_url,
),
),
response.data.poster_id,
);
};
export const deleteSeries = async (
series_id: number,
user_token: string,
): Promise<void> => {
await api.delete(`/events/${series_id}/`, {
headers: { Authorization: `Bearer ${user_token}` },
});
return;
};
export const putSeries = async (
series: EventSeriesObj,
user_token: string,
): Promise<EventSeriesObj> => {
const response = await api.put(`/events/${series.series_id}/`, series, {
headers: { Authorization: `Bearer ${user_token}` },
});
return new EventSeriesObj(
response.data.series_id,
response.data.name,
response.data.description,
response.data.events.map(
(event: EventObj) =>
new EventObj(
event.event_id,
event.location,
event.time,
event.ticket_url,
event.map_url,
),
),
response.data.poster_id,
);
};

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

@@ -0,0 +1,14 @@
:root {
--group-info-color: rgb(124, 1, 68);
--group-info-darker-color: rgba(73, 1, 41, 0.8);
--group-info-lighter-color: rgba(206, 6, 116, 0.8);
--grapefruit-yellow: rgb(252, 171, 83);
--grapefruit-yellow-washed-out: rgb(255, 204, 150);
--group-info-disabled-color: rgba(124, 1, 68, 0.5);
}
html,
body,
#root {
height: 100%;
}

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

@@ -0,0 +1,16 @@
import "bootstrap/dist/css/bootstrap.min.css";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { GoogleOAuthProvider } from "@react-oauth/google";
const googleClientID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string;
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<GoogleOAuthProvider clientId={googleClientID}>
<App />
</GoogleOAuthProvider>
</React.StrictMode>,
);

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

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

27
client/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"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"],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

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

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

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

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

15
server/.env.example Normal file
View File

@@ -0,0 +1,15 @@
[mysql]
DB_HOST=localhost
DB_USER=uresname
DB_PASSWORD=password
DB_DATABASE=databasename
[cloudinary]
CLOUDINARY_URL=cloudinary://yourkey:yoursecret@yourcloudname
[contact]
APP_PASSWORD=gmailpassword
EMAIL=destination@example.com
[oauth2-google]
AUDIENCE=some-value.apps.googleusercontent.com

36
server/.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: thegrapefruitsduo backend build
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- 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
run: |
poetry install
- name: Lint with black
run: |
poetry run black --check .
- name: Test with pytest
run: |
poetry run pytest -s

176
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,176 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# 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/#use-with-ide
.pdm.toml
# 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/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python

1
server/.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

45
server/README.md Normal file
View File

@@ -0,0 +1,45 @@
# Backend for The Grapefruits Duo
This is the backend for thegrapefruitsduo.com and is deployed at api.thegrapefruitsduo.com. It is a FastAPI app that serves information about the chamber music, the group's members, and upcoming events. It allows authorized users to modify most information. Data is persisted with MariaDB.
The general flow of this program is as follows, starting from the database layer:
- `python-mysql` is used to interact with the MariaDB database. This happens in `app/db`
- `app/controllers` contains the business logic for the app and consumes the database layer. Each controller is responsible for a different part of the app, with one main controller `app/controllers/controller.py` which imports, instantiates, and uses the other controllers.
- `app/routes` contains the FastAPI routes that consume the single controller. This controller is instantiated in `app/controllers/__init__.py` and passed to the routes.
No formal api specification is provided, but the routes are documented with FastAPI's Swagger UI at `/docs`.
## Basic Usage
Use of [poetry](https://python-poetry.org/docs/) is required. Creating the virtual environment with poetry is easy and should be done in the main project directory. `.venv` should be alongside `pyproject.toml`.
To install dependencies (venv will be created automatically if it doesn't exist):
```bash
poetry install
```
The following steps require proper environment variables to be set. An example can be found in `.env.example`
To seed the mysql database:
```bash
poetry run seed
```
To retrieve a token for testing:
```bash
poetry run token
```
To run the FastAPI app in development mode:
```bash
poetry run dev
```
### Deployment
This app is deployed on a Linode Ubuntu Server instance. NGINX is used as a reverse proxy and the app itself is managed by `systemd` and `uvicorn` as a service, and listens on port 6000. The app is served over HTTPS with a Let's Encrypt certificate.

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

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

View File

@@ -0,0 +1,3 @@
from fastapi.security import HTTPBearer, OAuth2PasswordBearer
oauth2_http = HTTPBearer()

View File

@@ -0,0 +1,17 @@
import smtplib
from email.mime.text import MIMEText
from os import getenv
HOST = "grapefruitswebsite@gmail.com"
def send_email(subject: str, body: str) -> None:
password = getenv("APP_PASSWORD")
email = getenv("EMAIL")
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = HOST
smtp_server = smtplib.SMTP_SSL("smtp.gmail.com", 465)
smtp_server.login(HOST, password) # type: ignore
smtp_server.sendmail(HOST, [email], msg.as_string()) # type: ignore
smtp_server.quit()

View File

@@ -0,0 +1,48 @@
# Set your Cloudinary credentials
# ==============================
from pprint import pprint
from dotenv import load_dotenv
load_dotenv()
# Import the Cloudinary libraries
# ==============================
import cloudinary
import cloudinary.api
import cloudinary.uploader
# Set configuration parameter: return "https" URLs by setting secure=True
# ==============================
cloudinary.config(secure=True)
uploader = cloudinary.uploader
class CloudinaryException(Exception):
pass
def delete_image(public_id: str) -> None:
result = uploader.destroy(public_id)
if result.get("result") != "ok":
raise CloudinaryException("Failed to delete image")
def get_image_data(public_id: str) -> dict:
data = cloudinary.api.resource(public_id)
return data
def get_image_url(public_id: str) -> str:
url = cloudinary.utils.cloudinary_url(public_id)[0]
if url is None:
raise CloudinaryException("Failed to get image URL")
return url
if __name__ == "__main__":
image_id = "coco_copy_jywbxm"

View File

@@ -0,0 +1,27 @@
from os import getenv
from fastapi.security.http import HTTPAuthorizationCredentials
from google.auth import jwt
from icecream import ic
def _token_claims(token: HTTPAuthorizationCredentials) -> dict:
aud = getenv("AUDIENCE")
credentials = token.credentials
claims = jwt.decode(credentials, aud, verify=False)
if not claims:
raise ValueError("Invalid token")
if claims.get("aud") != aud:
raise ValueError("Invalid audience")
if claims.get("email_verified") is not True:
raise ValueError("Email not verified")
if not claims.get("email"):
raise ValueError("Email not found in token")
if not claims.get("sub"):
raise ValueError("Sub not found in token")
return claims
def email_and_sub(token: HTTPAuthorizationCredentials) -> tuple[str, str]:
claims = _token_claims(token)
return claims["email"], claims["sub"]

View File

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

View File

@@ -0,0 +1,28 @@
from fastapi import HTTPException, UploadFile, status
from app.db.base_queries import BaseQueries
ALLOWED_FILES_TYPES = ["image/jpeg", "image/png"]
MAX_FILE_SIZE = 1000000 # 1 MB
class BaseController:
def __init__(self) -> None:
self.db: BaseQueries = None # type: ignore
self.ALL_FILES = ALLOWED_FILES_TYPES
self.MAX_FILE_SIZE = MAX_FILE_SIZE
async def verify_image(self, file: UploadFile) -> bytes:
print("verifying image")
if file.content_type not in self.ALL_FILES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {file.content_type} not allowed. Allowed file types are {self.ALL_FILES}",
)
image_file = await file.read()
if len(image_file) > self.MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size {len(image_file)} bytes exceeds maximum of {self.MAX_FILE_SIZE} bytes",
)
return image_file

View File

@@ -0,0 +1,99 @@
from fastapi import HTTPException, UploadFile, status
from fastapi.security import HTTPAuthorizationCredentials
from icecream import ic
from app.admin import oauth_token
from app.controllers.events import EventController
from app.controllers.group import GroupController
from app.controllers.musicians import MusicianController
from app.controllers.users import UserController
from app.models.event import EventSeries, NewEventSeries
from app.models.group import Group
from app.models.musician import Musician
from app.models.user import User
class Controller:
def __init__(self) -> None:
self.event_controller = EventController()
self.musician_controller = MusicianController()
self.user_controller = UserController()
self.group_controller = GroupController()
async def get_musicians(self) -> list[Musician]:
return await self.musician_controller.get_musicians()
async def get_musician(self, id: int) -> Musician:
return await self.musician_controller.get_musician(id)
async def update_musician(
self,
musician: Musician,
url_param_id: int,
token: HTTPAuthorizationCredentials,
file: UploadFile | None = None,
) -> Musician:
if musician.id != url_param_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in URL does not match ID in request body",
)
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.musician_controller.update_musician(
musician_id=musician.id,
new_bio=musician.bio,
file=file,
)
async def get_events(self) -> list[EventSeries]:
return await self.event_controller.get_all_series()
async def get_event(self, id: int) -> EventSeries:
return await self.event_controller.get_one_series(id)
async def create_event(
self, series: NewEventSeries, token: HTTPAuthorizationCredentials
) -> EventSeries:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.event_controller.create_series(series)
async def add_series_poster(
self, series_id: int, poster: UploadFile, token: HTTPAuthorizationCredentials
) -> EventSeries:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.event_controller.add_series_poster(series_id, poster)
async def delete_series(self, id: int, token: HTTPAuthorizationCredentials) -> None:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
await self.event_controller.delete_series(id)
async def update_series(
self, route_id: int, series: EventSeries, token: HTTPAuthorizationCredentials
) -> EventSeries:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.event_controller.update_series(route_id, series)
async def get_users(self) -> list[User]:
return await self.user_controller.get_users()
async def get_user(self, id: int) -> User:
return await self.user_controller.get_user_by_id(id)
async def create_user(self, token: HTTPAuthorizationCredentials) -> User:
return await self.user_controller.create_user(token)
async def get_group(self) -> Group:
return await self.group_controller.get_group()
async def update_group_bio(
self, bio: str, token: HTTPAuthorizationCredentials
) -> Group:
_, sub = oauth_token.email_and_sub(token)
await self.user_controller.get_user_by_sub(sub)
return await self.group_controller.update_group_bio(bio)

View File

@@ -0,0 +1,106 @@
from fastapi import HTTPException, UploadFile, status
from icecream import ic
from mysql.connector.errors import IntegrityError
from app.admin.images import uploader
from app.controllers.base_controller import BaseController
from app.db import event_queries
from app.db.events import EventQueries
from app.models.event import Event, EventSeries, NewEventSeries
class EventController(BaseController):
def __init__(self) -> None:
super().__init__()
self.db: EventQueries = event_queries
def _all_series(self, data: list[dict]) -> list[EventSeries]:
all_series: dict[str, EventSeries] = {}
for event_series_row in data:
series_name: str = event_series_row["name"]
event = Event(**event_series_row)
if series_name not in all_series:
all_series[series_name] = EventSeries(**event_series_row, events=[])
all_series[series_name].events.append(event)
return [series for series in all_series.values()]
async def get_all_series(self) -> list[EventSeries]:
data = await self.db.get_all()
try:
return self._all_series(data)
except Exception as e:
ic(e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving event objects: {e}",
)
async def get_one_series(self, id: int) -> EventSeries:
if not (data := await self.db.get_one(id)):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Event not found"
)
try:
event = EventSeries(
**data[0], events=[Event(**e) for e in data if e.get("event_id")]
)
return event
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating event object: {e}",
)
async def create_series(self, series: NewEventSeries) -> EventSeries:
try:
inserted_id = await self.db.insert_one_series(series)
for new_event in series.events:
await self.db.insert_one_event(new_event, inserted_id)
return await self.get_one_series(inserted_id)
except IntegrityError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Series name already exists. Each series must have a unique name.\n{e}",
)
async def add_series_poster(self, series_id, poster: UploadFile) -> EventSeries:
series = await self.get_one_series(series_id)
series.poster_id = await self._upload_poster(poster)
await self.db.update_series_poster(series)
return await self.get_one_series(series.series_id)
async def _upload_poster(self, poster: UploadFile) -> str:
image_file = await self.verify_image(poster)
try:
data = uploader.upload(image_file)
return data.get("public_id")
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error uploading image: {e}",
)
async def delete_series(self, id: int) -> None:
series = await self.get_one_series(id)
await self.db.delete_one_series(series)
async def update_series(self, route_id: int, series: EventSeries) -> EventSeries:
if route_id != series.series_id:
print("error")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in URL does not match ID in request body",
)
prev_series = await self.get_one_series(series.series_id)
if series.poster_id != prev_series.poster_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Poster ID cannot be updated directly. Use the /poster endpoint instead.",
)
await self.db.delete_events_by_series(series)
await self.db.replace_series(series)
for event in series.events:
await self.db.insert_one_event(event, series.series_id)
return await self.get_one_series(series.series_id)

View File

@@ -0,0 +1,35 @@
from fastapi import HTTPException, status
from app.controllers.base_controller import BaseController
from app.db import group_queries
from app.db.group import GroupQueries
from app.models.group import Group
class GroupController(BaseController):
def __init__(self) -> None:
super().__init__()
self.db: GroupQueries = group_queries
async def get_group(self) -> Group:
if (data := await self.db.get_one()) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
)
try:
return Group(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating group object: {e}",
)
async def update_group_bio(self, bio: str) -> Group:
try:
await self.db.update_group_bio(bio)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating group bio: {e}",
)
return await self.get_group()

View File

@@ -0,0 +1,89 @@
from fastapi import HTTPException, UploadFile, status
from icecream import ic
from app.admin.images import uploader
from app.controllers.base_controller import BaseController
from app.db import musician_queries
from app.db.musicians import MusicianQueries
from app.models.musician import Musician
class MusicianController(BaseController):
def __init__(self) -> None:
super().__init__()
self.db: MusicianQueries = musician_queries
async def get_musicians(self) -> list[Musician]:
data = await self.db.get_all()
try:
return [Musician(**m) for m in data]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating musician objects: {e}",
)
async def get_musician(self, id: int) -> Musician:
if (data := await self.db.get_one(id)) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Musician not found"
)
try:
return Musician(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating musician object: {e}",
)
async def update_musician(
self,
musician_id: int,
new_bio: str,
file: UploadFile | None = None,
) -> Musician:
musician = await self.get_musician(musician_id)
if new_bio != musician.bio:
return await self.update_musician_bio(musician.id, new_bio)
if file is not None:
return await self.upload_headshot(musician.id, file)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Update operation not implemented. Neither the bio or headshot was updated.",
)
async def update_musician_headshot(self, id: int, headshot_id: str) -> Musician:
await self.get_musician(id)
try:
await self.db.update_headshot(id, headshot_id)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating musician headshot: {e}",
)
return await self.get_musician(id)
async def update_musician_bio(self, id: int, bio: str) -> Musician:
await self.get_musician(id) # Check if musician exists
try:
await self.db.update_bio(id, bio)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating musician bio: {e}",
)
return await self.get_musician(id)
async def upload_headshot(self, id: int, file: UploadFile) -> Musician:
image_file = await self.verify_image(file)
data = uploader.upload(image_file)
public_id = data.get("public_id")
if public_id is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to upload image",
)
await self.update_musician_headshot(id, public_id)
return await self.get_musician(id)

View File

@@ -0,0 +1,70 @@
from fastapi import HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials
from app.admin import oauth_token
from app.controllers.base_controller import BaseController
from app.db import user_queries
from app.db.users import UserQueries
from app.models.user import User
class UserController(BaseController):
def __init__(self) -> None:
super().__init__()
self.db: UserQueries = user_queries
async def get_users(self) -> list[User]:
data = await self.db.get_all()
try:
return [User(**e) for e in data]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating user objects: {e}",
)
async def get_user_by_id(self, id: int) -> User:
if (data := await self.db.get_one(id)) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
try:
return User(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating user object: {e}",
)
async def get_user_by_email(self, email: str) -> User:
if (data := await self.db.get_one_by_email(email)) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User does not exist"
)
try:
return User(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating user object: {e}",
)
async def get_user_by_sub(self, sub: str) -> User:
if (data := await self.db.get_one_by_sub(sub)) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
try:
return User(**data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating user object: {e}",
)
async def create_user(self, token: HTTPAuthorizationCredentials) -> User:
email, sub = oauth_token.email_and_sub(token)
user: User = await self.get_user_by_email(email)
if user.sub is None:
await self.db.update_sub(user.email, sub)
return await self.get_user_by_sub(sub)

View File

@@ -0,0 +1,9 @@
from .events import EventQueries
from .group import GroupQueries
from .musicians import MusicianQueries
from .users import UserQueries
event_queries = EventQueries()
user_queries = UserQueries()
musician_queries = MusicianQueries()
group_queries = GroupQueries()

View File

@@ -0,0 +1,32 @@
from typing import Callable
from app.db.conn import connect_db
class BaseQueries:
from icecream import ic
def __init__(self) -> None:
self.table: str = None # type: ignore
self.connect_db: Callable = connect_db
async def get_all(self) -> list[dict]:
query = f"SELECT * FROM {self.table}"
db = connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query)
data = cursor.fetchall()
cursor.close()
db.close()
return data # type: ignore
async def get_one(self, id: int) -> dict | None:
query = f"SELECT * FROM {self.table} WHERE id = %s"
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query, (id,))
data = cursor.fetchone()
cursor.close()
db.close()
return data # type: ignore

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

@@ -0,0 +1,31 @@
import os
import mysql.connector
from dotenv import load_dotenv
class DBException(Exception):
pass
def connect_db() -> mysql.connector.MySQLConnection:
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

140
server/app/db/events.py Normal file
View File

@@ -0,0 +1,140 @@
from asyncio import gather
from icecream import ic
from app.db.base_queries import BaseQueries
from app.models.event import (
EVENT_TABLE,
SERIES_TABLE,
Event,
EventSeries,
NewEvent,
NewEventSeries,
)
class EventQueries(BaseQueries):
def __init__(self) -> None:
super().__init__()
self.table = SERIES_TABLE
async def get_one(self, series_id: int) -> list[dict] | None:
query = f"""
SELECT *
FROM {SERIES_TABLE}
INNER JOIN {EVENT_TABLE}
ON {SERIES_TABLE}.series_id = {EVENT_TABLE}.series_id
WHERE {SERIES_TABLE}.series_id = %s
"""
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query, (series_id,))
data = cursor.fetchall()
cursor.close()
db.close()
return data
async def get_all(self) -> list[dict]:
query = f"""
SELECT *
FROM {SERIES_TABLE}
INNER JOIN {EVENT_TABLE}
ON {SERIES_TABLE}.series_id = {EVENT_TABLE}.series_id
"""
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query)
data = cursor.fetchall()
cursor.close()
db.close()
return data
async def insert_one_series(self, series: NewEventSeries) -> int:
query = f"INSERT INTO {self.table} (name, description) VALUES (%s, %s)"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(
query,
(
series.name,
series.description,
),
)
inserted_id = cursor.lastrowid
db.commit()
cursor.close()
db.close()
return inserted_id
async def insert_one_event(self, event: NewEvent, series_id: int) -> int:
query = f"INSERT INTO {EVENT_TABLE} (series_id, location, time, ticket_url, map_url) VALUES (%s, %s, %s, %s, %s)"
db = self.connect_db()
cursor = db.cursor()
ticket_url = str(event.ticket_url) if event.ticket_url else None
map_url = str(event.map_url) if event.map_url else None
cursor.execute(
query, (series_id, event.location, event.time, ticket_url, map_url)
)
iserted_id = cursor.lastrowid
db.commit()
cursor.close()
db.close()
return iserted_id
async def delete_events_by_series(self, series: EventSeries) -> None:
query = f"DELETE FROM {EVENT_TABLE} WHERE series_id = %s"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(query, (series.series_id,))
db.commit()
cursor.close()
async def delete_one_series(self, series: EventSeries) -> None:
query = f"DELETE FROM {self.table} WHERE series_id = %s"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(query, (series.series_id,))
db.commit()
cursor.close()
async def update_series_poster(self, series: EventSeries) -> None:
query = f"UPDATE {self.table} SET poster_id = %s WHERE series_id = %s"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(query, (series.poster_id, series.series_id))
db.commit()
cursor.close()
async def replace_event(self, event: Event) -> None:
query = f"""
UPDATE {EVENT_TABLE}
SET location = %s, time = %s, ticket_url = %s, map_url = %s
WHERE event_id = %s
"""
db = self.connect_db()
cursor = db.cursor()
ticket_url = str(event.ticket_url) if event.ticket_url else None
map_url = str(event.map_url) if event.map_url else None
cursor.execute(
query, (event.location, event.time, ticket_url, map_url, event.event_id)
)
db.commit()
cursor.close()
db.close()
async def replace_series(self, series: EventSeries) -> None:
query = f"""
UPDATE {self.table}
SET name = %s, description = %s, poster_id = %s
WHERE series_id = %s
"""
db = self.connect_db()
cursor = db.cursor()
cursor.execute(
query, (series.name, series.description, series.poster_id, series.series_id)
)
db.commit()
cursor.close()
db.close()

36
server/app/db/group.py Normal file
View File

@@ -0,0 +1,36 @@
from app.db.base_queries import BaseQueries
from app.models.group import GROUP_TABLE
class GroupQueries(BaseQueries):
def __init__(self) -> None:
super().__init__()
self.table = GROUP_TABLE
async def get_one(self) -> dict:
query = f"SELECT * FROM {self.table}"
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query)
data = cursor.fetchone()
cursor.close()
db.close()
if not data:
raise Exception("error retrieving group")
return data
async def get_all(self) -> None:
raise NotImplementedError(
"get_all method not implemented for GroupQueries. There's only one row in the table."
)
async def update_group_bio(self, bio: str) -> None:
db = self.connect_db()
cursor = db.cursor()
query = f"UPDATE {self.table} SET bio = %s WHERE id = 1" # only one row in the table
cursor.execute(query, (bio,))
db.commit()
cursor.close()
db.close()

View File

@@ -0,0 +1,29 @@
from icecream import ic
from app.db.base_queries import BaseQueries
from app.db.conn import connect_db
from app.models.musician import MUSICIAN_TABLE
class MusicianQueries(BaseQueries):
def __init__(self) -> None:
super().__init__()
self.table = MUSICIAN_TABLE
async def update_bio(self, id: int, bio: str) -> None:
db = connect_db()
cursor = db.cursor()
query = f"UPDATE {self.table} SET bio = %s WHERE id = %s"
cursor.execute(query, (bio, id))
db.commit()
cursor.close()
db.close()
async def update_headshot(self, id: int, headshot_id: str) -> None:
db = connect_db()
cursor = db.cursor()
query = f"UPDATE {self.table} SET headshot_id = %s WHERE id = %s"
cursor.execute(query, (headshot_id, id))
db.commit()
cursor.close()
db.close()

42
server/app/db/users.py Normal file
View File

@@ -0,0 +1,42 @@
from app.db.base_queries import BaseQueries
from app.models.user import USER_TABLE
class UserQueries(BaseQueries):
def __init__(self) -> None:
super().__init__()
self.table = USER_TABLE
async def get_one_by_email(self, email: str) -> dict | None:
query = f"SELECT * FROM {self.table} WHERE email = %s"
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query, (email,))
data = cursor.fetchone()
cursor.close()
db.close()
return data
async def get_one_by_sub(self, sub: str) -> dict | None:
query = f"SELECT * FROM {self.table} WHERE sub = %s"
db = self.connect_db()
cursor = db.cursor(dictionary=True)
cursor.execute(query, (sub,))
data = cursor.fetchone()
cursor.close()
db.close()
if not data:
return None
return data
async def update_sub(self, email: str, sub: str) -> None:
query = f"UPDATE {self.table} SET sub = %s WHERE email = %s"
db = self.connect_db()
cursor = db.cursor()
cursor.execute(query, (sub, email))
db.commit()
cursor.close()
db.close()

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

@@ -0,0 +1,56 @@
from asyncio import gather
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.controllers import Controller
from app.models.tgd import TheGrapefruitsDuo
from app.routers.contact import router as contact_router
from app.routers.events import router as event_router
from app.routers.group import router as group_router
from app.routers.musicians import router as musician_router
from app.routers.users import router as user_router
from app.scripts.version import get_version
app = FastAPI(
title="The Grapefruits Duo API",
description="API for The Grapefruits Duo website",
version=get_version(),
)
app.include_router(musician_router)
app.include_router(group_router)
app.include_router(contact_router)
app.include_router(event_router)
app.include_router(user_router)
controller = Controller()
origins = [
"http://localhost:3000",
"https://thegrapefruitsduo.com",
"https://www.thegrapefruitsduo.com",
"https://tgd.lucasjensen.me",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/", tags=["root"])
async def root() -> TheGrapefruitsDuo:
musicians, events, group = await gather(
controller.get_musicians(),
controller.get_events(),
controller.get_group(),
)
return TheGrapefruitsDuo(
version=get_version(),
group=group,
musicians=musicians,
events=events,
)

View File

View File

@@ -0,0 +1,7 @@
from pydantic import BaseModel, EmailStr
class Contact(BaseModel):
name: str
email: EmailStr
message: str

View File

@@ -0,0 +1,36 @@
from datetime import datetime
from typing import Optional
from fastapi import UploadFile
from pydantic import BaseModel, HttpUrl
class Poster(BaseModel):
file: UploadFile
class NewEvent(BaseModel):
location: str
time: datetime
map_url: Optional[HttpUrl] = None
ticket_url: Optional[HttpUrl] = None
class Event(NewEvent):
event_id: int
class NewEventSeries(BaseModel):
name: str
description: str
events: list[NewEvent]
class EventSeries(NewEventSeries):
series_id: int
events: list[Event]
poster_id: Optional[str] = None
SERIES_TABLE = "series"
EVENT_TABLE = "events"

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class Group(BaseModel):
name: str
bio: str
id: int | None = None
GROUP_TABLE = "group_table"

View File

@@ -0,0 +1,16 @@
from typing import Optional
from pydantic import BaseModel, Field
class NewMusician(BaseModel):
name: str
bio: str
headshot_id: str # cloudinary id
class Musician(NewMusician):
id: int
MUSICIAN_TABLE = "musicians"

12
server/app/models/tgd.py Normal file
View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel
from app.models.event import EventSeries
from app.models.group import Group
from app.models.musician import Musician
class TheGrapefruitsDuo(BaseModel):
version: str
group: Group
musicians: list[Musician]
events: list[EventSeries]

Some files were not shown because too many files have changed in this diff Show More