@autobusal/common 1.28.0 → 1.30.0

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.
@@ -0,0 +1,72 @@
1
+ import { useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import { useAiAvailable } from './services';
4
+ import AiReviewModal from './AiReviewModal';
5
+ import { Trigger } from './styles';
6
+
7
+ interface Props {
8
+ /** The field's own current value - the base text for "Improve". */
9
+ text?: string
10
+ /** The English source text, for a "Translate from EN" trigger. */
11
+ sourceText?: string
12
+ /** This field's locale, e.g. "sq" - required for the translate trigger. */
13
+ targetLocale?: string
14
+ onInsert: (content: string) => void
15
+ t: TFunction<'common'>
16
+ }
17
+
18
+ /**
19
+ * Sits next to one content field (a 'component'-type Viewer row - see
20
+ * Data.tsx's render-prop support for that type) and renders nothing at all
21
+ * when no AI provider is enabled for this label, which is the whole
22
+ * visibility gate the plan calls for - no separate flag to keep in sync.
23
+ *
24
+ * Ferjolt Ozuni - Date: 2026-08-08
25
+ */
26
+ const AiFieldAssist = ({ text, sourceText, targetLocale, onInsert, t }: Props): JSX.Element | null => {
27
+ const { data: available } = useAiAvailable();
28
+
29
+ const [ mode, setMode ] = useState<'generate' | 'improve' | 'translate' | null>(null);
30
+
31
+ if (!available || Object.keys(available).length === 0) {
32
+ return null;
33
+ }
34
+
35
+ const hasText = (text ?? '').trim() !== '';
36
+ const canTranslate = (sourceText ?? '').trim() !== '' && !!targetLocale;
37
+
38
+ return (
39
+ <>
40
+ <Trigger>
41
+ <button type="button" onClick={ () => setMode('generate') }>
42
+ { t('ai_assist.trigger.generate') }
43
+ </button>
44
+
45
+ { hasText && (
46
+ <button type="button" onClick={ () => setMode('improve') }>
47
+ { t('ai_assist.trigger.improve') }
48
+ </button>
49
+ ) }
50
+
51
+ { canTranslate && (
52
+ <button type="button" onClick={ () => setMode('translate') }>
53
+ { t('ai_assist.trigger.translate') }
54
+ </button>
55
+ ) }
56
+ </Trigger>
57
+
58
+ { mode && (
59
+ <AiReviewModal
60
+ mode={ mode }
61
+ text={ mode === 'translate' ? sourceText : text }
62
+ targetLocale={ targetLocale }
63
+ onInsert={ onInsert }
64
+ onClose={ () => setMode(null) }
65
+ t={ t }
66
+ />
67
+ ) }
68
+ </>
69
+ );
70
+ };
71
+
72
+ export default AiFieldAssist;
@@ -0,0 +1,191 @@
1
+ import { useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import Modal from '../Modal/Modal';
4
+ import { useAiAvailable, useAiGenerate, useAiTranslate, useAiImprove } from './services';
5
+ import { Row, Field, Compare, Pane, Draft, Actions, Error } from './styles';
6
+
7
+ interface Props {
8
+ mode: 'generate' | 'improve' | 'translate'
9
+ text?: string
10
+ targetLocale?: string
11
+ sourceLocale?: string
12
+ onInsert: (content: string) => void
13
+ onClose: () => void
14
+ t: TFunction<'common'>
15
+ }
16
+
17
+ /**
18
+ * The one AI-review surface every content admin form shares - a draft
19
+ * NEVER lands in a field on its own. It renders in here first, the admin
20
+ * can hand-edit it or ask for a revision (which re-runs improve() with the
21
+ * CURRENT draft as the base text, regardless of what mode this opened in),
22
+ * and only an explicit Insert writes it back to the caller's field. Cancel
23
+ * discards everything; the form's own Save is a separate, second step.
24
+ *
25
+ * Ferjolt Ozuni - Date: 2026-08-08
26
+ */
27
+ const AiReviewModal = ({ mode, text, targetLocale, sourceLocale, onInsert, onClose, t }: Props): JSX.Element => {
28
+ const { data: available, isLoading: loadingAvailable } = useAiAvailable();
29
+
30
+ const providers = Object.keys(available ?? {});
31
+
32
+ const [ provider, setProvider ] = useState<string>('');
33
+ const [ model, setModel ] = useState<string>('');
34
+ const [ instruction, setInstruction ] = useState<string>('');
35
+ const [ draft, setDraft ] = useState<string | null>(null);
36
+ const [ error, setError ] = useState<string | null>(null);
37
+
38
+ const activeProvider = provider || providers[0] || '';
39
+ const activeModel = model || available?.[activeProvider]?.default_model || '';
40
+
41
+ const { mutate: Generate, isPending: generating } = useAiGenerate();
42
+ const { mutate: Translate, isPending: translating } = useAiTranslate();
43
+ const { mutate: Improve, isPending: improving } = useAiImprove();
44
+
45
+ const running = generating || translating || improving;
46
+
47
+ const onRun = (): void => {
48
+ setError(null);
49
+
50
+ const onError = (err: Error): void => setError(err.message);
51
+ const onSuccess = (result: { content: string }): void => {
52
+ setDraft(result.content);
53
+ setInstruction('');
54
+ };
55
+
56
+ // Once a draft exists, EVERY run (regardless of the mode this modal
57
+ // opened in) is a revision on that draft - improve() is the one
58
+ // endpoint that takes existing text + an instruction, which is exactly
59
+ // what a revision is.
60
+ if (draft !== null) {
61
+ Improve({ provider: activeProvider, model: activeModel, text: draft, instruction }, { onSuccess, onError });
62
+
63
+ return;
64
+ }
65
+
66
+ if (mode === 'generate') {
67
+ Generate({ provider: activeProvider, model: activeModel, prompt: instruction }, { onSuccess, onError });
68
+
69
+ return;
70
+ }
71
+
72
+ if (mode === 'translate') {
73
+ Translate({
74
+ provider: activeProvider,
75
+ model: activeModel,
76
+ text: text ?? '',
77
+ target_locale: targetLocale ?? '',
78
+ source_locale: sourceLocale
79
+ }, { onSuccess, onError });
80
+
81
+ return;
82
+ }
83
+
84
+ Improve({ provider: activeProvider, model: activeModel, text: text ?? '', instruction }, { onSuccess, onError });
85
+ };
86
+
87
+ const onInsertClick = (): void => {
88
+ if (draft !== null) {
89
+ onInsert(draft);
90
+ }
91
+
92
+ onClose();
93
+ };
94
+
95
+ const content = (
96
+ <>
97
+ { error && <Error>{ error }</Error> }
98
+
99
+ <Row>
100
+ <Field>
101
+ { t('ai_assist.provider') }
102
+
103
+ <select value={ activeProvider } onChange={ event => { setProvider(event.target.value); setModel(''); } }>
104
+ { providers.map(key => <option key={ key } value={ key }>{ key }</option>) }
105
+ </select>
106
+ </Field>
107
+
108
+ <Field>
109
+ { t('ai_assist.model') }
110
+
111
+ <select value={ activeModel } onChange={ event => setModel(event.target.value) }>
112
+ { (available?.[activeProvider]?.models ?? []).map(name => <option key={ name } value={ name }>{ name }</option>) }
113
+ </select>
114
+ </Field>
115
+ </Row>
116
+
117
+ { mode !== 'translate' && (
118
+ <Row>
119
+ <Field>
120
+ { draft !== null
121
+ ? t('ai_assist.revise_instruction')
122
+ : (mode === 'generate' ? t('ai_assist.generate_prompt') : t('ai_assist.improve_instruction')) }
123
+
124
+ <input
125
+ type="text"
126
+ value={ instruction }
127
+ onChange={ event => setInstruction(event.target.value) }
128
+ placeholder={ draft !== null ? t('ai_assist.revise_placeholder') : undefined }
129
+ />
130
+ </Field>
131
+ </Row>
132
+ ) }
133
+
134
+ { draft === null && mode === 'translate' && (
135
+ <Pane>
136
+ <h4>{ t('ai_assist.source') }</h4>
137
+
138
+ <div dangerouslySetInnerHTML={ { __html: text ?? '' } } />
139
+ </Pane>
140
+ ) }
141
+
142
+ { draft !== null && mode === 'translate' && (
143
+ <Compare>
144
+ <Pane>
145
+ <h4>{ t('ai_assist.source') }</h4>
146
+
147
+ <div dangerouslySetInnerHTML={ { __html: text ?? '' } } />
148
+ </Pane>
149
+
150
+ <Pane>
151
+ <h4>{ t('ai_assist.draft') }</h4>
152
+
153
+ <div dangerouslySetInnerHTML={ { __html: draft } } />
154
+ </Pane>
155
+ </Compare>
156
+ ) }
157
+
158
+ { draft !== null && (
159
+ <Draft value={ draft } onChange={ event => setDraft(event.target.value) } />
160
+ ) }
161
+
162
+ <Actions>
163
+ <button type="button" onClick={ onClose } disabled={ running }>
164
+ { t('ai_assist.cancel') }
165
+ </button>
166
+
167
+ <button type="button" onClick={ onRun } disabled={ running || !activeProvider || (mode !== 'translate' && draft === null && instruction.trim() === '') }>
168
+ { draft !== null ? t('ai_assist.revise') : t('ai_assist.run') }
169
+ </button>
170
+
171
+ { draft !== null && (
172
+ <button type="button" onClick={ onInsertClick } disabled={ running }>
173
+ { t('ai_assist.insert') }
174
+ </button>
175
+ ) }
176
+ </Actions>
177
+ </>
178
+ );
179
+
180
+ return (
181
+ <Modal
182
+ loading={ loadingAvailable }
183
+ width={ 720 }
184
+ title={ t(`ai_assist.title.${ mode }`) }
185
+ content={ content }
186
+ onClose={ onClose }
187
+ />
188
+ );
189
+ };
190
+
191
+ export default AiReviewModal;
@@ -0,0 +1,84 @@
1
+ import { useQuery, UseQueryResult, useMutation, UseMutationResult } from '@tanstack/react-query';
2
+ import { apiClient } from '@autobusal/providers';
3
+ import { AiAvailable, AiCompletion } from './types';
4
+
5
+ /**
6
+ * The providers+models this label may actually use right now - empty means
7
+ * no provider is enabled, which is the signal every AI-assist entry point
8
+ * hides itself on. Cached briefly (not Infinity like the settings query -
9
+ * this is admin-only and an admin who just enabled a provider should not
10
+ * have to reload the whole app to see it).
11
+ */
12
+ export const useAiAvailable = (): UseQueryResult<AiAvailable> => (
13
+ useQuery({
14
+ queryKey: [ 'ai-available' ],
15
+ queryFn: async () => (
16
+ await apiClient
17
+ .get('/api/ai/admin/available')
18
+ .then(response => (
19
+ response.data
20
+ ))
21
+ ),
22
+ staleTime: 60000
23
+ })
24
+ );
25
+
26
+ interface GenerateInput {
27
+ provider?: string
28
+ model?: string
29
+ prompt: string
30
+ }
31
+
32
+ export const useAiGenerate = (): UseMutationResult<AiCompletion, Error, GenerateInput, unknown> => (
33
+ useMutation({
34
+ mutationKey: [ 'ai-generate' ],
35
+ mutationFn: async (data: GenerateInput) => (
36
+ await apiClient
37
+ .post('/api/ai/admin/generate', data)
38
+ .then(response => (
39
+ response.data
40
+ ))
41
+ )
42
+ })
43
+ );
44
+
45
+ interface TranslateInput {
46
+ provider?: string
47
+ model?: string
48
+ text: string
49
+ target_locale: string
50
+ source_locale?: string
51
+ }
52
+
53
+ export const useAiTranslate = (): UseMutationResult<AiCompletion, Error, TranslateInput, unknown> => (
54
+ useMutation({
55
+ mutationKey: [ 'ai-translate' ],
56
+ mutationFn: async (data: TranslateInput) => (
57
+ await apiClient
58
+ .post('/api/ai/admin/translate', data)
59
+ .then(response => (
60
+ response.data
61
+ ))
62
+ )
63
+ })
64
+ );
65
+
66
+ interface ImproveInput {
67
+ provider?: string
68
+ model?: string
69
+ text: string
70
+ instruction: string
71
+ }
72
+
73
+ export const useAiImprove = (): UseMutationResult<AiCompletion, Error, ImproveInput, unknown> => (
74
+ useMutation({
75
+ mutationKey: [ 'ai-improve' ],
76
+ mutationFn: async (data: ImproveInput) => (
77
+ await apiClient
78
+ .post('/api/ai/admin/improve', data)
79
+ .then(response => (
80
+ response.data
81
+ ))
82
+ )
83
+ })
84
+ );
@@ -0,0 +1,94 @@
1
+ import styled from 'styled-components';
2
+
3
+ export const Trigger = styled.div`
4
+ display: flex;
5
+ gap: 6px;
6
+ margin: 6px 0 12px;
7
+
8
+ button {
9
+ font-size: ${ props => props.theme.size.xs };
10
+ padding: 4px 10px;
11
+ border-radius: ${ props => props.theme.borderRadius };
12
+ border: 1px solid ${ props => props.theme.inputs.border };
13
+ background: ${ props => props.theme.background.box };
14
+ cursor: pointer;
15
+
16
+ &:hover {
17
+ background: ${ props => props.theme.background.neutral };
18
+ }
19
+ }
20
+ `;
21
+
22
+ export const Row = styled.div`
23
+ display: flex;
24
+ gap: 10px;
25
+ align-items: flex-end;
26
+ margin-bottom: 14px;
27
+ flex-wrap: wrap;
28
+ `;
29
+
30
+ export const Field = styled.label`
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: 4px;
34
+ font-size: ${ props => props.theme.size.s };
35
+ flex: 1;
36
+ min-width: 160px;
37
+
38
+ select, input, textarea {
39
+ padding: 8px 10px;
40
+ border: 1px solid ${ props => props.theme.inputs.border };
41
+ border-radius: ${ props => props.theme.borderRadius };
42
+ font-size: ${ props => props.theme.size.s };
43
+ }
44
+ `;
45
+
46
+ export const Compare = styled.div`
47
+ display: flex;
48
+ gap: 16px;
49
+ margin-bottom: 14px;
50
+
51
+ > div {
52
+ flex: 1;
53
+ min-width: 0;
54
+ }
55
+ `;
56
+
57
+ export const Pane = styled.div`
58
+ border: 1px solid ${ props => props.theme.inputs.border };
59
+ border-radius: ${ props => props.theme.borderRadius };
60
+ padding: 10px;
61
+ max-height: 320px;
62
+ overflow-y: auto;
63
+ font-size: ${ props => props.theme.size.s };
64
+
65
+ h4 {
66
+ margin: 0 0 8px;
67
+ color: ${ props => props.theme.font.faded };
68
+ font-size: ${ props => props.theme.size.xs };
69
+ text-transform: uppercase;
70
+ }
71
+ `;
72
+
73
+ export const Draft = styled.textarea`
74
+ width: 100%;
75
+ min-height: 260px;
76
+ padding: 10px;
77
+ border: 1px solid ${ props => props.theme.inputs.border };
78
+ border-radius: ${ props => props.theme.borderRadius };
79
+ font-family: monospace;
80
+ font-size: ${ props => props.theme.size.s };
81
+ margin-bottom: 14px;
82
+ `;
83
+
84
+ export const Actions = styled.div`
85
+ display: flex;
86
+ justify-content: flex-end;
87
+ gap: 10px;
88
+ `;
89
+
90
+ export const Error = styled.p`
91
+ color: ${ props => props.theme.font.error };
92
+ font-size: ${ props => props.theme.size.s };
93
+ margin-bottom: 10px;
94
+ `;
@@ -0,0 +1,12 @@
1
+ export interface AiProviderOption {
2
+ models: string[]
3
+ default_model: string
4
+ }
5
+
6
+ export type AiAvailable = Record<string, AiProviderOption>;
7
+
8
+ export interface AiCompletion {
9
+ content: string
10
+ provider: string
11
+ model: string
12
+ }
package/CHANGELOG.md CHANGED
@@ -1,6 +1,35 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## 1.30.0
5
+
6
+ ### Changed
7
+
8
+ - **`Viewer`'s field grid stays at two columns on every screen width.** It
9
+ used to widen to three columns at >=1300px - fine for a form of
10
+ independent, unrelated fields, but a form built for a two-column rhythm
11
+ (one field, a divider row, a related pair, a divider row, ...) reflows
12
+ into three and starts mixing fields from different logical groups onto
13
+ the same row, with nothing to say which group a given row belongs to.
14
+ Affects every Viewer-based form in the app, not just admin-label's.
15
+
16
+ ## 1.29.0
17
+
18
+ ### Added
19
+
20
+ - **`AiFieldAssist` + the AI review modal.** The one AI-review surface every
21
+ content admin form shares - Generate/Improve/Translate triggers that open
22
+ a modal where a draft is always reviewed (and can be hand-edited or
23
+ revised on a follow-up instruction) before an explicit Insert writes it
24
+ into the calling field. Renders nothing at all when no AI provider is
25
+ enabled for the label - the visibility gate the AI plan calls for, with
26
+ no separate flag to keep in sync.
27
+ - **`Viewer`'s `'component'` field type accepts a function** of the form's
28
+ own `setValue`, not just plain JSX - the only way a component embedded in
29
+ the field list (AiFieldAssist writing a draft into a SIBLING field like
30
+ content_en) can reach state Viewer has always kept internal. Existing
31
+ callers passing plain JSX are unaffected.
32
+
4
33
  ## 1.28.0
5
34
 
6
35
  ### Added
package/Viewer/Data.tsx CHANGED
@@ -66,7 +66,11 @@ const Data = ({ id, item, refs, t, onUpdate }: Props): (JSX.Element | null) => {
66
66
  );
67
67
 
68
68
  case 'component':
69
- return <div className="row-data">{ item.value }</div>;
69
+ return (
70
+ <div className="row-data">
71
+ { typeof item.value === 'function' ? item.value(onUpdate) : item.value }
72
+ </div>
73
+ );
70
74
 
71
75
  case 'display':
72
76
  return <Display className="row-data">{ value.string }</Display>;
@@ -115,7 +119,9 @@ const Data = ({ id, item, refs, t, onUpdate }: Props): (JSX.Element | null) => {
115
119
  return <input type="number" defaultValue={ value.number } { ...refs(item.name, Validate(String(item.rules), t)) } step="0.01" />;
116
120
 
117
121
  case 'link':
118
- return <Linked id={ id } value={ item.value } url={ item.url } />;
122
+ // The function-value variant only ever arrives on a 'component' row -
123
+ // see ViewData.value's docblock.
124
+ return <Linked id={ id } value={ item.value as (string | number | JSX.Element | undefined) } url={ item.url } />;
119
125
 
120
126
  case 'number':
121
127
  return <input type="number" defaultValue={ value.number } { ...refs(item.name, Validate(String(item.rules), t)) } />
package/Viewer/styles.ts CHANGED
@@ -28,7 +28,7 @@ export const Container = styled.div`
28
28
 
29
29
  /*
30
30
  * Edited: Ferjolt Ozuni - Date: 2026-08-07
31
- * A rich-text editor gets the full row. At a third of the width it has
31
+ * A rich-text editor gets the full row - even at half the width it has
32
32
  * less room for its own toolbar than for the text, and every consumer
33
33
  * that uses one wants it wide - so this belongs here rather than as a
34
34
  * flag each caller has to remember to pass.
@@ -38,11 +38,17 @@ export const Container = styled.div`
38
38
  }
39
39
  }
40
40
 
41
- @media (min-width: 1300px) {
42
- & > div {
43
- flex-basis: calc(33.33333% - 14px);
44
- }
45
- }
41
+ /*
42
+ * Edited: Ferjolt Ozuni - Date: 2026-08-08
43
+ * DELIBERATELY no wider breakpoint pushing this to three columns - it
44
+ * used to (>=1300px, flex-basis 33%), and the result was a grid with no
45
+ * visual boundary between one logical group's fields and the next: a
46
+ * form built for a two-column rhythm (field, spacer-row, field pair,
47
+ * spacer-row, ...) reflowed into three columns and mixed unrelated
48
+ * fields onto the same row. Every admin-label tab (and every other
49
+ * Viewer-based form in the app) now stays at the same two-column layout
50
+ * from 540px up, at any screen width.
51
+ */
46
52
  `;
47
53
 
48
54
  export const ContainerActions = styled.div`
package/Viewer/types.ts CHANGED
@@ -1,10 +1,19 @@
1
+ import { UseFormSetValue } from 'react-hook-form';
1
2
  import { DropdownData } from '@autobusal/providers/types/other';
2
3
 
3
4
  export interface ViewData {
4
5
  label?: string
5
6
  name?: string
6
7
  type?: 'text' | 'select' | 'date' | 'dob' | 'picker' | 'textarea' | 'textarea-html' | 'file' | 'files' | 'email' | 'password' | 'display' | 'component' | 'checkbox' | 'checkbox-list' | 'checkbox-list-all' | 'link' | 'number' | 'float' | 'date-picker'
7
- value?: string | number | JSX.Element
8
+ /**
9
+ * Edited: Ferjolt Ozuni - Date: 2026-08-08
10
+ * A 'component' row's value may be a function of the form's own
11
+ * setValue instead of plain JSX - the only way a component embedded in
12
+ * the field list (e.g. AiFieldAssist, writing an AI draft into a
13
+ * SIBLING field like content_en) can reach the Viewer's internal
14
+ * react-hook-form state, which nothing outside Viewer has access to.
15
+ */
16
+ value?: string | number | JSX.Element | ((onUpdate: UseFormSetValue<any>) => JSX.Element)
8
17
  /**
9
18
  * Edited: Ferjolt Ozuni - Date: 2026-08-06
10
19
  * Leading empty option for a select, for "nothing chosen". Without one a
package/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import AiFieldAssist from './AiAssist/AiFieldAssist';
1
2
  import ApiDetails from './ApiDetails/ApiDetails';
2
3
  import Autocomplete from './Autocomplete/Autocomplete';
3
4
  import Avatar from './Avatar';
@@ -45,6 +46,7 @@ import useUnderConstruction from './UnderConstruction/useUnderConstruction';
45
46
  import Viewer from './Viewer/Viewer';
46
47
 
47
48
  export {
49
+ AiFieldAssist,
48
50
  ApiDetails,
49
51
  Autocomplete,
50
52
  Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"