Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import React, {
useState, useCallback, useEffect, useMemo,
} from 'react';

// Use the refactored hook and its exported type
import { useContentDisposition, type ContentDispositionSettings } from '../../../services/AdminContentDispositionSettings';

/**
* Helper function to ensure the mime type is normalized / clean before use.
*/
const normalizeMimeType = (mimeType: string): string => mimeType.trim().toLowerCase();

// Helper to remove a mimeType from an array
const removeMimeTypeFromArray = (array: string[], mimeType: string): string[] => (
array.filter(m => m !== mimeType)
);

const ContentDispositionSettings: React.FC = () => {

// 1. Updated destructuring from the refactored hook
const {
currentSettings,
isLoading,
isUpdating,
updateSettings,
} = useContentDisposition();

// 2. State for pending changes and input
const [pendingSettings, setPendingSettings] = useState<ContentDispositionSettings | null>(null);
const [currentInput, setCurrentInput] = useState<string>('');
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (currentSettings) {
// Deep copy to prevent mutating the original settings object
setPendingSettings({
inlineMimeTypes: [...currentSettings.inlineMimeTypes],
attachmentMimeTypes: [...currentSettings.attachmentMimeTypes],
});
setError(null);
}
}, [currentSettings]);

// Use the pending settings for display, falling back to an empty object if not loaded yet
const displaySettings = pendingSettings ?? { inlineMimeTypes: [], attachmentMimeTypes: [] };

// Calculate if there are differences between saved and pending state
const hasPendingChanges = useMemo(() => {
if (!currentSettings || !pendingSettings) return false;
// Check if the mime type lists have changed
return JSON.stringify(currentSettings.inlineMimeTypes.sort()) !== JSON.stringify(pendingSettings.inlineMimeTypes.sort())
|| JSON.stringify(currentSettings.attachmentMimeTypes.sort()) !== JSON.stringify(pendingSettings.attachmentMimeTypes.sort());
}, [currentSettings, pendingSettings]);


// 3. Handlers for setting (adding to pending state)
const handleSetMimeType = useCallback((disposition: 'inline' | 'attachment') => {
const mimeType = normalizeMimeType(currentInput);
if (!mimeType) return;

setError(null);
setPendingSettings((prev) => {
if (!prev) return null;

const newSettings = { ...prev };
const otherDisposition = disposition === 'inline' ? 'attachment' : 'inline';

// 1. Add to the target list (if not already present)
const targetKey = `${disposition}MimeTypes` as keyof ContentDispositionSettings;
if (!newSettings[targetKey].includes(mimeType)) {
newSettings[targetKey] = [...newSettings[targetKey], mimeType];
}

// 2. Remove from the other list
const otherKey = `${otherDisposition}MimeTypes` as keyof ContentDispositionSettings;
newSettings[otherKey] = removeMimeTypeFromArray(newSettings[otherKey], mimeType);

return newSettings;
});
setCurrentInput('');
}, [currentInput]);

const handleSetInline = useCallback(() => handleSetMimeType('inline'), [handleSetMimeType]);
const handleSetAttachment = useCallback(() => handleSetMimeType('attachment'), [handleSetMimeType]);

// Handler for removing from pending state
const handleRemove = useCallback((mimeType: string, disposition: 'inline' | 'attachment') => {
setError(null);
setPendingSettings((prev) => {
if (!prev) return null;
const key = `${disposition}MimeTypes` as keyof ContentDispositionSettings;
return {
...prev,
[key]: removeMimeTypeFromArray(prev[key], mimeType),
};
});
}, []);

// Handler for resetting to the last saved settings
const handleReset = useCallback(() => {
setError(null);
if (currentSettings) {
// Revert pending changes to the last fetched/saved state
setPendingSettings({
inlineMimeTypes: [...currentSettings.inlineMimeTypes],
attachmentMimeTypes: [...currentSettings.attachmentMimeTypes],
});
}
}, [currentSettings]);


// 4. Handler for updating (saving to server)
const handleUpdate = useCallback(async(): Promise<void> => {
if (!pendingSettings || !hasPendingChanges || isUpdating) return;

setError(null);
try {
await updateSettings(pendingSettings);
}
catch (err) {
const errorMessage = (err instanceof Error) ? err.message : 'An unknown error occurred during update.';
setError(`Failed to update settings: ${errorMessage}`);
console.error('Failed to update settings:', err);
}
}, [pendingSettings, hasPendingChanges, isUpdating, updateSettings]);

if (isLoading && !currentSettings) {
return <div>Loading content disposition settings...</div>;
}

const renderInlineMimeTypes = displaySettings.inlineMimeTypes;
const renderAttachmentMimeTypes = displaySettings.attachmentMimeTypes;

