Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat/121-realtime-card-edit: individual cards can be edited on the fly #140

Merged
merged 16 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,272 changes: 349 additions & 923 deletions front-end/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion front-end/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"typescript": "^5.2.2",
"vite": "^5.3.4"
"vite": "^5.3.6"
}
}
4 changes: 3 additions & 1 deletion front-end/src/common/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ export const sendToast = (
type: ToastType = TOAST_TYPE.ERROR,
duration: number = DEFAULT_EVENT_DURATION.SECONDS_TOAST
) => {
if (type === TOAST_TYPE.WARN || type === TOAST_TYPE.ERROR)
if (type === TOAST_TYPE.ERROR)
console.error(`Toast: ${type} - ${flattenMessage(message)}`);
else if (type === TOAST_TYPE.WARN)
console.log(`Toast: ${type} - ${flattenMessage(message)}`);

const toast = document.createElement("div");
toast.className = "toast " + type.trim().toLowerCase();
Expand Down
14 changes: 13 additions & 1 deletion front-end/src/common/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { Dispatch, SetStateAction } from "react";

export type CardsGenerationResponse = ApiResponse & {
data: {
generated: number;
cardsLyrics: SongPartsCards;
cardBottomColor: string;
};
};

Expand All @@ -16,6 +17,10 @@ export type CardsGenerationRequest = {
generateOutro: boolean;
includeBackgroundImg: boolean;
};
export type SingleCardGenerationRequest = CardsGenerationRequest & {
cardsContents: string[];
cardFilename: string;
};

export type SongPartsCards = string[][];

Expand Down Expand Up @@ -97,6 +102,13 @@ export type Statistics = {
lyricsFetches: number;
cardsGenerated: number;
};
export type DisplayedStatistics = {
dateFirstOperation: string;
dateLastOperation: string;
artworkGenerations: string;
lyricsFetches: string;
cardsGenerated: string;
};

/**************** GENERIC ****************/

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ export const AutoResizeTextarea = forwardRef<HTMLTextAreaElement, AutoResizeText
};
}
}, [value]);
return <textarea ref={combinedRef} value={value} {...props} />;
return <textarea ref={combinedRef} value={value} {...props} className="lyrics-textarea" />;
});
68 changes: 68 additions & 0 deletions front-end/src/components/CardsGallery.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
.card-gallery {
.card-container {
text-align: center;
position: relative;

img, input {
cursor: pointer;
}
}

.gallery-card {
transition: transform 0.3s ease;

&:hover {
transform: scale(1.02);
}
}

.modal-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; /* full view */

background-color: rgba(0, 0, 0, 0.7); /* dimmed background around overlay */

z-index: 9999;
}

.modal-content {
position: relative;
width: 70%;
padding: 2rem;
background-color: lightgray;
border-radius: .4rem;
box-shadow: 0 .2rem 1rem rgba(0, 0, 0, 0.1);
}

