digitalfabrik/integreat-cms

View on GitHub
integreat_cms/static/src/js/media-management/component/edit-sidebar.tsx

Summary

Maintainability
A
0 mins
Test Coverage
/* eslint react/button-has-type: 0 */
/*
 * This component renders a sidebar which shows information about the currently
 * active file as well as provides the possibility to modify and delete it
 *
 * Since file deletion is a bit more complex than other functions, the chain of events is explained in detail here:
 *
 *   1. User clicks on delete button
 *   2. Because of the onClick event handler, a confirmation popup is opened via showConfirmationPopup()
 *   3. If the user clicks on confirm, the custom event "action-confirmed" is dispatched for the delete button
 *   4. The file deletion form is submitted by triggering a click on its submit button
 *   5. The onSubmit action of the form is executed
 *   6. submitForm() submits the deletion form via AJAX
 *   7. On success, the media library is refreshed
 */
import { Dispatch, StateUpdater, useEffect, useState } from "preact/hooks";
import {
    AlertTriangle,
    CheckCircle,
    ChevronDown,
    ChevronUp,
    FileText,
    Loader,
    Lock,
    Image,
    Save,
    Sliders,
    Trash2,
    XCircle,
    Edit3,
    ExternalLink,
    Info,
    RefreshCw,
} from "lucide-preact";
import cn from "classnames";

import { showConfirmationPopupAjax } from "../../confirmation-popups";
import { MediaApiPaths, File, MediaLibraryEntry, FileUsages } from "../index";

type Props = {
    fileIndexState: [number | null, Dispatch<StateUpdater<number | null>>];
    mediaLibraryContent: MediaLibraryEntry[];
    apiEndpoints: MediaApiPaths;
    mediaTranslations: any;
    selectionMode?: boolean;
    globalEdit?: boolean;
    expertMode?: boolean;
    onlyImage?: boolean;
    selectMedia?: (file: File) => any;
    submitForm: (event: Event) => void;
    ajaxRequest: (
        url: string,
        urlParams: URLSearchParams,
        successCallback: (data: any) => void,
        loadingSetter?: Dispatch<StateUpdater<boolean>>
    ) => void;
    isLoading: boolean;
    canDeleteFile: boolean;
    canReplaceFile: boolean;
};

