packages feed

espial-0.0.40: frontend/src/components/NoteBulkEdit.tsx

import { TimeoutError } from 'ky';
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { apiErrorMsg } from '@/util';

import * as api from '../api';
import { app } from '../globals';
import type { NoteBulkAction } from '../types';

type BulkSelection = 'page' | 'all';

interface Props {
  ncount: number;
}

type BulkValidationKey = 'bulkEditNotes.pleaseAddSelection' | 'bulkEditNotes.pleaseChooseAction';

function disabledReason(
  selection: BulkSelection | null,
  action: NoteBulkAction | null,
): BulkValidationKey | null {
  if (selection === null) return 'bulkEditNotes.pleaseAddSelection';
  if (!action) return 'bulkEditNotes.pleaseChooseAction';
  return null;
}

function navLink<T>(current: T | null, value: T, label: string, onClick: (v: T) => void) {
  return (
    <a
      href="#"
      className={`dib mt1 link${current === value ? ' nav-active' : ' silver hover-alt'}`}
      onClick={(e) => {
        e.preventDefault();
        onClick(value);
      }}
    >
      {label}
    </a>
  );
}

export function NoteBulkEdit({ ncount }: Props) {
  const { t } = useTranslation();
  const [visible, setVisible] = useState(false);
  const [selection, setSelection] = useState<BulkSelection | null>(null);
  const [action, setAction] = useState<NoteBulkAction | null>(null);
  const [submitting, setSubmitting] = useState(false);
  const [confirmDelete, setConfirmDelete] = useState(false);
  const [confirmPublic, setConfirmPublic] = useState(false);
  const [validationMsg, setValidationMsg] = useState<string | null>(null);
  const [errorFadingOut, setErrorFadingOut] = useState(false);
  const validationTimer = useRef<number | null>(null);
  const fadeTimer = useRef<number | null>(null);

  const a = app();
  const bulkQuery = a.dat.query ?? null;
  const bulkSharedp = a.dat.sharedp ?? 'all';

  const nids = (a.dat.notes ?? []).map((n) => n.id);
  const pageCount = nids.length;
  const selectionCount = selection === null ? 0 : selection === 'page' ? pageCount : ncount;

  const reason = disabledReason(selection, action);
  const isVisuallyDisabled = submitting || reason !== null || confirmDelete || confirmPublic;

  const clearValidationTimers = () => {
    if (validationTimer.current !== null) {
      window.clearTimeout(validationTimer.current);
      validationTimer.current = null;
    }
    if (fadeTimer.current !== null) {
      window.clearTimeout(fadeTimer.current);
      fadeTimer.current = null;
    }
  };

  const resetForm = () => {
    setSelection(null);
    setAction(null);
    setConfirmDelete(false);
    setConfirmPublic(false);
    setValidationMsg(null);
    setErrorFadingOut(false);
    clearValidationTimers();
  };

  useEffect(() => {
    const el = document.getElementById('bulk-edit-toggle');
    if (!el) return;
    const handler = (e: Event) => {
      e.preventDefault();
      resetForm();
      setVisible((v) => !v);
    };
    el.addEventListener('click', handler);
    return () => {
      el.removeEventListener('click', handler);
    };
  }, []);

  useEffect(() => {
    const el = document.getElementById('bulk-edit-toggle');
    if (!el) return;
    el.classList.toggle('nav-active', visible);
    el.classList.toggle('silver', !visible);
  }, [visible]);

  useEffect(() => {
    setConfirmDelete(false);
    setConfirmPublic(false);
    setValidationMsg(null);
    setErrorFadingOut(false);
  }, [action, selection]);

  useEffect(() => {
    return () => {
      clearValidationTimers();
    };
  }, []);

  const showValidationMsg = (msg: string) => {
    clearValidationTimers();
    setErrorFadingOut(false);
    setValidationMsg(msg);
    validationTimer.current = window.setTimeout(() => {
      validationTimer.current = null;
      setErrorFadingOut(true);
      fadeTimer.current = window.setTimeout(() => {
        setValidationMsg(null);
        setErrorFadingOut(false);
        fadeTimer.current = null;
      }, 5000);
    }, 3000);
  };

  const handleSubmit = async (
    e: React.SyntheticEvent<HTMLFormElement, SubmitEvent>,
  ): Promise<void> => {
    e.preventDefault();
    setValidationMsg(null);
    setErrorFadingOut(false);

    const r = disabledReason(selection, action);
    if (r !== null) {
      showValidationMsg(t(r));
      return;
    }
    if (action === 'delete' && !confirmDelete) {
      setConfirmDelete(true);
      return;
    }
    if (action === 'public' && !confirmPublic) {
      setConfirmPublic(true);
      return;
    }
    setConfirmDelete(false);
    setConfirmPublic(false);
    setSubmitting(true);
    try {
      if (selection === null || action === null) return;
      const res = await api.noteBulkEdit(
        selection === 'page'
          ? {
              selection: 'page',
              nids,
              action,
              selectionCount,
            }
          : {
              selection: 'all',
              sharedp: bulkSharedp,
              query: bulkQuery,
              action,
              selectionCount,
            },
      );
      if (res.ok) {
        window.location.reload();
      } else {
        showValidationMsg(apiErrorMsg(t, res.status, res.bodyText ?? ''));
      }
    } catch (err) {
      if (err instanceof TimeoutError) {
        showValidationMsg(t('error.requestTimedOut'));
      } else {
        showValidationMsg(t('error.networkError'));
      }
    } finally {
      setSubmitting(false);
    }
  };

  if (!visible) return null;

  const toggleAction = (v: NoteBulkAction) => {
    setAction((cur) => (cur === v ? null : v));
  };

  return (
    <form
      onSubmit={(e) => {
        void handleSubmit(e);
      }}
      className="w-100 mw7 pa2 bulk_bookmark_form mt1"
    >
      <div className="mb2">
        <div>
          <span className="fw7">{t('bulkEdit.select')}</span>
          {selection !== null && (
            <span className="ml2">
              {selectionCount === 1
                ? t('bulkEditNotes.oneNoteSelected')
                : t('bulkEditNotes.notesSelected', { count: selectionCount })}
            </span>
          )}
        </div>
        <div>
          {navLink(selection, 'page', t('bulkEdit.thisPage'), setSelection)}
          {' ‧ '}
          {navLink(selection, 'all', t('bulkEdit.allPages'), setSelection)}
        </div>
      </div>

      <div className="mb2">
        <div>
          <span className="fw7">{t('bulkEdit.action')}</span>
        </div>
        <div>
          {navLink(action, 'private', t('bulkEdit.makePrivate'), toggleAction)}
          {' ‧ '}
          {navLink(action, 'public', t('bulkEdit.makePublic'), toggleAction)}
          {' ‧ '}
          {navLink(action, 'markdown', t('bulkEditNotes.useMarkdown'), toggleAction)}
          {' ‧ '}
          {navLink(action, 'plaintext', t('bulkEditNotes.usePlaintext'), toggleAction)}
          {' ‧ '}
          {navLink(action, 'delete', t('delete'), toggleAction)}
        </div>
      </div>

      <div className="flex items-center">
        <button
          type="submit"
          disabled={submitting || confirmDelete || confirmPublic}
          className={`mr2 pv1 ph2 thm-text-primary ba thm-border-default rdim${isVisuallyDisabled ? ' thm-bg-disabled not-allowed' : ' thm-bg-secondary pointer'}`}
          onClick={() => {
            if (reason !== null) showValidationMsg(t(reason));
          }}
        >
          {submitting ? t('bulkEdit.submitting') : t('bulkEdit.submit')}
        </button>{' '}
        <button
          type="button"
          className="pv1 ph2 thm-text-primary ba thm-border-default thm-bg-secondary pointer rdim"
          onClick={() => {
            resetForm();
            setVisible(false);
          }}
        >
          {t('cancel')}
        </button>
        {validationMsg != null && (
          <span
            className="thm-text-error f6 ml2"
            style={{
              opacity: errorFadingOut ? 0 : 1,
              transition: errorFadingOut ? 'opacity 5s' : 'none',
            }}
          >
            {validationMsg}
          </span>
        )}
        {confirmDelete && (
          <span className="f6 ml2">
            <span className="mr2">
              {selectionCount === 1
                ? t('bulkEditNotes.destroyOne')
                : t('bulkEditNotes.destroyMany', { count: selectionCount })}
            </span>
            <span className="nowrap">
              <button
                type="button"
                onClick={() => {
                  setConfirmDelete(false);
                }}
              >
                {t('cancel')}
              </button>
              {' / '}
              <button type="submit" className="thm-text-error">
                {t('destroy')}
              </button>
            </span>
          </span>
        )}
        {confirmPublic && (
          <span className="f6 ml2">
            <span className="mr2">
              {selectionCount === 1
                ? t('bulkEditNotes.publicOne')
                : t('bulkEditNotes.publicMany', { count: selectionCount })}
            </span>
            <span className="nowrap">
              <button
                type="button"
                onClick={() => {
                  setConfirmPublic(false);
                }}
              >
                {t('cancel')}
              </button>
              {' / '}
              <button type="submit" className="thm-text-error">
                {t('bulkEdit.makePublic')}
              </button>
            </span>
          </span>
        )}
      </div>
    </form>
  );
}