initial commit
This commit is contained in:
68
client/src/App.css
Normal file
68
client/src/App.css
Normal file
@@ -0,0 +1,68 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&::before {
|
||||
content: " ";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: linear-gradient(
|
||||
rgba(252, 171, 83, 0.3),
|
||||
rgba(252, 171, 83, 0.3)
|
||||
),
|
||||
url("/bg.jpeg");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
will-change: transform;
|
||||
z-index: -1;
|
||||
background-color: rgba(249, 255, 63, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
&::before {
|
||||
background-image: none;
|
||||
background-color: var(--grapefruit-yellow-washed-out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--group-info-color);
|
||||
border-color: var(--group-info-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--group-info-lighter-color);
|
||||
border-color: var(--group-info-lighter-color);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background-color: var(--group-info-disabled-color);
|
||||
border-color: var(--group-info-disabled-color);
|
||||
}
|
||||
|
||||
.btn-primary:focus {
|
||||
background-color: var(--group-info-darker-color);
|
||||
border-color: var(--group-info-darker-color);
|
||||
box-shadow: 0 0 0 0.2rem var(--group-info-darker-color);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
background-color: rgb(250, 201, 222);
|
||||
display: block;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: transparent !important;
|
||||
|
||||
backdrop-filter: blur(15px);
|
||||
border: 0px solid transparent !important;
|
||||
color: var(--group-info-color) !important;
|
||||
}
|
||||
86
client/src/App.tsx
Normal file
86
client/src/App.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import "./App.css";
|
||||
import Musicians from "./Musicians/Musicians";
|
||||
import NavBar from "./NavBar/NavBar";
|
||||
import ContactForm from "./Forms/Contact/ContactForm";
|
||||
import { Container } from "react-bootstrap";
|
||||
import Group, { GroupObj } from "./Group/Group";
|
||||
import SeriesList, { EventSeriesObj } from "./Series/SeriesList";
|
||||
import { useState, useEffect } from "react";
|
||||
import Footer from "./Footer/Footer";
|
||||
import { getRoot } from "./api";
|
||||
import { MusicianObj } from "./Musicians/Musician/Musician";
|
||||
import ErrorModal from "./ErrorModal/ErrorModal";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
function App() {
|
||||
const tokenCookie = Cookies.get("token");
|
||||
const [group, setGroup] = useState<GroupObj>();
|
||||
const [update, setUpdate] = useState<boolean>(false);
|
||||
const [musicians, setMusicians] = useState<MusicianObj[]>([]);
|
||||
const [seriesList, setSeriesList] = useState<EventSeriesObj[]>([]);
|
||||
const [apiVersion, setApiVersion] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [errorModalShow, setErrorModalShow] = useState<boolean>(false);
|
||||
const [errorEntity, setErrorEntity] = useState<string>("");
|
||||
const [token, setToken] = useState<string>(tokenCookie ? tokenCookie : "");
|
||||
const appVersion = import.meta.env.PACKAGE_VERSION;
|
||||
|
||||
const handleGroupBioChange = () => {
|
||||
setUpdate(!update);
|
||||
};
|
||||
|
||||
const handleError = (error: string, entity: string) => {
|
||||
console.error(error);
|
||||
setError(error);
|
||||
setErrorEntity(entity);
|
||||
setErrorModalShow(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getRoot()
|
||||
.then((tgd): void => {
|
||||
setGroup(tgd.group);
|
||||
setMusicians(tgd.musicians);
|
||||
setSeriesList(tgd.events);
|
||||
setApiVersion(tgd.version);
|
||||
})
|
||||
.catch((error) => {
|
||||
handleError(error.message, "root");
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div id="home">
|
||||
<NavBar
|
||||
musicians={musicians}
|
||||
apiVersion={apiVersion}
|
||||
appVersion={appVersion}
|
||||
token={token}
|
||||
setToken={setToken}
|
||||
/>
|
||||
<Container id="content" style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
||||
<Group
|
||||
group={group}
|
||||
onBioChange={handleGroupBioChange}
|
||||
setGroup={setGroup}
|
||||
token={token}
|
||||
/>
|
||||
<Musicians
|
||||
musicians={musicians}
|
||||
setMusicians={setMusicians}
|
||||
token={token}
|
||||
/>
|
||||
<SeriesList
|
||||
seriesList={seriesList}
|
||||
setSeriesList={setSeriesList}
|
||||
token={token}
|
||||
/>
|
||||
<ContactForm />
|
||||
</Container>
|
||||
<Footer />
|
||||
<ErrorModal error={error} show={errorModalShow} entity={errorEntity} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
30
client/src/Auth/Profile.tsx
Normal file
30
client/src/Auth/Profile.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Container } from "react-bootstrap";
|
||||
import { JwtPayload, jwtDecode } from "jwt-decode";
|
||||
|
||||
interface GoogleJwtPayload extends JwtPayload {
|
||||
picture?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface ProfileProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const Profile = (props: ProfileProps) => {
|
||||
const decoded = jwtDecode<GoogleJwtPayload>(props.token);
|
||||
const name = decoded.name;
|
||||
const email = decoded.email;
|
||||
return (
|
||||
props.token && (
|
||||
<Container className="text-end">
|
||||
<p>
|
||||
Logged in as: <strong>{name}</strong>
|
||||
</p>
|
||||
<p>{email}</p>
|
||||
</Container>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
13
client/src/Auth/User.tsx
Normal file
13
client/src/Auth/User.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export class UserObj {
|
||||
name: string;
|
||||
email: string;
|
||||
id: number;
|
||||
sub?: string;
|
||||
|
||||
constructor(name: string, email: string, id: number, sub?: string) {
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
this.id = id;
|
||||
this.sub = sub;
|
||||
}
|
||||
}
|
||||
31
client/src/BackgroundStyle.tsx
Normal file
31
client/src/BackgroundStyle.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
const bgFilter = "rgb(255, 180, 89,0.5)";
|
||||
const url = "d18xMDAwLGFyXzE2OjksY19maWxsLGdfYXV0byxlX3NoYXJwZW4=.jpeg";
|
||||
const BGStyleMobile = {
|
||||
backgroundColor: bgFilter,
|
||||
};
|
||||
|
||||
const BGStyleDesktop = {
|
||||
backgroundImage: `linear-gradient(${bgFilter}, ${bgFilter}),` + `url(${url})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundAttachment: "fixed",
|
||||
};
|
||||
|
||||
function isMobile() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
function isFixedBackgroundSupported() {
|
||||
const testEl = document.createElement("div");
|
||||
testEl.style.backgroundAttachment = "fixed";
|
||||
return testEl.style.backgroundAttachment === "fixed";
|
||||
}
|
||||
|
||||
const BGStyleFinal =
|
||||
!isMobile() && isFixedBackgroundSupported() ? BGStyleDesktop : BGStyleMobile;
|
||||
|
||||
// const BGStyleFinal = BGStyleDesktop;
|
||||
export default BGStyleFinal;
|
||||
34
client/src/Buttons/DeleteButton/DeleteButton.css
Normal file
34
client/src/Buttons/DeleteButton/DeleteButton.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.delete-icon {
|
||||
/* position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%); */
|
||||
font-size: 4rem;
|
||||
color: rgb(255, 0, 0);
|
||||
}
|
||||
|
||||
.delete-icon:hover {
|
||||
color: darkred;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-delete:focus {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: lightcoral;
|
||||
}
|
||||
|
||||
.btn-delete:active {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
40
client/src/Buttons/DeleteButton/DeleteButton.tsx
Normal file
40
client/src/Buttons/DeleteButton/DeleteButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { deleteSeries } from "../../api";
|
||||
import "./DeleteButton.css";
|
||||
import { EventSeriesObj } from "../../Series/SeriesList";
|
||||
|
||||
interface DeleteButtonProps {
|
||||
series: EventSeriesObj;
|
||||
setSeriesList: React.Dispatch<React.SetStateAction<EventSeriesObj[]>>;
|
||||
seriesList: EventSeriesObj[];
|
||||
actionName?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function DeleteButton(props: DeleteButtonProps) {
|
||||
const handleDelete = () => {
|
||||
console.log(props.series.series_id);
|
||||
deleteSeries(props.series.series_id, props.token)
|
||||
.then(() => {
|
||||
props.setSeriesList(
|
||||
props.seriesList.filter(
|
||||
(series) => series.series_id !== props.series.series_id,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button className="delete-icon btn-delete" onClick={handleDelete}>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
{props.actionName}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteButton;
|
||||
34
client/src/Buttons/EditButton/EditButton.css
Normal file
34
client/src/Buttons/EditButton/EditButton.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.edit-icon {
|
||||
/* position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%); */
|
||||
font-size: 4rem;
|
||||
color: green;
|
||||
}
|
||||
|
||||
.edit-icon:hover {
|
||||
color: darkgreen;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-edit:focus {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: var(--group-info-darker-color);
|
||||
}
|
||||
|
||||
.btn-edit:active {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
26
client/src/Buttons/EditButton/EditButton.tsx
Normal file
26
client/src/Buttons/EditButton/EditButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import "./EditButton.css";
|
||||
|
||||
interface EditButtonProps {
|
||||
setModalShow: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
faIcon: IconDefinition;
|
||||
actionName?: string;
|
||||
}
|
||||
|
||||
function EditButton(props: EditButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
className="edit-icon btn-edit"
|
||||
onClick={() => {
|
||||
props.setModalShow(true);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={props.faIcon} />
|
||||
{props.actionName}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditButton;
|
||||
42
client/src/Buttons/Login.tsx
Normal file
42
client/src/Buttons/Login.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
CredentialResponse,
|
||||
GoogleLogin,
|
||||
googleLogout,
|
||||
} from "@react-oauth/google";
|
||||
import { postUser } from "../api";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
interface LoginProps {
|
||||
setToken: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
function Login(props: LoginProps) {
|
||||
const handleSuccess = async (credentialResponse: CredentialResponse) => {
|
||||
if (credentialResponse.credential) {
|
||||
const token = credentialResponse.credential;
|
||||
props.setToken(token);
|
||||
Cookies.set("token", token, { expires: 1 });
|
||||
postUser(token)
|
||||
.then((user) => {
|
||||
console.log(`Welcome, ${user.name}!`);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error("Nice try, but you can't do that. Logging you out.");
|
||||
googleLogout();
|
||||
props.setToken("");
|
||||
});
|
||||
} else {
|
||||
console.error("Login Failed");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<GoogleLogin
|
||||
onSuccess={handleSuccess}
|
||||
onError={() => {
|
||||
console.error("Login Failed");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
25
client/src/Buttons/Logout.tsx
Normal file
25
client/src/Buttons/Logout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { googleLogout } from "@react-oauth/google";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faRightFromBracket } from "@fortawesome/free-solid-svg-icons";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
interface LogoutProps {
|
||||
setToken: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
function Logout(props: LogoutProps) {
|
||||
const handleLogout = () => {
|
||||
googleLogout();
|
||||
props.setToken("");
|
||||
Cookies.remove("token");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleLogout}>
|
||||
<FontAwesomeIcon icon={faRightFromBracket} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Logout;
|
||||
9
client/src/Cld/CloudinaryConfig.ts
Normal file
9
client/src/Cld/CloudinaryConfig.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Cloudinary } from "@cloudinary/url-gen/index";
|
||||
|
||||
const cld = new Cloudinary({
|
||||
cloud: {
|
||||
cloudName: "dreftv0ue",
|
||||
},
|
||||
});
|
||||
|
||||
export default cld;
|
||||
33
client/src/EditModals/EditModal.tsx
Normal file
33
client/src/EditModals/EditModal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import { MusicianProps } from "../Musicians/Musician/Musician";
|
||||
import { GroupObj } from "../Group/Group";
|
||||
import { EventSeriesObj } from "../Series/SeriesList";
|
||||
|
||||
interface EditModalProps {
|
||||
title: string;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
form: JSX.Element;
|
||||
entity?: MusicianProps | GroupObj | EventSeriesObj;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function EditModal(props: EditModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
size="lg"
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title id="contained-modal-title-vcenter">
|
||||
{props.title}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>{props.form}</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditModal;
|
||||
12
client/src/EmailSignupButton/EmailSignupButton.css
Normal file
12
client/src/EmailSignupButton/EmailSignupButton.css
Normal file
@@ -0,0 +1,12 @@
|
||||
#email-signup-button {
|
||||
background-color: var(--grapefruit-yellow);
|
||||
color: var(--group-info-darker);
|
||||
}
|
||||
|
||||
#email-signup-button:hover {
|
||||
background-color: var(--grapefruit-yellow-washed-out);
|
||||
}
|
||||
|
||||
#email-signup-button:focus {
|
||||
background-color: var(--grapefruit-yellow-washed-out);
|
||||
}
|
||||
19
client/src/EmailSignupButton/EmailSignupButton.tsx
Normal file
19
client/src/EmailSignupButton/EmailSignupButton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Button } from "react-bootstrap";
|
||||
import "./EmailSignupButton.css";
|
||||
|
||||
function EmailSignupButton() {
|
||||
const signupUrl = "http://eepurl.com/iNpJz-/";
|
||||
return (
|
||||
<Button
|
||||
id="email-signup-button"
|
||||
variant="primary"
|
||||
href={signupUrl}
|
||||
target="_self"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Sign up for our newsletter
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmailSignupButton;
|
||||
8
client/src/ErrorModal/ErrorModal.css
Normal file
8
client/src/ErrorModal/ErrorModal.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.error-modal {
|
||||
color: rgb(174, 0, 0);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.error-content {
|
||||
background-color: rgba(255, 201, 201, 0.8);
|
||||
}
|
||||
31
client/src/ErrorModal/ErrorModal.tsx
Normal file
31
client/src/ErrorModal/ErrorModal.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import "./ErrorModal.css";
|
||||
|
||||
interface ErrorModalProps {
|
||||
error: string;
|
||||
entity: string;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
function ErrorModal(props: ErrorModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
size="lg"
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
className="error-modal"
|
||||
>
|
||||
<Modal.Header className="error-content">
|
||||
<Modal.Title id="contained-modal-title-vcenter">API Error</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="error-content">
|
||||
<p>{props.error}</p>
|
||||
<p>error occurred while fetching {props.entity}</p>
|
||||
<p>Try again later or contact the site administrator</p>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorModal;
|
||||
16
client/src/Footer/Footer.css
Normal file
16
client/src/Footer/Footer.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.footer {
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
rgba(139, 215, 210, 0.4) 60%,
|
||||
transparent
|
||||
) !important;
|
||||
color: var(--group-info-color) !important;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--group-info-color) !important;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--group-info-lighter-color) !important;
|
||||
}
|
||||
56
client/src/Footer/Footer.tsx
Normal file
56
client/src/Footer/Footer.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faInstagram,
|
||||
faFacebook,
|
||||
faYoutube,
|
||||
} from "@fortawesome/free-brands-svg-icons";
|
||||
import "./Footer.css";
|
||||
import { Container, Row, Col } from "react-bootstrap";
|
||||
import { Nav } from "react-bootstrap";
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const copyright = (
|
||||
<p className="text-center">© {currentYear} The Grapefruits Duo</p>
|
||||
);
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="footer py-3">
|
||||
<Container>
|
||||
<Row>
|
||||
<Col className="text-center">
|
||||
<Nav className="justify-content-center">
|
||||
<Nav.Link
|
||||
href="https://www.instagram.com/thegrapefruitsduo/"
|
||||
className="m-2"
|
||||
target="_blank"
|
||||
>
|
||||
<FontAwesomeIcon icon={faInstagram} />
|
||||
</Nav.Link>
|
||||
<Nav.Link
|
||||
href="https://www.facebook.com/thegrapefruitsduo"
|
||||
className="m-2"
|
||||
target="_blank"
|
||||
>
|
||||
<FontAwesomeIcon icon={faFacebook} />
|
||||
</Nav.Link>
|
||||
<Nav.Link
|
||||
href="https://www.youtube.com/channel/UCzc-ds_awbx3RpGmLWEetKw"
|
||||
className="m-2"
|
||||
target="_blank"
|
||||
>
|
||||
<FontAwesomeIcon icon={faYoutube} />
|
||||
</Nav.Link>
|
||||
</Nav>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col className="text-center">{copyright}</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
110
client/src/Forms/Bio/BioForm.tsx
Normal file
110
client/src/Forms/Bio/BioForm.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Button, Container, Form } from "react-bootstrap";
|
||||
import { MusicianObj } from "../../Musicians/Musician/Musician";
|
||||
import { GroupObj } from "../../Group/Group";
|
||||
import { patchMusician, patchGroup } from "../../api";
|
||||
import { useState } from "react";
|
||||
|
||||
interface EditBioFormProps {
|
||||
entity: MusicianObj | GroupObj;
|
||||
hideModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onBioChange: () => void;
|
||||
setGroup?: React.Dispatch<React.SetStateAction<GroupObj | undefined>>;
|
||||
setMusician?: React.Dispatch<React.SetStateAction<MusicianObj>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function EditBioForm(props: EditBioFormProps) {
|
||||
const [formBio, setFormBio] = useState<string>(props.entity.bio);
|
||||
const [canSubmit, setCanSubmit] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const handleBioChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setFormBio(event.target.value);
|
||||
setCanSubmit(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (props.entity instanceof MusicianObj) {
|
||||
updateMusician(props.token, props.entity);
|
||||
} else if (props.entity instanceof GroupObj) {
|
||||
updateGroup(props.token, props.entity);
|
||||
} else {
|
||||
console.error("Invalid entity type");
|
||||
}
|
||||
props.onBioChange();
|
||||
props.hideModal(false);
|
||||
};
|
||||
|
||||
const updateMusician = async (
|
||||
accessToken: string,
|
||||
musician: MusicianObj,
|
||||
): Promise<void> => {
|
||||
patchMusician(
|
||||
musician.id,
|
||||
formBio,
|
||||
musician.name,
|
||||
musician.headshot_id,
|
||||
accessToken,
|
||||
)
|
||||
.then((patchedMusician) => {
|
||||
if (props.setMusician) {
|
||||
props.setMusician(patchedMusician);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setError("Failed to update bio: " + error.response.data.detail);
|
||||
});
|
||||
};
|
||||
|
||||
const updateGroup = async (
|
||||
accessToken: string,
|
||||
group: GroupObj,
|
||||
): Promise<void> => {
|
||||
patchGroup(group.id, formBio, group.name, accessToken)
|
||||
.then((patchedGroup) => {
|
||||
if (props.setGroup) {
|
||||
props.setGroup(patchedGroup);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setError("Failed to update bio: " + error.response.data.detail);
|
||||
});
|
||||
};
|
||||
|
||||
const SubmitButton = canSubmit ? (
|
||||
<Button variant="primary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" type="submit" disabled>
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group controlId="formBio">
|
||||
<Form.Label>Bio</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={10}
|
||||
required
|
||||
value={formBio}
|
||||
autoFocus
|
||||
onChange={handleBioChange}
|
||||
/>
|
||||
{error && (
|
||||
<Form.Text className="text-danger error-text">{error}</Form.Text>
|
||||
)}
|
||||
</Form.Group>
|
||||
<Container className="d-flex justify-content-end mt-3">
|
||||
{SubmitButton}
|
||||
</Container>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditBioForm;
|
||||
32
client/src/Forms/Contact/Confirmation/ConfirmationModal.tsx
Normal file
32
client/src/Forms/Contact/Confirmation/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Button from "react-bootstrap/Button";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
name: string;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
}
|
||||
|
||||
function ConfirmationModal(props: ConfirmationModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
size="sm"
|
||||
aria-labelledby="contained-modal-title-vcenter"
|
||||
centered
|
||||
>
|
||||
<Modal.Body className="d-flex flex-column align-items-center justify-content-center">
|
||||
<p>Thank you for your message, {props.name}!</p>
|
||||
<Button
|
||||
className="contact-button"
|
||||
variant="primary"
|
||||
onClick={props.onHide}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmationModal;
|
||||
13
client/src/Forms/Contact/ContactForm.css
Normal file
13
client/src/Forms/Contact/ContactForm.css
Normal file
@@ -0,0 +1,13 @@
|
||||
#contact {
|
||||
padding-top: 70px;
|
||||
margin-top: -70px;
|
||||
margin-bottom: 200px;
|
||||
}
|
||||
|
||||
.contact-title {
|
||||
margin-bottom: 6rem;
|
||||
}
|
||||
|
||||
.contact-text {
|
||||
color: var(--group-info-color);
|
||||
}
|
||||
105
client/src/Forms/Contact/ContactForm.tsx
Normal file
105
client/src/Forms/Contact/ContactForm.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import Button from "react-bootstrap/Button";
|
||||
import Form from "react-bootstrap/Form";
|
||||
import { postMessage } from "../../api";
|
||||
import { useState } from "react";
|
||||
import { Col, Container, Row } from "react-bootstrap";
|
||||
import "./ContactForm.css";
|
||||
import ConfirmationModal from "./Confirmation/ConfirmationModal";
|
||||
import EmailSignupButton from "../../EmailSignupButton/EmailSignupButton";
|
||||
|
||||
function ContactForm() {
|
||||
const [confirmationModalShow, setConfirmationModalShow] = useState(false);
|
||||
const [form, setForm] = useState<{ [key: string]: string }>({
|
||||
name: "",
|
||||
email: "",
|
||||
message: "",
|
||||
});
|
||||
|
||||
const handleFormChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
setForm({ ...form, [event.target.name]: event.target.value });
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
postMessage(form.name, form.email, form.message)
|
||||
.then(() => {
|
||||
setConfirmationModalShow(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormReset = () => {
|
||||
setForm({ name: "", email: "", message: "" });
|
||||
setConfirmationModalShow(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container id="contact">
|
||||
<Row className="justify-content-center text-end">
|
||||
<Col xs={12} md={8} lg={6}>
|
||||
<Form
|
||||
className="contact-text"
|
||||
id="contact-form"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<h3 className="display-3 contact-title">Contact Us</h3>
|
||||
<Form.Group className="mb-3" controlId="formBasicName">
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Enter name"
|
||||
required
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleFormChange}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId="formBasicEmail">
|
||||
<Form.Control
|
||||
type="email"
|
||||
placeholder="Enter email"
|
||||
required
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleFormChange}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3" controlId="formBasicMessage">
|
||||
<Form.Label>Message</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={3}
|
||||
required
|
||||
name="message"
|
||||
placeholder="Enter message"
|
||||
value={form.message}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Button variant="primary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</Form>
|
||||
<ConfirmationModal
|
||||
show={confirmationModalShow}
|
||||
onHide={handleFormReset}
|
||||
name={form.name}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className="justify-content-center">
|
||||
<Container className="mt-5 text-center">
|
||||
<EmailSignupButton />
|
||||
</Container>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContactForm;
|
||||
5
client/src/Forms/Event/EventForm.css
Normal file
5
client/src/Forms/Event/EventForm.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.event-form-container {
|
||||
border: 2px solid var(--group-info-color);
|
||||
border-radius: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
246
client/src/Forms/Event/EventForm.tsx
Normal file
246
client/src/Forms/Event/EventForm.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Container, Form } from "react-bootstrap";
|
||||
import { postSeries, putSeries } from "../../api";
|
||||
import { EventSeriesObj } from "../../Series/SeriesList";
|
||||
import { EventObj } from "../../Series/Events.tsx/Event/Event";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import "./EventForm.css";
|
||||
|
||||
interface AddEventFormProps {
|
||||
setModalShow: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSeriesList: React.Dispatch<React.SetStateAction<EventSeriesObj[]>>;
|
||||
seriesList: EventSeriesObj[];
|
||||
isNewSeries: boolean;
|
||||
series?: EventSeriesObj;
|
||||
setSeries?: React.Dispatch<React.SetStateAction<EventSeriesObj>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function EventForm(props: AddEventFormProps) {
|
||||
/*
|
||||
this form serves two purposes:
|
||||
1. Create a new series
|
||||
2. Edit an existing series
|
||||
|
||||
if isNewSeries is true,an empty form will be rendered to create a new series and post it to the server
|
||||
if isNewSeries is false, the form will be populated with existing series data and will be used to edit the series
|
||||
*/
|
||||
if (props.series && props.isNewSeries) {
|
||||
throw new Error("series provided for new series form");
|
||||
}
|
||||
const [formEvents, setFormEvents] = useState<(EventObj | undefined)[]>(
|
||||
props.series === undefined
|
||||
? [undefined]
|
||||
: props.series.events.map((event) => event),
|
||||
);
|
||||
const [postError, setPostError] = useState<string>("");
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
const formElements = form.elements as HTMLFormControlsCollection;
|
||||
const seriesNameElement = formElements.namedItem(
|
||||
"formSeriesName",
|
||||
) as HTMLInputElement;
|
||||
|
||||
const seriesName = seriesNameElement.value;
|
||||
const seriesDescriptionElement = formElements.namedItem(
|
||||
"formSeriesDescription",
|
||||
) as HTMLInputElement;
|
||||
const seriesDescription = seriesDescriptionElement.value;
|
||||
const eventObjects: EventObj[] = findEventElements(formElements);
|
||||
|
||||
const series = new EventSeriesObj(
|
||||
(props.series && props.series.series_id) || 0,
|
||||
seriesName,
|
||||
seriesDescription,
|
||||
eventObjects,
|
||||
(props.series && props.series.poster_id) || undefined,
|
||||
);
|
||||
|
||||
if (!props.token) {
|
||||
console.error("no access token");
|
||||
return;
|
||||
}
|
||||
if (props.isNewSeries) {
|
||||
postSeries(series, props.token)
|
||||
.then((newSeries) => {
|
||||
props.setSeriesList([...props.seriesList, newSeries]);
|
||||
props.setModalShow(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setPostError(
|
||||
"Failed to create series: " + error.response.data.detail,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
putSeries(series, props.token)
|
||||
.then((updatedSeries) => {
|
||||
props.setSeries?.(updatedSeries);
|
||||
props.setModalShow(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setPostError(
|
||||
"Failed to update series: " + error.response.data.detail,
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function findEventElements(
|
||||
formElements: HTMLFormControlsCollection,
|
||||
): EventObj[] {
|
||||
const events: EventObj[] = [];
|
||||
for (let idx = 0; idx < formEvents.length; idx++) {
|
||||
const locationElement = formElements.namedItem(
|
||||
`formEvent${idx}Location`,
|
||||
) as HTMLInputElement;
|
||||
const timeElement = formElements.namedItem(
|
||||
`formEvent${idx}Time`,
|
||||
) as HTMLInputElement;
|
||||
const ticketUrlElement = formElements.namedItem(
|
||||
`formEvent${idx}TicketUrl`,
|
||||
) as HTMLInputElement;
|
||||
const mapUrlElement = formElements.namedItem(
|
||||
`formEvent${idx}MapUrl`,
|
||||
) as HTMLInputElement;
|
||||
const location = locationElement.value;
|
||||
const time = timeElement.value;
|
||||
const ticketUrl = ticketUrlElement.value
|
||||
? ticketUrlElement.value
|
||||
: undefined;
|
||||
const mapUrl = mapUrlElement.value ? mapUrlElement.value : undefined;
|
||||
const event = new EventObj(0, location, time, ticketUrl, mapUrl);
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
const AddEventButton = (
|
||||
<Container className="d-flex justify-content-end mt-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setFormEvents([...formEvents, undefined])}
|
||||
>
|
||||
Add Event to Series
|
||||
</Button>
|
||||
</Container>
|
||||
);
|
||||
|
||||
const SubmitButton = (
|
||||
<Button variant="primary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3" controlId="formSeriesName">
|
||||
<Form.Label>Series Name *</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Enter series name"
|
||||
required
|
||||
name="name"
|
||||
{...(props.series && { defaultValue: props.series.name })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId="formSeriesDescription">
|
||||
<Form.Label>Series Description *</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={3}
|
||||
placeholder="Enter series description"
|
||||
required
|
||||
name="description"
|
||||
{...(props.series && { defaultValue: props.series.description })}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
{formEvents.map((event, idx) => (
|
||||
<Container
|
||||
key={event ? event.event_id : `temp-${idx}`}
|
||||
className="event-form-container"
|
||||
>
|
||||
<Form.Text className="mb-3">{`Event ${idx}`}</Form.Text>
|
||||
<Form.Group className="mb-3" controlId={`formEvent${idx}Location`}>
|
||||
<Form.Label>Event Location *</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Enter event location"
|
||||
required
|
||||
name="location"
|
||||
{...(event && {
|
||||
defaultValue: event.location,
|
||||
})}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId={`formEvent${idx}Time`}>
|
||||
<Form.Label>Event Time *</Form.Label>
|
||||
<Form.Control
|
||||
type="datetime-local"
|
||||
required
|
||||
name="time"
|
||||
{...(event && {
|
||||
defaultValue: event.time,
|
||||
})}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId={`formEvent${idx}TicketUrl`}>
|
||||
<Form.Label>Event Ticket URL</Form.Label>
|
||||
<Form.Control
|
||||
type="url"
|
||||
name="ticket_url"
|
||||
{...(event && {
|
||||
defaultValue: event.ticket_url,
|
||||
})}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3" controlId={`formEvent${idx}MapUrl`}>
|
||||
<Form.Label>Event Map URL</Form.Label>
|
||||
<Form.Control
|
||||
type="url"
|
||||
name="map_url"
|
||||
{...(event && {
|
||||
defaultValue: event.map_url,
|
||||
})}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Container className="d-flex justify-content-end mb-1">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (event) {
|
||||
setFormEvents(
|
||||
formEvents.filter((e) => e?.event_id !== event.event_id),
|
||||
);
|
||||
} else {
|
||||
setFormEvents(formEvents.filter((_, i) => i !== idx));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</Button>
|
||||
</Container>
|
||||
</Container>
|
||||
))}
|
||||
|
||||
{AddEventButton}
|
||||
|
||||
<Form.Text>
|
||||
A poster can be added after returning to the homepage.
|
||||
</Form.Text>
|
||||
{postError && (
|
||||
<Form.Text className="text-danger error-text">{postError}</Form.Text>
|
||||
)}
|
||||
|
||||
<Container className="d-flex justify-content-end">
|
||||
{SubmitButton}
|
||||
</Container>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventForm;
|
||||
8
client/src/Forms/HeadshotUpload/HeadshotUpload.css
Normal file
8
client/src/Forms/HeadshotUpload/HeadshotUpload.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.headshot-preview {
|
||||
max-width: 400px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
#headshot-upload {
|
||||
display: block;
|
||||
}
|
||||
123
client/src/Forms/HeadshotUpload/HeadshotUploadForm.tsx
Normal file
123
client/src/Forms/HeadshotUpload/HeadshotUploadForm.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Form, Image, Button, Container } from "react-bootstrap";
|
||||
import { MusicianObj } from "../../Musicians/Musician/Musician";
|
||||
import { useState } from "react";
|
||||
import { postHeadshot } from "../../api";
|
||||
import "./HeadshotUpload.css";
|
||||
|
||||
export const sizeLimit = 1000000; // one megabyte
|
||||
|
||||
interface HeadshotUploadProps {
|
||||
currentHeadshot: string;
|
||||
musician: MusicianObj;
|
||||
onHeadshotChange?: () => void;
|
||||
hideModal?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setMusician?: React.Dispatch<React.SetStateAction<MusicianObj>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function HeadshotUpload(props: HeadshotUploadProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [fileError, setFileError] = useState<string>("");
|
||||
const [preview, setPreview] = useState<string>(props.currentHeadshot);
|
||||
const [canSubmit, setCanSubmit] = useState<boolean>(false);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const allowedTypes = ["image/jpeg", "image/png"];
|
||||
const file = event.target.files?.[0];
|
||||
const fileSize = file?.size; // bytes
|
||||
const fileType = file?.type; // MIME type
|
||||
|
||||
if (fileSize && fileSize > sizeLimit) {
|
||||
console.error("file too large");
|
||||
setFileError("file too large");
|
||||
setCanSubmit(false);
|
||||
return;
|
||||
}
|
||||
if (fileType && !allowedTypes.includes(fileType)) {
|
||||
console.error("invalid file type");
|
||||
setFileError("invalid file type");
|
||||
setCanSubmit(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setCanSubmit(true);
|
||||
setFileError("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!props.token) {
|
||||
console.error("no access token");
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.musician && selectedFile) {
|
||||
uploadHeadshot(props.token, props.musician, selectedFile);
|
||||
props.hideModal?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
console.error("no file selected");
|
||||
};
|
||||
|
||||
const uploadHeadshot = async (
|
||||
accessToken: string,
|
||||
musician: MusicianObj,
|
||||
file: File,
|
||||
) => {
|
||||
postHeadshot(musician.id, file, accessToken)
|
||||
.then((updatedMusician) => {
|
||||
props.onHeadshotChange?.();
|
||||
props.setMusician?.(updatedMusician);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const SubmitButton = canSubmit ? (
|
||||
<Button variant="primary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" type="submit" disabled>
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group controlId="formFile" className="mb-3">
|
||||
<Container className="d-flex justify-content-center">
|
||||
<Image
|
||||
src={preview}
|
||||
className="img-fluid rounded-circle headshot-preview"
|
||||
alt={`${props.musician.name} headshot`}
|
||||
/>
|
||||
</Container>
|
||||
<Form.Label id="headshot-upload">Upload Headshot</Form.Label>
|
||||
<Form.Control type="file" onChange={handleFileChange} />
|
||||
<Form.Text className="text-muted">
|
||||
size limit: {sizeLimit / 1000000} MB
|
||||
</Form.Text>
|
||||
{fileError && (
|
||||
<Form.Text className="text-danger error-text">{fileError}</Form.Text>
|
||||
)}
|
||||
</Form.Group>
|
||||
<Container className="d-flex justify-content-end">
|
||||
{SubmitButton}
|
||||
</Container>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeadshotUpload;
|
||||
117
client/src/Forms/PosterUpload/PosterUploadForm.tsx
Normal file
117
client/src/Forms/PosterUpload/PosterUploadForm.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Button, Container, Form, Image } from "react-bootstrap";
|
||||
import { EventSeriesObj } from "../../Series/SeriesList";
|
||||
import { useState } from "react";
|
||||
import { sizeLimit } from "../HeadshotUpload/HeadshotUploadForm";
|
||||
import { postSeriesPoster } from "../../api";
|
||||
|
||||
interface PosterUploadFormProps {
|
||||
series: EventSeriesObj;
|
||||
currentPoster: string | undefined;
|
||||
setModalShow: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSeries: React.Dispatch<React.SetStateAction<EventSeriesObj>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function PosterUploadForm(props: PosterUploadFormProps) {
|
||||
const [preview, setPreview] = useState<string | undefined>(
|
||||
props.currentPoster,
|
||||
);
|
||||
const [fileError, setFileError] = useState<string>("");
|
||||
const [canSubmit, setCanSubmit] = useState<boolean>(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const allowedTypes = ["image/jpeg", "image/png"];
|
||||
const file = event.target.files?.[0];
|
||||
const fileSize = file?.size; // bytes
|
||||
const fileType = file?.type; // MIME type
|
||||
|
||||
if (fileSize && fileSize > sizeLimit) {
|
||||
console.error("file too large");
|
||||
setFileError("file too large");
|
||||
setCanSubmit(false);
|
||||
return;
|
||||
}
|
||||
if (fileType && !allowedTypes.includes(fileType)) {
|
||||
console.error("invalid file type");
|
||||
setFileError("invalid file type");
|
||||
setCanSubmit(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setCanSubmit(true);
|
||||
setFileError("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!props.token) {
|
||||
console.error("no access token");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFile) {
|
||||
postSeriesPoster(props.series.series_id, selectedFile, props.token)
|
||||
.then((updatedEventObj) => {
|
||||
props.setSeries(updatedEventObj);
|
||||
props.setModalShow(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setFileError(
|
||||
"Failed to upload poster: " + error.response.data.detail,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.error("no file selected");
|
||||
};
|
||||
|
||||
const SubmitButton = canSubmit ? (
|
||||
<Button variant="primary" type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" type="submit" disabled>
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group controlId="formFile" className="mb-3">
|
||||
<Container className="d-flex justify-content-center">
|
||||
{preview && (
|
||||
<Image
|
||||
src={preview}
|
||||
className="img-fluid rounded-circle poster-preview"
|
||||
alt={`${props.series.name} poster`}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<Form.Label id="poster-upload">Upload Poster</Form.Label>
|
||||
<Form.Control type="file" onChange={handleFileChange} />
|
||||
<Form.Text className="text-muted">
|
||||
size limit: {sizeLimit / 1000000} MB
|
||||
</Form.Text>
|
||||
{fileError && (
|
||||
<Form.Text className="text-danger error-text">{fileError}</Form.Text>
|
||||
)}
|
||||
</Form.Group>
|
||||
<Container className="d-flex justify-content-end">
|
||||
{SubmitButton}
|
||||
</Container>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default PosterUploadForm;
|
||||
13
client/src/Group/Group.css
Normal file
13
client/src/Group/Group.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.group-info {
|
||||
color: var(--group-info-color);
|
||||
}
|
||||
|
||||
.group-bio {
|
||||
color: var(--group-info-color);
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
#about {
|
||||
padding-top: 120px;
|
||||
margin-top: -70px;
|
||||
}
|
||||
79
client/src/Group/Group.tsx
Normal file
79
client/src/Group/Group.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Card, Container } from "react-bootstrap";
|
||||
import "./Group.css";
|
||||
import EditModal from "../EditModals/EditModal";
|
||||
import { useState } from "react";
|
||||
import EditBioForm from "../Forms/Bio/BioForm";
|
||||
import { faPen } from "@fortawesome/free-solid-svg-icons";
|
||||
import EditButton from "../Buttons/EditButton/EditButton";
|
||||
|
||||
export class GroupObj {
|
||||
id: number;
|
||||
name: string;
|
||||
bio: string;
|
||||
|
||||
constructor(id: number, name: string, bio: string) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.bio = bio;
|
||||
}
|
||||
}
|
||||
|
||||
interface GroupProps {
|
||||
group?: GroupObj;
|
||||
onBioChange: () => void;
|
||||
setGroup: React.Dispatch<React.SetStateAction<GroupObj | undefined>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function Group(props: GroupProps) {
|
||||
const [modalShow, setModalShow] = useState(false);
|
||||
if (!props.group) {
|
||||
return null;
|
||||
}
|
||||
const group = props.group;
|
||||
|
||||
const EditTitle = `Edit ${group.name}'s Bio`;
|
||||
|
||||
const EditIcon = (
|
||||
<Container>
|
||||
<EditButton
|
||||
setModalShow={setModalShow}
|
||||
faIcon={faPen}
|
||||
actionName=" Group Bio"
|
||||
/>
|
||||
<EditModal
|
||||
show={modalShow}
|
||||
onHide={() => setModalShow(false)}
|
||||
title={EditTitle}
|
||||
entity={group}
|
||||
form={
|
||||
<EditBioForm
|
||||
entity={group}
|
||||
hideModal={setModalShow}
|
||||
onBioChange={props.onBioChange}
|
||||
setGroup={props.setGroup}
|
||||
token={props.token}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="about">
|
||||
<Container className="vh-100 d-flex align-items-center justify-content-center text-center">
|
||||
<Card className="group-info">
|
||||
<Card.Body>
|
||||
<Card.Title>
|
||||
<h1>{group.name}</h1>
|
||||
</Card.Title>
|
||||
{props.token && EditIcon}
|
||||
<Card.Text className="lead group-bio">{group.bio}</Card.Text>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default Group;
|
||||
63
client/src/Musicians/Musician/Bio/Bio.tsx
Normal file
63
client/src/Musicians/Musician/Bio/Bio.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import EditButton from "../../../Buttons/EditButton/EditButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Col, Card, Container } from "react-bootstrap";
|
||||
import EditBioForm from "../../../Forms/Bio/BioForm";
|
||||
import EditModal from "../../../EditModals/EditModal";
|
||||
import { MusicianObj } from "../Musician";
|
||||
import { faPen } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
interface BioProps {
|
||||
musician: MusicianObj;
|
||||
textPosition: string;
|
||||
onBioChange: () => void;
|
||||
setMusician: React.Dispatch<React.SetStateAction<MusicianObj>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function MusicianBio(props: BioProps) {
|
||||
const [modalShow, setModalShow] = useState(false);
|
||||
const EditTitle = `Edit ${props.musician.name}'s Bio`;
|
||||
|
||||
useEffect(() => {
|
||||
props.setMusician(props.musician);
|
||||
}, [props]);
|
||||
|
||||
const Editable = (
|
||||
<Container>
|
||||
<EditButton
|
||||
setModalShow={setModalShow}
|
||||
faIcon={faPen}
|
||||
actionName=" Bio"
|
||||
/>
|
||||
<EditModal
|
||||
show={modalShow}
|
||||
onHide={() => setModalShow(false)}
|
||||
title={EditTitle}
|
||||
entity={props.musician}
|
||||
form={
|
||||
<EditBioForm
|
||||
entity={props.musician}
|
||||
hideModal={setModalShow}
|
||||
onBioChange={props.onBioChange}
|
||||
setMusician={props.setMusician}
|
||||
token={props.token}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
return (
|
||||
<Col md={6} key="bioCard">
|
||||
<Card className={`${props.textPosition}`}>
|
||||
<Card.Header className="display-6">{props.musician.name}</Card.Header>
|
||||
<Card.Body>
|
||||
{props.token && Editable}
|
||||
<Card.Text>{props.musician.bio}</Card.Text>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default MusicianBio;
|
||||
0
client/src/Musicians/Musician/Headshot/Headshot.css
Normal file
0
client/src/Musicians/Musician/Headshot/Headshot.css
Normal file
64
client/src/Musicians/Musician/Headshot/Headshot.tsx
Normal file
64
client/src/Musicians/Musician/Headshot/Headshot.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import EditButton from "../../../Buttons/EditButton/EditButton";
|
||||
import { Col, Container, Image } from "react-bootstrap";
|
||||
import HeadshotUpload from "../../../Forms/HeadshotUpload/HeadshotUploadForm";
|
||||
import EditModal from "../../../EditModals/EditModal";
|
||||
import { useState } from "react";
|
||||
import { MusicianObj } from "../Musician";
|
||||
import "./Headshot.css";
|
||||
|
||||
export interface HeadshotProps {
|
||||
src: string;
|
||||
musician: MusicianObj;
|
||||
onHeadshotChange?: () => void;
|
||||
setMusician?: React.Dispatch<React.SetStateAction<MusicianObj>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function Headshot(props: HeadshotProps) {
|
||||
const [modalShow, setModalShow] = useState(false);
|
||||
|
||||
const EditableHeadshot = (
|
||||
<Container>
|
||||
<EditButton
|
||||
setModalShow={setModalShow}
|
||||
faIcon={faUpload}
|
||||
actionName=" Headshot"
|
||||
/>
|
||||
<EditModal
|
||||
show={modalShow}
|
||||
onHide={() => setModalShow(false)}
|
||||
title="Edit Headshot"
|
||||
entity={props.musician}
|
||||
form={
|
||||
<HeadshotUpload
|
||||
currentHeadshot={props.src}
|
||||
musician={props.musician}
|
||||
onHeadshotChange={props?.onHeadshotChange}
|
||||
hideModal={setModalShow}
|
||||
setMusician={props?.setMusician}
|
||||
token={props.token}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
return (
|
||||
<Col
|
||||
key="headshot"
|
||||
className="d-flex align-items-center justify-content-center position-relative"
|
||||
>
|
||||
<Container className="d-flex align-items-center justify-content-center flex-column">
|
||||
<Image
|
||||
src={props.src}
|
||||
className="img-fluid rounded-circle"
|
||||
alt={props.musician.name}
|
||||
/>
|
||||
{props.token && EditableHeadshot}
|
||||
</Container>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default Headshot;
|
||||
9
client/src/Musicians/Musician/Musician.css
Normal file
9
client/src/Musicians/Musician/Musician.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.musician-container {
|
||||
margin-bottom: 6rem;
|
||||
padding-top: 70px;
|
||||
margin-top: -70px;
|
||||
}
|
||||
/*
|
||||
.musician-card {
|
||||
background-color: transparent !important;
|
||||
} */
|
||||
68
client/src/Musicians/Musician/Musician.tsx
Normal file
68
client/src/Musicians/Musician/Musician.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Container, Row } from "react-bootstrap";
|
||||
import cld from "../../Cld/CloudinaryConfig";
|
||||
import "./Musician.css";
|
||||
import Headshot from "./Headshot/Headshot";
|
||||
import MusicianBio from "./Bio/Bio";
|
||||
import { useState } from "react";
|
||||
|
||||
export class MusicianObj {
|
||||
id: number;
|
||||
name: string;
|
||||
bio: string;
|
||||
headshot_id: string;
|
||||
|
||||
constructor(id: number, name: string, bio: string, headshot_id: string) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.bio = bio;
|
||||
this.headshot_id = headshot_id;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MusicianProps {
|
||||
musician: MusicianObj;
|
||||
onBioChange: () => void;
|
||||
onHeadshotChange?: () => void;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function Musician(props: MusicianProps) {
|
||||
const [musician, setMusician] = useState<MusicianObj>(props.musician);
|
||||
const textPosition = musician.id % 2 === 0 ? "text-end" : "text-start";
|
||||
const image = cld.image(musician.headshot_id);
|
||||
const imgUrl = image.toURL();
|
||||
const musicianID = musician.name.split(" ").join("-").toLowerCase();
|
||||
const key = `musician-${musician.id}`;
|
||||
|
||||
const bioCard = (
|
||||
<MusicianBio
|
||||
key={key}
|
||||
musician={musician}
|
||||
textPosition={textPosition}
|
||||
onBioChange={props.onBioChange}
|
||||
setMusician={setMusician}
|
||||
token={props.token}
|
||||
/>
|
||||
);
|
||||
|
||||
const headshot = (
|
||||
<Headshot
|
||||
src={imgUrl}
|
||||
key="headshot"
|
||||
musician={musician}
|
||||
onHeadshotChange={props?.onHeadshotChange}
|
||||
setMusician={setMusician}
|
||||
token={props.token}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container id={musicianID} className="musician-container">
|
||||
<Row className="row-spacing">
|
||||
{musician.id % 2 === 0 ? [bioCard, headshot] : [headshot, bioCard]}
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Musician;
|
||||
10
client/src/Musicians/Musicians.css
Normal file
10
client/src/Musicians/Musicians.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.musicians-title {
|
||||
color: var(--group-info-color);
|
||||
margin-bottom: 6rem;
|
||||
}
|
||||
|
||||
#musicians {
|
||||
padding-top: 70px;
|
||||
margin-top: -70px;
|
||||
margin-bottom: 400px;
|
||||
}
|
||||
47
client/src/Musicians/Musicians.tsx
Normal file
47
client/src/Musicians/Musicians.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import Musician, { MusicianObj } from "./Musician/Musician";
|
||||
import { Col, Container } from "react-bootstrap";
|
||||
import "./Musicians.css";
|
||||
|
||||
interface MusiciansProps {
|
||||
musicians: MusicianObj[];
|
||||
setMusicians: React.Dispatch<React.SetStateAction<MusicianObj[]>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function Musicians(props: MusiciansProps) {
|
||||
const [update, setUpdate] = useState<boolean>(false);
|
||||
const musicians = props.musicians;
|
||||
|
||||
const handleBioChange = () => {
|
||||
setUpdate(!update);
|
||||
};
|
||||
const handleHeadshotChange = () => {
|
||||
setUpdate(!update);
|
||||
};
|
||||
|
||||
const musicianList = musicians.map((musician) => (
|
||||
<Musician
|
||||
key={musician.id}
|
||||
musician={musician}
|
||||
onBioChange={handleBioChange}
|
||||
onHeadshotChange={handleHeadshotChange}
|
||||
token={props.token}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<section id="musicians">
|
||||
<Col>
|
||||
<Container>
|
||||
<h3 className="display-3 text-end musicians-title">
|
||||
Meet the Musicians
|
||||
</h3>
|
||||
</Container>
|
||||
{musicianList}
|
||||
</Col>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default Musicians;
|
||||
7
client/src/NavBar/AdminDropdown/AdminDropdown.css
Normal file
7
client/src/NavBar/AdminDropdown/AdminDropdown.css
Normal file
@@ -0,0 +1,7 @@
|
||||
#api-container {
|
||||
font-size: smaller;
|
||||
}
|
||||
|
||||
#auth-row {
|
||||
background-color: var(--grapefruit-yellow-washed-out);
|
||||
}
|
||||
49
client/src/NavBar/AdminDropdown/AdminDropdown.tsx
Normal file
49
client/src/NavBar/AdminDropdown/AdminDropdown.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ButtonGroup, Container } from "react-bootstrap";
|
||||
import Dropdown from "react-bootstrap/Dropdown";
|
||||
import DropdownButton from "react-bootstrap/DropdownButton";
|
||||
import Profile from "../../Auth/Profile";
|
||||
import Login from "../../Buttons/Login";
|
||||
import Logout from "../../Buttons/Logout";
|
||||
import "./AdminDropdown.css";
|
||||
|
||||
interface AdminDropdownProps {
|
||||
appVersion: string;
|
||||
apiVersion: string;
|
||||
token: string;
|
||||
setToken: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
function AdminDropdown(props: AdminDropdownProps) {
|
||||
const AuthButton = () => {
|
||||
return props.token ? (
|
||||
<Logout setToken={props.setToken} />
|
||||
) : (
|
||||
<Login setToken={props.setToken} />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownButton
|
||||
as={ButtonGroup}
|
||||
align={{ lg: "end" }}
|
||||
title="Admin"
|
||||
id="admin-dropdown"
|
||||
variant="link"
|
||||
className="navbar-text-color"
|
||||
>
|
||||
{props.token && <Profile token={props.token} />}
|
||||
<Dropdown.Item eventKey="1" className="text-end" id="auth-row">
|
||||
<AuthButton />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Divider />
|
||||
<Container className="text-end text-muted" id="api-container">
|
||||
<p>APP Version: {props.appVersion}</p>
|
||||
<p>API Version: {props.apiVersion}</p>
|
||||
</Container>
|
||||
</DropdownButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminDropdown;
|
||||
36
client/src/NavBar/NavBar.css
Normal file
36
client/src/NavBar/NavBar.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.navbar-color {
|
||||
backdrop-filter: blur(10px);
|
||||
transition: background-color 0.5s ease;
|
||||
}
|
||||
|
||||
.navbar-text-color .dropdown-toggle {
|
||||
color: var(--group-info-color);
|
||||
}
|
||||
|
||||
.navbar-scrolled {
|
||||
background-color: var(--grapefruit-yellow);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 1.7rem;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#nav-contact {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
#admin-dropdown {
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
#admin-dropdown:hover {
|
||||
text-decoration: none;
|
||||
color: var(--group-info-lighter-color);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background-color: var(--grapefruit-yellow-washed-out);
|
||||
}
|
||||
91
client/src/NavBar/NavBar.tsx
Normal file
91
client/src/NavBar/NavBar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import Container from "react-bootstrap/Container";
|
||||
import Nav from "react-bootstrap/Nav";
|
||||
import Navbar from "react-bootstrap/Navbar";
|
||||
import "./NavBar.css";
|
||||
import { useState, useEffect } from "react";
|
||||
import AdminDropdown from "./AdminDropdown/AdminDropdown";
|
||||
import { Image, NavDropdown } from "react-bootstrap";
|
||||
import { MusicianObj } from "../Musicians/Musician/Musician";
|
||||
|
||||
interface NavBarProps {
|
||||
musicians: MusicianObj[];
|
||||
appVersion: string;
|
||||
apiVersion: string;
|
||||
token: string;
|
||||
setToken: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
function NavBar(props: NavBarProps) {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
const MusicianLinks = props.musicians.map((musician) => (
|
||||
<NavDropdown.Item
|
||||
className="navbar-text-color"
|
||||
href={"#" + musician.name.split(" ").join("-").toLowerCase()}
|
||||
key={musician.id}
|
||||
>
|
||||
{musician.name}
|
||||
</NavDropdown.Item>
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const isScrolled = window.scrollY > 0;
|
||||
if (isScrolled !== scrolled) {
|
||||
setScrolled(!scrolled);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
document.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [scrolled]);
|
||||
|
||||
return (
|
||||
<Navbar
|
||||
className={`navbar-color ${scrolled ? "navbar-scrolled" : ""}`}
|
||||
expand="lg"
|
||||
sticky="top"
|
||||
>
|
||||
<Container>
|
||||
<Navbar.Brand href="#root" className="navbar-text-color">
|
||||
<Image src="/favicon.ico" alt="TGD Logo" className="logo" />
|
||||
</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
<Navbar.Collapse className="justify-content-end">
|
||||
<Nav>
|
||||
<Nav.Link href="#home" className="navbar-text-color">
|
||||
About
|
||||
</Nav.Link>
|
||||
<NavDropdown title="Musicians" className="">
|
||||
<NavDropdown.Item href="#musicians" className="navbar-text-color">
|
||||
All
|
||||
</NavDropdown.Item>
|
||||
<NavDropdown.Divider />
|
||||
{MusicianLinks}
|
||||
</NavDropdown>
|
||||
<Nav.Link href="#events" className="navbar-text-color">
|
||||
Events
|
||||
</Nav.Link>
|
||||
<Nav.Link
|
||||
href="#contact"
|
||||
className="navbar-text-color"
|
||||
id="nav-contact"
|
||||
>
|
||||
Contact
|
||||
</Nav.Link>
|
||||
<AdminDropdown
|
||||
apiVersion={props.apiVersion}
|
||||
appVersion={props.appVersion}
|
||||
token={props.token}
|
||||
setToken={props.setToken}
|
||||
/>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavBar;
|
||||
5
client/src/Series/Events.tsx/Event/Event.css
Normal file
5
client/src/Series/Events.tsx/Event/Event.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.event-list-item {
|
||||
background-color: transparent !important;
|
||||
color: inherit;
|
||||
border: 0.15rem solid var(--group-info-color);
|
||||
}
|
||||
70
client/src/Series/Events.tsx/Event/Event.tsx
Normal file
70
client/src/Series/Events.tsx/Event/Event.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ListGroup } from "react-bootstrap";
|
||||
import "./Event.css";
|
||||
|
||||
export class EventObj {
|
||||
event_id: number;
|
||||
location: string;
|
||||
time: string; // ISO 8601 formatted date-time string
|
||||
ticket_url?: string;
|
||||
map_url?: string;
|
||||
|
||||
constructor(
|
||||
event_id: number,
|
||||
location: string,
|
||||
time: string,
|
||||
ticket_url?: string,
|
||||
map_url?: string,
|
||||
) {
|
||||
this.event_id = event_id;
|
||||
this.location = location;
|
||||
this.time = time;
|
||||
this.ticket_url = ticket_url;
|
||||
this.map_url = map_url;
|
||||
}
|
||||
}
|
||||
|
||||
interface EventProps {
|
||||
event: EventObj;
|
||||
}
|
||||
|
||||
function Event(props: EventProps) {
|
||||
const event = props.event;
|
||||
const date = new Date(event.time);
|
||||
const dateString = date.toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
});
|
||||
const location = event.map_url ? (
|
||||
<>
|
||||
<a href={event.map_url} target="_blank" rel="noreferrer">
|
||||
{event.location}
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
event.location
|
||||
);
|
||||
const tickets = event.ticket_url ? (
|
||||
<>
|
||||
|{" "}
|
||||
<a href={event.ticket_url} target="_blank" rel="noreferrer">
|
||||
Tickets
|
||||
</a>
|
||||
</>
|
||||
) : null;
|
||||
const timeString = date.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<ListGroup.Item className="event-list-item">
|
||||
<p>
|
||||
{dateString} {timeString.toLowerCase()} | {location} {tickets}
|
||||
</p>
|
||||
</ListGroup.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export default Event;
|
||||
3
client/src/Series/Events.tsx/Events.css
Normal file
3
client/src/Series/Events.tsx/Events.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.event-list {
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
23
client/src/Series/Events.tsx/Events.tsx
Normal file
23
client/src/Series/Events.tsx/Events.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ListGroup } from "react-bootstrap";
|
||||
import Event, { EventObj } from "./Event/Event";
|
||||
import "./Events.css";
|
||||
|
||||
interface EventsProps {
|
||||
events: EventObj[];
|
||||
}
|
||||
|
||||
function Events(props: EventsProps) {
|
||||
const events = props.events;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListGroup className="event-list">
|
||||
{events.map((event) => (
|
||||
<Event key={event.event_id} event={event} />
|
||||
))}
|
||||
</ListGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Events;
|
||||
90
client/src/Series/Series/Series.tsx
Normal file
90
client/src/Series/Series/Series.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Card, Col, Container, Row } from "react-bootstrap";
|
||||
import { EventSeriesObj } from "../SeriesList";
|
||||
import Events from "../Events.tsx/Events";
|
||||
import SeriesPoster from "./SeriesPoster";
|
||||
import EditButton from "../../Buttons/EditButton/EditButton";
|
||||
import DeleteButton from "../../Buttons/DeleteButton/DeleteButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { faEdit } from "@fortawesome/free-solid-svg-icons";
|
||||
import EditModal from "../../EditModals/EditModal";
|
||||
import EventForm from "../../Forms/Event/EventForm";
|
||||
|
||||
interface SeriesProps {
|
||||
series: EventSeriesObj;
|
||||
setSeriesList: React.Dispatch<React.SetStateAction<EventSeriesObj[]>>;
|
||||
seriesList: EventSeriesObj[];
|
||||
token: string;
|
||||
}
|
||||
|
||||
function Series(props: SeriesProps) {
|
||||
const [series, setSeries] = useState<EventSeriesObj>(props.series);
|
||||
const [modalShow, setModalShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSeries(props.series);
|
||||
}, [props.series]);
|
||||
|
||||
const EditableSeries = (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<Container className="text-center">
|
||||
<EditButton setModalShow={setModalShow} faIcon={faEdit} />
|
||||
<EditModal
|
||||
show={modalShow}
|
||||
onHide={() => setModalShow(false)}
|
||||
title="Edit Concert Series"
|
||||
entity={props.series}
|
||||
form={
|
||||
<EventForm
|
||||
setModalShow={setModalShow}
|
||||
setSeriesList={props.setSeriesList}
|
||||
seriesList={props.seriesList}
|
||||
series={series}
|
||||
isNewSeries={false}
|
||||
setSeries={setSeries}
|
||||
token={props.token}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
</Col>
|
||||
|
||||
<Col>
|
||||
<Container className="text-center">
|
||||
<DeleteButton
|
||||
series={series}
|
||||
setSeriesList={props.setSeriesList}
|
||||
seriesList={props.seriesList}
|
||||
token={props.token}
|
||||
/>
|
||||
</Container>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
|
||||
return (
|
||||
<Row id={`series-${series.series_id}-row`}>
|
||||
<Col>
|
||||
<SeriesPoster
|
||||
series={series}
|
||||
setSeries={setSeries}
|
||||
token={props.token}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Container>
|
||||
<Card>
|
||||
<h4>{series.name}</h4>
|
||||
{props.token && EditableSeries}
|
||||
<p>{series.description}</p>
|
||||
<Events events={series.events} />
|
||||
</Card>
|
||||
</Container>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default Series;
|
||||
57
client/src/Series/Series/SeriesPoster.tsx
Normal file
57
client/src/Series/Series/SeriesPoster.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Container, Image } from "react-bootstrap";
|
||||
import { EventSeriesObj } from "../SeriesList";
|
||||
import cld from "../../Cld/CloudinaryConfig";
|
||||
import { useState } from "react";
|
||||
import EditButton from "../../Buttons/EditButton/EditButton";
|
||||
import { faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import EditModal from "../../EditModals/EditModal";
|
||||
import PosterUploadForm from "../../Forms/PosterUpload/PosterUploadForm";
|
||||
|
||||
interface SeriesPosterProps {
|
||||
series: EventSeriesObj;
|
||||
setSeries: React.Dispatch<React.SetStateAction<EventSeriesObj>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function SeriesPoster(props: SeriesPosterProps) {
|
||||
const series = props.series;
|
||||
const imgUrl = cld.image(series.poster_id).toURL();
|
||||
const imgSrc = imgUrl ? imgUrl : undefined;
|
||||
const [modalShow, setModalShow] = useState(false);
|
||||
|
||||
const EditablePoster = (
|
||||
<Container className="">
|
||||
<EditButton
|
||||
setModalShow={setModalShow}
|
||||
faIcon={faUpload}
|
||||
actionName=" Poster"
|
||||
/>
|
||||
<EditModal
|
||||
show={modalShow}
|
||||
onHide={() => setModalShow(false)}
|
||||
title="Edit Poster"
|
||||
entity={props.series}
|
||||
form={
|
||||
<PosterUploadForm
|
||||
series={series}
|
||||
currentPoster={imgSrc}
|
||||
setModalShow={setModalShow}
|
||||
setSeries={props.setSeries}
|
||||
token={props.token}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{series.poster_id ? (
|
||||
<Image src={imgSrc} alt={series.name} className="img-fluid" />
|
||||
) : null}
|
||||
{props.token && EditablePoster}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesPoster;
|
||||
19
client/src/Series/SeriesList.css
Normal file
19
client/src/Series/SeriesList.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.events-title {
|
||||
color: var(--group-info-color);
|
||||
margin-bottom: 6rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
height: 4px; /* This controls the thickness of the divider */
|
||||
background: rgba(124, 1, 68); /* This sets the color of the divider */
|
||||
margin-top: 10rem;
|
||||
margin-bottom: 6rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#events {
|
||||
padding-top: 70px;
|
||||
margin-top: -70px;
|
||||
margin-bottom: 400px;
|
||||
}
|
||||
106
client/src/Series/SeriesList.tsx
Normal file
106
client/src/Series/SeriesList.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Col, Container } from "react-bootstrap";
|
||||
import { EventObj } from "./Events.tsx/Event/Event";
|
||||
import Series from "./Series/Series";
|
||||
import "./SeriesList.css";
|
||||
import EditButton from "../Buttons/EditButton/EditButton";
|
||||
import { useState } from "react";
|
||||
import { faAdd } from "@fortawesome/free-solid-svg-icons";
|
||||
import EditModal from "../EditModals/EditModal";
|
||||
import EventForm from "../Forms/Event/EventForm";
|
||||
|
||||
export class EventSeriesObj {
|
||||
series_id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
events: EventObj[];
|
||||
poster_id?: string; // Cloudinary public ID
|
||||
|
||||
constructor(
|
||||
series_id: number,
|
||||
name: string,
|
||||
description: string,
|
||||
events: EventObj[],
|
||||
poster_id?: string,
|
||||
) {
|
||||
this.series_id = series_id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.events = events;
|
||||
this.poster_id = poster_id;
|
||||
}
|
||||
}
|
||||
|
||||
interface SeriesListProps {
|
||||
seriesList: EventSeriesObj[];
|
||||
setSeriesList: React.Dispatch<React.SetStateAction<EventSeriesObj[]>>;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function SeriesList(props: SeriesListProps) {
|
||||
const seriesList = props.seriesList;
|
||||
const [modalShow, setModalShow] = useState(false);
|
||||
|
||||
const AddableSeries = (
|
||||
<Container className="text-end">
|
||||
<EditButton
|
||||
setModalShow={setModalShow}
|
||||
faIcon={faAdd}
|
||||
actionName=" Event"
|
||||
/>
|
||||
<EditModal
|
||||
show={modalShow}
|
||||
onHide={() => setModalShow(false)}
|
||||
title="Add Concert Series"
|
||||
form={
|
||||
<EventForm
|
||||
setModalShow={setModalShow}
|
||||
setSeriesList={props.setSeriesList}
|
||||
seriesList={props.seriesList}
|
||||
isNewSeries={true}
|
||||
token={props.token}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
if (seriesList.length === 0) {
|
||||
return (
|
||||
<section id="events">
|
||||
<Col>
|
||||
<Container>
|
||||
<h3 className="display-3 text-end events-title">Upcoming Events</h3>
|
||||
{props.token && AddableSeries}
|
||||
</Container>
|
||||
<Container>
|
||||
<h3 className="display-4 text-center events-title">Stay tuned!</h3>
|
||||
</Container>
|
||||
</Col>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="events">
|
||||
<Col>
|
||||
<Container>
|
||||
<h3 className="display-3 text-end events-title">Upcoming Events</h3>
|
||||
{props.token && AddableSeries}
|
||||
</Container>
|
||||
{seriesList.map((series, idx) => (
|
||||
<Container key={series.series_id}>
|
||||
<Series
|
||||
series={series}
|
||||
setSeriesList={props.setSeriesList}
|
||||
seriesList={props.seriesList}
|
||||
token={props.token}
|
||||
/>
|
||||
{idx < seriesList.length - 1 && <hr className="series-divider" />}
|
||||
</Container>
|
||||
))}
|
||||
</Col>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesList;
|
||||
293
client/src/api.tsx
Normal file
293
client/src/api.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import axios from "axios";
|
||||
import { GroupObj } from "./Group/Group";
|
||||
import { MusicianObj } from "./Musicians/Musician/Musician";
|
||||
import { EventSeriesObj } from "./Series/SeriesList";
|
||||
import { EventObj } from "./Series/Events.tsx/Event/Event";
|
||||
import { UserObj } from "./Auth/User";
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_URL as string;
|
||||
|
||||
class TheGrapefruitsDuoAPI {
|
||||
version: string;
|
||||
group: GroupObj;
|
||||
musicians: MusicianObj[];
|
||||
events: EventSeriesObj[];
|
||||
|
||||
constructor(
|
||||
version: string,
|
||||
group: GroupObj,
|
||||
musicians: MusicianObj[],
|
||||
events: EventSeriesObj[],
|
||||
) {
|
||||
this.version = version;
|
||||
this.group = group;
|
||||
this.musicians = musicians;
|
||||
this.events = events;
|
||||
}
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: baseURL,
|
||||
});
|
||||
|
||||
export const getRoot = async (): Promise<TheGrapefruitsDuoAPI> => {
|
||||
const response = await api.get("/");
|
||||
const tgd = new TheGrapefruitsDuoAPI(
|
||||
response.data.version,
|
||||
new GroupObj(
|
||||
response.data.group.id,
|
||||
response.data.group.name,
|
||||
response.data.group.bio,
|
||||
),
|
||||
response.data.musicians.map(
|
||||
(musician: MusicianObj) =>
|
||||
new MusicianObj(
|
||||
musician.id,
|
||||
musician.name,
|
||||
musician.bio,
|
||||
musician.headshot_id,
|
||||
),
|
||||
),
|
||||
response.data.events.map(
|
||||
(series: EventSeriesObj) =>
|
||||
new EventSeriesObj(
|
||||
series.series_id,
|
||||
series.name,
|
||||
series.description,
|
||||
series.events.map(
|
||||
(event: EventObj) =>
|
||||
new EventObj(
|
||||
event.event_id,
|
||||
event.location,
|
||||
event.time,
|
||||
event.ticket_url,
|
||||
event.map_url,
|
||||
),
|
||||
),
|
||||
series.poster_id,
|
||||
),
|
||||
),
|
||||
);
|
||||
console.log(tgd);
|
||||
return tgd;
|
||||
};
|
||||
|
||||
export const getUsers = async (): Promise<EventObj[]> => {
|
||||
const response = await api.get("/users/");
|
||||
return response.data.map(
|
||||
(user: UserObj) => new UserObj(user.name, user.email, user.id, user.sub),
|
||||
);
|
||||
};
|
||||
|
||||
export const getUser = async (id: number): Promise<UserObj> => {
|
||||
const response = await api.get(`/users/${id}/`);
|
||||
return new UserObj(
|
||||
response.data.name,
|
||||
response.data.email,
|
||||
response.data.id,
|
||||
response.data.sub,
|
||||
);
|
||||
};
|
||||
|
||||
export const postUser = async (token: string): Promise<UserObj> => {
|
||||
const response = await api.post(
|
||||
"/users/",
|
||||
{},
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
return new UserObj(
|
||||
response.data.name,
|
||||
response.data.email,
|
||||
response.data.id,
|
||||
response.data.sub,
|
||||
);
|
||||
};
|
||||
|
||||
export const getGroup = async (): Promise<GroupObj> => {
|
||||
const response = await api.get("/group/");
|
||||
return new GroupObj(response.data.id, response.data.name, response.data.bio);
|
||||
};
|
||||
|
||||
export const getMusicians = async (): Promise<MusicianObj[]> => {
|
||||
const response = await api.get("/musicians/");
|
||||
return response.data.map(
|
||||
(musician: MusicianObj) =>
|
||||
new MusicianObj(
|
||||
musician.id,
|
||||
musician.name,
|
||||
musician.bio,
|
||||
musician.headshot_id,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const patchMusician = async (
|
||||
id: number,
|
||||
bio: string,
|
||||
name: string,
|
||||
headshot_id: string,
|
||||
user_token: string,
|
||||
): Promise<MusicianObj> => {
|
||||
const response = await api.patch(
|
||||
`/musicians/${id}/`,
|
||||
{ id, bio, name, headshot_id },
|
||||
{ headers: { Authorization: `Bearer ${user_token}` } },
|
||||
);
|
||||
return new MusicianObj(
|
||||
response.data.id,
|
||||
response.data.name,
|
||||
response.data.bio,
|
||||
response.data.headshot_id,
|
||||
);
|
||||
};
|
||||
|
||||
export const patchGroup = async (
|
||||
id: number,
|
||||
bio: string,
|
||||
name: string,
|
||||
user_token: string,
|
||||
): Promise<GroupObj> => {
|
||||
const response = await api.patch(
|
||||
`/group/`,
|
||||
{ id, bio, name },
|
||||
{ headers: { Authorization: `Bearer ${user_token}` } },
|
||||
);
|
||||
return new GroupObj(response.data.id, response.data.name, response.data.bio);
|
||||
};
|
||||
|
||||
export const postHeadshot = async (
|
||||
id: number,
|
||||
file: File,
|
||||
user_token: string,
|
||||
): Promise<MusicianObj> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await api.post(`/musicians/${id}/headshot`, formData, {
|
||||
headers: { Authorization: `Bearer ${user_token}` },
|
||||
});
|
||||
return new MusicianObj(
|
||||
response.data.id,
|
||||
response.data.name,
|
||||
response.data.bio,
|
||||
response.data.headshot_id,
|
||||
);
|
||||
};
|
||||
|
||||
export const postMessage = async (
|
||||
name: string,
|
||||
email: string,
|
||||
message: string,
|
||||
): Promise<void> => {
|
||||
await api.post("/contact/", { name, email, message });
|
||||
return;
|
||||
};
|
||||
|
||||
export const postSeriesPoster = async (
|
||||
series_id: number,
|
||||
poster: File,
|
||||
user_token: string,
|
||||
): Promise<EventSeriesObj> => {
|
||||
const formData = new FormData();
|
||||
formData.append("poster", poster);
|
||||
const response = await api.post(`/events/${series_id}/poster`, formData, {
|
||||
headers: { Authorization: `Bearer ${user_token}` },
|
||||
});
|
||||
return new EventSeriesObj(
|
||||
response.data.series_id,
|
||||
response.data.name,
|
||||
response.data.description,
|
||||
response.data.events.map(
|
||||
(event: EventObj) =>
|
||||
new EventObj(
|
||||
event.event_id,
|
||||
event.location,
|
||||
event.time,
|
||||
event.ticket_url,
|
||||
event.map_url,
|
||||
),
|
||||
),
|
||||
response.data.poster_id,
|
||||
);
|
||||
};
|
||||
|
||||
export const getSeriesList = async (): Promise<EventSeriesObj[]> => {
|
||||
const response = await api.get("/events/");
|
||||
return response.data.map(
|
||||
(series: EventSeriesObj) =>
|
||||
new EventSeriesObj(
|
||||
series.series_id,
|
||||
series.name,
|
||||
series.description,
|
||||
series.events.map(
|
||||
(event: EventObj) =>
|
||||
new EventObj(
|
||||
event.event_id,
|
||||
event.location,
|
||||
event.time,
|
||||
event.ticket_url,
|
||||
event.map_url,
|
||||
),
|
||||
),
|
||||
series.poster_id,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const postSeries = async (
|
||||
series: EventSeriesObj,
|
||||
user_token: string,
|
||||
): Promise<EventSeriesObj> => {
|
||||
const response = await api.post("/events/", series, {
|
||||
headers: { Authorization: `Bearer ${user_token}` },
|
||||
});
|
||||
return new EventSeriesObj(
|
||||
response.data.series_id,
|
||||
response.data.name,
|
||||
response.data.description,
|
||||
response.data.events.map(
|
||||
(event: EventObj) =>
|
||||
new EventObj(
|
||||
event.event_id,
|
||||
event.location,
|
||||
event.time,
|
||||
event.ticket_url,
|
||||
event.map_url,
|
||||
),
|
||||
),
|
||||
response.data.poster_id,
|
||||
);
|
||||
};
|
||||
export const deleteSeries = async (
|
||||
series_id: number,
|
||||
user_token: string,
|
||||
): Promise<void> => {
|
||||
await api.delete(`/events/${series_id}/`, {
|
||||
headers: { Authorization: `Bearer ${user_token}` },
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
export const putSeries = async (
|
||||
series: EventSeriesObj,
|
||||
user_token: string,
|
||||
): Promise<EventSeriesObj> => {
|
||||
const response = await api.put(`/events/${series.series_id}/`, series, {
|
||||
headers: { Authorization: `Bearer ${user_token}` },
|
||||
});
|
||||
return new EventSeriesObj(
|
||||
response.data.series_id,
|
||||
response.data.name,
|
||||
response.data.description,
|
||||
response.data.events.map(
|
||||
(event: EventObj) =>
|
||||
new EventObj(
|
||||
event.event_id,
|
||||
event.location,
|
||||
event.time,
|
||||
event.ticket_url,
|
||||
event.map_url,
|
||||
),
|
||||
),
|
||||
response.data.poster_id,
|
||||
);
|
||||
};
|
||||
14
client/src/index.css
Normal file
14
client/src/index.css
Normal file
@@ -0,0 +1,14 @@
|
||||
:root {
|
||||
--group-info-color: rgb(124, 1, 68);
|
||||
--group-info-darker-color: rgba(73, 1, 41, 0.8);
|
||||
--group-info-lighter-color: rgba(206, 6, 116, 0.8);
|
||||
--grapefruit-yellow: rgb(252, 171, 83);
|
||||
--grapefruit-yellow-washed-out: rgb(255, 204, 150);
|
||||
--group-info-disabled-color: rgba(124, 1, 68, 0.5);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
16
client/src/main.tsx
Normal file
16
client/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||
|
||||
const googleClientID = import.meta.env.VITE_GOOGLE_CLIENT_ID as string;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<GoogleOAuthProvider clientId={googleClientID}>
|
||||
<App />
|
||||
</GoogleOAuthProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
1
client/src/vite-env.d.ts
vendored
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user