diff --git a/packages/core/admin/admin/src/index.ts b/packages/core/admin/admin/src/index.ts index 9cc7d9161e..765a82cc35 100644 --- a/packages/core/admin/admin/src/index.ts +++ b/packages/core/admin/admin/src/index.ts @@ -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'; diff --git a/packages/core/admin/admin/src/services/api.ts b/packages/core/admin/admin/src/services/api.ts index 8ff8a49af4..bf0b96cdbc 100644 --- a/packages/core/admin/admin/src/services/api.ts +++ b/packages/core/admin/admin/src/services/api.ts @@ -14,7 +14,7 @@ import { fetchBaseQuery } from '../utils/baseQuery'; const adminApi = createApi({ reducerPath: 'adminApi', baseQuery: fetchBaseQuery(), - tagTypes: ['GuidedTourMeta', 'HomepageKeyStatistics'], + tagTypes: ['GuidedTourMeta', 'HomepageKeyStatistics', 'AIUsage'], endpoints: () => ({}), }); diff --git a/packages/core/admin/admin/src/services/auth.ts b/packages/core/admin/admin/src/services/auth.ts index 359f0c6dd6..5032b50d19 100644 --- a/packages/core/admin/admin/src/services/auth.ts +++ b/packages/core/admin/admin/src/services/auth.ts @@ -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({ + 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, diff --git a/packages/core/admin/ee/admin/src/services/ai.ts b/packages/core/admin/ee/admin/src/services/ai.ts index 546e3b63f3..302ad6cd61 100644 --- a/packages/core/admin/ee/admin/src/services/ai.ts +++ b/packages/core/admin/ee/admin/src/services/ai.ts @@ -8,6 +8,7 @@ const aiService = adminApi.injectEndpoints({ method: 'GET', url: `/admin/ai-usage`, }), + providesTags: ['AIUsage'], }), getAiToken: builder.query({ query: () => ({ @@ -19,7 +20,7 @@ const aiService = adminApi.injectEndpoints({ }, }), }), - overrideExisting: false, + overrideExisting: true, }); const { useGetAIUsageQuery, useGetAiTokenQuery, useLazyGetAiTokenQuery } = aiService; diff --git a/packages/core/admin/server/src/controllers/__tests__/authenticated-user.test.ts b/packages/core/admin/server/src/controllers/__tests__/authenticated-user.test.ts new file mode 100644 index 0000000000..55b854546a --- /dev/null +++ b/packages/core/admin/server/src/controllers/__tests__/authenticated-user.test.ts @@ -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, + }); + }); + }); +}); diff --git a/packages/core/admin/server/src/controllers/authenticated-user.ts b/packages/core/admin/server/src/controllers/authenticated-user.ts index 6263c4d040..b855ea2fe2 100644 --- a/packages/core/admin/server/src/controllers/authenticated-user.ts +++ b/packages/core/admin/server/src/controllers/authenticated-user.ts @@ -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); + } + }, }; diff --git a/packages/core/admin/server/src/routes/users.ts b/packages/core/admin/server/src/routes/users.ts index 61924543f9..b2aff7ee50 100644 --- a/packages/core/admin/server/src/routes/users.ts +++ b/packages/core/admin/server/src/routes/users.ts @@ -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', diff --git a/packages/core/admin/server/src/services/user.ts b/packages/core/admin/server/src/services/user.ts index b188cd69e9..5e0b846ee8 100644 --- a/packages/core/admin/server/src/services/user.ts +++ b/packages/core/admin/server/src/services/user.ts @@ -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 => { 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, }; diff --git a/packages/core/admin/shared/contracts/users.ts b/packages/core/admin/shared/contracts/users.ts index e994352584..4ecc49b477 100644 --- a/packages/core/admin/shared/contracts/users.ts +++ b/packages/core/admin/shared/contracts/users.ts @@ -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; + } +} diff --git a/packages/core/upload/admin/src/ai/components/AIAssetCard.tsx b/packages/core/upload/admin/src/ai/components/AIAssetCard.tsx new file mode 100644 index 0000000000..9d7d9bc1f2 --- /dev/null +++ b/packages/core/upload/admin/src/ai/components/AIAssetCard.tsx @@ -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) => { + 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 ( + + + + + + + + + + + + + + + + + + handleEditAsset(arg as File)} + canUpdate={canUpdate} + canCopyLink={canCopyLink} + canDownload={canDownload} + omitFields={['caption', 'alternativeText']} + omitActions={['replace']} + /> + + + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * 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(); + const formattedDuration = duration ? formatDuration(duration) : undefined; + + switch (assetType) { + case AssetType.Image: + return ; + case AssetType.Video: + return ( + + + + {formattedDuration && {formattedDuration}} + + + ); + case AssetType.Audio: + return ( + + + + + + ); + default: + return ; + } +}; + +/* ------------------------------------------------------------------------------------------------- + * 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 ( + + + + + + + + + + + + + {asset.name} + + {formatMessage(getAssetBadgeLabel(assetType))} + + + + {getFileExtension(asset.ext)} + {fullSubtitle} + + + + + + + + + + + + + {formatMessage({ + id: getTrad('form.input.label.file-caption'), + defaultMessage: 'Caption', + })} + + + setCaption(e.target.value)} + placeholder={formatMessage({ + id: getTrad('form.input.placeholder.file-caption'), + defaultMessage: 'Enter caption', + })} + endAction={ + !wasCaptionChanged && + asset.caption && + } + /> + + + + + + {formatMessage({ + id: getTrad('form.input.label.file-alt'), + defaultMessage: 'Alternative text', + })} + + + + setAltText(e.target.value)} + placeholder={formatMessage({ + id: getTrad('form.input.placeholder.file-alt'), + defaultMessage: 'Enter alternative text', + })} + endAction={ + !wasAltTextChanged && + asset.alternativeText && + } + /> + + + + + + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * 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) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )); +}; diff --git a/packages/core/upload/admin/src/ai/components/AIUploadModal.tsx b/packages/core/upload/admin/src/ai/components/AIUploadModal.tsx new file mode 100644 index 0000000000..08bcc0257f --- /dev/null +++ b/packages/core/upload/admin/src/ai/components/AIUploadModal.tsx @@ -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) => { + 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(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 ( + + + + ); + } + + if ( + isUploading || + (state.assetsToUploadLength > 0 && state.uploadedAssets.length === 0 && !uploadError) + ) { + return ( + + + + {formatMessage({ + id: getTrad('ai.modal.uploading.title'), + defaultMessage: 'Uploading and processing with AI...', + })} + + + + + + + ); + } + + 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 ( + + + {title} + + + + {formatMessage({ + id: getTrad('ai.modal.error'), + defaultMessage: 'Could not generate AI metadata for the uploaded files.', + })} + + + + + + + + ); + } + + return ( + + + {title} + + + + + {state.uploadedAssets.map(({ file: asset, wasCaptionChanged, wasAltTextChanged }) => ( + + asset.id && handleCaptionChange(asset.id, caption) + } + onAltTextChange={(altText: string) => + asset.id && handleAltTextChange(asset.id, altText) + } + wasCaptionChanged={wasCaptionChanged} + wasAltTextChanged={wasAltTextChanged} + /> + ))} + + + + + + + + + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * 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; + 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 ( + + + + + + ); +}; + +export { useAIUploadModalContext }; diff --git a/packages/core/upload/admin/src/components/EditAssetDialog/EditAssetContent.tsx b/packages/core/upload/admin/src/components/EditAssetDialog/EditAssetContent.tsx index aed5019cff..1b86a27f9f 100644 --- a/packages/core/upload/admin/src/components/EditAssetDialog/EditAssetContent.tsx +++ b/packages/core/upload/admin/src/components/EditAssetDialog/EditAssetContent.tsx @@ -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 = ({ - - - {formatMessage({ - id: getTrad('form.input.label.file-alt'), - defaultMessage: 'Alternative text', + {!omitFields?.includes('alternativeText') && ( + - - - - + error={errors.alternativeText} + > + + {formatMessage({ + id: getTrad('form.input.label.file-alt'), + defaultMessage: 'Alternative text', + })} + + + + + + )} - - - {formatMessage({ - id: getTrad('form.input.label.file-caption'), - defaultMessage: 'Caption', - })} - - - + {!omitFields?.includes('caption') && ( + + + {formatMessage({ + id: getTrad('form.input.label.file-caption'), + defaultMessage: 'Caption', + })} + + + + )} @@ -356,12 +365,14 @@ export const EditAssetContent = ({ {formatMessage({ id: 'global.cancel', defaultMessage: 'Cancel' })} - + {!omitActions?.includes('replace') && ( + + )}