diff --git a/src/components/admin/ImageManager.tsx b/src/components/admin/ImageManager.tsx index 22a46f403421d22ae7783ce94b7f7b6c904daf5d..92c101d7b9648eccbfd1beebbee64a370827e1d9 100644 --- a/src/components/admin/ImageManager.tsx +++ b/src/components/admin/ImageManager.tsx @@ -1,63 +1,318 @@ -import React, { useState } from 'react' +import React, { useRef, useState } from 'react' import Button from '@material-ui/core/Button' import Dialog from '@material-ui/core/Dialog' import DialogActions from '@material-ui/core/DialogActions' import DialogContent from '@material-ui/core/DialogContent' import DialogTitle from '@material-ui/core/DialogTitle' +import IconButton from '@material-ui/core/IconButton' +import DeleteIcon from '@material-ui/icons/Delete' import CircularProgress from '@material-ui/core/CircularProgress' +import Chip from '@material-ui/core/Chip' import { useEffect } from 'react' import { ImagesInformationResponse } from '../../types/ApiTypes' +import { createStyles, makeStyles, Theme } from '@material-ui/core' +import { Checkbox } from '@material-ui/core' +import { useCallback } from 'react' + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + previewWrapper: { + width: '100%', + maxWidth: '100%', + position: 'relative', + overflow: 'hidden', + borderRadius: '6px', + backgroundColor: theme.palette.grey[500] + }, + previewWrapperSelected: { + '&::before': { + borderRadius: '6px', + position: 'absolute', + top: '0', + right: '0', + bottom: '0', + left: '0', + content: '""', + border: '4px solid ' + theme.palette.primary.main, + background: `linear-gradient(0deg, ${theme.palette.primary.main} 0%, rgba(255,255,255,0) 35%, rgba(255,255,255,0) 100%)` + } + }, + previewImage: { + width: '100%' + }, + previewOverlay: { + padding: '6px', + position: 'absolute', + display: 'grid', + gridTemplateColumns: 'auto auto', + top: '0', + left: '0', + right: '0', + bottom: '0' + }, + content: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '6px' + }, + deleteImage: { + backgroundColor: theme.palette.error.main, + '&:hover': { + backgroundColor: theme.palette.error.light + } + }, + selectImage: { + justifySelf: 'start', + alignSelf: 'end', + backgroundColor: 'rgba(255, 255, 255, 0.8)', + borderRadius: '6px' + }, + uploadDate: { + justifySelf: 'end', + alignSelf: 'end', + '& > p': { + backgroundColor: 'rgba(255, 255, 255, 0.8)', + margin: '0', + padding: '6px', + borderRadius: '6px' + } + }, + hiddenInput: { + display: 'none' + }, + dialogHeader: { + '& > h2': { + display: 'grid', + gridTemplateColumns: 'auto auto' + } + } + }) +) + +interface ImagePreviewProps { + imageMetaData: ImagesInformationResponse[number] + selectedUrl: string | undefined + onSelect: (url: string | undefined) => void + onDelete: (url: string | undefined) => void +} + +export const ImagePreview: React.FC<ImagePreviewProps> = ({ + imageMetaData, + selectedUrl, + onSelect, + onDelete +}) => { + const classes = useStyles() + + const isSelected = selectedUrl === imageMetaData.url + const wrapperClasses = [classes.previewWrapper] + if (isSelected) wrapperClasses.push(classes.previewWrapperSelected) + + const deleteImage = useCallback(async () => { + if (imageMetaData.activeUsages > 0) { + if ( + !window.confirm( + `Dieses Bild ist aktuell ${imageMetaData.activeUsages}x zum Anzeigen ausgewählt. Wenn sie dieses Bild löschen, werden alle Referenzen ebenfalls gelöscht.` + ) + ) { + console.log('cancelled image delete') + return + } + } + + const res = await fetch(imageMetaData.url, { method: 'DELETE' }) + if (res.status !== 200) { + console.error( + 'failed to delete image ' + + imageMetaData.url + + ' | status: ' + + res.status + ) + return + } + + if (selectedUrl === imageMetaData.url) { + onSelect(undefined) // make sure not to select deleted image + } + onDelete(imageMetaData.url) + }, [imageMetaData, onSelect, onDelete, selectedUrl]) + + return ( + <div className={wrapperClasses.join(' ')}> + <div className={classes.previewOverlay}> + <Chip + style={{ justifySelf: 'start' }} + label={`${imageMetaData.activeUsages}x in Nutzung`} + color="primary" + /> + <div style={{ justifySelf: 'end' }}> + <IconButton + aria-label="delete" + className={classes.deleteImage} + size="small" + onClick={deleteImage} + > + <DeleteIcon fontSize="small" /> + </IconButton> + </div> + <div className={classes.selectImage}> + <Checkbox + color="primary" + checked={isSelected} + onChange={(e) => + onSelect( + e.target.checked ? imageMetaData.url : undefined + ) + } + ></Checkbox> + </div> + <div className={classes.uploadDate}> + <p>{imageMetaData.uploaded}</p> + </div> + </div> + <img src={imageMetaData.url} className={classes.previewImage}></img> + </div> + ) +} + interface PopupProps { open: boolean onClose: (url: string | null) => void } export const ImageManagerPopup: React.FC<PopupProps> = ({ open, onClose }) => { + const classes = useStyles() const [ allImages, setAllImages ] = useState<ImagesInformationResponse | null>(null) + const [selectedUrl, setSelectedUrl] = useState<string | undefined>( + undefined + ) + const fileSelectorRef = useRef<HTMLInputElement>(null) + + async function fetchImages() { + setAllImages(null) + const response: ImagesInformationResponse = await ( + await fetch('/api/image') + ).json() + if (!Array.isArray(response)) { + console.error('error in /api/image response', response) + alert('Etwas ist schief gelaufen :/') + return + } + setAllImages(response) + } useEffect(() => { - async function inner() { - const response: ImagesInformationResponse = await ( - await fetch('/api/image') - ).json() - if (!Array.isArray(response)) { - console.error('error in /api/image response', response) - alert('Etwas ist schief gelaufen :/') + fetchImages() // allows async funcion inside useEffect + }, []) + + async function uploadImage(imageFileList: FileList | null) { + if (!imageFileList?.[0]) { + // no file selected + alert('Keine Datei ausgewählt oder falsche Endung') + return + } + const imageFile: File = imageFileList[0] + if (imageFile.size >= 4000000) { + alert('Bild darf max. 4MB groß sein (empfohlen: < 500kB)') + return + } + if (imageFile.size > 500000) { + if ( + !window.confirm( + `Bild hat ${Math.ceil( + imageFile.size / 1000 + )}kB Größe, empfohlen: <500kB. Wollen sie wirklich hochladen?` + ) + ) { return } - setAllImages(response) } - inner() // allows async funcion inside useEffect - }, []) + console.log('uploading image...') + + const res = await fetch('/api/image', { + method: 'POST', + body: imageFile + }) - function handleClose() { - onClose(null) + if (res.status !== 200) { + const body = await res.text() + alert('Upload failed: ' + body) + } else { + const { url } = await res.json() + // success, load image and set as selected + await fetchImages() + setSelectedUrl(url) + } } return ( <Dialog open={open} - onClose={handleClose} + onClose={() => onClose(null)} aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" > - <DialogTitle id="alert-dialog-title">Bild Manager</DialogTitle> - {allImages ? ( - <DialogContent> - <h1>Imagessss</h1> - </DialogContent> - ) : ( - <CircularProgress /> - )} - + <DialogTitle + id="alert-dialog-title" + className={classes.dialogHeader} + > + Bild Manager + <input + ref={fileSelectorRef} + accept="image/*" + className={classes.hiddenInput} + id="contained-button-file" + multiple + type="file" + onChange={(e) => uploadImage(e.target.files)} + /> + <label + htmlFor="contained-button-file" + style={{ justifySelf: 'end' }} + > + <Button + variant="contained" + color="primary" + component="span" + > + Bild hochladen + </Button> + </label> + </DialogTitle> + <DialogContent className={classes.content}> + {allImages ? ( + allImages.map((iMD) => ( + <ImagePreview + key={iMD.url} + imageMetaData={iMD} + selectedUrl={selectedUrl} + onSelect={setSelectedUrl} + onDelete={fetchImages} + /> + )) + ) : ( + <CircularProgress /> + )} + </DialogContent> <DialogActions> - <Button onClick={handleClose} color="primary"> + <Button + onClick={() => { + onClose(null) + }} + color="primary" + > Close </Button> - <Button onClick={handleClose} color="primary" autoFocus> + <Button + onClick={() => { + onClose(selectedUrl ?? null) + }} + color="primary" + autoFocus + > Agree </Button> </DialogActions> diff --git a/src/components/admin/snacks & co/SnackTable.tsx b/src/components/admin/snacks & co/SnackTable.tsx index 74304c1ac03b707013ec4f0fdbe8b894f320bd77..4ea181d03d6ee7078cfb1b542cbc0969e39afef7 100644 --- a/src/components/admin/snacks & co/SnackTable.tsx +++ b/src/components/admin/snacks & co/SnackTable.tsx @@ -89,7 +89,10 @@ export const SnackTable: React.FC<SnackTableProps> = ({ } else return null })} </TableBody> - <ImageManagerPopup open={showImage} onClose={() => setShowImage(false)}></ImageManagerPopup> + <ImageManagerPopup + open={showImage} + onClose={() => setShowImage(false)} + ></ImageManagerPopup> </Table> ) }