const EditSidebar = ({
    fileIndexState,
    mediaLibraryContent,
    apiEndpoints,
    mediaTranslations,
    selectionMode,
    selectMedia,
    globalEdit,
    expertMode,
    onlyImage,
    submitForm,
    ajaxRequest,
    isLoading,
    canDeleteFile,
    canReplaceFile,
    apiEndpoints: { getFileUsages },
}: Props) => {
    // The file index contains the index of the file which is currently opened in the sidebar
    const [fileIndex, setFileIndex] = fileIndexState;
    // The current directory is the last element of the directory path
    const file = mediaLibraryContent[fileIndex] as File;
    // This state is a buffer for the currently changed file
    const [changedFile, setChangedFile] = useState<File>(file);
    // This state is a semaphore to block actions while an ajax call is running
    const [isFileUsagesLoading, setFileUsagesLoading] = useState<boolean>(false);
    // This state is a buffer for the usages of the current file
    const [fileUsages, setFileUsages] = useState<FileUsages | null>(null);
    // This state determines whether the file name is currently being edited
    const [isFileNameEditable, setFileNameEditable] = useState<boolean>(false);
    // This state determines whether the alternative text of the file is currently being edited
    const [isAltTextEditable, setAltTextEditable] = useState<boolean>(false);
    // Editing is allowed if either global edit is enabled or the file is not global
    const isEditingAllowed = globalEdit || !file.isGlobal;

    const toggleFileUsages = () => {
        if (isFileUsagesLoading) {
            return;
        }
        if (fileUsages) {
            setFileUsages(null);
        } else {
            const urlParams = new URLSearchParams({
                file: file.id.toString(),
            });
            console.debug(`Loading usages for file "${file.id}"...`);
            // Load the search result
            ajaxRequest(getFileUsages, urlParams, setFileUsages, setFileUsagesLoading);
        }
    };

    useEffect(() => {
        console.debug("Opening sidebar for file:", file);
        // Reset temporary file buffer
        setChangedFile(file);
        // Reset usage buffer
        setFileUsages(null);
        // Hide input fields
        setFileNameEditable(false);
        setAltTextEditable(false);
        return () => {
            console.debug("Closing file sidebar...");
        };
    }, [file]);

    const onIsHiddenCheckboxClick = () => {
        setChangedFile({
            ...changedFile,
            isHidden: !changedFile.isHidden,
        });
        console.log(changedFile);
    };

    return (
        <div className="absolute w-full h-full flex flex-col rounded border border-blue-500 shadow-2xl bg-white">
            <div class="rounded w-full p-4 bg-water-500 font-bold">
                <div class="flex flex-row justify-between">
                    <span>
                        <Sliders class="mr-1 inline-block h-5" />
                        {mediaTranslations.heading_file_properties}
                    </span>
                    <button
                        title={mediaTranslations.btn_close}
                        class="hover:bg-blue-500 hover:text-white font-bold rounded-full"
                        onClick={() => setFileIndex(null)}
                        aria-label="Close">
                        <XCircle class="inline-block h-5 align-text-bottom" />
                    </button>
                </div>
            </div>
            <div className="overflow-auto flex-1">
                <div class="items-center max-w-full">
                    {/* eslint-disable-next-line no-nested-ternary */}
                    {file.thumbnailUrl ? (
                        <img src={file.thumbnailUrl} alt="" class="max-w-60 m-2 mx-auto" />
                    ) : file.type.startsWith("image/") ? (
                        <Image className="w-full h-36 align-middle mt-4" />
                    ) : (
                        <FileText className="w-full h-36 align-middle mt-4" />
                    )}
                </div>
                <form onSubmit={submitForm} action={apiEndpoints.editFile}>
                    <input name="id" type="hidden" value={file.id} />
                    {/* Add button which submits the form when the enter-key is pressed (otherwise, the edit-buttons would be triggered) */}
                    {/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
                    <button class="hidden" disabled={isLoading} />
                    <div class="flex flex-wrap justify-between gap-2 hover:bg-gray-50 p-4 border-t border-b">
                        <label
                            for="filename-input"
                            className={cn("secondary my-0", {
                                "cursor-auto": !isEditingAllowed,
                            })}
                            onClick={() => isEditingAllowed && !isLoading && setFileNameEditable(!isFileNameEditable)}
                            onKeyDown={() =>
                                isEditingAllowed && !isLoading && setFileNameEditable(!isFileNameEditable)
                            }>
                            {mediaTranslations.label_file_name}
                        </label>
                        {!isFileNameEditable && (
                            <p class="break-all">
                                {file.name}
                                {isEditingAllowed && (
                                    <button
                                        class="hover:text-blue-500 ml-1 h-5"
                                        onClick={(e) => {
                                            e.preventDefault();
                                            setFileNameEditable(true);
                                        }}
                                        disabled={isLoading}
                                        aria-label="Edit">
                                        <Edit3 class="inline-block" />
                                    </button>
                                )}
                            </p>
                        )}
                        <input
                            id="filename-input"
                            name="name"
                            type={isFileNameEditable ? "text" : "hidden"}
                            value={changedFile.name}
                            disabled={isLoading}
                            onInput={({ target }) =>
                                setChangedFile({
                                    ...changedFile,
                                    name: (target as HTMLInputElement).value,
                                })
                            }
                            required
                        />
                    </div>
                    <div class="flex flex-wrap justify-between gap-2 hover:bg-gray-50 p-4 border-b">
                        <label
                            for="alt-text-input"
                            className={cn("secondary my-0", {
                                "cursor-auto": !isEditingAllowed,
                            })}
                            onClick={() => isEditingAllowed && !isLoading && setAltTextEditable(!isAltTextEditable)}
                            onKeyDown={() => isEditingAllowed && !isLoading && setAltTextEditable(!isAltTextEditable)}>
                            {mediaTranslations.label_alt_text}
                        </label>
                        {!isAltTextEditable && (
                            <p class="break-all">
                                {file.altText}
                                {isEditingAllowed && (
                                    <button
                                        class="hover:text-blue-500 ml-1 h-5"
                                        onClick={(e) => {
                                            e.preventDefault();
                                            setAltTextEditable(true);
                                        }}
                                        disabled={isLoading}
                                        aria-label="Edit">
                                        <Edit3 class="inline-block" />
                                    </button>
                                )}
                            </p>
                        )}
                        <input
                            id="alt-text-input"
                            name="alt_text"
                            type={isAltTextEditable ? "text" : "hidden"}
                            value={changedFile.altText}
                            disabled={isLoading}
                            onInput={({ target }) =>
                                setChangedFile({
                                    ...changedFile,
                                    altText: (target as HTMLInputElement).value,
                                })
                            }
                        />
                    </div>
                    <div class="flex flex-wrap justify-between gap-2 hover:bg-gray-50 p-4 border-b">
                        <label class="secondary my-0">{mediaTranslations.label_data_type}</label>
                        <p>{file.typeDisplay}</p>
                    </div>
                    <div class="flex flex-wrap justify-between gap-2 hover:bg-gray-50 p-4 border-b">
                        <label class="secondary my-0">{mediaTranslations.label_file_size}</label>
                        <p>{file.fileSize}</p>
                    </div>
                    <div class="flex flex-wrap justify-between gap-2 hover:bg-gray-50 p-4 border-b">
                        <label class="secondary my-0">{mediaTranslations.label_file_uploaded}</label>
                        <p>{file.uploadedDate}</p>
                    </div>
                    <div className="flex flex-wrap justify-between gap-2 hover:bg-gray-50 p-4 border-b">
                        <label className="secondary my-0">{mediaTranslations.label_file_modified}</label>
                        <p>{file.lastModified}</p>
                    </div>
                    {file.isGlobal && globalEdit && (
                        <div className="flex flex-wrap justify-between gap-2 hover:bg-gray-50 p-4 border-b">
                            <label className="secondary my-0" for="file-hide-input">
                                {mediaTranslations.label_file_is_hidden}
                            </label>
                            <input
                                id="file-hide-input"
                                name="is_hidden"
                                type="checkbox"
                                value={String(changedFile.isHidden)}
                                checked={changedFile.isHidden}
                                onClick={() => {
                                    onIsHiddenCheckboxClick();
                                }}
                            />
                        </div>
                    )}
                    <div class="border-b">
                        <div
                            class="flex flex-wrap justify-between gap-2 hover:bg-gray-50 hover:text-blue-500 p-4 cursor-pointer"
                            onClick={toggleFileUsages}
                            onKeyPress={toggleFileUsages}>
                            <label class="secondary my-0 !cursor-pointer">{mediaTranslations.label_file_usages}:</label>
                            {isFileUsagesLoading ? (
                                <Loader class="inline-block text-gray-600 animate-spin" />
                            ) : (
                                <button
                                    class="ml-1 h-5"
                                    onClick={(e) => {
                                        e.preventDefault();
                                        toggleFileUsages();
                                    }}
                                    disabled={isFileUsagesLoading}>
                                    {fileUsages ? (
                                        <ChevronUp class="inline-block" />
                                    ) : (
                                        <ChevronDown class="inline-block" />
                                    )}
                                </button>
                            )}
                        </div>
                        {fileUsages && (
                            <>
                                {!fileUsages.isUsed && (
                                    <p class="italic px-4 py-2">
                                        <AlertTriangle class="mr-1 inline-block h-5" />
                                        {mediaTranslations.label_file_unused}
                                    </p>
                                )}
                                {fileUsages.iconUsages && (
                                    <div class="flex flex-wrap justify-between gap-2 p-4">
                                        <label class="secondary my-0 !font-normal">
                                            {mediaTranslations.label_file_icon_usages}:
                                        </label>
                                        <div class="text-right grow">
                                            {fileUsages.iconUsages.map((usage) => (
                                                <p key={usage.url}>
                                                    <a
                                                        href={usage.url}
                                                        title={usage.title}
                                                        class="hover:text-blue-500 break-all">
                                                        {usage.name}
                                                    </a>
                                                </p>
                                            ))}
                                        </div>
                                    </div>
                                )}
                                {fileUsages.contentUsages && (
                                    <div class="flex flex-wrap justify-between gap-2 p-4">
                                        <label class="secondary my-0 !font-normal">
                                            {mediaTranslations.label_file_content_usages}:
                                        </label>
                                        <div class="text-right grow">
                                            {fileUsages.contentUsages.map((usage) => (
                                                <p key={usage.url}>
                                                    <a
                                                        href={usage.url}
                                                        title={usage.title}
                                                        class="hover:text-blue-500 break-all">
                                                        {usage.name}
                                                    </a>
                                                </p>
                                            ))}
                                        </div>
                                    </div>
                                )}
                            </>
                        )}
                    </div>
                    {expertMode && (
                        <div class="flex flex-wrap justify-between gap-2 hover:bg-gray-50 p-4 border-b">
                            <label class="secondary my-0">{mediaTranslations.label_url}</label>
                            <a
                                href={file.url}
                                target="_blank"
                                rel="noreferrer"
                                class="hover:text-blue-500 break-all"
                                {...{ native: "" }}>
                                {file.url}
                                <ExternalLink class="inline-block ml-1 h-5" />
                            </a>
                        </div>
                    )}
                    <div className="flex flex-col p-4 gap-4">
                        {isEditingAllowed ? (
                            <>
                                {(isFileNameEditable ||
                                    isAltTextEditable ||
                                    changedFile.isHidden !== file.isHidden) && (
                                    <button title={mediaTranslations.btn_save_file} class="btn" disabled={isLoading}>
                                        <Save class="inline-block" />
                                        {mediaTranslations.btn_save_file}
                                    </button>
                                )}
                                {!selectionMode && canReplaceFile && (
                                    <label
                                        for="replace-file-input"
                                        title={mediaTranslations.btn_replace_file}
                                        className={cn(
                                            "w-full text-white text-center font-bold py-3 px-4 m-0 rounded",
                                            { "cursor-not-allowed bg-gray-500": isLoading },
                                            { "bg-blue-500 hover:bg-blue-600": !isLoading }
                                        )}
                                        disabled={isLoading}>
                                        <RefreshCw class="mr-1 inline-block h-5" />
                                        {mediaTranslations.btn_replace_file}
                                    </label>
                                )}
                                {canDeleteFile && (
                                    <button
                                        title={
                                            file.deletable
                                                ? mediaTranslations.btn_delete_file
                                                : mediaTranslations.btn_delete_used_file
                                        }
                                        className={cn("btn", { "btn-red": !isLoading && file.deletable })}
                                        data-confirmation-title={mediaTranslations.text_file_delete_confirm}
                                        data-confirmation-subject={file.name}
                                        disabled={isLoading || !file.deletable}
                                        onClick={showConfirmationPopupAjax}
                                        onaction-confirmed={() => document.getElementById("delete-file").click()}>
                                        <Trash2 class="inline-block" />
                                        {mediaTranslations.btn_delete_file}
                                    </button>
                                )}
                            </>
                        ) : (
                            <p class="italic">
                                <Lock class="mr-1 inline-block h-5" />
                                {mediaTranslations.text_file_readonly}
                            </p>
                        )}
                        {selectionMode &&
                            (!(onlyImage && !file.type.startsWith("image/")) ? (
                                <button
                                    title={mediaTranslations.btn_select}
                                    onClick={(e) => {
                                        e.preventDefault();
                                        if (selectMedia) {
                                            selectMedia(file);
                                        }
                                    }}
                                    class="btn"
                                    disabled={isLoading}>
                                    <CheckCircle class="inline-block" />
                                    {mediaTranslations.btn_select}
                                </button>
                            ) : (
                                <p class="italic">
                                    <Info class="mr-1 inline-block h-5" />
                                    {mediaTranslations.text_only_image}
                                </p>
                            ))}
                    </div>
                </form>

                {/* Hidden form for file replacement */}
                <form onSubmit={submitForm} action={apiEndpoints.replaceFile} class="hidden">
                    <input name="id" type="hidden" value={file.id} />
                    <input
                        id="replace-file-input"
                        name="file"
                        type="file"
                        maxLength={255}
                        accept={file.type}
                        disabled={isLoading}
                        onChange={() => {
                            document.getElementById("replace-file").click();
                        }}
                    />
                    <button id="replace-file" type="submit" aria-label="submit replace file" />
                </form>

                {/* Hidden form for file deletion (on success, close sidebar) */}
                <form onSubmit={submitForm} action={apiEndpoints.deleteFile} class="hidden">
                    <input name="id" type="hidden" value={file.id} />
                    <button id="delete-file" aria-label="delete file" />
                </form>
            </div>
        </div>
    );
};
export default EditSidebar;