initial commit
This commit is contained in:
5
client/.env.example
Normal file
5
client/.env.example
Normal 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
18
client/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
33
client/.github/workflows/build.yml
vendored
Normal file
33
client/.github/workflows/build.yml
vendored
Normal 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
27
client/.gitignore
vendored
Normal 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
4
client/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
dist
|
||||
1
client/.prettierrc
Normal file
1
client/.prettierrc
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
46
client/README.md
Normal file
46
client/README.md
Normal 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
27
client/index.html
Normal 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
3819
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
client/package.json
Normal file
44
client/package.json
Normal 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
BIN
client/public/bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
68
client/src/App.css
Normal file
68
client/src/App.css
Normal 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
86
client/src/App.tsx
Normal 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;
|
||||
30
client/src/Auth/Profile.tsx
Normal file
30
client/src/Auth/Profile.tsx
Normal 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
13
client/src/Auth/User.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
31
client/src/BackgroundStyle.tsx
Normal file
31
client/src/BackgroundStyle.tsx
Normal 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;
|
||||
34
client/src/Buttons/DeleteButton/DeleteButton.css
Normal file
34
client/src/Buttons/DeleteButton/DeleteButton.css
Normal 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;
|
||||
}
|
||||
40
client/src/Buttons/DeleteButton/DeleteButton.tsx
Normal file
40
client/src/Buttons/DeleteButton/DeleteButton.tsx
Normal 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;
|
||||
34
client/src/Buttons/EditButton/EditButton.css
Normal file
34
client/src/Buttons/EditButton/EditButton.css
Normal 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;
|
||||
}
|
||||
26
client/src/Buttons/EditButton/EditButton.tsx
Normal file
26
client/src/Buttons/EditButton/EditButton.tsx
Normal 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;
|
||||
42
client/src/Buttons/Login.tsx
Normal file
42
client/src/Buttons/Login.tsx
Normal 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;
|
||||
25
client/src/Buttons/Logout.tsx
Normal file
25
client/src/Buttons/Logout.tsx
Normal 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;
|
||||
9
client/src/Cld/CloudinaryConfig.ts
Normal file
9
client/src/Cld/CloudinaryConfig.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Cloudinary } from "@cloudinary/url-gen/index";
|
||||
|
||||
const cld = new Cloudinary({
|
||||
cloud: {
|
||||
cloudName: "dreftv0ue",
|
||||
},
|
||||
});
|
||||
|
||||
export default cld;
|
||||
33
client/src/EditModals/EditModal.tsx
Normal file
33
client/src/EditModals/EditModal.tsx
Normal 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;
|
||||
12
client/src/EmailSignupButton/EmailSignupButton.css
Normal file
12
client/src/EmailSignupButton/EmailSignupButton.css
Normal 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);
|
||||
}
|
||||
19
client/src/EmailSignupButton/EmailSignupButton.tsx
Normal file
19
client/src/EmailSignupButton/EmailSignupButton.tsx
Normal 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;
|
||||
8
client/src/ErrorModal/ErrorModal.css
Normal file
8
client/src/ErrorModal/ErrorModal.css
Normal 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);
|
||||
}
|
||||
31
client/src/ErrorModal/ErrorModal.tsx
Normal file
31
client/src/ErrorModal/ErrorModal.tsx
Normal 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;
|
||||
16
client/src/Footer/Footer.css
Normal file
16
client/src/Footer/Footer.css
Normal 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;
|
||||
}
|
||||
56
client/src/Footer/Footer.tsx
Normal file
56
client/src/Footer/Footer.tsx
Normal 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">© {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;
|
||||
110
client/src/Forms/Bio/BioForm.tsx
Normal file
110
client/src/Forms/Bio/BioForm.tsx
Normal 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;
|
||||
32
client/src/Forms/Contact/Confirmation/ConfirmationModal.tsx
Normal file
32
client/src/Forms/Contact/Confirmation/ConfirmationModal.tsx
Normal 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;
|
||||
13
client/src/Forms/Contact/ContactForm.css
Normal file
13
client/src/Forms/Contact/ContactForm.css
Normal 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);
|
||||
}
|
||||
105
client/src/Forms/Contact/ContactForm.tsx
Normal file
105
client/src/Forms/Contact/ContactForm.tsx
Normal 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;
|
||||
5
client/src/Forms/Event/EventForm.css
Normal file
5
client/src/Forms/Event/EventForm.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.event-form-container {
|
||||
border: 2px solid var(--group-info-color);
|
||||
border-radius: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
246
client/src/Forms/Event/EventForm.tsx
Normal file
246
client/src/Forms/Event/EventForm.tsx
Normal 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;
|
||||
8
client/src/Forms/HeadshotUpload/HeadshotUpload.css
Normal file
8
client/src/Forms/HeadshotUpload/HeadshotUpload.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.headshot-preview {
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
#headshot-upload {
|
||||
display: block;
|
||||
}
|
||||
123
client/src/Forms/HeadshotUpload/HeadshotUploadForm.tsx
Normal file
123
client/src/Forms/HeadshotUpload/HeadshotUploadForm.tsx
Normal 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;
|
||||
117
client/src/Forms/PosterUpload/PosterUploadForm.tsx
Normal file
117
client/src/Forms/PosterUpload/PosterUploadForm.tsx
Normal 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;
|
||||
13
client/src/Group/Group.css
Normal file
13
client/src/Group/Group.css
Normal 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;
|
||||
}
|
||||
79
client/src/Group/Group.tsx
Normal file
79
client/src/Group/Group.tsx
Normal 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;
|
||||
63
client/src/Musicians/Musician/Bio/Bio.tsx
Normal file
63
client/src/Musicians/Musician/Bio/Bio.tsx
Normal 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;
|
||||
0
client/src/Musicians/Musician/Headshot/Headshot.css
Normal file
0
client/src/Musicians/Musician/Headshot/Headshot.css
Normal file
64
client/src/Musicians/Musician/Headshot/Headshot.tsx
Normal file
64
client/src/Musicians/Musician/Headshot/Headshot.tsx
Normal 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;
|
||||
9
client/src/Musicians/Musician/Musician.css
Normal file
9
client/src/Musicians/Musician/Musician.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.musician-container {
|
||||
margin-bottom: 6rem;
|
||||
padding-top: 70px;
|
||||
margin-top: -70px;
|
||||
}
|
||||
/*
|
||||
.musician-card {
|
||||
background-color: transparent !important;
|
||||
} */
|
||||
68
client/src/Musicians/Musician/Musician.tsx
Normal file
68
client/src/Musicians/Musician/Musician.tsx
Normal 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;
|
||||
10
client/src/Musicians/Musicians.css
Normal file
10
client/src/Musicians/Musicians.css
Normal 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;
|
||||
}
|
||||
47
client/src/Musicians/Musicians.tsx
Normal file
47
client/src/Musicians/Musicians.tsx
Normal 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;
|
||||
7
client/src/NavBar/AdminDropdown/AdminDropdown.css
Normal file
7
client/src/NavBar/AdminDropdown/AdminDropdown.css
Normal file
@@ -0,0 +1,7 @@
|
||||
#api-container {
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
#auth-row {
|
||||
background-color: var(--grapefruit-yellow-washed-out);
|
||||
}
|
||||
49
client/src/NavBar/AdminDropdown/AdminDropdown.tsx
Normal file
49
client/src/NavBar/AdminDropdown/AdminDropdown.tsx
Normal 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;
|
||||
36
client/src/NavBar/NavBar.css
Normal file
36
client/src/NavBar/NavBar.css
Normal 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);
|
||||
}
|
||||
91
client/src/NavBar/NavBar.tsx
Normal file
91
client/src/NavBar/NavBar.tsx
Normal 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;
|
||||
5
client/src/Series/Events.tsx/Event/Event.css
Normal file
5
client/src/Series/Events.tsx/Event/Event.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.event-list-item {
|
||||
background-color: transparent !important;
|
||||
color: inherit;
|
||||
border: 0.15rem solid var(--group-info-color);
|
||||
}
|
||||
70
client/src/Series/Events.tsx/Event/Event.tsx
Normal file
70
client/src/Series/Events.tsx/Event/Event.tsx
Normal 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;
|
||||
3
client/src/Series/Events.tsx/Events.css
Normal file
3
client/src/Series/Events.tsx/Events.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.event-list {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
23
client/src/Series/Events.tsx/Events.tsx
Normal file
23
client/src/Series/Events.tsx/Events.tsx
Normal 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;
|
||||
90
client/src/Series/Series/Series.tsx
Normal file
90
client/src/Series/Series/Series.tsx
Normal 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;
|
||||
57
client/src/Series/Series/SeriesPoster.tsx
Normal file
57
client/src/Series/Series/SeriesPoster.tsx
Normal 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;
|
||||
19
client/src/Series/SeriesList.css
Normal file
19
client/src/Series/SeriesList.css
Normal 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;
|
||||
}
|
||||
106
client/src/Series/SeriesList.tsx
Normal file
106
client/src/Series/SeriesList.tsx
Normal 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
293
client/src/api.tsx
Normal 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
14
client/src/index.css
Normal 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
16
client/src/main.tsx
Normal 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
1
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
client/tsconfig.json
Normal file
27
client/tsconfig.json
Normal 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
10
client/tsconfig.node.json
Normal 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
8
client/vite.config.ts
Normal 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
15
server/.env.example
Normal 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
36
server/.github/workflows/build.yml
vendored
Normal 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
176
server/.gitignore
vendored
Normal 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
1
server/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
45
server/README.md
Normal file
45
server/README.md
Normal 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
1
server/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from app.main import app
|
||||
3
server/app/admin/__init__.py
Normal file
3
server/app/admin/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from fastapi.security import HTTPBearer, OAuth2PasswordBearer
|
||||
|
||||
oauth2_http = HTTPBearer()
|
||||
17
server/app/admin/contact.py
Normal file
17
server/app/admin/contact.py
Normal 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()
|
||||
48
server/app/admin/images.py
Normal file
48
server/app/admin/images.py
Normal 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"
|
||||
27
server/app/admin/oauth_token.py
Normal file
27
server/app/admin/oauth_token.py
Normal 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"]
|
||||
3
server/app/controllers/__init__.py
Normal file
3
server/app/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.controllers.controller import Controller
|
||||
|
||||
controller = Controller()
|
||||
28
server/app/controllers/base_controller.py
Normal file
28
server/app/controllers/base_controller.py
Normal 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
|
||||
99
server/app/controllers/controller.py
Normal file
99
server/app/controllers/controller.py
Normal 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)
|
||||
106
server/app/controllers/events.py
Normal file
106
server/app/controllers/events.py
Normal 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)
|
||||
35
server/app/controllers/group.py
Normal file
35
server/app/controllers/group.py
Normal 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()
|
||||
89
server/app/controllers/musicians.py
Normal file
89
server/app/controllers/musicians.py
Normal 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)
|
||||
70
server/app/controllers/users.py
Normal file
70
server/app/controllers/users.py
Normal 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)
|
||||
9
server/app/db/__init__.py
Normal file
9
server/app/db/__init__.py
Normal 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()
|
||||
32
server/app/db/base_queries.py
Normal file
32
server/app/db/base_queries.py
Normal 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
31
server/app/db/conn.py
Normal 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
140
server/app/db/events.py
Normal 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
36
server/app/db/group.py
Normal 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()
|
||||
29
server/app/db/musicians.py
Normal file
29
server/app/db/musicians.py
Normal 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
42
server/app/db/users.py
Normal 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
56
server/app/main.py
Normal 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,
|
||||
)
|
||||
0
server/app/models/__init__.py
Normal file
0
server/app/models/__init__.py
Normal file
7
server/app/models/contact.py
Normal file
7
server/app/models/contact.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
name: str
|
||||
email: EmailStr
|
||||
message: str
|
||||
36
server/app/models/event.py
Normal file
36
server/app/models/event.py
Normal 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"
|
||||
10
server/app/models/group.py
Normal file
10
server/app/models/group.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Group(BaseModel):
|
||||
name: str
|
||||
bio: str
|
||||
id: int | None = None
|
||||
|
||||
|
||||
GROUP_TABLE = "group_table"
|
||||
16
server/app/models/musician.py
Normal file
16
server/app/models/musician.py
Normal 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
12
server/app/models/tgd.py
Normal 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
Reference in New Issue
Block a user