Просмотр исходного кода

Merge pull request #10438 from growilabs/feat/167084-mime-type-settings-frontend-component

feat: Frontend component for content disposition settings
Yuki Takei 2 месяцев назад
Родитель
Сommit
5a6bebd35b

+ 12 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -442,6 +442,18 @@
       "tag_names": "Tag names",
       "tag_attributes": "Tag attributes",
       "import_recommended": "Import recommended {{target}}"
+    },
+    "content-disposition_header": "Content-Disposition Mime Type Settings",
+    "content-disposition_options": {
+      "add_header": "Add Mime Types",
+      "note": "Note: Adding a mime type will automatically remove it from the other list.",
+      "inline_header": "Inline Mime Types",
+      "attachment_header": "Attachment Mime Types",
+      "inline_button": "Inline",
+      "attachment_button": "Attachment",
+      "no_inline": "No inline types set.",
+      "no_attachment": "No attachment types set.",
+      "remove_button": "Remove"
     }
   },
   "customize_settings": {

+ 12 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -442,6 +442,18 @@
       "tag_names": "Nom de tags",
       "tag_attributes": "Attributs de tags",
       "import_recommended": "Importer {{target}}"
+    },
+    "content-disposition_header": "Paramètres des types MIME Content-Disposition",
+    "content-disposition_options": {
+      "add_header": "Ajouter des types MIME",
+      "note": "Note : L'ajout d'un type MIME le supprimera automatiquement de l'autre liste.",
+      "inline_header": "Types MIME Inline",
+      "attachment_header": "Types MIME en pièce jointe",
+      "inline_button": "Inline",
+      "attachment_button": "Pièce jointe",
+      "no_inline": "Aucun type inline défini.",
+      "no_attachment": "Aucun type de pièce jointe défini.",
+      "remove_button": "Supprimer"
     }
   },
   "customize_settings": {

+ 12 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -451,6 +451,18 @@
       "tag_names": "タグ名",
       "tag_attributes": "タグ属性",
       "import_recommended": "{{target}} のおすすめをインポート"
+    },
+    "content-disposition_header": "Content-Disposition マイムタイプ設定",
+    "content-disposition_options": {
+      "add_header": "マイムタイプを追加する",
+      "note": "注意: マイムタイプを追加すると、もう一方のリストからは自動的に削除されます。",
+      "inline_header": "インライン マイムタイプ",
+      "attachment_header": "添付ファイル マイムタイプ",
+      "inline_button": "インライン",
+      "no_inline": "設定済みのインラインタイプはありません。",
+      "no_attachment": "設定済みの添付ファイルタイプはありません。",
+      "attachment_button": "添付ファイル",
+      "remove_button": "削除"
     }
   },
   "customize_settings": {

+ 12 - 0
apps/app/public/static/locales/ko_KR/admin.json

@@ -442,6 +442,18 @@
       "tag_names": "태그 이름",
       "tag_attributes": "태그 속성",
       "import_recommended": "권장 {{target}} 가져오기"
+    },
+    "content-disposition_header": "Content-Disposition 마임 타입(MIME Type) 설정",
+    "content-disposition_options": {
+      "add_header": "마임 타입 추가",
+      "note": "참고: 마임 타입을 추가하면 다른 리스트에서 자동으로 제거됩니다.",
+      "inline_header": "인라인(Inline) 마임 타입",
+      "attachment_header": "파일 첨부(Attachment) 마임 타입",
+      "inline_button": "인라인",
+      "attachment_button": "파일 첨부",
+      "no_inline": "설정된 인라인 타입이 없습니다.",
+      "no_attachment": "설정된 파일 첨부 타입이 없습니다.",
+      "remove_button": "삭제"
     }
   },
   "customize_settings": {

+ 12 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -451,6 +451,18 @@
       "tag_names": "标记名",
       "tag_attributes": "标记属性",
       "import_recommended": "导入建议 {{target}}"
+    },
+    "content-disposition_header": "Content-Disposition MIME 类型设置",
+    "content-disposition_options": {
+      "add_header": "添加 MIME 类型",
+      "note": "注意:添加 MIME 类型将自动从另一个列表中将其删除。",
+      "inline_header": "内联 (Inline) MIME 类型",
+      "attachment_header": "附件 (Attachment) MIME 类型",
+      "inline_button": "内联",
+      "attachment_button": "附件",
+      "no_inline": "未设置内联类型。",
+      "no_attachment": "未设置附件类型。",
+      "remove_button": "移除"
     }
   },
   "customize_settings": {

+ 197 - 0
apps/app/src/client/components/Admin/MarkdownSetting/ContentDispositionSettings.tsx

@@ -0,0 +1,197 @@
+import React, { useState, useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
+
+import { useContentDisposition, type ContentDispositionSettings } from '../../../services/admin-content-disposition';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+interface MimeTypeListProps {
+  title: string;
+  items: string[];
+  emptyText: string;
+  onRemove: (mimeType: string) => void;
+  removeLabel: string;
+  isUpdating: boolean;
+}
+
+const normalizeMimeType = (mimeType: string): string => mimeType.trim().toLowerCase();
+
+const MimeTypeList = ({
+  title, items, emptyText, onRemove, removeLabel, isUpdating,
+}: MimeTypeListProps) => (
+  <div className="col-md-6 col-sm-12 mb-4">
+    <div className="card shadow-sm rounded-3">
+      <div className="card-header bg-transparent fw-bold">{title}</div>
+      <div className="card-body">
+        <ul className="list-group list-group-flush">
+          {items.length === 0 && <li className="list-group-item text-muted small border-0">{emptyText}</li>}
+          {items.map((m: string) => (
+            <li key={m} className="list-group-item d-flex justify-content-between align-items-center border-0 px-0">
+              <code>{m}</code>
+              <button
+                type="button"
+                className="btn btn-sm btn-outline-danger rounded-3"
+                onClick={() => onRemove(m)}
+                disabled={isUpdating}
+              >
+                {removeLabel}
+              </button>
+            </li>
+          ))}
+        </ul>
+      </div>
+    </div>
+  </div>
+);
+
+const ContentDispositionSettings: React.FC = () => {
+  const { t } = useTranslation('admin');
+  const {
+    currentSettings, isLoading, isUpdating, updateSettings,
+  } = useContentDisposition();
+
+  const [currentInput, setCurrentInput] = useState<string>('');
+  const [error, setError] = useState<string | null>(null);
+
+  const {
+    handleSubmit,
+    setValue,
+    watch,
+    reset,
+    formState: { isDirty },
+  } = useForm<ContentDispositionSettings>({
+    defaultValues: {
+      inlineMimeTypes: [],
+      attachmentMimeTypes: [],
+    },
+  });
+
+  useEffect(() => {
+    if (currentSettings) {
+      reset(currentSettings);
+    }
+  }, [currentSettings, reset]);
+
+  const inlineMimeTypes = watch('inlineMimeTypes');
+  const attachmentMimeTypes = watch('attachmentMimeTypes');
+
+  const handleSetMimeType = useCallback((disposition: 'inline' | 'attachment') => {
+    const mimeType = normalizeMimeType(currentInput);
+    if (!mimeType) return;
+
+    const otherDisposition = disposition === 'inline' ? 'attachment' : 'inline';
+
+    const currentTargetList = watch(`${disposition}MimeTypes`);
+    const currentOtherList = watch(`${otherDisposition}MimeTypes`);
+
+    if (!currentTargetList.includes(mimeType)) {
+      setValue(`${disposition}MimeTypes`, [...currentTargetList, mimeType], { shouldDirty: true });
+    }
+
+    setValue(
+      `${otherDisposition}MimeTypes`,
+      currentOtherList.filter(m => m !== mimeType),
+      { shouldDirty: true },
+    );
+
+    setCurrentInput('');
+    setError(null);
+  }, [currentInput, setValue, watch]);
+
+  const handleRemove = useCallback((mimeType: string, disposition: 'inline' | 'attachment') => {
+    const currentList = watch(`${disposition}MimeTypes`);
+    setValue(
+      `${disposition}MimeTypes`,
+      currentList.filter(m => m !== mimeType),
+      { shouldDirty: true },
+    );
+  }, [setValue, watch]);
+
+  const onSubmit = async(data: ContentDispositionSettings) => {
+    try {
+      setError(null);
+      await updateSettings(data);
+      reset(data);
+    }
+    catch (err) {
+      setError((err as Error).message);
+    }
+  };
+
+  if (isLoading && !currentSettings) return <div>Loading...</div>;
+
+  return (
+    <div className="row">
+      <div className="col-12">
+        <h2 className="mb-4 border-0">{t('markdown_settings.content-disposition_header')}</h2>
+
+        <div className="card shadow-sm mb-4 rounded-3 border-0">
+          <div className="card-body">
+            <div className="form-group">
+              <label className="form-label fw-bold">
+                {t('markdown_settings.content-disposition_options.add_header')}
+              </label>
+              <div className="d-flex align-items-center gap-2 mb-3">
+                <input
+                  type="text"
+                  className="form-control rounded-3 w-50"
+                  value={currentInput}
+                  onChange={e => setCurrentInput(e.target.value)}
+                  placeholder="e.g. image/png"
+                />
+                <button
+                  className="btn btn-primary px-3 flex-shrink-0 rounded-3 fw-bold"
+                  type="button"
+                  onClick={() => handleSetMimeType('inline')}
+                  disabled={!currentInput.trim() || isUpdating}
+                >
+                  {t('markdown_settings.content-disposition_options.inline_button')}
+                </button>
+                <button
+                  className="btn btn-primary text-white px-3 flex-shrink-0 rounded-3 fw-bold"
+                  type="button"
+                  onClick={() => handleSetMimeType('attachment')}
+                  disabled={!currentInput.trim() || isUpdating}
+                >
+                  {t('markdown_settings.content-disposition_options.attachment_button')}
+                </button>
+              </div>
+              <small className="form-text text-muted">
+                {t('markdown_settings.content-disposition_options.note')}
+              </small>
+            </div>
+          </div>
+        </div>
+
+        {error && <div className="alert alert-danger rounded-3">{error}</div>}
+
+        <div className="row">
+          <MimeTypeList
+            title={t('markdown_settings.content-disposition_options.inline_header')}
+            items={inlineMimeTypes}
+            emptyText={t('markdown_settings.content-disposition_options.no_inline')}
+            onRemove={m => handleRemove(m, 'inline')}
+            removeLabel={t('markdown_settings.content-disposition_options.remove_button')}
+            isUpdating={isUpdating}
+          />
+          <MimeTypeList
+            title={t('markdown_settings.content-disposition_options.attachment_header')}
+            items={attachmentMimeTypes}
+            emptyText={t('markdown_settings.content-disposition_options.no_attachment')}
+            onRemove={m => handleRemove(m, 'attachment')}
+            removeLabel={t('markdown_settings.content-disposition_options.remove_button')}
+            isUpdating={isUpdating}
+          />
+        </div>
+
+        <AdminUpdateButtonRow
+          onClick={handleSubmit(onSubmit)}
+          disabled={!isDirty || isUpdating}
+        />
+      </div>
+    </div>
+  );
+};
+
+export default ContentDispositionSettings;

+ 4 - 0
apps/app/src/client/components/Admin/MarkdownSetting/MarkDownSettingContents.tsx

@@ -10,6 +10,7 @@ import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
+import ContentDispositionSettings from './ContentDispositionSettings';
 import IndentForm from './IndentForm';
 import LineBreakForm from './LineBreakForm';
 import XssForm from './XssForm';
@@ -61,6 +62,9 @@ const MarkDownSettingContents = React.memo((props: Props): JSX.Element => {
         <CardBody className="px-0 py-2">{ t('markdown_settings.xss_desc') }</CardBody>
       </Card>
       <XssForm />
+
+      {/* Content-Disposition Setting */}
+      <ContentDispositionSettings />
     </div>
   );
 });

+ 28 - 64
apps/app/src/client/services/admin-content-disposition.ts

@@ -1,12 +1,11 @@
-import { useCallback, useMemo } from 'react';
+import { useCallback } from 'react';
 
-import type { SWRResponse } from 'swr';
 import useSWR from 'swr';
-import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+import useSWRMutation from 'swr/mutation';
 
 import { apiv3Get, apiv3Put } from '../util/apiv3-client';
 
-interface ContentDispositionSettings {
+export interface ContentDispositionSettings {
   inlineMimeTypes: string[];
   attachmentMimeTypes: string[];
 }
@@ -24,80 +23,45 @@ interface ContentDispositionUpdateResponse {
   currentDispositionSettings: ContentDispositionSettings;
 }
 
-export const useSWRxContentDispositionSettings = (): SWRResponse<ContentDispositionSettings, Error> => {
-  return useSWR(
+interface UseContentDisposition {
+  currentSettings: ContentDispositionSettings | undefined;
+  isLoading: boolean;
+  isUpdating: boolean;
+  updateSettings: (newSettings: ContentDispositionSettings) => Promise<ContentDispositionSettings>;
+}
+
+export const useContentDisposition = (): UseContentDisposition => {
+  const { data, isLoading, mutate } = useSWR(
     '/content-disposition-settings/',
-    endpoint => apiv3Get<ContentDispositionGetResponse>(endpoint).then((response) => {
-      return response.data.currentDispositionSettings;
-    }),
+    endpoint => apiv3Get<ContentDispositionGetResponse>(endpoint).then(res => res.data.currentDispositionSettings),
   );
-};
 
-export const useSWRMUTxContentDispositionSettings = (): SWRMutationResponse<
-  ContentDispositionSettings,
-  Error,
-  string,
-  ContentDispositionUpdateRequest
-> => {
-  return useSWRMutation(
+  const { trigger, isMutating: isUpdating } = useSWRMutation(
     '/content-disposition-settings/',
     async(endpoint: string, { arg }: { arg: ContentDispositionUpdateRequest }) => {
       const response = await apiv3Put<ContentDispositionUpdateResponse>(endpoint, arg);
       return response.data.currentDispositionSettings;
     },
   );
-};
-
-export const useContentDisposition = (): {
-  setInline: (mimeType: string) => Promise<void>;
-  setAttachment: (mimeType: string) => Promise<void>;
-} => {
-  const { data, mutate } = useSWRxContentDispositionSettings();
-  const { trigger } = useSWRMUTxContentDispositionSettings();
-
-  const inlineMimeTypesStr = data?.inlineMimeTypes?.join(',');
-  const attachmentMimeTypesStr = data?.attachmentMimeTypes?.join(',');
-  // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally using array contents instead of data object reference
-  const memoizedData = useMemo(() => data, [inlineMimeTypesStr, attachmentMimeTypesStr]);
-
-  const setInline = useCallback(async(mimeType: string): Promise<void> => {
-    if (!memoizedData) return;
-
-    const newInlineMimeTypes = [...memoizedData.inlineMimeTypes];
-    const newAttachmentMimeTypes = memoizedData.attachmentMimeTypes.filter(m => m !== mimeType);
-
-    if (!newInlineMimeTypes.includes(mimeType)) {
-      newInlineMimeTypes.push(mimeType);
-    }
-
-    await trigger({
-      newInlineMimeTypes,
-      newAttachmentMimeTypes,
-    });
-
-    mutate();
-  }, [memoizedData, trigger, mutate]);
-
-  const setAttachment = useCallback(async(mimeType: string): Promise<void> => {
-    if (!memoizedData) return;
 
-    const newInlineMimeTypes = memoizedData.inlineMimeTypes.filter(m => m !== mimeType);
-    const newAttachmentMimeTypes = [...memoizedData.attachmentMimeTypes];
+  const updateSettings = useCallback(async(newSettings: ContentDispositionSettings): Promise<ContentDispositionSettings> => {
+    const request: ContentDispositionUpdateRequest = {
+      newInlineMimeTypes: newSettings.inlineMimeTypes,
+      newAttachmentMimeTypes: newSettings.attachmentMimeTypes,
+    };
 
-    if (!newAttachmentMimeTypes.includes(mimeType)) {
-      newAttachmentMimeTypes.push(mimeType);
-    }
+    const updatedData = await trigger(request);
 
-    await trigger({
-      newInlineMimeTypes,
-      newAttachmentMimeTypes,
-    });
+    // Update local cache and avoid an unnecessary extra GET request
+    await mutate(updatedData, { revalidate: false });
 
-    mutate();
-  }, [memoizedData, trigger, mutate]);
+    return updatedData;
+  }, [trigger, mutate]);
 
   return {
-    setInline,
-    setAttachment,
+    currentSettings: data,
+    isLoading,
+    isUpdating,
+    updateSettings,
   };
 };