.modal-actions {
button {
width: -webkit-fill-available;
padding: .5rem 1rem;
border: none;

color: #ffffff;
font-size: .8rem;

border-radius: .25rem;
cursor: pointer;
transition: background-color 0.3s ease;

background-color: #007bff;
&:hover { background-color: #0056b3; }
&:last-child {
background-color: #d9534f;
&:hover { background-color: #c9302c; }
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}

#cards-generation_generate-single {
* { /* for spinner */
padding-left: 0 !important;
}
}
}

}
193 changes: 193 additions & 0 deletions front-end/src/components/CardsGallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import React, { FormEvent, useRef, useState } from "react";

import { is2xxSuccessful, sendRequest } from "../common/Requests";
import { hideSpinner, showSpinner } from "../common/Spinner";
import { sendToast } from "../common/Toast";
import { ApiResponse, ImageDownloadRequest, SingleCardGenerationRequest } from "../common/Types";

import { API, BACKEND_URL, SPINNER_ID, TOAST, TOAST_TYPE } from "../constants/Common";

import { AutoResizeTextarea } from "./AutoResizeTextarea";

import "./CardsGallery.css";

export interface CardData {
id: number;
lyrics: string;
src: string; // Img source path
}

type GenerationProps = {
cardMetaname: string;
bgImg: File | undefined;
colorPick: string;
includeCenterArtwork: boolean | undefined;
generateOutro: boolean;
includeBackgroundImg: boolean;

cardBottomColor: string;
};

type Props = {
id: string;
initialCards: CardData[];
handleDownloadCard: (e: FormEvent<HTMLFormElement> | undefined, body: ImageDownloadRequest) => void;
generationProps: GenerationProps;
};

const CardsGallery: React.FC<Props> = ({ id, initialCards, handleDownloadCard, generationProps }) => {
const [cards, setCards] = useState<CardData[]>(initialCards);

const [isModalOpen, setIsModalOpen] = useState(false);
const [isModalSaving, setIsModalSaving] = useState(false);
const [currentCard, setCurrentCard] = useState<CardData | null>(null);
const [newLyrics, setNewLyrics] = useState("");

const closeModal = () => { setIsModalOpen(false); setIsModalSaving(false); };

const generateFormData = (body: SingleCardGenerationRequest, formData: FormData): void => {
if (body.bgImg) {
formData.append("enforceBackgroundImage", body.bgImg);
formData.append("includeCenterArtwork", (body.includeCenterArtwork ?? "").toString());
}
if (body.colorPick !== "")
formData.append("enforceBottomColor", body.colorPick);
formData.append("cardMetaname", body.cardMetaname);
formData.append("generateOutro", body.generateOutro.toString());
formData.append("includeBackgroundImg", body.includeBackgroundImg.toString());
formData.append("cardsContents", JSON.stringify(body.cardsContents));
formData.append("cardFilename", body.cardFilename);
};

const updateCard = (currentCard: CardData, cardFilename: string) => {
setCards((prevCards) =>
prevCards.map((img) => img.id === currentCard.id // update only the card that was edited
? {
id: img.id,
lyrics: newLyrics,
src: `${cardFilename}?t=${Date.now()}` // busting cached image with the same name thanks to timestamp
} : img
)
);
};
const saveText = () => {
if (currentCard === null) {
sendToast(TOAST.CARD_EDIT_FAILED, TOAST_TYPE.ERROR);
return;
}

if (isModalSaving) {
sendToast(TOAST.CARD_EDIT_IN_PROGRESS, TOAST_TYPE.WARN);
return;
}

setIsModalSaving(true);
showSpinner(SPINNER_ID.CARDS_GENERATE_SINGLE);

const cardFilename = currentCard.src.split('?')[0] ?? "card";
const body: SingleCardGenerationRequest = {
...generationProps,
cardsContents: newLyrics.split("\n"),
cardFilename: cardFilename,
}
const formData = new FormData();
generateFormData(body, formData);

sendRequest("POST", BACKEND_URL + API.CARDS_GENERATION.GENERATE_SINGLE_CARD, formData).then((response: ApiResponse) => {
if (!is2xxSuccessful(response.status)) {
console.error(response.message);
sendToast(response.message, TOAST_TYPE.ERROR);
return;
}

updateCard(currentCard, cardFilename);

const toastMsg = TOAST.CARD_EDITED + `: ${(currentCard.id < 10 ? "0" : "")}${currentCard.id}.png`;
sendToast(toastMsg, TOAST_TYPE.SUCCESS);
}).catch((error) => {
console.error("Failed to upload text:", error);
sendToast(TOAST.CARD_EDIT_FAILED, TOAST_TYPE.ERROR);
}).finally(() => {
hideSpinner(SPINNER_ID.CARDS_GENERATE_SINGLE);
setIsModalSaving(false);
closeModal();
});
};
const openModal = (card: CardData, cardFileName: string) => {
if (cardFileName === "00" || cardFileName === "outro") {
sendToast(TOAST.CARD_NOT_EDITABLE, TOAST_TYPE.WARN);
return;
}

setIsModalOpen(true);
setCurrentCard(card);
setNewLyrics(card.lyrics);
};

// used to prevent the modal from closing when click&dragging from inside it
const isMouseDownRef = useRef(false);
const clickedInsideModalRef = useRef(false);

const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
isMouseDownRef.current = true;

const modalContent = document.querySelector('.modal-content');
clickedInsideModalRef.current = modalContent?.contains(e.target as Node) ?? false; // click inside modal?
};
const handleMouseUp = () => { isMouseDownRef.current = false; };
const handleOverlayClick = () => { !clickedInsideModalRef.current && closeModal() };

const renderCard = (card: CardData, nb: number): JSX.Element => {
const cardFileName = (card.src.split('/').pop() ?? "").split('?')[0] ?? "card";
const shortCardFileName = cardFileName.replace(".png", "");
const alt = "card" + "-" + nb.toString() + "_" + cardFileName;

return (
<div key={alt} className="card card-container">
<div onClick={() => openModal(card, shortCardFileName)}>
<img src={card.src} alt={card.lyrics} className="gallery-card" />
</div>
<form onSubmit={(e) => handleDownloadCard(e, {selectedImage: card.src})}>
<input type="submit" value={"Download " + shortCardFileName} className="button" />
</form>
</div>
);
};

return (
<div id={id} className="card-gallery flex-row" onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
{ cards?.map((card, idx) =>
renderCard(card, idx))
}

{ isModalOpen && currentCard && (
<div className="modal-overlay flexbox" onClick={handleOverlayClick}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h3 className="mv-0">
{`Edit Lyrics of Card ${currentCard.id < 10 ? "0" : ""}${currentCard.id}`}
</h3>

<AutoResizeTextarea title={"card-edit"} disabled={isModalSaving}
value={newLyrics} onChange={(e) => setNewLyrics(e.target.value)}
style={{ width: "100%" }}
/>

<div className="modal-actions flex-row g-1">
<button type="button" onClick={saveText} disabled={isModalSaving}>
{isModalSaving ? "Saving..." : "Save"}
</button>

<div id={SPINNER_ID.CARDS_GENERATE_SINGLE} />

<button type="button" onClick={closeModal} disabled={isModalSaving}>
{"Cancel"}
</button>
</div>
</div>
</div>
)}
</div>
);
};

