initial commit

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

5
client/.env.example Normal file
View File

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

18
client/.eslintrc.cjs Normal file
View File

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

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

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

27
client/.gitignore vendored Normal file
View File

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

4
client/.prettierignore Normal file
View File

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

1
client/.prettierrc Normal file
View File

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

46
client/README.md Normal file
View File

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

27
client/index.html Normal file
View File

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

3819
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
client/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "thegrapefruitsduo",
"private": true,
"version": "0.3.6",
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"prettier:check": "npx prettier --check .",
"prettier:fix": "npx prettier --write ."
},
"dependencies": {
"@cloudinary/react": "^1.11.2",
"@cloudinary/url-gen": "^1.16.0",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@react-oauth/google": "^0.12.1",
"bootstrap": "^5.3.2",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"react": "^18.2.0",
"react-axios": "^2.0.6",
"react-bootstrap": "^2.10.0",
"react-dom": "^18.2.0",
"vite-plugin-package-version": "^1.1.0"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"prettier": "3.2.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

BIN
client/public/bg.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

27
client/tsconfig.json Normal file
View File

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

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

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

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

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