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()],
|
||||
});
|
||||
Reference in New Issue
Block a user