@focus-reactive/payload-plugin-translator 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/client/app/cache/CacheProvider.jsx +11 -0
  2. package/dist/client/app/config/TranslateKitConfigContext.jsx +13 -0
  3. package/dist/client/entities/translation/ui/CompletedTranslationStatus/CompletedTranslationStatus.jsx +25 -0
  4. package/dist/client/entities/translation/ui/FailedTranslationStatus/FailedTranslationStatus.jsx +24 -0
  5. package/dist/client/entities/translation/ui/PendingTranslationStatus/PendingTranslationStatus.jsx +38 -0
  6. package/dist/client/entities/translation/ui/RunningTranslationStatus/RunningTranslationStatus.jsx +32 -0
  7. package/dist/client/entities/translation/ui/TranslationDirection/TranslationDirection.jsx +16 -0
  8. package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.jsx +25 -0
  9. package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.jsx +23 -0
  10. package/dist/client/features/collection-translation-progress/ui/CollectionTranslationProgress.jsx +47 -0
  11. package/dist/client/features/document-translation-progress-failed/ui/DocumentTranslationProgressFailed.jsx +19 -0
  12. package/dist/client/features/document-translation-progress-pending/ui/DocumentTranslationProgressPending.jsx +34 -0
  13. package/dist/client/features/document-translation-progress-running/ui/DocumentTranslationProgressRunning.jsx +19 -0
  14. package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.jsx +15 -0
  15. package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.jsx +24 -0
  16. package/dist/client/shared/lib/assets/icons/LanguageTranslateIcon.jsx +4 -0
  17. package/dist/client/shared/lib/assets/icons/QuestionCircleIcon.jsx +3 -0
  18. package/dist/client/shared/lib/assets/icons/ReloadIcon.jsx +3 -0
  19. package/dist/client/shared/ui/Alert/Alert.jsx +33 -0
  20. package/dist/client/shared/ui/Button/Button.jsx +35 -0
  21. package/dist/client/shared/ui/Checkbox/Checkbox.jsx +6 -0
  22. package/dist/client/shared/ui/ColorIndicator/ColorIndicator.jsx +7 -0
  23. package/dist/client/shared/ui/Description/Description.jsx +7 -0
  24. package/dist/client/shared/ui/Divider/Divider.jsx +7 -0
  25. package/dist/client/shared/ui/Label/Label.jsx +9 -0
  26. package/dist/client/shared/ui/Loading/Loading.jsx +11 -0
  27. package/dist/client/shared/ui/Popup/Popup.jsx +13 -0
  28. package/dist/client/shared/ui/ProgressBar/ProgressBar.jsx +11 -0
  29. package/dist/client/shared/ui/Select/Select.jsx +41 -0
  30. package/dist/client/shared/ui/StatusCounter/StatusCounter.jsx +26 -0
  31. package/dist/client/shared/ui/StatusIndicator/StatusIndicator.jsx +10 -0
  32. package/dist/client/shared/ui/Tooltip/Tooltip.jsx +16 -0
  33. package/dist/client/shared/ui/form/FormCheckbox/FormCheckbox.jsx +24 -0
  34. package/dist/client/shared/ui/form/FormCheckboxPublish/FormCheckboxPublish.jsx +21 -0
  35. package/dist/client/shared/ui/form/FormSelect/FormSelect.jsx +44 -0
  36. package/dist/client/shared/ui/form/FormSelectLocale/FormSelectLocale.jsx +6 -0
  37. package/dist/client/shared/ui/form/FormSelectStrategy/FormSelectStrategy.jsx +25 -0
  38. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.export.jsx +11 -0
  39. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.jsx +55 -0
  40. package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.jsx +17 -0
  41. package/dist/client/widgets/translate-document/ui/TranslateDocument.jsx +55 -0
  42. package/dist/client/widgets/translate-document/ui/TranslateDocument.server.jsx +16 -0
  43. package/dist/plugin.js +7 -3
  44. package/dist/server/features/translate-document/handler.js +4 -2
  45. package/package.json +1 -1