// 5. Render logic
return (
<div>
<h2>Content-Disposition Mime Type Settings ⚙️</h2>

{/* Input and Add Buttons */}
<div>
<input
type="text"
value={currentInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCurrentInput(e.target.value)}
placeholder="e.g., image/png"
/>
<button
type="button"
onClick={handleSetInline}
disabled={!currentInput.trim() || isUpdating}
>
Add Inline
</button>
<button
type="button"
onClick={handleSetAttachment}
disabled={!currentInput.trim() || isUpdating}
>
Add Attachment
</button>
</div>

<p style={{ fontSize: '12px', color: '#666' }}>
Note: Adding a mime type will **automatically remove it** from the other list if it exists there.
</p>

{/* Error Display */}
{error && (
<div>
**Error:** {error}
</div>
)}

{/* Update and Reset Buttons */}
<div style={{ marginBottom: '20px', display: 'flex', gap: '10px' }}>
<button
type="button"
onClick={handleUpdate}
disabled={!hasPendingChanges || isUpdating}
>
{isUpdating ? 'Updating...' : 'Update Settings'}
</button>
<button
type="button"
onClick={handleReset}
disabled={!hasPendingChanges || isUpdating}
>
Reset Changes
</button>
</div>


<hr />

<div style={{ display: 'flex', justifyContent: 'space-between' }}>

{/* INLINE List */}
<div>
<h3>Inline Mime Types (Viewable)</h3>
<ul>
{renderInlineMimeTypes.length === 0 && <li>No inline mime types set.</li>}
{renderInlineMimeTypes.map((mimeType: string) => (
<li
key={mimeType}
>
{mimeType}
<button
type="button"
onClick={() => handleRemove(mimeType, 'inline')}
disabled={isUpdating}
>
Remove
</button>
</li>
))}
</ul>
</div>

{/* ATTACHMENT List */}
<div>
<h3>Attachment Mime Types (Forces Download)</h3>
<ul>
{renderAttachmentMimeTypes.length === 0 && <li>No attachment mime types set.</li>}
{renderAttachmentMimeTypes.map((mimeType: string) => (
<li
key={mimeType}
>
{mimeType}
<button
type="button"
onClick={() => handleRemove(mimeType, 'attachment')}
disabled={isUpdating}
>
Remove
</button>
</li>
))}
</ul>
</div>
</div>
</div>
);
};

export default ContentDispositionSettings;
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import React, { useEffect, type JSX } from 'react';
import { useTranslation } from 'next-i18next';
import { Card, CardBody } from 'reactstrap';


import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
import { toastError } from '~/client/util/toastr';
import { toArrayIfNot } from '~/utils/array-utils';
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';
Expand Down Expand Up @@ -61,6 +63,8 @@ const MarkDownSettingContents = React.memo((props: Props): JSX.Element => {
<CardBody className="px-0 py-2">{ t('markdown_settings.xss_desc') }</CardBody>
</Card>
<XssForm />

<ContentDispositionSettings />
</div>
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';

import { apiv3Get, apiv3Put } from '../util/apiv3-client';

interface ContentDispositionSettings {
export interface ContentDispositionSettings {
inlineMimeTypes: string[];
attachmentMimeTypes: string[];
}
Expand Down Expand Up @@ -48,56 +48,53 @@ export const useSWRMUTxContentDispositionSettings = (): SWRMutationResponse<
);
};

// --- REFACTORED HOOK ---
export const useContentDisposition = (): {
setInline: (mimeType: string) => Promise<void>;
setAttachment: (mimeType: string) => Promise<void>;
currentSettings: ContentDispositionSettings | undefined;
isLoading: boolean;
isUpdating: boolean;
updateSettings: (newSettings: ContentDispositionSettings) => Promise<ContentDispositionSettings>;
} => {
const { data, mutate } = useSWRxContentDispositionSettings();
const { trigger } = useSWRMUTxContentDispositionSettings();
const {
data, isLoading, mutate, error,
} = useSWRxContentDispositionSettings();
const { trigger, isMutating } = 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 currentSettings = memoizedData;

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]);
// New unified update function
const updateSettings = useCallback(async(newSettings: ContentDispositionSettings): Promise<ContentDispositionSettings> => {

const setAttachment = useCallback(async(mimeType: string): Promise<void> => {
if (!memoizedData) return;
// Create the request object matching the backend API
const request: ContentDispositionUpdateRequest = {
newInlineMimeTypes: newSettings.inlineMimeTypes,
newAttachmentMimeTypes: newSettings.attachmentMimeTypes,
};

const newInlineMimeTypes = memoizedData.inlineMimeTypes.filter(m => m !== mimeType);
const newAttachmentMimeTypes = [...memoizedData.attachmentMimeTypes];
// 1. Trigger the mutation
const updatedData = await trigger(request);

if (!newAttachmentMimeTypes.includes(mimeType)) {
newAttachmentMimeTypes.push(mimeType);
}
// 2. Optimistically update SWR cache with the response from the server,
// or simply re-validate by calling mutate(). Since 'trigger' returns the
// new data, we can use that to update the local cache immediately.
// We don't need to await the full re-fetch from the network.
mutate(updatedData, { revalidate: true });

await trigger({
newInlineMimeTypes,
newAttachmentMimeTypes,
});
return updatedData;
}, [trigger, mutate]);

mutate();
}, [memoizedData, trigger, mutate]);

return {
setInline,
setAttachment,
currentSettings,
isLoading,
isUpdating: isMutating,
updateSettings,
// Note: If you need a function to force a fresh data fetch (for a hard "Reset"),
// you can expose `mutate` from useSWRxContentDispositionSettings() as `fetchSettings`
};
};