packages feed

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

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

import { apiErrorMsg, normalizeTags } from '@/util';

import * as api from '../api';
import { app } from '../globals';
import { useTagSuggestions } from '../hooks/useTagSuggestions';
import { useBookmarksStore } from '../stores/bookmarksStore';
import { TagSuggestionsDropdown } from './TagSuggestionsDropdown';

type BulkAction = 'read' | 'unread' | 'star' | 'unstar' | 'delete' | 'private' | 'public';
type BulkSelection = 'page' | 'all';

interface Props {
  bcount: number;
}

type BulkValidationKey =
  | 'bulkEdit.pleaseAddSelection'
  | 'bulkEdit.pleaseChooseAction'
  | 'bulkEdit.tooManyTags';

function disabledReason(
  selection: BulkSelection | null,
  action: BulkAction | null,
  addTags: string,
  removeTags: string,
  bcount: number,
): BulkValidationKey | null {
  if (selection === null) return 'bulkEdit.pleaseAddSelection';
  if (!action && !addTags.trim() && !removeTags.trim()) return 'bulkEdit.pleaseChooseAction';
  if (bcount > 20000 && addTags.trim().split(/\s+/).filter(Boolean).length > 10)
    return 'bulkEdit.tooManyTags';
  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 BulkEdit({ bcount }: Props) {
  const { t } = useTranslation();
  const [visible, setVisible] = useState(false);
  const [selection, setSelection] = useState<BulkSelection | null>(null);
  const [action, setAction] = useState<BulkAction | null>(null);
  const [addTags, setAddTags] = useState('');
  const [removeTags, setRemoveTags] = useState('');
  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 suggestEnabled = a.dat.suggestTags === true;
  const suggestUseReturnKey = a.dat.suggestTagsUseReturnKey !== false;
  const bulkFilter = a.dat.filter ?? { tag: 'FilterAll' as const };
  const bulkSharedp = a.dat.sharedp ?? 'all';
  const bulkTags = a.dat.tags ?? [];
  const bulkQuery = a.dat.query ?? null;

  const bmarks = useBookmarksStore((s) => s.bmarks);
  const bids = bmarks.map((b) => b.bid);
  const pageCount = bids.length;
  const selectionCount = selection === null ? 0 : selection === 'page' ? pageCount : bcount;

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

  const {
    tagInputRef: removeTagsRef,
    suggestionState: removeSuggestionState,
    closeSuggestions: closeRemoveSuggestions,
    onTagsChange: onRemoveTagsChange,
    onTagsKeyDown: onRemoveTagsKeyDown,
    onTagsSelect: onRemoveTagsSelect,
    onSuggestionHover: onRemoveSuggestionHover,
    onSuggestionPick: onRemoveSuggestionPick,
  } = useTagSuggestions({
    enabled: suggestEnabled && action !== 'delete',
    useReturnKey: suggestUseReturnKey,
    tags: removeTags,
    onTagsUpdate: setRemoveTags,
  });

  const {
    tagInputRef: addTagsRef,
    suggestionState: addSuggestionState,
    closeSuggestions: closeAddSuggestions,
    onTagsChange: onAddTagsChange,
    onTagsKeyDown: onAddTagsKeyDown,
    onTagsSelect: onAddTagsSelect,
    onSuggestionHover: onAddSuggestionHover,
    onSuggestionPick: onAddSuggestionPick,
  } = useTagSuggestions({
    enabled: suggestEnabled && action !== 'delete',
    useReturnKey: suggestUseReturnKey,
    tags: addTags,
    onTagsUpdate: setAddTags,
  });

  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);
    setAddTags('');
    setRemoveTags('');
    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 addTagsTrimmed = normalizeTags(addTags);
    const removeTagsTrimmed = normalizeTags(removeTags);
    const r = disabledReason(selection, action, addTagsTrimmed, removeTagsTrimmed, bcount);
    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) return;
      const res = await api.bulkEdit(
        selection === 'page'
          ? {
              selection: 'page',
              bids,
              action,
              addTags: addTagsTrimmed,
              removeTags: removeTagsTrimmed,
              selectionCount,
            }
          : {
              selection: 'all',
              filter: bulkFilter,
              sharedp: bulkSharedp,
              tags: bulkTags,
              query: bulkQuery,
              action,
              addTags: addTagsTrimmed,
              removeTags: removeTagsTrimmed,
              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: BulkAction) => {
    setAction((cur) => (cur === v ? null : v));
    if (v === 'delete') {
      setAddTags('');
      setRemoveTags('');
    }
  };

  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('bulkEdit.oneBookmarkSelected')
                : t('bulkEdit.bookmarksSelected', { 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, 'read', t('bulkEdit.markAsRead'), toggleAction)}
          {' ‧ '}
          {navLink(action, 'unread', t('bulkEdit.unread'), toggleAction)}
          {' ‧ '}
          {navLink(action, 'star', t('bulkEdit.addStars'), toggleAction)}
          {' ‧ '}
          {navLink(action, 'unstar', t('bulkEdit.removeStars'), toggleAction)}
          {' ‧ '}
          {navLink(action, 'delete', t('delete'), toggleAction)}
        </div>
      </div>

      <div className="mb2">
        <div>
          <span className={`fw7${normalizeTags(removeTags) ? ' nav-active' : ''}`}>
            {t('bulkEdit.removeTags')}
          </span>
        </div>
        <div className="relative">
          <input
            ref={removeTagsRef}
            type="text"
            className={`tags w-100 mb1 pt1 edit_form_input${action === 'delete' ? ' o-50 not-allowed' : ''}`}
            disabled={action === 'delete'}
            autoComplete="off"
            autoCapitalize="off"
            value={removeTags}
            onChange={onRemoveTagsChange}
            onKeyDown={onRemoveTagsKeyDown}
            onClick={onRemoveTagsSelect}
            onSelect={onRemoveTagsSelect}
            onBlur={() => {
              window.setTimeout(() => {
                closeRemoveSuggestions();
              }, 0);
            }}
          />
          {removeSuggestionState != null ? (
            <TagSuggestionsDropdown
              suggestionState={removeSuggestionState}
              onHover={onRemoveSuggestionHover}
              onPick={onRemoveSuggestionPick}
            />
          ) : null}
        </div>
      </div>

      <div className="mb2">
        <div>
          <span className={`fw7${normalizeTags(addTags) ? ' nav-active' : ''}`}>
            {t('bulkEdit.addTags')}
          </span>
        </div>
        <div className="relative">
          <input
            ref={addTagsRef}
            type="text"
            className={`tags w-100 mb1 pt1 edit_form_input${action === 'delete' ? ' o-50 not-allowed' : ''}`}
            disabled={action === 'delete'}
            autoComplete="off"
            autoCapitalize="off"
            value={addTags}
            onChange={onAddTagsChange}
            onKeyDown={onAddTagsKeyDown}
            onClick={onAddTagsSelect}
            onSelect={onAddTagsSelect}
            onBlur={() => {
              window.setTimeout(() => {
                closeAddSuggestions();
              }, 0);
            }}
          />
          {addSuggestionState != null ? (
            <TagSuggestionsDropdown
              suggestionState={addSuggestionState}
              onHover={onAddSuggestionHover}
              onPick={onAddSuggestionPick}
            />
          ) : null}
        </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('bulkEdit.destroyOne')
                : t('bulkEdit.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('bulkEdit.publicOne')
                : t('bulkEdit.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>
  );
}