mirror of
https://github.com/strapi/strapi.git
synced 2025-10-10 15:43:41 +00:00
feat: ai generated image metadata (#24422)
* feat: chat feat: apply changes feat: integrate with ctb feat: marker chore: remove comment feat: new chat feat: copy message feat: upload modal feat: upload file feat: errors and stop chat chore: refactor transforms chore: format relations chore: chat title chore: remove architect dependency feat: empt state chore: improve text area focus * feat: chat imports feat: resizable text area fix: re add chat chore: translations feat: env vars fix: minor chat issues feat: feedback fix: rebase feat: import folder feat: limits feat: attachments dropzone chore: file attachments cleanup chore: track chat id feat: figma import fix :token feat: figma token fix: attribute status when chat makes updates feat: image upload * feat: staging integration * chore: remove logs * feat: use tool call result instead of annotation * fix: invalid component uid * chore: chat input focus ring * fix: ui issues * fix: default draft and publish and do not modify singular name * fix: minor transforms * fix: linting * test(front): update snapshots * chore: fix misplaced getstarted project schema * chore: remove unused import * security: validate exact path of host * fix: define process better for playwright to work * fix: process env in vite config for playwright * chore: use production url * feat(ctb): Tracking events for AI Chat interaction (#23731) * feat(content-type-builder): WIP tracking events for chat interactions * fix: typescript build errors * fix: event name and build errors * chore: send ai key with analytics * chore: actually send licenseKey * chore: send ailicensekey with groupproperties * fix: didStartNewChat * chore: track new chats * feat: enhance attachment type management in AI chat components * fix: update chat status handling in ChatProvider component * feat: add optional aiLicenseKey to Strapi interface --------- Co-authored-by: Jamie Howard <jhoward1994@gmail.com> * refactor: remove didusersendmessage tracking event (#23777) * fix: merge conflict * fix: send projectId to AI server * feat: add a enabled config for AI features (#24060) * feat: add getAiToken endpoint (#24172) * feat: add getAiToken route * fix: change route name, remove project id * fix: type issue and fix schema * feat: retrieve ai token from frontend (#24226) --------- Co-authored-by: Jamie Howard <jhoward1994@gmail.com> * fix: use primary500 for links in ai chat * chore: migrate to AI SDK v5 (#24252) * fix: migrate code for v5 * t:wq * feat: push schemas to ctb * chore: remove old code * chore: remove ts-no-check * chore: fix comment * fix: ai server logs (#24318) * test(back): fix error log tests --------- Co-authored-by: Marc Roig <marc12info@gmail.com> * fix: configure ai ctb csp middleware without overriding user or default config * future(upload): generate image metadata on file upload (#24365) * chore: create aiMetadata service with isEnabled * chore: extract getAiToken to service * fix: unit test * future(upload): generate metadata with ai * fix: ts build * fix: only send images to ai server * test: add unit tests * fix: unit test --------- Co-authored-by: markkaylor <mark.kaylor@strapi.io> * AI media lib bulk update (#24414) * feat(packages): adding endpoint for bulk update * feat(packages): linting * feat(packages): adding tests * feat(packages): cleanup * feat: guided tour for ai ctb (#24411) * feat(upload): adding aiMetadata into settings (#24468) feat(upload): adding aiMetadata into settings * feat: add AI upload modal (#24407) * chore: create aiMetadata service with isEnabled * chore: extract getAiToken to service * fix: unit test * future(upload): generate metadata with ai * feat: add AI upload modal * feat: add edit and delete to upload modal * fix: remove sparkle icon on edit * fix: add error handling * chore: refactor ai upload modal reducer * fix: catch ai token generation error * chore: add useBulkEdit hook * feat: connect to bulk edit endpoint * fix: e2e test * fix: ci in both ce and ee --------- Co-authored-by: Rémi de Juvigny <remi.dejuvigny@strapi.io> Co-authored-by: Rémi de Juvigny <8087692+remidej@users.noreply.github.com> * feat(upload): applying ai enabled logic for media library (#24486) feat(upload): applying ai enabled logic for media library * fix: sparkle icon * fix: set ai server prod url * fix: default config to enabled * fix: cursor moving to the end when editing text * fix: upload in the right folder * fix: close modal when deleting last item * chore: use STRAPI_AI_URL everywhere * fix: bulk upload from frontend * fix: restore sparkle icon on inputs * fix: unit test ci * feat(upload): generating metadata from thumbnail (#24515) * feat(upload): generating metadata from thumbnail * feat(upload): fixing linting issue * feat(upload): fixing tests and addressiing feedback * feat(upload): fixing lint * fix: check for cms-ai entitlement * fix: tests * fix: race condition failing front unit tests --------- Co-authored-by: Marc-Roig <marc12info@gmail.com> Co-authored-by: Ben Irvin <ben@innerdvations.com> Co-authored-by: Bassel Kanso <bassel.kanso@strapi.io> Co-authored-by: Ben Irvin <ben.irvin@strapi.io> Co-authored-by: Jamie Howard <jhoward1994@gmail.com> Co-authored-by: Jamie Howard <48524071+jhoward1994@users.noreply.github.com> Co-authored-by: Bassel Kanso <basselkanso82@gmail.com> Co-authored-by: Ziyi <daydreamnation@live.com> Co-authored-by: markkaylor <mark.kaylor@strapi.io> Co-authored-by: Araksya Gevorgyan <31159659+araksyagevorgyan@users.noreply.github.com> Co-authored-by: Adrien L <thewebsdoor@gmail.com>
This commit is contained in:
parent
3ae249212a
commit
5e751dbf11
@ -60,6 +60,7 @@ export { useFocusInputField } from './hooks/useFocusInputField';
|
||||
export { useRBAC, type AllowedActions } from './hooks/useRBAC';
|
||||
export { useClipboard } from './hooks/useClipboard';
|
||||
export { useElementOnScreen } from './hooks/useElementOnScreen';
|
||||
export { useDebounce } from './hooks/useDebounce';
|
||||
export { useMediaQuery, useIsDesktop, useIsTablet, useIsMobile } from './hooks/useMediaQuery';
|
||||
export { useDeviceType } from './hooks/useDeviceType';
|
||||
export { useAdminUsers } from './services/users';
|
||||
|
@ -14,7 +14,7 @@ import { fetchBaseQuery } from '../utils/baseQuery';
|
||||
const adminApi = createApi({
|
||||
reducerPath: 'adminApi',
|
||||
baseQuery: fetchBaseQuery(),
|
||||
tagTypes: ['GuidedTourMeta', 'HomepageKeyStatistics'],
|
||||
tagTypes: ['GuidedTourMeta', 'HomepageKeyStatistics', 'AIUsage'],
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
|
||||
|
@ -10,7 +10,12 @@ import {
|
||||
} from '../../../shared/contracts/authentication';
|
||||
import { Check } from '../../../shared/contracts/permissions';
|
||||
import { GetProviders, IsSSOLocked } from '../../../shared/contracts/providers';
|
||||
import { type GetOwnPermissions, type GetMe, type UpdateMe } from '../../../shared/contracts/users';
|
||||
import {
|
||||
type GetOwnPermissions,
|
||||
type GetMe,
|
||||
type UpdateMe,
|
||||
type GetAiToken,
|
||||
} from '../../../shared/contracts/users';
|
||||
|
||||
import { adminApi } from './api';
|
||||
|
||||
@ -53,6 +58,15 @@ const authService = adminApi
|
||||
},
|
||||
invalidatesTags: ['Me'],
|
||||
}),
|
||||
getAiToken: builder.query<GetAiToken.Response['data'], void>({
|
||||
query: () => ({
|
||||
method: 'GET',
|
||||
url: '/admin/users/me/ai-token',
|
||||
}),
|
||||
transformResponse(res: GetAiToken.Response) {
|
||||
return res.data;
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Permissions
|
||||
*/
|
||||
@ -197,7 +211,7 @@ const authService = adminApi
|
||||
invalidatesTags: ['ProvidersOptions'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
overrideExisting: true,
|
||||
});
|
||||
|
||||
const {
|
||||
@ -214,6 +228,8 @@ const {
|
||||
useGetRegistrationInfoQuery,
|
||||
useForgotPasswordMutation,
|
||||
useGetMyPermissionsQuery,
|
||||
useGetAiTokenQuery,
|
||||
useLazyGetAiTokenQuery,
|
||||
useIsSSOLockedQuery,
|
||||
useGetProvidersQuery,
|
||||
useGetProviderOptionsQuery,
|
||||
@ -234,6 +250,8 @@ export {
|
||||
useGetRegistrationInfoQuery,
|
||||
useForgotPasswordMutation,
|
||||
useGetMyPermissionsQuery,
|
||||
useGetAiTokenQuery,
|
||||
useLazyGetAiTokenQuery,
|
||||
useIsSSOLockedQuery,
|
||||
useGetProvidersQuery,
|
||||
useGetProviderOptionsQuery,
|
||||
|
@ -8,6 +8,7 @@ const aiService = adminApi.injectEndpoints({
|
||||
method: 'GET',
|
||||
url: `/admin/ai-usage`,
|
||||
}),
|
||||
providesTags: ['AIUsage'],
|
||||
}),
|
||||
getAiToken: builder.query<GetAiToken.Response['data'], void>({
|
||||
query: () => ({
|
||||
@ -19,7 +20,7 @@ const aiService = adminApi.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
overrideExisting: true,
|
||||
});
|
||||
|
||||
const { useGetAIUsageQuery, useGetAiTokenQuery, useLazyGetAiTokenQuery } = aiService;
|
||||
|
@ -0,0 +1,101 @@
|
||||
// @ts-expect-error - types are not generated for this file
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import createContext from '../../../../../../../tests/helpers/create-context';
|
||||
import authenticatedUserController from '../authenticated-user';
|
||||
|
||||
// Mock the getService function
|
||||
const mockUserService = {
|
||||
getAiToken: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('../../utils', () => ({
|
||||
getService: jest.fn((serviceName: string) => {
|
||||
if (serviceName === 'user') {
|
||||
return mockUserService;
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Authenticated User Controller', () => {
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUserService.getAiToken.mockReset();
|
||||
process.env = { ...ORIGINAL_ENV }; // fresh copy for each test
|
||||
|
||||
// Reset global fetch
|
||||
delete (global as any).fetch;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = ORIGINAL_ENV; // fully restore after suite
|
||||
});
|
||||
|
||||
describe('getAiToken', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
firstname: 'Test',
|
||||
lastname: 'User',
|
||||
};
|
||||
|
||||
const createMockContext = (user = mockUser as any, overrides = {}) => {
|
||||
return createContext(
|
||||
{},
|
||||
{
|
||||
state: { user },
|
||||
unauthorized: jest.fn(),
|
||||
badRequest: jest.fn(),
|
||||
forbidden: jest.fn(),
|
||||
notFound: jest.fn(),
|
||||
internalServerError: jest.fn(),
|
||||
requestTimeout: jest.fn(),
|
||||
...overrides,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
test('Should return unauthorized when user is not authenticated', async () => {
|
||||
const ctx = createMockContext(null);
|
||||
|
||||
await authenticatedUserController.getAiToken(ctx as any);
|
||||
|
||||
expect(ctx.unauthorized).toHaveBeenCalledWith('Authentication required');
|
||||
});
|
||||
|
||||
test('Should return internal server error when service throws error', async () => {
|
||||
const ctx = createMockContext();
|
||||
|
||||
// Mock service to throw an error
|
||||
mockUserService.getAiToken.mockRejectedValueOnce(new Error('Service error'));
|
||||
|
||||
await authenticatedUserController.getAiToken(ctx as any);
|
||||
|
||||
expect(ctx.internalServerError).toHaveBeenCalledWith(
|
||||
'AI token request failed. Check server logs for details.'
|
||||
);
|
||||
|
||||
expect(mockUserService.getAiToken).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
test('Should successfully return AI token when service succeeds', async () => {
|
||||
const ctx = createMockContext();
|
||||
const tokenData = {
|
||||
token: 'test-jwt-token',
|
||||
expiresAt: '2025-01-01T12:00:00Z',
|
||||
};
|
||||
|
||||
// Mock service to return successful result
|
||||
mockUserService.getAiToken.mockResolvedValueOnce(tokenData);
|
||||
|
||||
await authenticatedUserController.getAiToken(ctx as any);
|
||||
|
||||
expect(mockUserService.getAiToken).toHaveBeenCalledWith();
|
||||
expect(ctx.body).toEqual({
|
||||
data: tokenData,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -3,7 +3,7 @@ import type { AdminUser } from '../../../shared/contracts/shared';
|
||||
|
||||
import { getService } from '../utils';
|
||||
import { validateProfileUpdateInput } from '../validation/user';
|
||||
import { GetMe, GetOwnPermissions, UpdateMe } from '../../../shared/contracts/users';
|
||||
import { GetMe, GetOwnPermissions, UpdateMe, GetAiToken } from '../../../shared/contracts/users';
|
||||
|
||||
export default {
|
||||
async getMe(ctx: Context) {
|
||||
@ -52,4 +52,22 @@ export default {
|
||||
data: userPermissions.map(sanitizePermission),
|
||||
} satisfies GetOwnPermissions.Response;
|
||||
},
|
||||
|
||||
async getAiToken(ctx: Context) {
|
||||
try {
|
||||
// Security check: Ensure user is authenticated and has proper permissions
|
||||
if (!ctx.state.user) {
|
||||
return ctx.unauthorized('Authentication required');
|
||||
}
|
||||
|
||||
const tokenData = await getService('user').getAiToken();
|
||||
|
||||
ctx.body = {
|
||||
data: tokenData,
|
||||
} satisfies GetAiToken.Response;
|
||||
} catch (error) {
|
||||
const errorMessage = 'AI token request failed. Check server logs for details.';
|
||||
return ctx.internalServerError(errorMessage);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -23,6 +23,14 @@ export default [
|
||||
policies: ['admin::isAuthenticatedAdmin'],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/users/me/ai-token',
|
||||
handler: 'authenticated-user.getAiToken',
|
||||
config: {
|
||||
policies: ['admin::isAuthenticatedAdmin'],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/users',
|
||||
|
@ -3,6 +3,9 @@ import _ from 'lodash';
|
||||
import { defaults } from 'lodash/fp';
|
||||
import { arrays, errors } from '@strapi/utils';
|
||||
import type { Data } from '@strapi/types';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createUser, hasSuperAdminRole } from '../domain/user';
|
||||
import type {
|
||||
AdminUser,
|
||||
@ -414,6 +417,140 @@ const getLanguagesInUse = async (): Promise<string[]> => {
|
||||
return users.map((user) => user.preferedLanguage || 'en');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate an AI token for the user performing the request
|
||||
*/
|
||||
const getAiToken = async (): Promise<{ token: string; expiresAt?: string }> => {
|
||||
const ERROR_PREFIX = 'AI token request failed:';
|
||||
|
||||
// Check if EE features are enabled first
|
||||
if (!strapi.ee?.isEE) {
|
||||
strapi.log.error(`${ERROR_PREFIX} Enterprise Edition features are not enabled`);
|
||||
throw new Error('AI token request failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
// Get the EE license
|
||||
// First try environment variable, then try reading from file
|
||||
let eeLicense = process.env.STRAPI_LICENSE;
|
||||
|
||||
if (!eeLicense) {
|
||||
try {
|
||||
const licensePath = path.join(strapi.dirs.app.root, 'license.txt');
|
||||
eeLicense = fs.readFileSync(licensePath).toString();
|
||||
} catch (error) {
|
||||
// License file doesn't exist or can't be read
|
||||
}
|
||||
}
|
||||
|
||||
if (!eeLicense) {
|
||||
strapi.log.error(
|
||||
`${ERROR_PREFIX} No EE license found. Please ensure STRAPI_LICENSE environment variable is set or license.txt file exists.`
|
||||
);
|
||||
throw new Error('AI token request failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
const aiServerUrl = process.env.STRAPI_AI_URL || 'https://strapi-ai.apps.strapi.io';
|
||||
|
||||
if (!aiServerUrl) {
|
||||
strapi.log.error(
|
||||
`${ERROR_PREFIX} AI server URL not configured. Please set STRAPI_AI_URL environment variable.`
|
||||
);
|
||||
throw new Error('AI token request failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
// Create a secure user identifier using only user ID
|
||||
const user = strapi.requestContext.get()?.state?.user as AdminUser | undefined;
|
||||
if (!user) {
|
||||
strapi.log.error(`${ERROR_PREFIX} No authenticated user in request context`);
|
||||
throw new Error('AI token request failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
const userIdentifier = user.id.toString();
|
||||
|
||||
// Get project ID
|
||||
const projectId = strapi.config.get('uuid');
|
||||
if (!projectId) {
|
||||
strapi.log.error(`${ERROR_PREFIX} Project ID not configured`);
|
||||
throw new Error('AI token request failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
strapi.log.http('Contacting AI Server for token generation');
|
||||
|
||||
try {
|
||||
// Call the AI server's getAiJWT endpoint
|
||||
const response = await fetch(`${aiServerUrl}/auth/getAiJWT`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// No authorization header needed for public endpoint
|
||||
// Add request ID for tracing
|
||||
'X-Request-Id': crypto.randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
eeLicense,
|
||||
userIdentifier,
|
||||
projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
let errorText;
|
||||
try {
|
||||
errorText = await response.text();
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = { error: errorText || 'Failed to parse error response' };
|
||||
}
|
||||
|
||||
strapi.log.error(`${ERROR_PREFIX} ${errorData?.error || 'Unknown error'}`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
errorText,
|
||||
projectId,
|
||||
});
|
||||
|
||||
throw new Error('AI token request failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = (await response.json()) as {
|
||||
jwt: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
} catch (parseError) {
|
||||
strapi.log.error(`${ERROR_PREFIX} Failed to parse AI server response`, parseError);
|
||||
throw new Error('AI token request failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
if (!data.jwt) {
|
||||
strapi.log.error(`${ERROR_PREFIX} Invalid response: missing JWT token`);
|
||||
throw new Error('AI token request failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
strapi.log.info('AI token generated successfully', {
|
||||
userId: user.id,
|
||||
expiresAt: data.expiresAt,
|
||||
});
|
||||
|
||||
// Return the AI JWT with metadata
|
||||
// Note: Token expires in 1 hour, client should handle refresh
|
||||
return {
|
||||
token: data.jwt,
|
||||
expiresAt: data.expiresAt, // 1 hour from generation
|
||||
};
|
||||
} catch (fetchError) {
|
||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
||||
strapi.log.error(`${ERROR_PREFIX} Request to AI server timed out`);
|
||||
throw new Error('AI token request failed. Check server logs for details.');
|
||||
}
|
||||
|
||||
throw fetchError;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
create,
|
||||
updateById,
|
||||
@ -433,4 +570,5 @@ export default {
|
||||
resetPasswordByEmail,
|
||||
getLanguagesInUse,
|
||||
isFirstSuperAdminUser,
|
||||
getAiToken,
|
||||
};
|
||||
|
@ -76,3 +76,21 @@ export declare namespace GetOwnPermissions {
|
||||
error?: errors.ApplicationError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /users/me/ai-token - Get AI token for the current admin user
|
||||
*/
|
||||
export declare namespace GetAiToken {
|
||||
export interface Request {
|
||||
query: {};
|
||||
body: {};
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
data: {
|
||||
token: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
error?: errors.ApplicationError;
|
||||
}
|
||||
}
|
||||
|
491
packages/core/upload/admin/src/ai/components/AIAssetCard.tsx
Normal file
491
packages/core/upload/admin/src/ai/components/AIAssetCard.tsx
Normal file
@ -0,0 +1,491 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { ConfirmDialog } from '@strapi/admin/strapi-admin';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardAction,
|
||||
CardAsset,
|
||||
CardBadge,
|
||||
CardBody,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardSubtitle,
|
||||
CardTitle,
|
||||
CardTimer,
|
||||
Field,
|
||||
Flex,
|
||||
Grid,
|
||||
TextInput,
|
||||
Typography,
|
||||
IconButton,
|
||||
Dialog,
|
||||
Modal,
|
||||
} from '@strapi/design-system';
|
||||
import { Pencil, Sparkle, Trash } from '@strapi/icons';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import { AudioPreview } from '../../components/AssetCard/AudioPreview';
|
||||
import { VideoPreview } from '../../components/AssetCard/VideoPreview';
|
||||
import { type Asset, EditAssetContent } from '../../components/EditAssetDialog/EditAssetContent';
|
||||
import { AssetType } from '../../constants';
|
||||
import { useMediaLibraryPermissions } from '../../hooks/useMediaLibraryPermissions';
|
||||
import { useRemoveAsset } from '../../hooks/useRemoveAsset';
|
||||
import {
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
getFileExtension,
|
||||
getTrad,
|
||||
prefixFileUrlWithBackendUrl,
|
||||
} from '../../utils';
|
||||
import { typeFromMime } from '../../utils/typeFromMime';
|
||||
|
||||
import { useAIUploadModalContext } from './AIUploadModal';
|
||||
|
||||
import type { File } from '../../../../shared/contracts/files';
|
||||
|
||||
const CardActionsContainer = styled(CardAction)`
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const CardContainer = styled(Box)`
|
||||
background: ${({ theme }) => theme.colors.neutral0};
|
||||
border: 1px solid ${({ theme }) => theme.colors.neutral150};
|
||||
border-radius: ${({ theme }) => theme.borderRadius};
|
||||
|
||||
&:hover {
|
||||
${CardActionsContainer} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* AssetCardActions
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const AssetCardActions = ({ asset }: { asset: File }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const dispatch = useAIUploadModalContext('AssetCardActions', (s) => s.dispatch);
|
||||
const state = useAIUploadModalContext('AssetCardActions', (s) => s.state);
|
||||
const onClose = useAIUploadModalContext('AssetCardActions', (s) => s.onClose);
|
||||
const { canUpdate, canCopyLink, canDownload } = useMediaLibraryPermissions();
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
|
||||
|
||||
const { removeAsset } = useRemoveAsset(() => {});
|
||||
|
||||
const handleConfirmRemove = async (event?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
event?.preventDefault();
|
||||
await removeAsset(asset.id);
|
||||
dispatch({
|
||||
type: 'remove_uploaded_asset',
|
||||
payload: { id: asset.id },
|
||||
});
|
||||
|
||||
// Close modal if this was the last asset
|
||||
if (state.uploadedAssets.length === 1) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePropagationClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleEditAsset = (editedAsset?: File | null) => {
|
||||
if (editedAsset) {
|
||||
dispatch({
|
||||
type: 'edit_uploaded_asset',
|
||||
payload: { editedAsset },
|
||||
});
|
||||
|
||||
setIsEditModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardActionsContainer onClick={handlePropagationClick} position="end">
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger>
|
||||
<IconButton
|
||||
label={formatMessage({
|
||||
id: getTrad('control-card.remove-selection'),
|
||||
defaultMessage: 'Remove from selection',
|
||||
})}
|
||||
>
|
||||
<Trash />
|
||||
</IconButton>
|
||||
</Dialog.Trigger>
|
||||
<ConfirmDialog onConfirm={handleConfirmRemove} />
|
||||
</Dialog.Root>
|
||||
|
||||
<Modal.Root open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<Modal.Trigger>
|
||||
<IconButton
|
||||
label={formatMessage({ id: getTrad('control-card.edit'), defaultMessage: 'Edit' })}
|
||||
>
|
||||
<Pencil />
|
||||
</IconButton>
|
||||
</Modal.Trigger>
|
||||
<Modal.Content>
|
||||
<EditAssetContent
|
||||
// Is Local must be set to false to trigger the correct branch of logic in the EditAssetContent on submit
|
||||
asset={
|
||||
{
|
||||
...asset,
|
||||
isLocal: false,
|
||||
folder: typeof asset.folder === 'number' ? { id: asset.folder } : asset.folder,
|
||||
} as Asset
|
||||
}
|
||||
onClose={(arg) => handleEditAsset(arg as File)}
|
||||
canUpdate={canUpdate}
|
||||
canCopyLink={canCopyLink}
|
||||
canDownload={canDownload}
|
||||
omitFields={['caption', 'alternativeText']}
|
||||
omitActions={['replace']}
|
||||
/>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</CardActionsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* Asset
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
interface AssetProps {
|
||||
assetType: AssetType;
|
||||
thumbnailUrl: string;
|
||||
assetUrl: string;
|
||||
asset: File;
|
||||
}
|
||||
|
||||
interface AssetCardProps {
|
||||
asset: File;
|
||||
onCaptionChange: (caption: string) => void;
|
||||
onAltTextChange: (altText: string) => void;
|
||||
wasCaptionChanged: boolean;
|
||||
wasAltTextChanged: boolean;
|
||||
}
|
||||
|
||||
const Extension = styled.span`
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const VideoPreviewWrapper = styled(Box)`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
canvas,
|
||||
video {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: ${({ theme }) => theme.borderRadius};
|
||||
}
|
||||
`;
|
||||
|
||||
const VideoTimerOverlay = styled(CardTimer)`
|
||||
position: absolute;
|
||||
bottom: 0.5rem;
|
||||
right: 0.5rem;
|
||||
`;
|
||||
|
||||
const AudioPreviewWrapper = styled(Box)`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
canvas,
|
||||
audio {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const Asset = ({ assetType, thumbnailUrl, assetUrl, asset }: AssetProps) => {
|
||||
const [duration, setDuration] = React.useState<number>();
|
||||
const formattedDuration = duration ? formatDuration(duration) : undefined;
|
||||
|
||||
switch (assetType) {
|
||||
case AssetType.Image:
|
||||
return <CardAsset src={thumbnailUrl} size="S" alt={asset.alternativeText || asset.name} />;
|
||||
case AssetType.Video:
|
||||
return (
|
||||
<CardAsset size="S">
|
||||
<VideoPreviewWrapper>
|
||||
<VideoPreview
|
||||
url={assetUrl}
|
||||
mime={asset.mime || 'video/mp4'}
|
||||
onLoadDuration={setDuration}
|
||||
alt={asset.alternativeText || asset.name}
|
||||
/>
|
||||
{formattedDuration && <VideoTimerOverlay>{formattedDuration}</VideoTimerOverlay>}
|
||||
</VideoPreviewWrapper>
|
||||
</CardAsset>
|
||||
);
|
||||
case AssetType.Audio:
|
||||
return (
|
||||
<CardAsset size="S">
|
||||
<AudioPreviewWrapper>
|
||||
<AudioPreview url={assetUrl} alt={asset.alternativeText || asset.name} />
|
||||
</AudioPreviewWrapper>
|
||||
</CardAsset>
|
||||
);
|
||||
default:
|
||||
return <CardAsset src={thumbnailUrl} size="S" alt={asset.alternativeText || asset.name} />;
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* AssetCard
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const StyledCardBody = styled(CardBody)`
|
||||
display: flex;
|
||||
padding: ${({ theme }) => theme.spaces[2]} ${({ theme }) => theme.spaces[1]};
|
||||
`;
|
||||
|
||||
const StyledCard = styled(Card)`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
const getAssetBadgeLabel = (assetType: AssetType) => {
|
||||
switch (assetType) {
|
||||
case AssetType.Image:
|
||||
return { id: getTrad('settings.section.image.label'), defaultMessage: 'IMAGE' };
|
||||
case AssetType.Video:
|
||||
return { id: getTrad('settings.section.video.label'), defaultMessage: 'VIDEO' };
|
||||
case AssetType.Audio:
|
||||
return { id: getTrad('settings.section.audio.label'), defaultMessage: 'AUDIO' };
|
||||
default:
|
||||
return { id: getTrad('settings.section.doc.label'), defaultMessage: 'DOC' };
|
||||
}
|
||||
};
|
||||
|
||||
export const AIAssetCard = ({
|
||||
asset,
|
||||
onCaptionChange,
|
||||
onAltTextChange,
|
||||
wasAltTextChanged,
|
||||
wasCaptionChanged,
|
||||
}: AssetCardProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const assetType = typeFromMime(asset.mime || '');
|
||||
const thumbnailUrl =
|
||||
prefixFileUrlWithBackendUrl(asset?.formats?.thumbnail?.url || asset.url) || '';
|
||||
const assetUrl = prefixFileUrlWithBackendUrl(asset.url) || '';
|
||||
const subtitle = asset.height && asset.width ? ` - ${asset.width}x${asset.height}` : '';
|
||||
const formattedSize = asset.size ? formatBytes(asset.size) : '';
|
||||
const fullSubtitle = `${subtitle}${subtitle && formattedSize ? ' - ' : ''}${formattedSize}`;
|
||||
|
||||
const [caption, setCaption] = React.useState(asset.caption || '');
|
||||
React.useEffect(() => {
|
||||
onCaptionChange(caption);
|
||||
}, [caption, onCaptionChange]);
|
||||
|
||||
const [altText, setAltText] = React.useState(asset.alternativeText || '');
|
||||
React.useEffect(() => {
|
||||
onAltTextChange(altText);
|
||||
}, [altText, onAltTextChange]);
|
||||
|
||||
return (
|
||||
<CardContainer>
|
||||
<Grid.Root>
|
||||
<Grid.Item col={5} alignItems="stretch">
|
||||
<StyledCard width="100%" height="100%" shadow="none" borderRadius={0} padding={0}>
|
||||
<CardHeader style={{ borderStyle: 'none' }}>
|
||||
<AssetCardActions asset={asset} />
|
||||
<Asset
|
||||
assetType={assetType}
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
assetUrl={assetUrl}
|
||||
asset={asset}
|
||||
/>
|
||||
</CardHeader>
|
||||
<StyledCardBody>
|
||||
<CardContent width="100%">
|
||||
<Flex justifyContent="space-between" alignItems="start">
|
||||
<Typography tag="h2">
|
||||
<CardTitle tag="span">{asset.name}</CardTitle>
|
||||
</Typography>
|
||||
<CardBadge>{formatMessage(getAssetBadgeLabel(assetType))}</CardBadge>
|
||||
</Flex>
|
||||
<Flex>
|
||||
<CardSubtitle>
|
||||
<Extension>{getFileExtension(asset.ext)}</Extension>
|
||||
{fullSubtitle}
|
||||
</CardSubtitle>
|
||||
</Flex>
|
||||
</CardContent>
|
||||
</StyledCardBody>
|
||||
</StyledCard>
|
||||
</Grid.Item>
|
||||
|
||||
<Grid.Item col={7} flex={1}>
|
||||
<Flex direction="column" height="100%" alignItems="stretch" flex={1} padding={4} gap={2}>
|
||||
<Field.Root name="caption">
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<Field.Label>
|
||||
{formatMessage({
|
||||
id: getTrad('form.input.label.file-caption'),
|
||||
defaultMessage: 'Caption',
|
||||
})}
|
||||
</Field.Label>
|
||||
</Flex>
|
||||
<TextInput
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
placeholder={formatMessage({
|
||||
id: getTrad('form.input.placeholder.file-caption'),
|
||||
defaultMessage: 'Enter caption',
|
||||
})}
|
||||
endAction={
|
||||
!wasCaptionChanged &&
|
||||
asset.caption && <Sparkle width="16px" height="16px" fill="#AC73E6" />
|
||||
}
|
||||
/>
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root
|
||||
name="alternativeText"
|
||||
hint={formatMessage({
|
||||
id: getTrad('form.input.description.file-alt'),
|
||||
defaultMessage: "This text will be displayed if the asset can't be shown.",
|
||||
})}
|
||||
>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<Field.Label>
|
||||
{formatMessage({
|
||||
id: getTrad('form.input.label.file-alt'),
|
||||
defaultMessage: 'Alternative text',
|
||||
})}
|
||||
</Field.Label>
|
||||
</Flex>
|
||||
|
||||
<TextInput
|
||||
value={altText}
|
||||
onChange={(e) => setAltText(e.target.value)}
|
||||
placeholder={formatMessage({
|
||||
id: getTrad('form.input.placeholder.file-alt'),
|
||||
defaultMessage: 'Enter alternative text',
|
||||
})}
|
||||
endAction={
|
||||
!wasAltTextChanged &&
|
||||
asset.alternativeText && <Sparkle width="16px" height="16px" fill="#AC73E6" />
|
||||
}
|
||||
/>
|
||||
<Field.Hint />
|
||||
</Field.Root>
|
||||
</Flex>
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
</CardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* AssetCardSkeletons
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const SkeletonBox = styled(Box)<{ width?: string; height?: string }>`
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${({ theme }) => theme.colors.neutral100} 25%,
|
||||
${({ theme }) => theme.colors.neutral150} 50%,
|
||||
${({ theme }) => theme.colors.neutral100} 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
border-radius: ${({ theme }) => theme.borderRadius};
|
||||
width: ${({ width }) => width || '100%'};
|
||||
height: ${({ height }) => height || '1rem'};
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AIAssetCardSkeletons = ({ count = 1 }: { count?: number }) => {
|
||||
const skeletons = Array.from({ length: count }, (_, i) => i);
|
||||
|
||||
return skeletons.map((index) => (
|
||||
<Box
|
||||
key={index}
|
||||
background="neutral0"
|
||||
borderColor="neutral150"
|
||||
borderStyle="solid"
|
||||
borderWidth="1px"
|
||||
borderRadius="4px"
|
||||
marginBottom={4}
|
||||
>
|
||||
<Grid.Root>
|
||||
<Grid.Item col={5} alignItems="stretch">
|
||||
<Card
|
||||
height="100%"
|
||||
width="100%"
|
||||
borderStyle="none"
|
||||
shadow="none"
|
||||
borderRadius={0}
|
||||
padding={2}
|
||||
>
|
||||
<Box height="150px" padding={2}>
|
||||
<SkeletonBox height="100%" />
|
||||
</Box>
|
||||
<CardBody style={{ display: 'flex', padding: '8px 4px' }}>
|
||||
<CardContent width="100%">
|
||||
<Flex justifyContent="space-between" alignItems="start" marginBottom={1}>
|
||||
<SkeletonBox width="60%" height="18px" />
|
||||
<SkeletonBox width="40px" height="16px" />
|
||||
</Flex>
|
||||
<SkeletonBox width="80%" height="14px" />
|
||||
</CardContent>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Grid.Item>
|
||||
|
||||
<Grid.Item col={7} flex={1}>
|
||||
<Flex direction="column" height="100%" alignItems="stretch" flex={1} padding={4} gap={2}>
|
||||
<Box>
|
||||
<SkeletonBox width="60px" height="16px" marginBottom={1} />
|
||||
<SkeletonBox height="32px" />
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<SkeletonBox width="100px" height="16px" marginBottom={1} />
|
||||
<SkeletonBox height="32px" />
|
||||
<Box marginTop={1}>
|
||||
<SkeletonBox width="70%" height="12px" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
</Box>
|
||||
));
|
||||
};
|
378
packages/core/upload/admin/src/ai/components/AIUploadModal.tsx
Normal file
378
packages/core/upload/admin/src/ai/components/AIUploadModal.tsx
Normal file
@ -0,0 +1,378 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { createContext } from '@strapi/admin/strapi-admin';
|
||||
import { Alert, Button, Flex, Modal } from '@strapi/design-system';
|
||||
import { produce } from 'immer';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import {
|
||||
AddAssetStep,
|
||||
FileWithRawFile,
|
||||
} from '../../components/UploadAssetDialog/AddAssetStep/AddAssetStep';
|
||||
import { useBulkEdit } from '../../hooks/useBulkEdit';
|
||||
import { useUpload } from '../../hooks/useUpload';
|
||||
import { getTrad } from '../../utils';
|
||||
|
||||
import { AIAssetCard, AIAssetCardSkeletons } from './AIAssetCard';
|
||||
|
||||
import type { File } from '../../../../shared/contracts/files';
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* ModalBody
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const StyledModalBody = styled(Modal.Body)`
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
[data-radix-scroll-area-viewport] {
|
||||
padding-top: ${({ theme }) => theme.spaces[6]};
|
||||
padding-bottom: ${({ theme }) => theme.spaces[6]};
|
||||
padding-left: ${({ theme }) => theme.spaces[7]};
|
||||
padding-right: ${({ theme }) => theme.spaces[7]};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledAlert = styled(Alert)`
|
||||
& > button {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const ModalContent = ({ onClose }: Pick<AIUploadModalProps, 'onClose'>) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const state = useAIUploadModalContext('ModalContent', (s) => s.state);
|
||||
const dispatch = useAIUploadModalContext('ModalContent', (s) => s.dispatch);
|
||||
const folderId = useAIUploadModalContext('ModalContent', (s) => s.folderId);
|
||||
const { upload } = useUpload();
|
||||
const { edit, isLoading: isSaving } = useBulkEdit();
|
||||
const [isUploading, setIsUploading] = React.useState(false);
|
||||
const [uploadError, setUploadError] = React.useState<Error | null>(null);
|
||||
|
||||
const handleCaptionChange = (assetId: number, caption: string) => {
|
||||
dispatch({
|
||||
type: 'set_uploaded_asset_caption',
|
||||
payload: { id: assetId, caption },
|
||||
});
|
||||
};
|
||||
|
||||
const handleAltTextChange = (assetId: number, altText: string) => {
|
||||
dispatch({
|
||||
type: 'set_uploaded_asset_alt_text',
|
||||
payload: { id: assetId, altText },
|
||||
});
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
dispatch({ type: 'set_uploaded_assets', payload: [] });
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
if (state.hasUnsavedChanges) {
|
||||
const assetsToUpdate = state.uploadedAssets.filter(
|
||||
(asset) => (asset.wasCaptionChanged || asset.wasAltTextChanged) && asset.file.id
|
||||
);
|
||||
|
||||
if (assetsToUpdate.length > 0) {
|
||||
const updates = assetsToUpdate.map((asset) => ({
|
||||
id: asset.file.id!,
|
||||
fileInfo: {
|
||||
name: asset.file.name,
|
||||
alternativeText: asset.file.alternativeText ?? null,
|
||||
caption: asset.file.caption ?? null,
|
||||
folder:
|
||||
typeof asset.file.folder === 'object' && asset.file.folder !== null
|
||||
? // @ts-expect-error types are wrong
|
||||
asset.file.folder.id
|
||||
: asset.file.folder,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
await edit(updates);
|
||||
dispatch({ type: 'clear_unsaved_changes' });
|
||||
} catch (err) {
|
||||
console.error('Failed to save asset changes:', err);
|
||||
return; // Don't close modal on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleUpload = async (assets: FileWithRawFile[]) => {
|
||||
dispatch({ type: 'set_assets_to_upload_length', payload: assets.length });
|
||||
setUploadError(null);
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const assetsForUpload = assets.map((asset) => ({
|
||||
...asset,
|
||||
id: asset.id ? Number(asset.id) : undefined,
|
||||
}));
|
||||
|
||||
const uploadedFiles = await upload(assetsForUpload, folderId);
|
||||
const filesWithFolder = uploadedFiles.map((file: File) => ({
|
||||
...file,
|
||||
// The upload API doesn't populate the folder relation, so we add it manually
|
||||
folder: folderId || file.folder,
|
||||
}));
|
||||
dispatch({ type: 'set_uploaded_assets', payload: filesWithFolder });
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
setUploadError(error instanceof Error ? error : new Error('Upload failed'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (state.assetsToUploadLength === 0) {
|
||||
return (
|
||||
<Modal.Content>
|
||||
<AddAssetStep onClose={onClose} onAddAsset={handleUpload} />
|
||||
</Modal.Content>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isUploading ||
|
||||
(state.assetsToUploadLength > 0 && state.uploadedAssets.length === 0 && !uploadError)
|
||||
) {
|
||||
return (
|
||||
<Modal.Content>
|
||||
<Modal.Header>
|
||||
<Modal.Title>
|
||||
{formatMessage({
|
||||
id: getTrad('ai.modal.uploading.title'),
|
||||
defaultMessage: 'Uploading and processing with AI...',
|
||||
})}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<StyledModalBody>
|
||||
<AIAssetCardSkeletons count={state.assetsToUploadLength} />
|
||||
</StyledModalBody>
|
||||
</Modal.Content>
|
||||
);
|
||||
}
|
||||
|
||||
const title = formatMessage(
|
||||
{
|
||||
id: getTrad('ai.modal.title'),
|
||||
defaultMessage:
|
||||
'{count, plural, one {# Asset uploaded} other {# Assets uploaded}} time to review AI generated content',
|
||||
},
|
||||
{ count: state.uploadedAssets.length }
|
||||
);
|
||||
|
||||
if (uploadError) {
|
||||
return (
|
||||
<Modal.Content>
|
||||
<Modal.Header>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<StyledAlert closeLabel="" variant="danger">
|
||||
{formatMessage({
|
||||
id: getTrad('ai.modal.error'),
|
||||
defaultMessage: 'Could not generate AI metadata for the uploaded files.',
|
||||
})}
|
||||
</StyledAlert>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button onClick={handleCancel} variant="tertiary">
|
||||
{formatMessage({ id: 'cancel', defaultMessage: 'Cancel' })}
|
||||
</Button>
|
||||
<Button onClick={handleFinish} loading={isSaving}>
|
||||
{formatMessage({ id: 'global.finish', defaultMessage: 'Finish' })}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal.Content>
|
||||
<Modal.Header>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<StyledModalBody>
|
||||
<Flex gap={6} direction="column" alignItems="stretch">
|
||||
{state.uploadedAssets.map(({ file: asset, wasCaptionChanged, wasAltTextChanged }) => (
|
||||
<AIAssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
onCaptionChange={(caption: string) =>
|
||||
asset.id && handleCaptionChange(asset.id, caption)
|
||||
}
|
||||
onAltTextChange={(altText: string) =>
|
||||
asset.id && handleAltTextChange(asset.id, altText)
|
||||
}
|
||||
wasCaptionChanged={wasCaptionChanged}
|
||||
wasAltTextChanged={wasAltTextChanged}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</StyledModalBody>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button onClick={handleCancel} variant="tertiary">
|
||||
{formatMessage({ id: 'cancel', defaultMessage: 'Cancel' })}
|
||||
</Button>
|
||||
<Button onClick={handleFinish} loading={isSaving}>
|
||||
{formatMessage({ id: 'global.finish', defaultMessage: 'Finish' })}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* UploadModal
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
interface AIUploadModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
folderId?: number | null;
|
||||
}
|
||||
|
||||
type State = {
|
||||
uploadedAssets: Array<{ file: File; wasCaptionChanged: boolean; wasAltTextChanged: boolean }>;
|
||||
assetsToUploadLength: number;
|
||||
hasUnsavedChanges: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: 'set_uploaded_assets';
|
||||
payload: File[];
|
||||
}
|
||||
| {
|
||||
type: 'set_assets_to_upload_length';
|
||||
payload: number;
|
||||
}
|
||||
| {
|
||||
type: 'set_uploaded_asset_caption';
|
||||
payload: { id: number; caption: string };
|
||||
}
|
||||
| {
|
||||
type: 'set_uploaded_asset_alt_text';
|
||||
payload: { id: number; altText: string };
|
||||
}
|
||||
| {
|
||||
type: 'remove_uploaded_asset';
|
||||
payload: { id: number };
|
||||
}
|
||||
| {
|
||||
type: 'edit_uploaded_asset';
|
||||
payload: { editedAsset: File };
|
||||
}
|
||||
| {
|
||||
type: 'clear_unsaved_changes';
|
||||
};
|
||||
|
||||
const [AIUploadModalContext, useAIUploadModalContext] = createContext<{
|
||||
state: State;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
folderId: number | null;
|
||||
onClose: () => void;
|
||||
}>('AIUploadModalContext');
|
||||
|
||||
const reducer = (state: State, action: Action): State => {
|
||||
return produce(state, (draft: State) => {
|
||||
if (action.type === 'set_uploaded_assets') {
|
||||
draft.uploadedAssets = action.payload.map((file) => ({
|
||||
file,
|
||||
wasCaptionChanged: false,
|
||||
wasAltTextChanged: false,
|
||||
}));
|
||||
draft.hasUnsavedChanges = false;
|
||||
}
|
||||
|
||||
if (action.type === 'set_assets_to_upload_length') {
|
||||
draft.assetsToUploadLength = action.payload;
|
||||
}
|
||||
|
||||
if (action.type === 'set_uploaded_asset_caption') {
|
||||
const asset = draft.uploadedAssets.find((a) => a.file.id === action.payload.id);
|
||||
if (asset && asset.file.caption !== action.payload.caption) {
|
||||
asset.file.caption = action.payload.caption;
|
||||
asset.wasCaptionChanged = true;
|
||||
draft.hasUnsavedChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'set_uploaded_asset_alt_text') {
|
||||
const asset = draft.uploadedAssets.find((a) => a.file.id === action.payload.id);
|
||||
if (asset && asset.file.alternativeText !== action.payload.altText) {
|
||||
asset.file.alternativeText = action.payload.altText;
|
||||
asset.wasAltTextChanged = true;
|
||||
draft.hasUnsavedChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'remove_uploaded_asset') {
|
||||
draft.uploadedAssets = draft.uploadedAssets.filter((a) => a.file.id !== action.payload.id);
|
||||
}
|
||||
|
||||
if (action.type === 'edit_uploaded_asset') {
|
||||
const assetIndex = draft.uploadedAssets.findIndex(
|
||||
(a) => a.file.id === action.payload.editedAsset.id
|
||||
);
|
||||
if (assetIndex !== -1) {
|
||||
draft.uploadedAssets[assetIndex] = {
|
||||
file: action.payload.editedAsset,
|
||||
wasCaptionChanged: draft.uploadedAssets[assetIndex].wasCaptionChanged,
|
||||
wasAltTextChanged: draft.uploadedAssets[assetIndex].wasAltTextChanged,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type === 'clear_unsaved_changes') {
|
||||
draft.hasUnsavedChanges = false;
|
||||
draft.uploadedAssets.forEach((asset) => {
|
||||
asset.wasCaptionChanged = false;
|
||||
asset.wasAltTextChanged = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const AIUploadModal = ({ open, onClose, folderId = null }: AIUploadModalProps) => {
|
||||
const [state, dispatch] = React.useReducer(reducer, {
|
||||
uploadedAssets: [],
|
||||
assetsToUploadLength: 0,
|
||||
hasUnsavedChanges: false,
|
||||
});
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
// Reset state when modal closes
|
||||
dispatch({ type: 'set_uploaded_assets', payload: [] });
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<AIUploadModalContext
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
folderId={folderId}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<Modal.Root open={open} onOpenChange={handleClose}>
|
||||
<ModalContent onClose={handleClose} />
|
||||
</Modal.Root>
|
||||
</AIUploadModalContext>
|
||||
);
|
||||
};
|
||||
|
||||
export { useAIUploadModalContext };
|
@ -59,6 +59,8 @@ interface EditAssetContentProps {
|
||||
canDownload?: boolean;
|
||||
trackedLocation?: string;
|
||||
onClose: (arg?: Asset | null | boolean) => void;
|
||||
omitFields?: ('caption' | 'alternativeText')[];
|
||||
omitActions?: 'replace'[];
|
||||
}
|
||||
|
||||
interface FormInitialData {
|
||||
@ -78,6 +80,8 @@ export const EditAssetContent = ({
|
||||
canCopyLink = false,
|
||||
canDownload = false,
|
||||
trackedLocation,
|
||||
omitFields = [],
|
||||
omitActions = [],
|
||||
}: EditAssetContentProps) => {
|
||||
const { formatMessage, formatDate } = useIntl();
|
||||
const { trackUsage } = useTracking();
|
||||
@ -274,42 +278,47 @@ export const EditAssetContent = ({
|
||||
<Field.Error />
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root
|
||||
name="alternativeText"
|
||||
hint={formatMessage({
|
||||
id: getTrad('form.input.description.file-alt'),
|
||||
defaultMessage: 'This text will be displayed if the asset can’t be shown.',
|
||||
})}
|
||||
error={errors.alternativeText}
|
||||
>
|
||||
<Field.Label>
|
||||
{formatMessage({
|
||||
id: getTrad('form.input.label.file-alt'),
|
||||
defaultMessage: 'Alternative text',
|
||||
{!omitFields?.includes('alternativeText') && (
|
||||
<Field.Root
|
||||
name="alternativeText"
|
||||
hint={formatMessage({
|
||||
id: getTrad('form.input.description.file-alt'),
|
||||
defaultMessage:
|
||||
'This text will be displayed if the asset can’t be shown.',
|
||||
})}
|
||||
</Field.Label>
|
||||
<TextInput
|
||||
value={values.alternativeText}
|
||||
onChange={handleChange}
|
||||
disabled={formDisabled}
|
||||
/>
|
||||
<Field.Hint />
|
||||
<Field.Error />
|
||||
</Field.Root>
|
||||
error={errors.alternativeText}
|
||||
>
|
||||
<Field.Label>
|
||||
{formatMessage({
|
||||
id: getTrad('form.input.label.file-alt'),
|
||||
defaultMessage: 'Alternative text',
|
||||
})}
|
||||
</Field.Label>
|
||||
<TextInput
|
||||
value={values.alternativeText}
|
||||
onChange={handleChange}
|
||||
disabled={formDisabled}
|
||||
/>
|
||||
<Field.Hint />
|
||||
<Field.Error />
|
||||
</Field.Root>
|
||||
)}
|
||||
|
||||
<Field.Root name="caption" error={errors.caption}>
|
||||
<Field.Label>
|
||||
{formatMessage({
|
||||
id: getTrad('form.input.label.file-caption'),
|
||||
defaultMessage: 'Caption',
|
||||
})}
|
||||
</Field.Label>
|
||||
<TextInput
|
||||
value={values.caption}
|
||||
onChange={handleChange}
|
||||
disabled={formDisabled}
|
||||
/>
|
||||
</Field.Root>
|
||||
{!omitFields?.includes('caption') && (
|
||||
<Field.Root name="caption" error={errors.caption}>
|
||||
<Field.Label>
|
||||
{formatMessage({
|
||||
id: getTrad('form.input.label.file-caption'),
|
||||
defaultMessage: 'Caption',
|
||||
})}
|
||||
</Field.Label>
|
||||
<TextInput
|
||||
value={values.caption}
|
||||
onChange={handleChange}
|
||||
disabled={formDisabled}
|
||||
/>
|
||||
</Field.Root>
|
||||
)}
|
||||
|
||||
<Flex direction="column" alignItems="stretch" gap={1}>
|
||||
<Field.Root name="parent" id="asset-folder">
|
||||
@ -356,12 +365,14 @@ export const EditAssetContent = ({
|
||||
{formatMessage({ id: 'global.cancel', defaultMessage: 'Cancel' })}
|
||||
</Button>
|
||||
<Flex gap={2}>
|
||||
<ReplaceMediaButton
|
||||
onSelectMedia={setReplacementFile}
|
||||
acceptedMime={asset?.mime ?? ''}
|
||||
disabled={formDisabled}
|
||||
trackedLocation={trackedLocation}
|
||||
/>
|
||||
{!omitActions?.includes('replace') && (
|
||||
<ReplaceMediaButton
|
||||
onSelectMedia={setReplacementFile}
|
||||
acceptedMime={asset?.mime ?? ''}
|
||||
disabled={formDisabled}
|
||||
trackedLocation={trackedLocation}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => submitButtonRef.current?.click()}
|
||||
|
14
packages/core/upload/admin/src/hooks/useAiAvailability.ts
Normal file
14
packages/core/upload/admin/src/hooks/useAiAvailability.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { useAIAvailability as useGlobalAIAvailability } from '@strapi/admin/strapi-admin/ee';
|
||||
|
||||
import { useSettings } from './useSettings';
|
||||
|
||||
export const useAIAvailability = () => {
|
||||
const isAiAvailable = useGlobalAIAvailability();
|
||||
const { status, data } = useSettings(isAiAvailable);
|
||||
|
||||
if (!isAiAvailable) {
|
||||
return { status: 'success' as const, isEnabled: false };
|
||||
}
|
||||
|
||||
return { status, isEnabled: data?.aiMetadata };
|
||||
};
|
65
packages/core/upload/admin/src/hooks/useBulkEdit.ts
Normal file
65
packages/core/upload/admin/src/hooks/useBulkEdit.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { useNotification, useFetchClient } from '@strapi/admin/strapi-admin';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { BulkUpdateFiles } from '../../../shared/contracts/files';
|
||||
import { pluginId } from '../pluginId';
|
||||
import { getTrad } from '../utils';
|
||||
|
||||
interface FileInfoUpdate {
|
||||
name: string;
|
||||
alternativeText: string | null;
|
||||
caption: string | null;
|
||||
folder: number | null;
|
||||
}
|
||||
|
||||
interface BulkEditParams {
|
||||
updates: Array<{
|
||||
id: number;
|
||||
fileInfo: FileInfoUpdate;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const useBulkEdit = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { toggleNotification } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
const { post } = useFetchClient();
|
||||
|
||||
const bulkEditQuery = ({ updates }: BulkEditParams) => {
|
||||
return post('/upload/actions/bulk-update', { updates });
|
||||
};
|
||||
|
||||
const mutation = useMutation<
|
||||
BulkUpdateFiles.Response,
|
||||
BulkUpdateFiles.Response['error'],
|
||||
BulkEditParams
|
||||
>(bulkEditQuery, {
|
||||
onSuccess(res) {
|
||||
const { data } = res;
|
||||
|
||||
if (data && data.length > 0) {
|
||||
queryClient.refetchQueries([pluginId, 'assets'], { active: true });
|
||||
queryClient.refetchQueries([pluginId, 'asset-count'], { active: true });
|
||||
queryClient.refetchQueries([pluginId, 'folders'], { active: true });
|
||||
}
|
||||
|
||||
toggleNotification({
|
||||
type: 'success',
|
||||
message: formatMessage({
|
||||
id: getTrad('modal.edit.success-label'),
|
||||
defaultMessage: 'Files have been successfully updated.',
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const edit = (
|
||||
updates: Array<{
|
||||
id: number;
|
||||
fileInfo: FileInfoUpdate;
|
||||
}>
|
||||
) => mutation.mutateAsync({ updates });
|
||||
|
||||
return { ...mutation, edit };
|
||||
};
|
20
packages/core/upload/admin/src/hooks/useSettings.ts
Normal file
20
packages/core/upload/admin/src/hooks/useSettings.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useFetchClient } from '@strapi/admin/strapi-admin';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import type { GetSettings } from '../../../shared/contracts/settings';
|
||||
|
||||
export function useSettings(isEnabled: boolean = true) {
|
||||
const { get } = useFetchClient();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['upload', 'settings'],
|
||||
enabled: isEnabled,
|
||||
async queryFn() {
|
||||
const {
|
||||
data: { data },
|
||||
} = await get<GetSettings.Response['data']>('/upload/settings');
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
}
|
@ -15,27 +15,35 @@ interface Asset extends Omit<File, 'id' | 'hash'> {
|
||||
hash?: File['hash'];
|
||||
}
|
||||
|
||||
const uploadAsset = (
|
||||
asset: Asset,
|
||||
const uploadAssets = (
|
||||
assets: Asset | Asset[],
|
||||
folderId: number | null,
|
||||
signal: AbortSignal,
|
||||
onProgress: (progress: number) => void,
|
||||
post: FetchClient['post']
|
||||
) => {
|
||||
const { rawFile, caption, name, alternativeText } = asset;
|
||||
const assetsArray = Array.isArray(assets) ? assets : [assets];
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('files', rawFile!);
|
||||
// Add all files to the form data
|
||||
assetsArray.forEach((asset) => {
|
||||
if (asset.rawFile) {
|
||||
formData.append('files', asset.rawFile);
|
||||
}
|
||||
});
|
||||
|
||||
formData.append(
|
||||
'fileInfo',
|
||||
JSON.stringify({
|
||||
name,
|
||||
caption,
|
||||
alternativeText,
|
||||
folder: folderId,
|
||||
})
|
||||
);
|
||||
// Add each fileInfo as a separate stringified field
|
||||
assetsArray.forEach((asset) => {
|
||||
formData.append(
|
||||
'fileInfo',
|
||||
JSON.stringify({
|
||||
name: asset.name,
|
||||
caption: asset.caption,
|
||||
alternativeText: asset.alternativeText,
|
||||
folder: folderId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* onProgress is not possible using native fetch
|
||||
@ -58,22 +66,22 @@ export const useUpload = () => {
|
||||
const mutation = useMutation<
|
||||
CreateFile.Response['data'],
|
||||
CreateFile.Response['error'],
|
||||
{ asset: Asset; folderId: number | null }
|
||||
{ assets: Asset | Asset[]; folderId: number | null }
|
||||
>(
|
||||
({ asset, folderId }) => {
|
||||
return uploadAsset(asset, folderId, signal, setProgress, post);
|
||||
({ assets, folderId }) => {
|
||||
return uploadAssets(assets, folderId, signal, setProgress, post);
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
queryClient.refetchQueries([pluginId, 'assets'], { active: true });
|
||||
queryClient.refetchQueries([pluginId, 'asset-count'], { active: true });
|
||||
dispatch(adminApi.util.invalidateTags(['HomepageKeyStatistics']));
|
||||
dispatch(adminApi.util.invalidateTags(['HomepageKeyStatistics', 'AIUsage']));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const upload = (asset: Asset, folderId: number | null) =>
|
||||
mutation.mutateAsync({ asset, folderId });
|
||||
const upload = (assets: Asset | Asset[], folderId: number | null) =>
|
||||
mutation.mutateAsync({ assets, folderId });
|
||||
|
||||
const cancel = () => abortController.abort();
|
||||
|
||||
|
@ -25,6 +25,7 @@ import { useIntl } from 'react-intl';
|
||||
import { Link as ReactRouterLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import { AIUploadModal } from '../../../ai/components/AIUploadModal';
|
||||
import { AssetGridList } from '../../../components/AssetGridList/AssetGridList';
|
||||
import { EditAssetDialog } from '../../../components/EditAssetDialog/EditAssetContent';
|
||||
import { EditFolderDialog } from '../../../components/EditFolderDialog/EditFolderDialog';
|
||||
@ -37,6 +38,7 @@ import { SortPicker } from '../../../components/SortPicker/SortPicker';
|
||||
import { TableList } from '../../../components/TableList/TableList';
|
||||
import { UploadAssetDialog } from '../../../components/UploadAssetDialog/UploadAssetDialog';
|
||||
import { localStorageKeys, viewOptions } from '../../../constants';
|
||||
import { useAIAvailability } from '../../../hooks/useAiAvailability';
|
||||
import { useAssets } from '../../../hooks/useAssets';
|
||||
import { useFolder } from '../../../hooks/useFolder';
|
||||
import { useFolders } from '../../../hooks/useFolders';
|
||||
@ -87,6 +89,7 @@ export const MediaLibrary = () => {
|
||||
canConfigureView,
|
||||
isLoading: permissionsLoading,
|
||||
} = useMediaLibraryPermissions();
|
||||
const { isEnabled: isAiEnabled, status: aiAvailabilityStatus } = useAIAvailability();
|
||||
const currentFolderToEditRef = React.useRef<HTMLDivElement>();
|
||||
const { formatMessage } = useIntl();
|
||||
const { pathname } = useLocation();
|
||||
@ -144,7 +147,12 @@ export const MediaLibrary = () => {
|
||||
const assetCount = assets?.length ?? 0;
|
||||
const totalAssetCount = assetsData?.pagination?.total;
|
||||
|
||||
const isLoading = isCurrentFolderLoading || foldersLoading || permissionsLoading || assetsLoading;
|
||||
const isLoading =
|
||||
isCurrentFolderLoading ||
|
||||
foldersLoading ||
|
||||
permissionsLoading ||
|
||||
assetsLoading ||
|
||||
aiAvailabilityStatus === 'loading';
|
||||
const [showUploadAssetDialog, setShowUploadAssetDialog] = React.useState(false);
|
||||
const [showEditFolderDialog, setShowEditFolderDialog] = React.useState(false);
|
||||
const [assetToEdit, setAssetToEdit] = React.useState<Asset | undefined>(undefined);
|
||||
@ -227,7 +235,7 @@ export const MediaLibrary = () => {
|
||||
return <Page.Loading />;
|
||||
}
|
||||
|
||||
if (assetsError || foldersError) {
|
||||
if (assetsError || foldersError || aiAvailabilityStatus === 'error') {
|
||||
return <Page.Error />;
|
||||
}
|
||||
|
||||
@ -519,14 +527,21 @@ export const MediaLibrary = () => {
|
||||
</Pagination.Root>
|
||||
</Layouts.Content>
|
||||
</Page.Main>
|
||||
{showUploadAssetDialog && (
|
||||
<UploadAssetDialog
|
||||
open={showUploadAssetDialog}
|
||||
onClose={toggleUploadAssetDialog}
|
||||
trackedLocation="upload"
|
||||
folderId={query?.folder as string | number | null | undefined}
|
||||
/>
|
||||
)}
|
||||
{showUploadAssetDialog &&
|
||||
(isAiEnabled ? (
|
||||
<AIUploadModal
|
||||
open={showUploadAssetDialog}
|
||||
onClose={toggleUploadAssetDialog}
|
||||
folderId={query?.folder ? Number(query.folder) : null}
|
||||
/>
|
||||
) : (
|
||||
<UploadAssetDialog
|
||||
open={showUploadAssetDialog}
|
||||
onClose={toggleUploadAssetDialog}
|
||||
trackedLocation="upload"
|
||||
folderId={query?.folder as string | number | null | undefined}
|
||||
/>
|
||||
))}
|
||||
{showEditFolderDialog && (
|
||||
<EditFolderDialog
|
||||
open={showEditFolderDialog}
|
||||
|
@ -3,13 +3,14 @@ import * as React from 'react';
|
||||
|
||||
import { Page, useNotification, useFetchClient, Layouts } from '@strapi/admin/strapi-admin';
|
||||
import { Box, Button, Flex, Grid, Toggle, Typography, Field } from '@strapi/design-system';
|
||||
import { Check } from '@strapi/icons';
|
||||
import { Check, Sparkle } from '@strapi/icons';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
|
||||
import { UpdateSettings } from '../../../../shared/contracts/settings';
|
||||
import { PERMISSIONS } from '../../constants';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import { getTrad } from '../../utils';
|
||||
|
||||
import { init } from './init';
|
||||
@ -20,20 +21,11 @@ import type { InitialState } from './reducer';
|
||||
export const SettingsPage = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { toggleNotification } = useNotification();
|
||||
const { get, put } = useFetchClient();
|
||||
const { put } = useFetchClient();
|
||||
|
||||
const [{ initialData, modifiedData }, dispatch] = React.useReducer(reducer, initialState, init);
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['upload', 'settings'],
|
||||
async queryFn() {
|
||||
const {
|
||||
data: { data },
|
||||
} = await get('/upload/settings');
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
const { data, isLoading, refetch } = useSettings();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data) {
|
||||
@ -102,7 +94,7 @@ export const SettingsPage = () => {
|
||||
<Page.Title>
|
||||
{formatMessage({
|
||||
id: getTrad('page.title'),
|
||||
defaultMessage: 'Settings - Media Libray',
|
||||
defaultMessage: 'Settings - Media Library',
|
||||
})}
|
||||
</Page.Title>
|
||||
<form onSubmit={handleSubmit}>
|
||||
@ -132,7 +124,62 @@ export const SettingsPage = () => {
|
||||
/>
|
||||
<Layouts.Content>
|
||||
<Layouts.Root>
|
||||
<Flex direction="column" alignItems="stretch" gap={12}>
|
||||
<Flex direction="column" alignItems="stretch" gap={4}>
|
||||
<Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
|
||||
<Flex direction="column" alignItems="stretch" gap={1}>
|
||||
<Grid.Root gap={6}>
|
||||
<Grid.Item col={8} s={12} direction="column" alignItems="stretch">
|
||||
<Flex gap={2}>
|
||||
<Box color="alternative700">
|
||||
<Sparkle />
|
||||
</Box>
|
||||
<Typography variant="delta" tag="h2">
|
||||
{formatMessage({
|
||||
id: getTrad('settings.form.aiMetadata.label'),
|
||||
defaultMessage:
|
||||
'Generate AI captions and alt texts automatically on upload!',
|
||||
})}
|
||||
</Typography>
|
||||
</Flex>
|
||||
<Flex paddingTop={1}>
|
||||
<Typography variant="pi" textColor="neutral600">
|
||||
{formatMessage({
|
||||
id: getTrad('settings.form.aiMetadata.description'),
|
||||
defaultMessage:
|
||||
'Enable this feature to save time, optimize your SEO and increase accessibility by letting our AI generate captions and alternative texts for you.',
|
||||
})}
|
||||
</Typography>
|
||||
</Flex>
|
||||
</Grid.Item>
|
||||
<Grid.Item
|
||||
col={4}
|
||||
s={12}
|
||||
direction="column"
|
||||
alignItems="end"
|
||||
justifyContent={'center'}
|
||||
>
|
||||
<Field.Root name="aiMetadata" width={'158px'}>
|
||||
<Toggle
|
||||
checked={modifiedData?.aiMetadata}
|
||||
offLabel={formatMessage({
|
||||
id: 'app.components.ToggleCheckbox.off-label',
|
||||
defaultMessage: 'Disabled',
|
||||
})}
|
||||
onLabel={formatMessage({
|
||||
id: 'app.components.ToggleCheckbox.on-label',
|
||||
defaultMessage: 'Enabled',
|
||||
})}
|
||||
onChange={(e) => {
|
||||
handleChange({
|
||||
target: { name: 'aiMetadata', value: e.target.checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Field.Root>
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
|
||||
<Flex direction="column" alignItems="stretch" gap={4}>
|
||||
<Flex>
|
||||
|
@ -1,19 +1,19 @@
|
||||
import { produce } from 'immer';
|
||||
import set from 'lodash/set';
|
||||
|
||||
import { SettingsData } from '../../../../shared/contracts/settings';
|
||||
|
||||
const initialData: SettingsData = {
|
||||
responsiveDimensions: true,
|
||||
sizeOptimization: true,
|
||||
autoOrientation: false,
|
||||
videoPreview: false,
|
||||
aiMetadata: true,
|
||||
};
|
||||
|
||||
export type InitialState = {
|
||||
initialData: {
|
||||
responsiveDimensions?: boolean;
|
||||
sizeOptimization?: boolean;
|
||||
autoOrientation?: boolean;
|
||||
videoPreview?: boolean;
|
||||
} | null;
|
||||
modifiedData: {
|
||||
responsiveDimensions?: boolean;
|
||||
sizeOptimization?: boolean;
|
||||
autoOrientation?: boolean;
|
||||
videoPreview?: boolean;
|
||||
} | null;
|
||||
initialData: SettingsData | null;
|
||||
modifiedData: SettingsData | null;
|
||||
};
|
||||
|
||||
interface ActionGetDataSucceeded {
|
||||
@ -30,18 +30,8 @@ interface ActionOnChange {
|
||||
export type Action = ActionGetDataSucceeded | ActionOnChange;
|
||||
|
||||
const initialState: InitialState = {
|
||||
initialData: {
|
||||
responsiveDimensions: true,
|
||||
sizeOptimization: true,
|
||||
autoOrientation: false,
|
||||
videoPreview: false,
|
||||
},
|
||||
modifiedData: {
|
||||
responsiveDimensions: true,
|
||||
sizeOptimization: true,
|
||||
autoOrientation: false,
|
||||
videoPreview: false,
|
||||
},
|
||||
initialData,
|
||||
modifiedData: { ...initialData },
|
||||
};
|
||||
|
||||
const reducer = (state: InitialState, action: Action) =>
|
||||
|
@ -12,6 +12,7 @@
|
||||
"control-card.crop": "Crop",
|
||||
"control-card.download": "Download",
|
||||
"control-card.edit": "Edit",
|
||||
"control-card.remove-selection": "Remove from selection",
|
||||
"control-card.replace-media": "Replace Media",
|
||||
"control-card.save": "Save",
|
||||
"control-card.stop-crop": "Stop cropping",
|
||||
@ -20,6 +21,7 @@
|
||||
"form.input.description.file-alt": "This text will be displayed if the asset can’t be shown.",
|
||||
"form.input.label.file-alt": "Alternative text",
|
||||
"form.input.label.file-caption": "Caption",
|
||||
"form.input.placeholder.file-caption": "Enter caption",
|
||||
"form.input.label.file-name": "File name",
|
||||
"form.upload-url.error.url.invalid": "One URL is invalid",
|
||||
"form.upload-url.error.url.invalids": "{number} URLs are invalids",
|
||||
@ -105,6 +107,7 @@
|
||||
"settings.form.videoPreview.description": "It will generate a six-second preview of the video (GIF)",
|
||||
"settings.form.videoPreview.label": "Preview",
|
||||
"settings.header.label": "Media Library",
|
||||
"settings.section.audio.label": "Audio",
|
||||
"settings.section.doc.label": "Doc",
|
||||
"settings.section.image.label": "Image",
|
||||
"settings.section.video.label": "Video",
|
||||
@ -136,5 +139,8 @@
|
||||
"config.note": "Note: You can override this value in the media library.",
|
||||
"config.popUpWarning.warning.updateAllSettings": "This will modify all your settings",
|
||||
"view-switch.list": "List View",
|
||||
"view-switch.grid": "Grid View"
|
||||
"view-switch.grid": "Grid View",
|
||||
"ai.modal.uploading.title": "Uploading and processing with AI...",
|
||||
"ai.modal.title": "{count, plural, one {# Asset uploaded} other {# Assets uploaded}} time to review AI generated content",
|
||||
"ai.modal.error": "Could not generate AI metadata for the uploaded files."
|
||||
}
|
||||
|
@ -159,6 +159,7 @@ const handlers = [
|
||||
sizeOptimization: true,
|
||||
responsiveDimensions: true,
|
||||
autoOrientation: true,
|
||||
aiMetadata: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
@ -81,6 +81,7 @@ describe('Upload plugin bootstrap function', () => {
|
||||
|
||||
expect(setStore).toHaveBeenCalledWith({
|
||||
value: {
|
||||
aiMetadata: true,
|
||||
autoOrientation: false,
|
||||
sizeOptimization: true,
|
||||
responsiveDimensions: true,
|
||||
|
@ -9,6 +9,7 @@ export async function bootstrap({ strapi }: { strapi: Core.Strapi }) {
|
||||
sizeOptimization: true,
|
||||
responsiveDimensions: true,
|
||||
autoOrientation: false,
|
||||
aiMetadata: true,
|
||||
},
|
||||
view_configuration: {
|
||||
pageSize: 10,
|
||||
|
@ -0,0 +1,281 @@
|
||||
import type { Context } from 'koa';
|
||||
|
||||
import adminUploadController from '../admin-upload';
|
||||
import { getService } from '../../utils';
|
||||
import { validateBulkUpdateBody, validateUploadBody } from '../validation/admin/upload';
|
||||
import * as findEntityAndCheckPermissionsModule from '../utils/find-entity-and-check-permissions';
|
||||
import { ACTIONS } from '../../constants';
|
||||
|
||||
jest.mock('../../utils');
|
||||
jest.mock('../validation/admin/upload');
|
||||
jest.mock('../utils/find-entity-and-check-permissions');
|
||||
|
||||
const mockGetService = getService as jest.MockedFunction<typeof getService>;
|
||||
const mockValidateUploadBody = validateUploadBody as jest.MockedFunction<typeof validateUploadBody>;
|
||||
const mockValidateBulkUpdateBody = validateBulkUpdateBody as jest.MockedFunction<
|
||||
typeof validateBulkUpdateBody
|
||||
>;
|
||||
const mockFindEntityAndCheckPermissions =
|
||||
findEntityAndCheckPermissionsModule.findEntityAndCheckPermissions as jest.MockedFunction<
|
||||
typeof findEntityAndCheckPermissionsModule.findEntityAndCheckPermissions
|
||||
>;
|
||||
|
||||
describe('Admin Upload Controller - AI Service Connection', () => {
|
||||
let mockContext: Partial<Context>;
|
||||
let ctxBulk: Partial<Context>;
|
||||
|
||||
let mockAiMetadataService: any;
|
||||
|
||||
let uploadService: {
|
||||
upload: jest.Mock;
|
||||
updateFileInfo: jest.Mock;
|
||||
replace: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockAiMetadataService = {
|
||||
isEnabled: jest.fn(),
|
||||
processFiles: jest.fn(),
|
||||
};
|
||||
|
||||
uploadService = {
|
||||
upload: jest.fn().mockResolvedValue([{}]),
|
||||
updateFileInfo: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
};
|
||||
|
||||
mockGetService.mockImplementation((serviceName: string) => {
|
||||
if (serviceName === 'aiMetadata') return mockAiMetadataService;
|
||||
if (serviceName === 'upload') return uploadService;
|
||||
if (serviceName === 'file') {
|
||||
return {
|
||||
upload: jest.fn().mockResolvedValue([{}]),
|
||||
signFileUrls: jest.fn((file) => Promise.resolve(file)),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const pm = {
|
||||
isAllowed: true,
|
||||
sanitizeOutput: jest.fn((data) => Promise.resolve(data)),
|
||||
};
|
||||
|
||||
global.strapi = {
|
||||
service: jest.fn().mockReturnValue({
|
||||
createPermissionsManager: jest.fn().mockReturnValue(pm),
|
||||
}),
|
||||
admin: {
|
||||
services: {
|
||||
permission: {
|
||||
createPermissionsManager: jest.fn().mockReturnValue(pm),
|
||||
},
|
||||
},
|
||||
},
|
||||
log: { warn: jest.fn() },
|
||||
} as any;
|
||||
|
||||
mockValidateUploadBody.mockResolvedValue({
|
||||
fileInfo: { name: 'test.jpg', alternativeText: '', caption: '', folder: null },
|
||||
});
|
||||
|
||||
mockValidateBulkUpdateBody.mockResolvedValue({
|
||||
updates: [],
|
||||
});
|
||||
|
||||
mockFindEntityAndCheckPermissions.mockResolvedValue({
|
||||
pm: {
|
||||
sanitizeOutput: jest.fn((data) => Promise.resolve({ ...data, cleaned: true })),
|
||||
},
|
||||
} as any);
|
||||
|
||||
mockContext = {
|
||||
state: { userAbility: {}, user: { id: 1 } },
|
||||
request: {
|
||||
body: {},
|
||||
files: { files: { filepath: '/tmp/test.jpg', mimetype: 'image/jpeg' } },
|
||||
} as any,
|
||||
forbidden: jest.fn(),
|
||||
} as any;
|
||||
|
||||
ctxBulk = {
|
||||
state: { userAbility: {}, user: { id: 42 } },
|
||||
request: { body: {} },
|
||||
} as any;
|
||||
});
|
||||
|
||||
describe('uploadFiles - AI Service Connection', () => {
|
||||
it('should call AI processFiles when service is enabled', async () => {
|
||||
mockAiMetadataService.isEnabled.mockReturnValue(true);
|
||||
mockAiMetadataService.processFiles.mockResolvedValue([{}]);
|
||||
|
||||
// Mock upload service to return files with proper structure
|
||||
uploadService.upload.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: 'test.jpg',
|
||||
mime: 'image/jpeg',
|
||||
url: '/uploads/test.jpg',
|
||||
provider: 'local',
|
||||
},
|
||||
]);
|
||||
|
||||
await adminUploadController.uploadFiles(mockContext as Context);
|
||||
|
||||
expect(mockAiMetadataService.processFiles).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
filepath: '/uploads/test.jpg',
|
||||
mimetype: 'image/jpeg',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not call AI processFiles when service is disabled', async () => {
|
||||
mockAiMetadataService.isEnabled.mockReturnValue(false);
|
||||
|
||||
await adminUploadController.uploadFiles(mockContext as Context);
|
||||
|
||||
expect(mockAiMetadataService.processFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle AI service errors gracefully', async () => {
|
||||
mockAiMetadataService.isEnabled.mockReturnValue(true);
|
||||
mockAiMetadataService.processFiles.mockRejectedValue(new Error('AI service unavailable'));
|
||||
|
||||
await adminUploadController.uploadFiles(mockContext as Context);
|
||||
|
||||
expect(strapi.log.warn).toHaveBeenCalledWith(
|
||||
'AI metadata generation failed, proceeding without AI enhancements',
|
||||
{ error: 'AI service unavailable' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUpdateFileInfo', () => {
|
||||
it('updates multiple files, sanitizes outputs, and returns an array', async () => {
|
||||
mockValidateBulkUpdateBody.mockResolvedValue({
|
||||
updates: [
|
||||
{
|
||||
id: 1,
|
||||
fileInfo: {
|
||||
name: 'fileA.jpg',
|
||||
caption: 'A',
|
||||
alternativeText: 'alternativeA',
|
||||
folder: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
fileInfo: {
|
||||
name: 'fileB.jpg',
|
||||
alternativeText: 'alternativeB',
|
||||
caption: 'B',
|
||||
folder: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
uploadService.updateFileInfo
|
||||
.mockResolvedValueOnce({ id: 1, caption: 'A' })
|
||||
.mockResolvedValueOnce({ id: 2, alternativeText: 'B' });
|
||||
|
||||
await adminUploadController.bulkUpdateFileInfo(ctxBulk as Context);
|
||||
|
||||
expect(mockValidateBulkUpdateBody).toHaveBeenCalledWith({});
|
||||
expect(uploadService.updateFileInfo).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
1,
|
||||
{ name: 'fileA.jpg', alternativeText: 'alternativeA', caption: 'A', folder: null },
|
||||
{ user: { id: 42 } }
|
||||
);
|
||||
expect(uploadService.updateFileInfo).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
2,
|
||||
{ name: 'fileB.jpg', alternativeText: 'alternativeB', caption: 'B', folder: null },
|
||||
{ user: { id: 42 } }
|
||||
);
|
||||
|
||||
expect(mockFindEntityAndCheckPermissions).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(ctxBulk.body).toEqual([
|
||||
{ id: 1, caption: 'A', cleaned: true },
|
||||
{ id: 2, alternativeText: 'B', cleaned: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty array when no updates provided', async () => {
|
||||
mockValidateBulkUpdateBody.mockResolvedValue({ updates: [] });
|
||||
|
||||
await adminUploadController.bulkUpdateFileInfo(ctxBulk as Context);
|
||||
|
||||
expect(ctxBulk.body).toEqual([]);
|
||||
expect(uploadService.updateFileInfo).not.toHaveBeenCalled();
|
||||
expect(mockFindEntityAndCheckPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates validation errors from validateBulkUpdateBody', async () => {
|
||||
mockValidateBulkUpdateBody.mockRejectedValue(new Error('Invalid payload'));
|
||||
|
||||
await expect(adminUploadController.bulkUpdateFileInfo(ctxBulk as Context)).rejects.toThrow(
|
||||
'Invalid payload'
|
||||
);
|
||||
});
|
||||
|
||||
it('sanitizes each updated entity with ACTIONS.read', async () => {
|
||||
const sanitizeOutput = jest.fn((data) => Promise.resolve({ ok: true, ...data }));
|
||||
mockFindEntityAndCheckPermissions.mockResolvedValue({ pm: { sanitizeOutput } } as any);
|
||||
|
||||
mockValidateBulkUpdateBody.mockResolvedValue({
|
||||
updates: [
|
||||
{
|
||||
id: 10,
|
||||
fileInfo: {
|
||||
name: 'fileA.jpg',
|
||||
caption: 'X',
|
||||
alternativeText: 'alternativeA',
|
||||
folder: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
uploadService.updateFileInfo.mockResolvedValue({ id: 10, caption: 'X' });
|
||||
|
||||
await adminUploadController.bulkUpdateFileInfo(ctxBulk as Context);
|
||||
|
||||
expect(sanitizeOutput).toHaveBeenCalledWith(
|
||||
{ id: 10, caption: 'X' },
|
||||
{ action: ACTIONS.read }
|
||||
);
|
||||
expect(ctxBulk.body).toEqual([{ ok: true, id: 10, caption: 'X' }]);
|
||||
});
|
||||
|
||||
it('passes the authenticated user to updateFileInfo', async () => {
|
||||
mockValidateBulkUpdateBody.mockResolvedValue({
|
||||
updates: [
|
||||
{
|
||||
id: 7,
|
||||
fileInfo: {
|
||||
name: 'fileA.jpg',
|
||||
alternativeText: 'hello',
|
||||
caption: 'A',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
uploadService.updateFileInfo.mockResolvedValue({ id: 7 });
|
||||
|
||||
await adminUploadController.bulkUpdateFileInfo(ctxBulk as Context);
|
||||
|
||||
expect(uploadService.updateFileInfo).toHaveBeenCalledWith(
|
||||
7,
|
||||
{ name: 'fileA.jpg', alternativeText: 'hello', caption: 'A' },
|
||||
{ user: { id: 42 } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
@ -5,11 +5,38 @@ import type { Context } from 'koa';
|
||||
|
||||
import { getService } from '../utils';
|
||||
import { ACTIONS, FILE_MODEL_UID } from '../constants';
|
||||
import { validateUploadBody } from './validation/admin/upload';
|
||||
import { validateBulkUpdateBody, validateUploadBody } from './validation/admin/upload';
|
||||
import { findEntityAndCheckPermissions } from './utils/find-entity-and-check-permissions';
|
||||
import { FileInfo } from '../types';
|
||||
|
||||
export default {
|
||||
async bulkUpdateFileInfo(ctx: Context) {
|
||||
const {
|
||||
state: { userAbility, user },
|
||||
request: { body },
|
||||
} = ctx;
|
||||
|
||||
const { updates } = await validateBulkUpdateBody(body);
|
||||
const uploadService = getService('upload');
|
||||
|
||||
const results = await async.map(
|
||||
updates,
|
||||
async ({ id, fileInfo }: { id: number; fileInfo: FileInfo }) => {
|
||||
const { pm } = await findEntityAndCheckPermissions(
|
||||
userAbility,
|
||||
ACTIONS.update,
|
||||
FILE_MODEL_UID,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = await uploadService.updateFileInfo(id, fileInfo as any, { user });
|
||||
return pm.sanitizeOutput(updated, { action: ACTIONS.read });
|
||||
}
|
||||
);
|
||||
|
||||
ctx.body = results;
|
||||
},
|
||||
|
||||
async updateFileInfo(ctx: Context) {
|
||||
const {
|
||||
state: { userAbility, user },
|
||||
@ -85,8 +112,55 @@ export default {
|
||||
return ctx.forbidden();
|
||||
}
|
||||
|
||||
const data = await validateUploadBody(body);
|
||||
const uploadedFiles = await uploadService.upload({ data, files }, { user });
|
||||
const data = await validateUploadBody(body, Array.isArray(files));
|
||||
const filesArray = Array.isArray(files) ? files : [files];
|
||||
// Upload files first to get thumbnails
|
||||
const uploadedFiles = await uploadService.upload({ data, files: filesArray }, { user });
|
||||
|
||||
const aiMetadataService = getService('aiMetadata');
|
||||
|
||||
// AFTER upload - use thumbnail versions for AI processing
|
||||
if (await aiMetadataService.isEnabled()) {
|
||||
try {
|
||||
// Use thumbnail URLs instead of original files
|
||||
const thumbnailFiles = uploadedFiles.map(
|
||||
(file) =>
|
||||
({
|
||||
filepath: file.formats?.thumbnail?.url || file.url, // Use thumbnail if available
|
||||
mimetype: file.mime,
|
||||
originalFilename: file.name,
|
||||
size: file.formats?.thumbnail?.size || file.size,
|
||||
provider: file.provider,
|
||||
}) as unknown as any
|
||||
);
|
||||
|
||||
const metadataResults = await aiMetadataService.processFiles(thumbnailFiles);
|
||||
|
||||
// Update the uploaded files with AI metadata
|
||||
await Promise.all(
|
||||
uploadedFiles.map(async (uploadedFile, index) => {
|
||||
const aiMetadata = metadataResults[index];
|
||||
if (aiMetadata) {
|
||||
await uploadService.updateFileInfo(
|
||||
uploadedFile.id,
|
||||
{
|
||||
alternativeText: aiMetadata.altText,
|
||||
caption: aiMetadata.caption,
|
||||
},
|
||||
{ user }
|
||||
);
|
||||
|
||||
uploadedFiles[index].alternativeText = aiMetadata.altText;
|
||||
uploadedFiles[index].caption = aiMetadata.caption;
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
strapi.log.warn('AI metadata generation failed, proceeding without AI enhancements', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sign file urls for private providers
|
||||
const signedFiles = await async.map(uploadedFiles, getService('file').signFileUrls);
|
||||
|
@ -134,7 +134,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
|
||||
const uploadedFiles = await getService('upload').upload({
|
||||
data,
|
||||
files,
|
||||
files: Array.isArray(files) ? files : [files],
|
||||
});
|
||||
|
||||
ctx.body = await sanitizeOutput(uploadedFiles as any, ctx);
|
||||
|
@ -4,6 +4,7 @@ const settingsSchema = yup.object({
|
||||
sizeOptimization: yup.boolean().required(),
|
||||
responsiveDimensions: yup.boolean().required(),
|
||||
autoOrientation: yup.boolean(),
|
||||
aiMetadata: yup.boolean().default(true),
|
||||
});
|
||||
|
||||
export default validateYupSchema(settingsSchema);
|
||||
|
@ -39,3 +39,18 @@ export { validateUploadBody };
|
||||
export type UploadBody =
|
||||
| yup.InferType<typeof uploadSchema>
|
||||
| yup.InferType<typeof multiUploadSchema>;
|
||||
|
||||
const bulkUpdatesSchema = yup.object({
|
||||
updates: yup
|
||||
.array()
|
||||
.of(
|
||||
yup.object({
|
||||
id: yup.number().required(),
|
||||
fileInfo: fileInfoSchema.required(),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.required(),
|
||||
});
|
||||
|
||||
export const validateBulkUpdateBody = validateYupSchema(bulkUpdatesSchema);
|
||||
|
@ -201,5 +201,21 @@ export const routes = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/actions/bulk-update',
|
||||
handler: 'admin-upload.bulkUpdateFileInfo',
|
||||
config: {
|
||||
policies: [
|
||||
'admin::isAuthenticatedAdmin',
|
||||
{
|
||||
name: 'admin::hasPermissions',
|
||||
config: {
|
||||
actions: ['plugin::upload.assets.update'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -0,0 +1,308 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createAIMetadataService } from '../ai-metadata';
|
||||
import type { InputFile } from '../../types';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('node:fs/promises');
|
||||
const mockReadFile = readFile as jest.MockedFunction<typeof readFile>;
|
||||
|
||||
const mockGetSettings = jest.fn();
|
||||
|
||||
// Mock fetch globally
|
||||
const mockArrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8));
|
||||
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: mockArrayBuffer, // Add this
|
||||
json: jest.fn().mockResolvedValue({
|
||||
results: [{ altText: 'image alt', caption: 'image caption' }],
|
||||
}),
|
||||
text: jest.fn().mockResolvedValue(''),
|
||||
headers: {
|
||||
get: jest.fn().mockReturnValue('image/jpeg'),
|
||||
},
|
||||
} as any);
|
||||
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Mock FormData
|
||||
global.FormData = jest.fn().mockImplementation(() => ({
|
||||
append: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Blob
|
||||
global.Blob = jest.fn().mockImplementation((parts, options) => ({
|
||||
parts,
|
||||
type: options?.type,
|
||||
}));
|
||||
|
||||
describe('AI Metadata Service', () => {
|
||||
let mockStrapi: any;
|
||||
let aiMetadataService: ReturnType<typeof createAIMetadataService>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockStrapi = {
|
||||
config: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
ee: {
|
||||
isEE: true,
|
||||
features: {
|
||||
isEnabled: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
},
|
||||
service: jest.fn().mockReturnValue({
|
||||
getAiToken: jest.fn().mockResolvedValue({ token: 'mock-token' }),
|
||||
}),
|
||||
log: {
|
||||
http: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
plugin: jest.fn().mockImplementation((pluginName) => {
|
||||
if (pluginName === 'upload') {
|
||||
return {
|
||||
service: jest.fn().mockImplementation((serviceName) => {
|
||||
if (serviceName === 'upload') {
|
||||
return { getSettings: mockGetSettings };
|
||||
}
|
||||
// ...other services if needed
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
|
||||
process.env.STRAPI_AI_URL = 'https://ai.strapi.com';
|
||||
|
||||
aiMetadataService = createAIMetadataService({ strapi: mockStrapi });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.STRAPI_AI_URL;
|
||||
});
|
||||
|
||||
describe('isEnabled', () => {
|
||||
it('should return true when AI is enabled, EE is available and aiMetadata is set to true', async () => {
|
||||
mockStrapi.config.get.mockReturnValue(true);
|
||||
mockGetSettings.mockResolvedValue({ aiMetadata: true });
|
||||
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(true);
|
||||
|
||||
expect(await aiMetadataService.isEnabled()).toBe(true);
|
||||
expect(mockGetSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when AI is disabled but EE is available', async () => {
|
||||
mockStrapi.config.get.mockReturnValue(false);
|
||||
mockGetSettings.mockResolvedValue({ aiMetadata: false });
|
||||
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(true);
|
||||
|
||||
expect(await aiMetadataService.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when AI is enabled but EE is not available', async () => {
|
||||
mockStrapi.config.get.mockReturnValue(true);
|
||||
mockGetSettings.mockResolvedValue({ aiMetadata: true });
|
||||
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(false);
|
||||
|
||||
expect(await aiMetadataService.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when both AI and EE are disabled', async () => {
|
||||
mockStrapi.config.get.mockReturnValue(false);
|
||||
mockGetSettings.mockResolvedValue({ aiMetadata: false });
|
||||
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(false);
|
||||
|
||||
expect(await aiMetadataService.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when both AI and EE are enabled but aiMetadata is disabled', async () => {
|
||||
mockStrapi.config.get.mockReturnValue(true);
|
||||
mockGetSettings.mockResolvedValue({ aiMetadata: false });
|
||||
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(true);
|
||||
|
||||
expect(await aiMetadataService.isEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processFiles', () => {
|
||||
const mockImageFile: InputFile = {
|
||||
filepath: '/tmp/image.jpg',
|
||||
mimetype: 'image/jpeg',
|
||||
originalFilename: 'image.jpg',
|
||||
size: 1024,
|
||||
provider: 'local',
|
||||
} as InputFile;
|
||||
|
||||
const mockImageFile2: InputFile = {
|
||||
filepath: 'image2.png',
|
||||
mimetype: 'image/png',
|
||||
originalFilename: 'image2.png',
|
||||
size: 2048,
|
||||
} as InputFile;
|
||||
|
||||
const mockPdfFile: InputFile = {
|
||||
filepath: '/tmp/document.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
originalFilename: 'document.pdf',
|
||||
size: 2048,
|
||||
} as InputFile;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock strapi config
|
||||
mockStrapi.config.get.mockImplementation((key: string) => {
|
||||
if (key === 'server.url') return 'test-url';
|
||||
if (key === 'admin.ai.enabled') return true;
|
||||
if (key === 'server.port') return 1337;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Mock service as enabled by default
|
||||
mockGetSettings.mockResolvedValue({ aiMetadata: true });
|
||||
mockStrapi.ee.isEE = true;
|
||||
|
||||
// Mock readFile to return proper Buffer with .buffer property
|
||||
const mockBuffer = Buffer.from('image-data');
|
||||
mockReadFile.mockResolvedValue(mockBuffer);
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should throw error when service is disabled', async () => {
|
||||
mockStrapi.config.get.mockReturnValue(false);
|
||||
mockGetSettings.mockResolvedValue({ aiMetadata: false });
|
||||
|
||||
await expect(aiMetadataService.processFiles([mockImageFile])).rejects.toThrow(
|
||||
'AI Metadata service is not enabled'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if getSettings throws an error', async () => {
|
||||
mockStrapi.config.get.mockReturnValue(true);
|
||||
mockGetSettings.mockRejectedValue(new Error('Settings error'));
|
||||
mockStrapi.ee.isEE = true;
|
||||
|
||||
const files = [mockImageFile, mockPdfFile, mockImageFile2, mockPdfFile];
|
||||
|
||||
await expect(aiMetadataService.processFiles(files)).rejects.toThrow('Settings error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-image file handling', () => {
|
||||
it('should return array of nulls for non-image files', async () => {
|
||||
const result = await aiMetadataService.processFiles([mockPdfFile]);
|
||||
|
||||
expect(result).toEqual([null]);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return proper sparse array for mixed file types', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: mockArrayBuffer,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
results: [{ altText: 'image alt', caption: 'image caption' }],
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const files = [mockPdfFile, mockImageFile, mockPdfFile];
|
||||
const result = await aiMetadataService.processFiles(files);
|
||||
|
||||
expect(result).toEqual([null, { altText: 'image alt', caption: 'image caption' }, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('image file processing', () => {
|
||||
it('should process single image file correctly', async () => {
|
||||
const expectedMetadata = { altText: 'A beautiful image', caption: 'Image caption' };
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: mockArrayBuffer,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
results: [expectedMetadata],
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await aiMetadataService.processFiles([mockImageFile]);
|
||||
|
||||
expect(result).toEqual([expectedMetadata]);
|
||||
expect(mockFetch).toHaveBeenCalledWith('test-url/tmp/image.jpg');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://ai.strapi.com/media-library/generate-metadata',
|
||||
{
|
||||
method: 'POST',
|
||||
body: expect.any(Object),
|
||||
headers: {
|
||||
Authorization: 'Bearer mock-token',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should process multiple image files correctly', async () => {
|
||||
const expectedMetadata = [
|
||||
{ altText: 'First image', caption: 'First caption' },
|
||||
{ altText: 'Second image', caption: 'Second caption' },
|
||||
];
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: mockArrayBuffer,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
results: expectedMetadata,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await aiMetadataService.processFiles([mockImageFile, mockImageFile2]);
|
||||
|
||||
expect(result).toEqual(expectedMetadata);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3); // 2 files + 1 AI service call
|
||||
expect(mockFetch).toHaveBeenCalledWith('test-url/tmp/image.jpg');
|
||||
expect(mockFetch).toHaveBeenCalledWith('image2.png');
|
||||
});
|
||||
|
||||
it('should handle mixed file types with correct sparse array mapping', async () => {
|
||||
const expectedMetadata = [
|
||||
{ altText: 'First image', caption: 'First caption' },
|
||||
{ altText: 'Second image', caption: 'Second caption' },
|
||||
];
|
||||
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: mockArrayBuffer,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
results: expectedMetadata,
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// Order: image, pdf, image, pdf
|
||||
const files = [mockImageFile, mockPdfFile, mockImageFile2, mockPdfFile];
|
||||
const result = await aiMetadataService.processFiles(files);
|
||||
|
||||
expect(result).toEqual([
|
||||
expectedMetadata[0], // first image
|
||||
null, // pdf
|
||||
expectedMetadata[1], // second image
|
||||
null, // pdf
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not call fetch and throw if aiMetadata is false, even if AI and EE are enabled', async () => {
|
||||
mockStrapi.config.get.mockReturnValue(true);
|
||||
mockGetSettings.mockResolvedValue({ aiMetadata: false });
|
||||
mockStrapi.ee.isEE = true;
|
||||
|
||||
const files = [mockImageFile, mockPdfFile, mockImageFile2, mockPdfFile];
|
||||
|
||||
await expect(aiMetadataService.processFiles(files)).rejects.toThrow(
|
||||
'AI Metadata service is not enabled'
|
||||
);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
113
packages/core/upload/server/src/services/ai-metadata.ts
Normal file
113
packages/core/upload/server/src/services/ai-metadata.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import type { Core } from '@strapi/types';
|
||||
import { z } from 'zod';
|
||||
import { InputFile } from '../types';
|
||||
import { Settings } from '../controllers/validation/admin/settings';
|
||||
|
||||
const createAIMetadataService = ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
const aiServerUrl = process.env.STRAPI_AI_URL || 'https://strapi-ai.apps.strapi.io';
|
||||
|
||||
return {
|
||||
async isEnabled() {
|
||||
// Check if user disabled AI features globally
|
||||
const isAIEnabled = strapi.config.get('admin.ai.enabled', true);
|
||||
if (!isAIEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the user's license grants access to AI features
|
||||
const hasAccess = strapi.ee.features.isEnabled('cms-ai');
|
||||
if (!hasAccess) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if feature is specifically enabled, defaulting to true
|
||||
const settings: Settings = await strapi.plugin('upload').service('upload').getSettings();
|
||||
const aiMetadata: boolean = settings.aiMetadata ?? true;
|
||||
|
||||
return aiMetadata;
|
||||
},
|
||||
|
||||
async processFiles(
|
||||
files: InputFile[]
|
||||
): Promise<Array<{ altText: string; caption: string } | null>> {
|
||||
if (!(await this.isEnabled()) || !aiServerUrl) {
|
||||
throw new Error('AI Metadata service is not enabled');
|
||||
}
|
||||
|
||||
// Filter for image files only and track their original positions
|
||||
// We need to maintain the original indices so we can map AI results back correctly
|
||||
const imageFiles = files
|
||||
.map((file, index) => ({ file, originalIndex: index }))
|
||||
.filter(({ file }) => file.mimetype?.startsWith('image/'));
|
||||
|
||||
// If no image files, return sparse array with all nulls to avoid calling the AI server
|
||||
// This maintains the same array length as input files for proper index alignment
|
||||
if (imageFiles.length === 0) {
|
||||
return new Array(files.length).fill(null);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
for (const { file } of imageFiles) {
|
||||
const fullUrl =
|
||||
file.provider === 'local'
|
||||
? strapi.config.get('server.url') + file.filepath
|
||||
: file.filepath;
|
||||
|
||||
const resp = await fetch(fullUrl);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch image from URL: ${fullUrl} (${resp.status})`);
|
||||
}
|
||||
const ab = await resp.arrayBuffer();
|
||||
const blob: Blob = new Blob([ab], { type: file.mimetype || undefined });
|
||||
formData.append('files', blob);
|
||||
}
|
||||
|
||||
let token: string;
|
||||
try {
|
||||
const tokenData = await strapi.service('admin::user').getAiToken();
|
||||
token = tokenData.token;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to retrieve AI token', {
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
strapi.log.http('Contacting AI Server for media metadata generation');
|
||||
const res = await fetch(`${aiServerUrl}/media-library/generate-metadata`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw Error(`AI metadata generation failed`, { cause: await res.text() });
|
||||
}
|
||||
|
||||
const responseSchema = z.object({
|
||||
results: z.array(
|
||||
z.object({
|
||||
altText: z.string(),
|
||||
caption: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const { results } = responseSchema.parse(await res.json());
|
||||
strapi.log.http(`Media metadata generated successfully for ${results.length} files`);
|
||||
|
||||
// Create sparse array with results at original indices
|
||||
// Example: files=[img1, pdf, img2] -> imageFiles=[{img1, index:0}, {img2, index:2}]
|
||||
// AI results=[meta1, meta2] -> sparse=[meta1, null, meta2]
|
||||
// This ensures metadata[i] corresponds to files[i], with null for non-images
|
||||
return imageFiles.reduce((sparseResults, { originalIndex }, resultIndex) => {
|
||||
sparseResults[originalIndex] = results[resultIndex];
|
||||
return sparseResults;
|
||||
}, new Array(files.length).fill(null));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export { createAIMetadataService };
|
@ -7,6 +7,7 @@ import weeklyMetrics from './weekly-metrics';
|
||||
import metrics from './metrics';
|
||||
import apiUploadFolder from './api-upload-folder';
|
||||
import extensions from './extensions';
|
||||
import { createAIMetadataService } from './ai-metadata';
|
||||
|
||||
export const services = {
|
||||
provider,
|
||||
@ -18,4 +19,5 @@ export const services = {
|
||||
'image-manipulation': imageManipulation,
|
||||
'api-upload-folder': apiUploadFolder,
|
||||
extensions,
|
||||
aiMetadata: createAIMetadataService,
|
||||
};
|
||||
|
@ -223,7 +223,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
files,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
files: InputFile | InputFile[];
|
||||
files: InputFile[];
|
||||
},
|
||||
opts?: CommonOptions
|
||||
) {
|
||||
|
@ -3,6 +3,7 @@ import type { File as FormidableFile } from 'formidable';
|
||||
export type InputFile = FormidableFile & {
|
||||
path?: string;
|
||||
tmpWorkingDirectory?: string;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
export interface File {
|
||||
|
@ -7,6 +7,7 @@ import type file from '../services/file';
|
||||
import type weeklyMetrics from '../services/weekly-metrics';
|
||||
import type metrics from '../services/metrics';
|
||||
import type extensions from '../services/extensions';
|
||||
import type { createAIMetadataService } from '../services/ai-metadata';
|
||||
|
||||
type Services = {
|
||||
upload: ReturnType<typeof upload>;
|
||||
@ -18,6 +19,7 @@ type Services = {
|
||||
metrics: ReturnType<typeof metrics>;
|
||||
'api-upload-folder': typeof apiUploadFolder;
|
||||
extensions: typeof extensions;
|
||||
aiMetadata: ReturnType<typeof createAIMetadataService>;
|
||||
};
|
||||
|
||||
export const getService = <TName extends keyof Services>(name: TName): Services[TName] => {
|
||||
|
@ -234,3 +234,27 @@ export declare namespace UpdateFile {
|
||||
error?: errors.ApplicationError | errors.ValidationError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /upload/actions/bulk-update - Bulk update files
|
||||
*/
|
||||
export declare namespace BulkUpdateFiles {
|
||||
export interface Request {
|
||||
body: {
|
||||
updates: Array<{
|
||||
id: number;
|
||||
fileInfo: {
|
||||
name?: string;
|
||||
alternativeText?: string | null;
|
||||
caption?: string | null;
|
||||
folder?: number | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
data: File[];
|
||||
error?: errors.ApplicationError | errors.ValidationError;
|
||||
}
|
||||
}
|
||||
|
@ -12,9 +12,12 @@ export interface Settings {
|
||||
responsiveDimensions?: boolean;
|
||||
autoOrientation?: boolean;
|
||||
videoPreview?: boolean;
|
||||
aiMetadata?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type SettingsData = Settings['data'];
|
||||
|
||||
/**
|
||||
* GET /upload/settings
|
||||
*
|
||||
|
@ -43,6 +43,7 @@ describe('Settings', () => {
|
||||
autoOrientation: false,
|
||||
sizeOptimization: true,
|
||||
responsiveDimensions: true,
|
||||
aiMetadata: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -5,6 +5,8 @@ import { clickAndWait, navToHeader } from '../../utils/shared';
|
||||
import { waitForRestart } from '../../utils/restart';
|
||||
import { EDITOR_EMAIL_ADDRESS, EDITOR_PASSWORD } from '../../constants';
|
||||
|
||||
const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE';
|
||||
|
||||
test.describe('Home as super admin', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await resetDatabaseAndImportDataFromPath('with-admin.tar');
|
||||
@ -174,10 +176,13 @@ test.describe('Home as super admin', () => {
|
||||
await page
|
||||
.getByLabel('Drag & Drop here or')
|
||||
.setInputFiles('public/assets/administration_panel.png');
|
||||
await page
|
||||
.getByRole('button', { name: 'Upload 1 asset to the library' })
|
||||
.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await page.getByRole('button', { name: 'Upload 1 asset to the library' }).click();
|
||||
const uploadButton = page.getByRole('button', { name: 'Upload 1 asset to the library' });
|
||||
try {
|
||||
await uploadButton.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await uploadButton.click();
|
||||
} catch {
|
||||
await page.getByRole('button', { name: /^finish$/i }).click();
|
||||
}
|
||||
await clickAndWait(page, page.getByRole('link', { name: 'Home' }));
|
||||
|
||||
// Create a content type and a component
|
||||
|
135
yarn.lock
135
yarn.lock
@ -13430,7 +13430,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:1.12.2, axios@npm:^1.6.0, axios@npm:^1.6.8, axios@npm:^1.7.4":
|
||||
"axios@npm:1.12.2":
|
||||
version: 1.12.2
|
||||
resolution: "axios@npm:1.12.2"
|
||||
dependencies:
|
||||
@ -13441,6 +13441,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^1.6.0, axios@npm:^1.7.4":
|
||||
version: 1.8.2
|
||||
resolution: "axios@npm:1.8.2"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.0"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10c0/d8c2969e4642dc6d39555ac58effe06c051ba7aac2bd40cad7a9011c019fb2f16ee011c5a6906cb25b8a4f87258c359314eb981f852e60ad445ecaeb793c7aa2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axios@npm:^1.6.8":
|
||||
version: 1.7.7
|
||||
resolution: "axios@npm:1.7.7"
|
||||
dependencies:
|
||||
follow-redirects: "npm:^1.15.6"
|
||||
form-data: "npm:^4.0.0"
|
||||
proxy-from-env: "npm:^1.1.0"
|
||||
checksum: 10c0/4499efc89e86b0b49ffddc018798de05fab26e3bf57913818266be73279a6418c3ce8f9e934c7d2d707ab8c095e837fc6c90608fb7715b94d357720b5f568af7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"axobject-query@npm:^3.1.1":
|
||||
version: 3.1.1
|
||||
resolution: "axobject-query@npm:3.1.1"
|
||||
@ -14120,7 +14142,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
|
||||
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "call-bind-apply-helpers@npm:1.0.2"
|
||||
dependencies:
|
||||
@ -14143,28 +14165,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"call-bind@npm:^1.0.8":
|
||||
version: 1.0.8
|
||||
resolution: "call-bind@npm:1.0.8"
|
||||
dependencies:
|
||||
call-bind-apply-helpers: "npm:^1.0.0"
|
||||
es-define-property: "npm:^1.0.0"
|
||||
get-intrinsic: "npm:^1.2.4"
|
||||
set-function-length: "npm:^1.2.2"
|
||||
checksum: 10c0/a13819be0681d915144467741b69875ae5f4eba8961eb0bf322aab63ec87f8250eb6d6b0dcbb2e1349876412a56129ca338592b3829ef4343527f5f18a0752d4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"call-bound@npm:^1.0.3, call-bound@npm:^1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "call-bound@npm:1.0.4"
|
||||
dependencies:
|
||||
call-bind-apply-helpers: "npm:^1.0.2"
|
||||
get-intrinsic: "npm:^1.3.0"
|
||||
checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"call-me-maybe@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "call-me-maybe@npm:1.0.1"
|
||||
@ -18667,15 +18667,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"for-each@npm:^0.3.5":
|
||||
version: 0.3.5
|
||||
resolution: "for-each@npm:0.3.5"
|
||||
dependencies:
|
||||
is-callable: "npm:^1.2.7"
|
||||
checksum: 10c0/0e0b50f6a843a282637d43674d1fb278dda1dd85f4f99b640024cfb10b85058aac0cc781bf689d5fe50b4b7f638e91e548560723a4e76e04fe96ae35ef039cee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"for-in@npm:^1.0.1":
|
||||
version: 1.0.2
|
||||
resolution: "for-in@npm:1.0.2"
|
||||
@ -18732,7 +18723,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"form-data@npm:4.0.4, form-data@npm:^4.0.0, form-data@npm:^4.0.4":
|
||||
"form-data@npm:4.0.4, form-data@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "form-data@npm:4.0.4"
|
||||
dependencies:
|
||||
@ -18745,6 +18736,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"form-data@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "form-data@npm:4.0.0"
|
||||
dependencies:
|
||||
asynckit: "npm:^0.4.0"
|
||||
combined-stream: "npm:^1.0.8"
|
||||
mime-types: "npm:^2.1.12"
|
||||
checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"form-data@npm:~2.3.2":
|
||||
version: 2.3.3
|
||||
resolution: "form-data@npm:2.3.3"
|
||||
@ -19054,7 +19056,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0":
|
||||
"get-intrinsic@npm:^1.2.6":
|
||||
version: 1.3.0
|
||||
resolution: "get-intrinsic@npm:1.3.0"
|
||||
dependencies:
|
||||
@ -21300,15 +21302,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-typed-array@npm:^1.1.14":
|
||||
version: 1.1.15
|
||||
resolution: "is-typed-array@npm:1.1.15"
|
||||
dependencies:
|
||||
which-typed-array: "npm:^1.1.16"
|
||||
checksum: 10c0/415511da3669e36e002820584e264997ffe277ff136643a3126cc949197e6ca3334d0f12d084e83b1994af2e9c8141275c741cf2b7da5a2ff62dd0cac26f76c4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-typedarray@npm:^1.0.0, is-typedarray@npm:~1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "is-typedarray@npm:1.0.0"
|
||||
@ -28860,7 +28853,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0":
|
||||
"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.2, safe-buffer@npm:~5.2.0":
|
||||
version: 5.2.1
|
||||
resolution: "safe-buffer@npm:5.2.1"
|
||||
checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3
|
||||
@ -29157,7 +29150,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"set-function-length@npm:^1.2.1, set-function-length@npm:^1.2.2":
|
||||
"set-function-length@npm:^1.2.1":
|
||||
version: 1.2.2
|
||||
resolution: "set-function-length@npm:1.2.2"
|
||||
dependencies:
|
||||
@ -29216,15 +29209,14 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"sha.js@npm:^2.4.11":
|
||||
version: 2.4.12
|
||||
resolution: "sha.js@npm:2.4.12"
|
||||
version: 2.4.11
|
||||
resolution: "sha.js@npm:2.4.11"
|
||||
dependencies:
|
||||
inherits: "npm:^2.0.4"
|
||||
safe-buffer: "npm:^5.2.1"
|
||||
to-buffer: "npm:^1.2.0"
|
||||
inherits: "npm:^2.0.1"
|
||||
safe-buffer: "npm:^5.0.1"
|
||||
bin:
|
||||
sha.js: bin.js
|
||||
checksum: 10c0/9d36bdd76202c8116abbe152a00055ccd8a0099cb28fc17c01fa7bb2c8cffb9ca60e2ab0fe5f274ed6c45dc2633d8c39cf7ab050306c231904512ba9da4d8ab1
|
||||
sha.js: ./bin.js
|
||||
checksum: 10c0/b7a371bca8821c9cc98a0aeff67444a03d48d745cb103f17228b96793f455f0eb0a691941b89ea1e60f6359207e36081d9be193252b0f128e0daf9cfea2815a5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -30929,17 +30921,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"to-buffer@npm:^1.2.0":
|
||||
version: 1.2.1
|
||||
resolution: "to-buffer@npm:1.2.1"
|
||||
dependencies:
|
||||
isarray: "npm:^2.0.5"
|
||||
safe-buffer: "npm:^5.2.1"
|
||||
typed-array-buffer: "npm:^1.0.3"
|
||||
checksum: 10c0/bbf07a2a7d6ff9e3ffe503c689176c7149cf3ec25887ce7c4aa5c4841a8845cc71121cd7b4a4769957f823b3f31dbf6b1be6e0a5955798ad864bf2245ee8b5e4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"to-fast-properties@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "to-fast-properties@npm:2.0.0"
|
||||
@ -31420,17 +31401,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typed-array-buffer@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "typed-array-buffer@npm:1.0.3"
|
||||
dependencies:
|
||||
call-bound: "npm:^1.0.3"
|
||||
es-errors: "npm:^1.3.0"
|
||||
is-typed-array: "npm:^1.1.14"
|
||||
checksum: 10c0/1105071756eb248774bc71646bfe45b682efcad93b55532c6ffa4518969fb6241354e4aa62af679ae83899ec296d69ef88f1f3763657cdb3a4d29321f7b83079
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typed-array-byte-length@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "typed-array-byte-length@npm:1.0.0"
|
||||
@ -32699,21 +32669,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"which-typed-array@npm:^1.1.16":
|
||||
version: 1.1.19
|
||||
resolution: "which-typed-array@npm:1.1.19"
|
||||
dependencies:
|
||||
available-typed-arrays: "npm:^1.0.7"
|
||||
call-bind: "npm:^1.0.8"
|
||||
call-bound: "npm:^1.0.4"
|
||||
for-each: "npm:^0.3.5"
|
||||
get-proto: "npm:^1.0.1"
|
||||
gopd: "npm:^1.2.0"
|
||||
has-tostringtag: "npm:^1.0.2"
|
||||
checksum: 10c0/702b5dc878addafe6c6300c3d0af5983b175c75fcb4f2a72dfc3dd38d93cf9e89581e4b29c854b16ea37e50a7d7fca5ae42ece5c273d8060dcd603b2404bbb3f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"which@npm:^1.2.14, which@npm:^1.2.9":
|
||||
version: 1.3.1
|
||||
resolution: "which@npm:1.3.1"
|
||||
|
Loading…
x
Reference in New Issue
Block a user