export default CardsGallery;
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState } from "react";

import { TOAST, TOAST_TYPE } from "../../constants/Common";
import { sendToast } from "../Toast";
import { sendToast } from "../common/Toast";
import { TOAST, TOAST_TYPE } from "../constants/Common";

type Props = {
id: string;
Expand Down
8 changes: 7 additions & 1 deletion front-end/src/constants/Common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export const TOAST: Record<string, string> = {
UNAUTHORIZED_OUTRO: "The outro card cannot be generated\n"
+ "since the card creation was manual.",
CARDS_GENERATED: "Cards generated successfully.",
CARD_NOT_EDITABLE: "The contents of this card cannot be edited.",
CARD_EDIT_IN_PROGRESS: "Card edit already in progress.",
CARD_EDIT_FAILED: "Failed to edit card.",
CARD_EDITED: "Card edited successfully", // no full stop on purpose
};

export const RESPONSE_STATUS: Record<string, ResponseStatus> = {
Expand Down Expand Up @@ -80,10 +84,11 @@ export const SPINNER_ID = {

// Lyrics
LYRICS_SEARCH: "lyrics_search",
LYRICS_SAVE: "lyrics_save",
LYRICS_CONVERT: "lyrics_convert",

// Cards Generation
CARDS_GENERATE: "cards-generation_generate",
CARDS_GENERATE_SINGLE: "cards-generation_generate-single",
}

/***************** CONFIG ****************/
Expand Down Expand Up @@ -132,6 +137,7 @@ export const API = {
CARDS_GENERATION: {
SAVE_CARDS_CONTENTS: PATHS.cardsGeneration + "/save-cards-contents",
GENERATE_CARDS: PATHS.cardsGeneration + "/generate",
GENERATE_SINGLE_CARD: PATHS.cardsGeneration + "/generate-single",
},
};

Expand Down
4 changes: 2 additions & 2 deletions front-end/src/pages/ArtworkGeneration/ArtworkGeneration.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { FormEvent, JSX, useState } from "react";
import { useNavigate } from "react-router-dom";

import FileUploader from "../../common/components/FileUploader";
import ImgButton from "../../common/components/ImgButton";
import { is2xxSuccessful, objectToQueryString, sendRequest } from "../../common/Requests";
import { hideSpinner, showSpinner } from "../../common/Spinner";
import { sendToast } from "../../common/Toast";
import { ApiResponse, FileUploadRequest, ItunesRequest, ItunesResponse, ItunesResult, YoutubeRequest } from "../../common/Types";
import useTitle from "../../common/UseTitle";
import { isFileExtensionAccepted } from "../../common/utils/FileUtils";
import FileUploader from "../../components/FileUploader";
import ImgButton from "../../components/ImgButton";
import { FILE_UPLOAD, ITUNES, YOUTUBE } from "../../constants/ArtworkGeneration";
import { API, BACKEND_URL, ITUNES_URL, PATHS, SPINNER_ID, TITLE, TOAST, TOAST_TYPE } from "../../constants/Common";

Expand Down
Loading