@@ -0,0 +1,11 @@
1
+ 'use client';
2
+ import { QueryClientProvider } from '@tanstack/react-query';
3
+ import { CacheClientProviderFactory } from '../../shared/providers/CacheClientProvider';
4
+ import { TranslateKitConfigProvider } from '../config';
5
+ const queryClient = new CacheClientProviderFactory().create('client');
6
+ export function CacheProvider({ basePath, children }) {
7
+ return (<TranslateKitConfigProvider basePath={basePath}>
8
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
9
+ </TranslateKitConfigProvider>);
10
+ }
11
+ export default CacheProvider;
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+ import { createContext, useContext, useMemo } from 'react';
3
+ const defaultConfig = {
4
+ basePath: '/translate',
5
+ };
6
+ const TranslateKitConfigContext = createContext(defaultConfig);
7
+ export function TranslateKitConfigProvider({ basePath = defaultConfig.basePath, children, }) {
8
+ const value = useMemo(() => ({ basePath }), [basePath]);
9
+ return <TranslateKitConfigContext.Provider value={value}>{children}</TranslateKitConfigContext.Provider>;
10
+ }
11
+ export function useTranslateKitConfig() {
12
+ return useContext(TranslateKitConfigContext);
13
+ }
@@ -0,0 +1,25 @@
1
+ import { LanguageTranslateIcon } from '../../../../shared/lib/assets/icons/LanguageTranslateIcon';
2
+ import Button from '../../../../shared/ui/Button';
3
+ import StatusIndicator from '../../../../shared/ui/StatusIndicator';
4
+ import Tooltip from '../../../../shared/ui/Tooltip';
5
+ import { TranslationDirection } from '../TranslationDirection';
6
+ import styles from './styles.module.scss';
7
+ export function CompletedTranslationStatus({ completed_at, sourceLocale, targetLocale, }) {
8
+ return (<StatusIndicator className={styles.indicator} $color="green" title="Completed">
9
+ <Tooltip sideOffset={12} side="bottom" content={<time dateTime={completed_at}>
10
+ Completed at:{' '}
11
+ {new Date(completed_at).toLocaleString(undefined, {
12
+ year: 'numeric',
13
+ month: 'short',
14
+ day: 'numeric',
15
+ hour: '2-digit',
16
+ minute: '2-digit',
17
+ })}
18
+ </time>}>
19
+ <Button type="button" $variant="unstyled">
20
+ <LanguageTranslateIcon />
21
+ </Button>
22
+ </Tooltip>
23
+ <TranslationDirection sourceLocale={sourceLocale} targetLocale={targetLocale}/>
24
+ </StatusIndicator>);
25
+ }
@@ -0,0 +1,24 @@
1
+ import { LanguageTranslateIcon } from '../../../../shared/lib/assets/icons/LanguageTranslateIcon';
2
+ import { ReloadIcon } from '../../../../shared/lib/assets/icons/ReloadIcon';
3
+ import Button from '../../../../shared/ui/Button';
4
+ import Divider from '../../../../shared/ui/Divider';
5
+ import StatusIndicator from '../../../../shared/ui/StatusIndicator';
6
+ import Tooltip from '../../../../shared/ui/Tooltip';
7
+ import { TranslationDirection } from '../TranslationDirection';
8
+ import styles from './styles.module.scss';
9
+ export function FailedTranslationStatus({ onRetry, message, isLoading, sourceLocale, targetLocale, }) {
10
+ return (<StatusIndicator $color="red" title="Failed">
11
+ <Tooltip sideOffset={12} side="bottom" content={message}>
12
+ <Button type="button" $variant="unstyled">
13
+ <LanguageTranslateIcon />
14
+ </Button>
15
+ </Tooltip>
16
+ <TranslationDirection sourceLocale={sourceLocale} targetLocale={targetLocale}/>
17
+ <Divider className={styles.divider} $orientation="vertical" $size="sm"/>
18
+ <Tooltip side="bottom" sideOffset={12} content="Retry">
19
+ <Button $variant="light" $size="sm" $isIconButton aria-label="Retry failed translation" type="button" onClick={onRetry} disabled={isLoading} $isLoading={isLoading}>
20
+ <ReloadIcon />
21
+ </Button>
22
+ </Tooltip>
23
+ </StatusIndicator>);
24
+ }
@@ -0,0 +1,38 @@
1
+ import { TrashIcon } from '@payloadcms/ui/icons/Trash';
2
+ import { LanguageTranslateIcon } from '../../../../shared/lib/assets/icons/LanguageTranslateIcon';
3
+ import { ReloadIcon } from '../../../../shared/lib/assets/icons/ReloadIcon';
4
+ import Button from '../../../../shared/ui/Button';
5
+ import Divider from '../../../../shared/ui/Divider';
6
+ import StatusIndicator from '../../../../shared/ui/StatusIndicator';
7
+ import Tooltip from '../../../../shared/ui/Tooltip';
8
+ import { TranslationDirection } from '../TranslationDirection';
9
+ export function PendingTranslationStatus({ onCancel, onRun, isLoading, disabled, sourceLocale, targetLocale, createdAt, }) {
10
+ return (<StatusIndicator $color="gray" $animated title="Pending">
11
+ <Tooltip side="bottom" sideOffset={12} content={<time dateTime={createdAt}>
12
+ Created at:{' '}
13
+ {new Date(createdAt).toLocaleString(undefined, {
14
+ year: 'numeric',
15
+ month: 'short',
16
+ day: 'numeric',
17
+ hour: '2-digit',
18
+ minute: '2-digit',
19
+ })}
20
+ </time>}>
21
+ <Button type="button" $variant="unstyled">
22
+ <LanguageTranslateIcon />
23
+ </Button>
24
+ </Tooltip>
25
+ <TranslationDirection sourceLocale={sourceLocale} targetLocale={targetLocale}/>
26
+ <Divider $size="sm" $orientation="vertical"/>
27
+ <Tooltip sideOffset={12} side="bottom" content="Cancel">
28
+ <Button $variant="light" $size="sm" $isIconButton aria-label="Cancel" type="button" onClick={onCancel} disabled={isLoading || disabled} $isLoading={isLoading}>
29
+ <TrashIcon />
30
+ </Button>
31
+ </Tooltip>
32
+ <Tooltip sideOffset={12} side="bottom" content="Run queued translation">
33
+ <Button $variant="light" $size="sm" $isIconButton aria-label="Run queued translation" type="button" onClick={onRun} disabled={isLoading || disabled} $isLoading={isLoading}>
34
+ <ReloadIcon />
35
+ </Button>
36
+ </Tooltip>
37
+ </StatusIndicator>);
38
+ }
@@ -0,0 +1,32 @@
1
+ import { TrashIcon } from '@payloadcms/ui/icons/Trash';
2
+ import { LanguageTranslateIcon } from '../../../../shared/lib/assets/icons/LanguageTranslateIcon';
3
+ import Button from '../../../../shared/ui/Button';
4
+ import Divider from '../../../../shared/ui/Divider';
5
+ import StatusIndicator from '../../../../shared/ui/StatusIndicator';
6
+ import Tooltip from '../../../../shared/ui/Tooltip';
7
+ import { TranslationDirection } from '../TranslationDirection';
8
+ export function RunningTranslationStatus({ onCancel, isLoading, disabled, sourceLocale, targetLocale, createdAt, }) {
9
+ return (<StatusIndicator $animated $color="blue" title="In Progress">
10
+ <Tooltip side="bottom" sideOffset={12} content={<time dateTime={createdAt}>
11
+ Created at:{' '}
12
+ {new Date(createdAt).toLocaleString(undefined, {
13
+ year: 'numeric',
14
+ month: 'short',
15
+ day: 'numeric',
16
+ hour: '2-digit',
17
+ minute: '2-digit',
18
+ })}
19
+ </time>}>
20
+ <Button type="button" $variant="unstyled">
21
+ <LanguageTranslateIcon />
22
+ </Button>
23
+ </Tooltip>
24
+ <TranslationDirection sourceLocale={sourceLocale} targetLocale={targetLocale}/>
25
+ <Divider $size="sm" $orientation="vertical"/>
26
+ <Tooltip sideOffset={12} side="bottom" content="Cancel">
27
+ <Button $variant="light" $size="sm" $isIconButton aria-label="Cancel" type="button" onClick={onCancel} disabled={isLoading || disabled} $isLoading={isLoading}>
28
+ <TrashIcon />
29
+ </Button>
30
+ </Tooltip>
31
+ </StatusIndicator>);
32
+ }
@@ -0,0 +1,16 @@
1
+ import styles from './styles.module.scss';
2
+ export const TranslationDirection = ({ sourceLocale, targetLocale, className }) => {
3
+ return (<div className={`${styles.container} ${className || ''}`} role="group" aria-label={`Translation from ${sourceLocale} to ${targetLocale}`}>
4
+ <span className={styles.locale} aria-label={`Source language: ${sourceLocale}`}>
5
+ {sourceLocale.toLowerCase()}
6
+ </span>
7
+
8
+ <div className={styles.arrow} aria-hidden="true">
9
+
10
+ </div>
11
+
12
+ <span className={styles.locale} aria-label={`Target language: ${targetLocale}`}>
13
+ {targetLocale.toLowerCase()}
14
+ </span>
15
+ </div>);
16
+ };
@@ -0,0 +1,25 @@
1
+ import { FormProvider } from 'react-hook-form';
2
+ import Button from '../../../shared/ui/Button';
3
+ import FormSelectLocale from '../../../shared/ui/form/FormSelectLocale';
4
+ import { FormSelectStrategy } from '../../../shared/ui/form/FormSelectStrategy';
5
+ import { FormCheckboxPublish } from '../../../shared/ui/form/FormCheckboxPublish';
6
+ import { FORM_FIELDS } from '../model/constants';
7
+ import styles from './styles.module.scss';
8
+ export function CollectionTranslationForm({ form, onSubmit, selectedCount, hasDrafts, }) {
9
+ return (<FormProvider {...form}>
10
+ <fieldset className={styles.fieldset}>
11
+ <div className={styles.row}>
12
+ <div className={styles['locale-group']}>
13
+ <FormSelectLocale label="From" className={styles['select-locale-field']} size="md" name={FORM_FIELDS.SOURCE_LNG}/>
14
+ <FormSelectLocale label="To" className={styles['select-locale-field']} size="md" name={FORM_FIELDS.TARGET_LNG}/>
15
+ </div>
16
+ <FormSelectStrategy className={styles['strategy-field']} name={FORM_FIELDS.STRATEGY} size="md"/>
17
+ </div>
18
+ {hasDrafts && <FormCheckboxPublish name={FORM_FIELDS.PUBLISH_ON_TRANSLATION}/>}
19
+ <input {...form.register(FORM_FIELDS.HIDDEN_COLLECTION_SLUG)} type="hidden"/>
20
+ <Button $variant="filled" disabled={form.formState.isSubmitting || selectedCount === 0} className={styles['submit-button']} onClick={form.handleSubmit(onSubmit)} type="submit" $size="md" $isLoading={form.formState.isSubmitting}>
21
+ {selectedCount ? `Queue ${selectedCount} translations` : 'Queue translations'}
22
+ </Button>
23
+ </fieldset>
24
+ </FormProvider>);
25
+ }
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+ import { LanguageTranslateIcon } from '../../../shared/lib/assets/icons/LanguageTranslateIcon';
3
+ import { useToggle } from '../../../shared/lib/utils/react/useToggle';
4
+ import Button from '../../../shared/ui/Button';
5
+ import ColorIndicator from '../../../shared/ui/ColorIndicator';
6
+ import Popup from '../../../shared/ui/Popup';
7
+ import styles from './styles.module.scss';
8
+ function CollectionTranslationPopup({ children, translationInProgress, selectedCount, }) {
9
+ const [isPopupOpen, popupOpen] = useToggle();
10
+ return (<Popup $align="start" onOpenChange={popupOpen.setValue} $trigger={<Button $size="md" $variant="filled" className={styles['popup-trigger-button']} aria-label="Open translation options" onClick={popupOpen.setTrue}>
11
+ {translationInProgress && <ColorIndicator title="Translations In Progress" $animated $color="blue"/>}
12
+ <LanguageTranslateIcon />
13
+ {selectedCount > 0 && !translationInProgress && (<>
14
+ <span className={styles.arrow} aria-hidden="true">
15
+
16
+ </span>
17
+ {selectedCount}
18
+ </>)}
19
+ </Button>} open={isPopupOpen}>
20
+ <div className={styles['popup-content']}>{children}</div>
21
+ </Popup>);
22
+ }
23
+ export default CollectionTranslationPopup;
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+ import { ShimmerEffect } from '@payloadcms/ui';
3
+ import { TrashIcon } from '@payloadcms/ui/icons/Trash';
4
+ import { TranslationsApi } from '../../../entities/translation';
5
+ import { LanguageTranslateIcon } from '../../../shared/lib/assets/icons/LanguageTranslateIcon';
6
+ import Button from '../../../shared/ui/Button';
7
+ import StatusIndicator from '../../../shared/ui/StatusIndicator';
8
+ import Tooltip from '../../../shared/ui/Tooltip';
9
+ import styles from './styles.module.scss';
10
+ export function CollectionTranslationProgress({ collection, data, isPending }) {
11
+ const cancelPendingTranslations = TranslationsApi.useCancelCollectionTranslations();
12
+ return (<div className={styles.container}>
13
+ {isPending && (<>
14
+ <ShimmerEffect width="100%" height={32}></ShimmerEffect>
15
+ <ShimmerEffect width="100%" height={32}></ShimmerEffect>
16
+ <ShimmerEffect width="100%" height={32}></ShimmerEffect>
17
+ <ShimmerEffect width="100%" height={32}></ShimmerEffect>
18
+ </>)}
19
+
20
+ {data && (<>
21
+ <StatusIndicator className={styles['status-indicator']} title="Completed" key="completed" $color="green">
22
+ <LanguageTranslateIcon />
23
+ <b>{data.completed.length}</b>
24
+ </StatusIndicator>
25
+
26
+ <StatusIndicator className={styles['status-indicator']} title="Failed" key="failed" $color="red">
27
+ <LanguageTranslateIcon />
28
+ <b>{data.failed.length}</b>
29
+ </StatusIndicator>
30
+
31
+ <StatusIndicator className={styles['status-indicator']} title="In progress" $animated={data.running.length > 0} key="running" $color="blue">
32
+ <LanguageTranslateIcon />
33
+ <b>{data.running.length}</b>
34
+ </StatusIndicator>
35
+
36
+ <StatusIndicator className={styles['status-indicator']} title="Pending" $animated={data.pending.length > 0} key="pending" $color="gray">
37
+ <LanguageTranslateIcon />
38
+ <b>{data.pending.length}</b>
39
+ <Tooltip sideOffset={12} side="bottom" content="Cancel translations">
40
+ <Button $variant="light" $size="sm" $isIconButton aria-label="Cancel all queued translation" type="button" onClick={() => cancelPendingTranslations.mutateAsync({ collection })} disabled={cancelPendingTranslations.isPending || !data.pending.length} $isLoading={cancelPendingTranslations.isPending}>
41
+ <TrashIcon />
42
+ </Button>
43
+ </Tooltip>
44
+ </StatusIndicator>
45
+ </>)}
46
+ </div>);
47
+ }
@@ -0,0 +1,19 @@
1
+ import { toast } from '@payloadcms/ui';
2
+ import { FailedTranslationStatus, TranslationsApi } from '../../../entities/translation';
3
+ export function DocumentTranslationProgressFailed({ data }) {
4
+ const runQueuedTranslationApi = TranslationsApi.useRunDocumentTranslation();
5
+ const handleRunDocumentTranslation = async () => {
6
+ try {
7
+ await runQueuedTranslationApi.mutateAsync({ id: data.id });
8
+ }
9
+ catch (e) {
10
+ if (e instanceof Error) {
11
+ toast.error(e.message);
12
+ }
13
+ else {
14
+ toast.error('Error running document translation');
15
+ }
16
+ }
17
+ };
18
+ return (<FailedTranslationStatus onRetry={handleRunDocumentTranslation} isLoading={runQueuedTranslationApi.isPending} sourceLocale={data.input.source_lng} targetLocale={data.input.target_lng} message={data.error.message}/>);
19
+ }
@@ -0,0 +1,34 @@
1
+ import { toast } from '@payloadcms/ui';
2
+ import { TranslationsApi } from '../../../entities/translation';
3
+ import { PendingTranslationStatus } from '../../../entities/translation';
4
+ export function DocumentTranslationProgressPending({ data }) {
5
+ const cancelDocumentTranslationApi = TranslationsApi.useCancelDocumentTranslation();
6
+ const runQueuedTranslationApi = TranslationsApi.useRunDocumentTranslation();
7
+ const handleCancelDocumentTranslation = async () => {
8
+ try {
9
+ await cancelDocumentTranslationApi.mutateAsync({ id: data.id });
10
+ }
11
+ catch (e) {
12
+ if (e instanceof Error) {
13
+ toast.error(e.message);
14
+ }
15
+ else {
16
+ toast.error('Error canceling document translation');
17
+ }
18
+ }
19
+ };
20
+ const handleRunDocumentTranslation = async () => {
21
+ try {
22
+ await runQueuedTranslationApi.mutateAsync({ id: data.id });
23
+ }
24
+ catch (e) {
25
+ if (e instanceof Error) {
26
+ toast.error(e.message);
27
+ }
28
+ else {
29
+ toast.error('Error running document translation');
30
+ }
31
+ }
32
+ };
33
+ return (<PendingTranslationStatus onRun={handleRunDocumentTranslation} createdAt={data.created_at} sourceLocale={data.input.source_lng} targetLocale={data.input.target_lng} isLoading={cancelDocumentTranslationApi.isPending || runQueuedTranslationApi.isPending} onCancel={handleCancelDocumentTranslation}/>);
34
+ }
@@ -0,0 +1,19 @@
1
+ import { toast } from '@payloadcms/ui';
2
+ import { RunningTranslationStatus, TranslationsApi } from '../../../entities/translation';
3
+ export function DocumentTranslationProgressRunning({ data }) {
4
+ const cancelDocumentTranslationApi = TranslationsApi.useCancelDocumentTranslation();
5
+ const handleCancelDocumentTranslation = async () => {
6
+ try {
7
+ await cancelDocumentTranslationApi.mutateAsync({ id: data.id });
8
+ }
9
+ catch (e) {
10
+ if (e instanceof Error) {
11
+ toast.error(e.message);
12
+ }
13
+ else {
14
+ toast.error('Error canceling document translation');
15
+ }
16
+ }
17
+ };
18
+ return (<RunningTranslationStatus createdAt={data.created_at} sourceLocale={data.input.source_lng} targetLocale={data.input.target_lng} isLoading={cancelDocumentTranslationApi.isPending} onCancel={handleCancelDocumentTranslation}/>);
19
+ }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+ import { LanguageTranslateIcon } from '../../../shared/lib/assets/icons/LanguageTranslateIcon';
3
+ import { useToggle } from '../../../shared/lib/utils/react/useToggle';
4
+ import Button from '../../../shared/ui/Button';
5
+ import Popup from '../../../shared/ui/Popup';
6
+ import styles from './styles.module.scss';
7
+ function OpenDocumentTranslationPopup({ children, isLoading }) {
8
+ const [isPopupOpen, popupOpen] = useToggle();
9
+ return (<Popup $align="start" onOpenChange={popupOpen.setValue} $trigger={<Button $size="md" $variant="filled" $isIconButton className={styles['popup-trigger-button']} aria-label="Open translation options" onClick={popupOpen.setTrue} disabled={isLoading} $isLoading={isLoading}>
10
+ <LanguageTranslateIcon />
11
+ </Button>} open={isPopupOpen}>
12
+ <div className={styles['popup-content']}>{children({ close: popupOpen.setFalse })}</div>
13
+ </Popup>);
14
+ }
15
+ export default OpenDocumentTranslationPopup;
@@ -0,0 +1,24 @@
1
+ import { FormProvider } from 'react-hook-form';
2
+ import Button from '../../../shared/ui/Button';
3
+ import FormSelectLocale from '../../../shared/ui/form/FormSelectLocale';
4
+ import { FormSelectStrategy } from '../../../shared/ui/form/FormSelectStrategy';
5
+ import { FormCheckboxPublish } from '../../../shared/ui/form/FormCheckboxPublish';
6
+ import { FORM_FIELDS } from '../model/constants';
7
+ import styles from './styles.module.scss';
8
+ export function DocumentTranslationForm({ form, onSubmit, hasDrafts }) {
9
+ return (<FormProvider {...form}>
10
+ <fieldset className={styles.fieldset}>
11
+ <div className={styles.row}>
12
+ <FormSelectLocale label="From" name={FORM_FIELDS.SOURCE_LNG}/>
13
+ <FormSelectLocale label="To" name={FORM_FIELDS.TARGET_LNG}/>
14
+ </div>
15
+ <FormSelectStrategy name={FORM_FIELDS.STRATEGY}/>
16
+ {hasDrafts && <FormCheckboxPublish name={FORM_FIELDS.PUBLISH_ON_TRANSLATION}/>}
17
+ <input {...form.register(FORM_FIELDS.HIDDEN_COLLECTION_SLUG)} type="hidden"/>
18
+ <input {...form.register(FORM_FIELDS.HIDDEN_COLLECTION_ID)} type="hidden"/>
19
+ <Button $variant="filled" disabled={form.formState.isSubmitting} className={styles['submit-button']} onClick={form.handleSubmit(onSubmit)} type="submit" $size="lg" $isLoading={form.formState.isSubmitting}>
20
+ Queue Translation
21
+ </Button>
22
+ </fieldset>
23
+ </FormProvider>);
24
+ }
@@ -0,0 +1,4 @@
1
+ export const LanguageTranslateIcon = () => (<svg fill="currentColor" width="1em" height="1em" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M478.33,433.6l-90-218a22,22,0,0,0-40.67,0l-90,218a22,22,0,1,0,40.67,16.79L316.66,406H419.33l18.33,44.39A22,22,0,0,0,458,464a22,22,0,0,0,20.32-30.4ZM334.83,362,368,281.65,401.17,362Z"/>
3
+ <path d="M267.84,342.92a22,22,0,0,0-4.89-30.7c-.2-.15-15-11.13-36.49-34.73,39.65-53.68,62.11-114.75,71.27-143.49H330a22,22,0,0,0,0-44H214V70a22,22,0,0,0-44,0V90H54a22,22,0,0,0,0,44H251.25c-9.52,26.95-27.05,69.5-53.79,108.36-31.41-41.68-43.08-68.65-43.17-68.87a22,22,0,0,0-40.58,17c.58,1.38,14.55,34.23,52.86,83.93.92,1.19,1.83,2.35,2.74,3.51-39.24,44.35-77.74,71.86-93.85,80.74a22,22,0,1,0,21.07,38.63c2.16-1.18,48.6-26.89,101.63-85.59,22.52,24.08,38,35.44,38.93,36.1a22,22,0,0,0,30.75-4.9Z"/>
4
+ </svg>);
@@ -0,0 +1,3 @@
1
+ export const QuestionCircleIcon = () => (<svg width="1em" height="1em" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
2
+ <path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zM6.92 6.085c.081-.16.19-.299.34-.398.145-.097.371-.187.74-.187.28 0 .553.087.738.225.167.127.262.285.262.478 0 .39-.152.586-.293.732a2.138 2.138 0 01-.508.39l-.024.014c-.178.107-.357.214-.504.395-.15.185-.25.427-.25.75a.75.75 0 001.5 0c0-.088.024-.137.054-.175.035-.045.1-.098.195-.15l.029-.018c.152-.09.381-.226.594-.424.213-.197.523-.534.523-1.114 0-.57-.282-1.023-.656-1.306-.356-.27-.816-.392-1.26-.392-.6 0-1.068.167-1.418.39-.345.218-.583.498-.748.793a.75.75 0 001.326.704z"/>
3
+ </svg>);
@@ -0,0 +1,3 @@
1
+ export const ReloadIcon = () => (<svg width="1em" height="1em" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
2
+ <path fill="currentColor" d="M7.248 1.307A.75.75 0 118.252.193l2.5 2.25a.75.75 0 010 1.114l-2.5 2.25a.75.75 0 01-1.004-1.114l1.29-1.161a4.5 4.5 0 103.655 2.832.75.75 0 111.398-.546A6 6 0 118.018 2l-.77-.693z"/>
3
+ </svg>);
@@ -0,0 +1,33 @@
1
+ import classNames from 'classnames';
2
+ import React from 'react';
3
+ import styles from './styles.module.scss';
4
+ const getRole = (variant) => {
5
+ switch (variant) {
6
+ case 'error':
7
+ return 'alert';
8
+ case 'warning':
9
+ return 'alert';
10
+ case 'info':
11
+ return 'status';
12
+ default:
13
+ return 'status';
14
+ }
15
+ };
16
+ const getAriaLive = (variant) => {
17
+ switch (variant) {
18
+ case 'error':
19
+ return 'assertive';
20
+ case 'warning':
21
+ return 'polite';
22
+ case 'info':
23
+ return 'polite';
24
+ default:
25
+ return 'polite';
26
+ }
27
+ };
28
+ const Alert = ({ variant = 'transparent', children, className }) => {
29
+ return (<div className={classNames(styles.alert, styles[`alert--${variant}`], className)} role={getRole(variant)} aria-live={getAriaLive(variant)} aria-atomic="true">
30
+ {children}
31
+ </div>);
32
+ };
33
+ export default Alert;
@@ -0,0 +1,35 @@
1
+ import classNames from 'classnames';
2
+ import { forwardRef } from 'react';
3
+ import Loading from '../Loading';
4
+ import styles from './styles.module.scss';
5
+ const sizes = {
6
+ sm: {
7
+ height: '24px',
8
+ paddingInline: '0.25rem',
9
+ },
10
+ md: {
11
+ height: '32px',
12
+ paddingInline: '0.5rem',
13
+ },
14
+ lg: {
15
+ height: '40px',
16
+ paddingInline: '0.75rem',
17
+ },
18
+ };
19
+ const Button = forwardRef(function Button({ className = '', children, $fullWidth, style, $startContent, $variant, $isLoading, $size = 'md', $isIconButton, ...props }, ref) {
20
+ const _style = {
21
+ '--button-width': $fullWidth ? '100%' : 'auto',
22
+ '--height': sizes[$size].height,
23
+ '--padding-inline': $isIconButton ? '0' : sizes[$size].paddingInline,
24
+ '--width': $isIconButton ? sizes[$size].height : 'auto',
25
+ ...style,
26
+ };
27
+ const buttonClassName = $variant === 'unstyled'
28
+ ? classNames(styles['unstyled'], className, $isIconButton && styles['icon-button'])
29
+ : classNames(styles['button'], className, $variant ? styles[$variant] : undefined, $isIconButton && styles['icon-button']);
30
+ return (<button {...props} style={_style} ref={ref} className={buttonClassName}>
31
+ {$startContent && <div className={styles['start-content']}>{$startContent}</div>}
32
+ {$isLoading ? <Loading size={$isIconButton ? 'small' : 'medium'}/> : children}
33
+ </button>);
34
+ });
35
+ export default Button;
@@ -0,0 +1,6 @@
1
+ import { forwardRef } from 'react';
2
+ import styles from './styles.module.scss';
3
+ const Checkbox = forwardRef(function Checkbox({ className = '', hasError, ...props }, ref) {
4
+ return (<input ref={ref} type="checkbox" className={`${styles.checkbox} ${hasError ? styles['checkbox--error'] : ''} ${className}`} {...props}/>);
5
+ });
6
+ export default Checkbox;
@@ -0,0 +1,7 @@
1
+ import classNames from 'classnames';
2
+ import styles from './styles.module.scss';
3
+ export default function ColorIndicator({ $color, $animated, title, className }) {
4
+ return (<div title={title} className={classNames(styles.indicator, styles[`${$color}-indicator`], {
5
+ [styles.animated]: $animated,
6
+ }, className)}/>);
7
+ }
@@ -0,0 +1,7 @@
1
+ import styles from './styles.module.scss';
2
+ export default function Description({ children, className = '', id, variant = 'default', ...props }) {
3
+ const descriptionClassName = `${styles.description} ${styles[variant]} ${className}`;
4
+ return (<p id={id} className={descriptionClassName} role={variant === 'error' ? 'alert' : undefined} aria-live={variant === 'error' ? 'polite' : undefined} {...props}>
5
+ {children}
6
+ </p>);
7
+ }
@@ -0,0 +1,7 @@
1
+ import classNames from 'classnames';
2
+ import * as React from 'react';
3
+ import styles from './styles.module.scss';
4
+ const Divider = React.forwardRef(function Divider({ $orientation = 'horizontal', $size = 'md', $color = 'medium', className, style, ...props }, ref) {
5
+ return (<hr ref={ref} className={classNames(styles.divider, styles[$orientation], styles[$size], styles[$color], className)} style={style} role="separator" aria-orientation={$orientation} {...props}/>);
6
+ });
7
+ export default Divider;
@@ -0,0 +1,9 @@
1
+ import { forwardRef } from 'react';
2
+ import styles from './styles.module.scss';
3
+ const Label = forwardRef(function Label({ children, className = '', label, ...props }, ref) {
4
+ return (<label ref={ref} className={`${styles.label} ${className}`} {...props}>
5
+ <span className={styles.label__text}>{label}</span>
6
+ {children}
7
+ </label>);
8
+ });
9
+ export default Label;
@@ -0,0 +1,11 @@
1
+ import classNames from 'classnames';
2
+ import styles from './styles.module.scss';
3
+ function Loading({ text, className, size = 'medium' }) {
4
+ return (<div className={classNames(styles['loading-overlay'], styles['loading-overlay--entering'], className)} style={{ animationDuration: '500ms' }}>
5
+ <div className={classNames(styles['loading-overlay__bars'], styles[`loading-overlay__bars_${size}`])}>
6
+ {new Array(size === 'small' ? 2 : 5).fill(null).map((_, index) => (<div key={index} className={classNames(styles['loading-overlay__bar'], styles[`loading-overlay__bar_size-${size}`])}/>))}
7
+ </div>
8
+ {text && <span className={styles['loading-overlay__text']}>{text}</span>}
9
+ </div>);
10
+ }
11
+ export default Loading;
@@ -0,0 +1,13 @@
1
+ import * as Popover from '@radix-ui/react-popover';
2
+ import style from './styles.module.scss';
3
+ function Popup({ open, children, onOpenChange, $trigger, $align, $side }) {
4
+ return (<Popover.Root open={open} onOpenChange={onOpenChange}>
5
+ <Popover.Trigger asChild>{$trigger}</Popover.Trigger>
6
+ <Popover.Portal>
7
+ <Popover.Content sideOffset={8} side={$side} align={$align} className={style.popover}>
8
+ {children}
9
+ </Popover.Content>
10
+ </Popover.Portal>
11
+ </Popover.Root>);
12
+ }
13
+ export default Popup;
@@ -0,0 +1,11 @@
1
+ import classNames from 'classnames';
2
+ import styles from './styles.module.scss';
3
+ export default function ProgressBar({ percentage, size = 'medium', color = 'blue', showPercentage = true, className, }) {
4
+ const clampedPercentage = Math.max(0, Math.min(100, percentage));
5
+ return (<div className={classNames(styles.container, className)}>
6
+ <div className={classNames(styles.track, styles[size])}>
7
+ <div className={classNames(styles.fill, styles[`${color}-fill`])} style={{ width: `${clampedPercentage}%` }}/>
8
+ </div>
9
+ {showPercentage && (<span className={classNames(styles.label, styles[`${size}-label`])}>{Math.round(clampedPercentage)}%</span>)}
10
+ </div>);
11
+ }
@@ -0,0 +1,41 @@
1
+ import classNames from 'classnames';
2
+ import { forwardRef } from 'react';
3
+ import styles from './styles.module.scss';
4
+ const sizes = {
5
+ sm: {
6
+ height: '24px',
7
+ paddingInline: '0.25rem',
8
+ fontSize: '0.875rem',
9
+ },
10
+ md: {
11
+ height: '32px',
12
+ paddingInline: '0.5rem',
13
+ fontSize: '1rem',
14
+ },
15
+ lg: {
16
+ height: '40px',
17
+ paddingInline: '0.75rem',
18
+ fontSize: '1rem',
19
+ },
20
+ };
21
+ const SelectOption = ({ children, ...props }) => <option {...props}>{children}</option>;
22
+ const SelectEmptyOption = ({ children, ...props }) => (<option value="" {...props}>
23
+ {children}
24
+ </option>);
25
+ const Select = forwardRef(function Select({ children, className = '', hasError = false, $size = 'lg', style, 'aria-invalid': ariaInvalid, ...props }, ref) {
26
+ const _style = {
27
+ '--height': sizes[$size].height,
28
+ '--padding-inline': sizes[$size].paddingInline,
29
+ '--font-size': sizes[$size].fontSize,
30
+ ...style,
31
+ };
32
+ const selectClassName = classNames(styles.select, styles[`size-${$size}`], {
33
+ [styles.selectError]: hasError,
34
+ }, className);
35
+ return (<select ref={ref} className={selectClassName} style={_style} aria-invalid={ariaInvalid ?? hasError} {...props}>
36
+ {children}
37
+ </select>);
38
+ });
39
+ Select.SelectOption = SelectOption;
40
+ Select.SelectEmptyOption = SelectEmptyOption;
41
+ export default Select;
@@ -0,0 +1,26 @@
1
+ import classNames from 'classnames';
2
+ import StatusIndicator from '../StatusIndicator';
3
+ import styles from './styles.module.scss';
4
+ const statusColorMap = {
5
+ pending: 'gray',
6
+ running: 'blue',
7
+ completed: 'green',
8
+ failed: 'red',
9
+ };
10
+ const statusLabelMap = {
11
+ pending: 'В очереди',
12
+ running: 'В процессе',
13
+ completed: 'Завершено',
14
+ failed: 'Ошибки',
15
+ };
16
+ export default function StatusCounter({ status, count, label, animated = false, size = 'medium', className, }) {
17
+ const displayLabel = label || statusLabelMap[status];
18
+ const color = statusColorMap[status];
19
+ return (<div className={classNames(styles.container, styles[size], className)}>
20
+ <StatusIndicator $color={color} $animated={animated && status === 'running'} className={styles.indicator}/>
21
+ <div className={styles.content}>
22
+ <span className={styles.count}>{count}</span>
23
+ <span className={styles.label}>{displayLabel}</span>
24
+ </div>
25
+ </div>);
26
+ }
@@ -0,0 +1,10 @@
1
+ import classNames from 'classnames';
2
+ import ColorIndicator from '../ColorIndicator';
3
+ import styles from './styles.module.scss';
4
+ export default function StatusIndicator({ $color, $animated, title, className, children }) {
5
+ const cardClassName = classNames(styles['status-indicator'], styles[$color], className);
6
+ return (<div className={cardClassName}>
7
+ <ColorIndicator $color={$color} $animated={$animated} title={title}/>
8
+ {children}
9
+ </div>);
10
+ }
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+ import * as RadixTooltip from '@radix-ui/react-tooltip';
3
+ import styles from './styles.module.scss';
4
+ export default function Tooltip({ children, content, side = 'top', sideOffset = 6, delayDuration = 200, }) {
5
+ return (<RadixTooltip.Provider delayDuration={delayDuration}>
6
+ <RadixTooltip.Root>
7
+ <RadixTooltip.Trigger asChild>{children}</RadixTooltip.Trigger>
8
+ <RadixTooltip.Portal>
9
+ <RadixTooltip.Content className={styles.content} side={side} sideOffset={sideOffset}>
10
+ {content}
11
+ <RadixTooltip.Arrow className={styles.arrow}/>
12
+ </RadixTooltip.Content>
13
+ </RadixTooltip.Portal>
14
+ </RadixTooltip.Root>
15
+ </RadixTooltip.Provider>);
16
+ }
@@ -0,0 +1,24 @@
1
+ import { useId } from 'react';
2
+ import { useController } from 'react-hook-form';
3
+ import { Checkbox } from '../../Checkbox';
4
+ import Description from '../../Description';
5
+ import styles from './styles.module.scss';
6
+ export function FormCheckbox({ name, label, description, disabled, className }) {
7
+ const formControl = useController({ name });
8
+ const errorId = useId();
9
+ const descriptionId = useId();
10
+ const inputId = useId();
11
+ return (<div className={`${styles.container} ${className ?? ''}`}>
12
+ <label className={styles.label} htmlFor={inputId}>
13
+ <Checkbox id={inputId} hasError={formControl.fieldState.invalid} name={formControl.field.name} ref={formControl.field.ref} checked={formControl.field.value ?? false} onChange={formControl.field.onChange} onBlur={formControl.field.onBlur} disabled={formControl.field.disabled ||
14
+ formControl.formState.disabled ||
15
+ formControl.formState.isSubmitting ||
16
+ disabled} aria-describedby={`${errorId} ${descriptionId}`}/>
17
+ <span className={styles.label__text}>{label}</span>
18
+ </label>
19
+ {formControl.fieldState.error && (<Description variant="error" id={errorId}>
20
+ {formControl.fieldState.error.message}
21
+ </Description>)}
22
+ {description && <Description id={descriptionId}>{description}</Description>}
23
+ </div>);
24
+ }
@@ -0,0 +1,21 @@
1
+ import { FormCheckbox } from '../FormCheckbox';
2
+ import Tooltip from '../../Tooltip';
3
+ import { QuestionCircleIcon } from '../../../lib/assets/icons/QuestionCircleIcon';
4
+ import styles from './styles.module.scss';
5
+ const PUBLISH_LABEL = 'Publish after translation';
6
+ const PUBLISH_TOOLTIP = (<>
7
+ When enabled, translated documents will be published immediately.
8
+ <br />
9
+ When disabled, translated documents will be saved as drafts.
10
+ </>);
11
+ const PublishLabel = (<span className={styles.label}>
12
+ {PUBLISH_LABEL}
13
+ <Tooltip content={PUBLISH_TOOLTIP} side="bottom">
14
+ <span className={styles.label__icon}>
15
+ <QuestionCircleIcon />
16
+ </span>
17
+ </Tooltip>
18
+ </span>);
19
+ export function FormCheckboxPublish({ name, disabled, className }) {
20
+ return <FormCheckbox name={name} label={PublishLabel} disabled={disabled} className={className}/>;
21
+ }
@@ -0,0 +1,44 @@
1
+ import { useId } from 'react';
2
+ import { useController } from 'react-hook-form';
3
+ import Description from '../../Description';
4
+ import Label from '../../Label';
5
+ import Select from '../../Select';
6
+ export default function FormSelect({ name, required, options, label, description, disabled, size, placeholder, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, className, }) {
7
+ const formControl = useController({ name, rules: { required } });
8
+ const errorId = useId();
9
+ const descriptionId = useId();
10
+ const getAccessibilityProps = () => {
11
+ if (label) {
12
+ return {};
13
+ }
14
+ if (ariaLabelledby) {
15
+ return { 'aria-labelledby': ariaLabelledby };
16
+ }
17
+ if (ariaLabel) {
18
+ return { 'aria-label': ariaLabel };
19
+ }
20
+ return { 'aria-label': 'Select option' };
21
+ };
22
+ const selectElement = (<Select $size={size} hasError={formControl.fieldState.invalid} required={required} name={formControl.field.name} ref={formControl.field.ref} value={formControl.field.value} onChange={formControl.field.onChange} onBlur={formControl.field.onBlur} disabled={formControl.field.disabled || formControl.formState.disabled || formControl.formState.isSubmitting || disabled} aria-describedby={`${errorId} ${descriptionId}`} {...getAccessibilityProps()}>
23
+ {placeholder && <Select.SelectEmptyOption>{placeholder}</Select.SelectEmptyOption>}
24
+ {options.map((option) => (<Select.SelectOption key={option.value} value={option.value}>
25
+ {option.label}
26
+ </Select.SelectOption>))}
27
+ </Select>);
28
+ const errorAndDescription = (<>
29
+ {formControl.fieldState.error && (<Description variant="error" id={errorId}>
30
+ {formControl.fieldState.error.message}
31
+ </Description>)}
32
+ {description && <Description id={descriptionId}>{description}</Description>}
33
+ </>);
34
+ if (!label) {
35
+ return (<div className={className}>
36
+ {selectElement}
37
+ {errorAndDescription}
38
+ </div>);
39
+ }
40
+ return (<Label className={className} label={label}>
41
+ {selectElement}
42
+ {errorAndDescription}
43
+ </Label>);
44
+ }
@@ -0,0 +1,6 @@
1
+ import { useLocaleOptions } from '../../../lib/payload/hooks/useLocaleOptions';
2
+ import FormSelect from '../FormSelect';
3
+ export default function FormSelectLocale({ name, required, label, description, disabled, size, className, }) {
4
+ const localeOptions = useLocaleOptions();
5
+ return (<FormSelect placeholder="-" size={size} className={className} description={description} label={label} required={required} name={name} options={localeOptions} disabled={disabled}/>);
6
+ }
@@ -0,0 +1,25 @@
1
+ import FormSelect from '../FormSelect';
2
+ import Tooltip from '../../Tooltip';
3
+ import { QuestionCircleIcon } from '../../../lib/assets/icons/QuestionCircleIcon';
4
+ import styles from './styles.module.scss';
5
+ const STRATEGY_OPTIONS = [
6
+ { value: 'overwrite', label: 'Overwrite' },
7
+ { value: 'skip_existing', label: 'Skip existing' },
8
+ ];
9
+ const STRATEGY_LABEL = 'Update mode';
10
+ const STRATEGY_TOOLTIP = (<>
11
+ <strong>Overwrite</strong> — translate all fields, replacing existing values
12
+ <br />
13
+ <strong>Skip existing</strong> — only translate empty fields, keep existing values
14
+ </>);
15
+ const StrategyLabel = (<span className={styles.label}>
16
+ {STRATEGY_LABEL}
17
+ <Tooltip content={STRATEGY_TOOLTIP} side="bottom">
18
+ <span className={styles.label__icon}>
19
+ <QuestionCircleIcon />
20
+ </span>
21
+ </Tooltip>
22
+ </span>);
23
+ export function FormSelectStrategy({ name, required, description, disabled, size, className, }) {
24
+ return (<FormSelect size={size} className={className} description={description} label={StrategyLabel} required={required} name={name} options={[...STRATEGY_OPTIONS]} disabled={disabled}/>);
25
+ }
@@ -0,0 +1,11 @@
1
+ import { AnyAccessGuard } from '../../../../server/shared';
2
+ import { clientComponentPath } from '../../../shared/utils/componentPath';
3
+ export class BulkDocumentTranslationDashboard {
4
+ access;
5
+ serverProps;
6
+ constructor(access = new AnyAccessGuard()) {
7
+ this.access = access;
8
+ this.serverProps = { access };
9
+ }
10
+ path = clientComponentPath('widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server');
11
+ }
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+ import { toast, useLocale, useSelection } from '@payloadcms/ui';
3
+ import { SelectAllStatus } from '@payloadcms/ui/providers/Selection';
4
+ import { useEffect, useMemo } from 'react';
5
+ import { TranslationsApi } from '../../../entities/translation';
6
+ import { CollectionTranslationForm, FORM_FIELDS } from '../../../features/collection-translation-form';
7
+ import { CollectionTranslationFormModel } from '../../../features/collection-translation-form/index.client';
8
+ import CollectionTranslationPopup from '../../../features/collection-translation-popup';
9
+ import { CollectionTranslationProgress } from '../../../features/collection-translation-progress';
10
+ import { handleFormError } from '../../../shared/lib/forms/handle-form-error';
11
+ import { useCollectionDashboardUrlParams } from '../../../shared/lib/payload/hooks/useCollectionDashboardUrlParams';
12
+ export default function BulkTranslationDashboard({ hasDrafts }) {
13
+ const locale = useLocale();
14
+ const { collection } = useCollectionDashboardUrlParams();
15
+ const documentsSelection = useSelection();
16
+ const queueTranslationApi = TranslationsApi.useQueueDocumentTranslation();
17
+ const collectionTranslationApi = TranslationsApi.useCollectionTranslationStatus({ collection });
18
+ useEffect(() => {
19
+ if (collectionTranslationApi.error) {
20
+ toast.error(collectionTranslationApi.error.message);
21
+ }
22
+ }, [collectionTranslationApi.error]);
23
+ const initialValues = useMemo(() => ({
24
+ [FORM_FIELDS.SOURCE_LNG]: locale.code,
25
+ [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: collection,
26
+ }), [locale.code, collection]);
27
+ const { form } = CollectionTranslationFormModel.useForm({
28
+ initialValues,
29
+ disabled: collectionTranslationApi.isLoading,
30
+ });
31
+ const selectAll = documentsSelection.selectAll === SelectAllStatus.AllAvailable;
32
+ const selectedCount = selectAll ? documentsSelection.totalDocs : documentsSelection.count;
33
+ const translationInProgress = !!(collectionTranslationApi.data?.pending.length || collectionTranslationApi.data?.running.length);
34
+ const handleSubmit = async (formData) => {
35
+ try {
36
+ await queueTranslationApi.mutateAsync({
37
+ ...formData,
38
+ collection_id: documentsSelection.selectedIDs,
39
+ select_all: selectAll,
40
+ });
41
+ }
42
+ catch (e) {
43
+ handleFormError(e, form);
44
+ }
45
+ };
46
+ return (<CollectionTranslationPopup translationInProgress={translationInProgress} selectedCount={selectedCount}>
47
+ <h3>Bulk Translation Dashboard</h3>
48
+ <p>
49
+ Translate multiple documents at once using AI. Select the records you want to translate on the collection
50
+ dashboard, then configure your translation settings here.
51
+ </p>
52
+ <CollectionTranslationForm form={form} onSubmit={handleSubmit} selectedCount={selectedCount} hasDrafts={hasDrafts}/>
53
+ <CollectionTranslationProgress collection={collection} data={collectionTranslationApi.data} isPending={collectionTranslationApi.isPending}/>
54
+ </CollectionTranslationPopup>);
55
+ }
@@ -0,0 +1,17 @@
1
+ import { headers as getHeaders } from 'next/headers';
2
+ import { collectionHasDrafts } from '../../../../server/shared/guards';
3
+ import BulkTranslationDashboard from './BulkTranslationDashboard';
4
+ const BulkTranslationDashboardServer = async (props) => {
5
+ const headers = await getHeaders();
6
+ const hasAccess = await props.access.check({
7
+ req: { user: props.user, headers, payload: props.payload },
8
+ });
9
+ if (!hasAccess)
10
+ return null;
11
+ if (!props.collectionSlug)
12
+ return null;
13
+ const collection = props.payload.collections[props.collectionSlug]?.config;
14
+ const hasDrafts = collection ? collectionHasDrafts(collection) : false;
15
+ return <BulkTranslationDashboard hasDrafts={hasDrafts}/>;
16
+ };
17
+ export default BulkTranslationDashboardServer;
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+ import { toast, useLocale } from '@payloadcms/ui';
3
+ import { useEffect, useMemo } from 'react';
4
+ import { CompletedTranslationStatus, TranslationsApi } from '../../../entities/translation';
5
+ import { OpenDocumentTranslationPopup } from '../../../features/open-document-translation-popup';
6
+ import { DocumentTranslationForm, FORM_FIELDS } from '../../../features/translate-document-form';
7
+ import { DocumentTranslationFormModel } from '../../../features/translate-document-form/index.client';
8
+ import { handleFormError } from '../../../shared/lib/forms/handle-form-error';
9
+ import { useCollectionDocumentUrlParams } from '../../../shared/lib/payload/hooks/useCollectionDocumentUrlParams';
10
+ import { DocumentTranslationProgressFailed } from '../../../features/document-translation-progress-failed';
11
+ import { DocumentTranslationProgressRunning } from '../../../features/document-translation-progress-running';
12
+ import { DocumentTranslationProgressPending } from '../../../features/document-translation-progress-pending';
13
+ const TranslateDocument = ({ hasDrafts }) => {
14
+ const locale = useLocale();
15
+ const params = useCollectionDocumentUrlParams();
16
+ const queueTranslationApi = TranslationsApi.useQueueDocumentTranslation();
17
+ const { data, error, isLoading } = TranslationsApi.useDocumentTranslation(params);
18
+ useEffect(() => {
19
+ if (error) {
20
+ toast.error(error.message);
21
+ }
22
+ }, [error]);
23
+ const initialValues = useMemo(() => ({
24
+ [FORM_FIELDS.SOURCE_LNG]: locale.code,
25
+ [FORM_FIELDS.HIDDEN_COLLECTION_SLUG]: params.collection,
26
+ [FORM_FIELDS.HIDDEN_COLLECTION_ID]: params.id,
27
+ }), [locale.code, params.collection, params.id]);
28
+ const { form } = DocumentTranslationFormModel.useForm({ initialValues });
29
+ const handleSubmit = async (formData, onSuccess) => {
30
+ try {
31
+ await queueTranslationApi.mutateAsync({
32
+ ...formData,
33
+ collection_id: [formData[FORM_FIELDS.HIDDEN_COLLECTION_ID]],
34
+ });
35
+ onSuccess?.();
36
+ }
37
+ catch (e) {
38
+ handleFormError(e, form);
39
+ }
40
+ };
41
+ return (<>
42
+ <OpenDocumentTranslationPopup isLoading={isLoading}>
43
+ {({ close }) => (<>
44
+ <h4>Document Translation</h4>
45
+ <DocumentTranslationForm form={form} onSubmit={(data) => handleSubmit(data, close)} hasDrafts={hasDrafts}/>
46
+ </>)}
47
+ </OpenDocumentTranslationPopup>
48
+
49
+ {data?.status === 'completed' && (<CompletedTranslationStatus sourceLocale={data.input.source_lng} targetLocale={data.input.target_lng} completed_at={data.completed_at}/>)}
50
+ {data?.status === 'failed' && <DocumentTranslationProgressFailed data={data}/>}
51
+ {data?.status === 'running' && <DocumentTranslationProgressRunning data={data}/>}
52
+ {data?.status === 'pending' && <DocumentTranslationProgressPending data={data}/>}
53
+ </>);
54
+ };
55
+ export default TranslateDocument;
@@ -0,0 +1,16 @@
1
+ import { headers as getHeaders } from 'next/headers';
2
+ import { collectionHasDrafts } from '../../../../server/shared/guards';
3
+ import TranslateDocument from './TranslateDocument';
4
+ async function TranslateDocumentServer(props) {
5
+ const headers = await getHeaders();
6
+ const hasAccess = await props.access.check({
7
+ req: { user: props.user, headers, payload: props.payload },
8
+ });
9
+ if (!hasAccess)
10
+ return null;
11
+ if (!props.id)
12
+ return null;
13
+ const hasDrafts = collectionHasDrafts(props.collection);
14
+ return <TranslateDocument hasDrafts={hasDrafts}/>;
15
+ }
16
+ export default TranslateDocumentServer;
package/dist/plugin.js CHANGED
@@ -19,15 +19,19 @@ import { normalizePath, pipe } from './server/shared';
19
19
  const { access, translationProvider, basePath: rawBasePath = '/translate' } = this.pluginConfig;
20
20
  // Build schema map from deep-cloned collections
21
21
  // Deep clone is required because Payload mutates the original collection objects,
22
- // removing `localized: true` from nested fields during sanitization
22
+ // removing `localized: true` from nested fields during sanitization.
23
+ // We use JSON round-trip instead of structuredClone because Lexical editor
24
+ // configs contain async functions that structuredClone cannot handle.
25
+ // TODO: Consider introducing a FieldLike interface with only the properties
26
+ // used by the pipeline (name, type, localized, fields, blocks, tabs, custom)
27
+ // to make the contract explicit and avoid reliance on JSON round-trip.
23
28
  const schemaMap = new Map(this.pluginConfig.collections.map((col)=>[
24
29
  col.slug,
25
- structuredClone(col.fields)
30
+ JSON.parse(JSON.stringify(col.fields))
26
31
  ]));
27
32
  const collectionSlugs = new Set(schemaMap.keys());
28
33
  const basePath = normalizePath(rawBasePath);
29
34
  const translateHandler = new TranslateDocumentHandler(translationProvider, schemaMap);
30
- // Configure runner with handler callback
31
35
  const runnerConfigModifier = this.pluginConfig.runner.configure({
32
36
  handler: async (payload, input)=>{
33
37
  await translateHandler.handle(payload, {
@@ -18,13 +18,15 @@ import { createTranslationStrategy } from '../../modules/translation-pipeline/st
18
18
  const sourceData = await payload.findByID({
19
19
  collection,
20
20
  id: collectionId,
21
- locale: sourceLng
21
+ locale: sourceLng,
22
+ depth: 0
22
23
  });
23
24
  const targetData = await payload.findByID({
24
25
  collection,
25
26
  id: collectionId,
26
27
  locale: targetLng,
27
- fallbackLocale: false
28
+ fallbackLocale: false,
29
+ depth: 0
28
30
  });
29
31
  const translationStrategy = createTranslationStrategy(strategy);
30
32
  const pipeline = new TranslationPipeline({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.",
5
5
  "type": "module",
6
6
  "license": "MIT",