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

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" />