@morphemecms/cms 0.11.0 → 0.12.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.
- package/CHANGELOG.md +20 -0
- package/astro.mjs +2 -1
- package/lib/appId.d.mts +1 -0
- package/lib/appId.mjs +6 -0
- package/lib/apps.mjs +13 -3
- package/lib/apps.test.mjs +10 -1
- package/lib/contracts.d.mts +3 -2
- package/lib/contracts.mjs +3 -2
- package/lib/folderChanges.d.mts +29 -0
- package/lib/folderChanges.mjs +34 -0
- package/lib/section-ranges.d.mts +1 -1
- package/lib/section-ranges.mjs +8 -14
- package/lib/section-ranges.test.mjs +26 -5
- package/lib/storeEntries.d.mts +3 -1
- package/lib/storeEntries.mjs +22 -8
- package/lib/storeEntries.test.mjs +56 -7
- package/lib/text-sections.d.mts +11 -1
- package/lib/text-sections.mjs +18 -8
- package/lib/text-sections.test.mjs +34 -1
- package/lib/video-id.d.mts +6 -2
- package/lib/video-id.mjs +5 -1
- package/lib/video.d.mts +6 -2
- package/lib/video.mjs +5 -1
- package/lib/without-h1.d.mts +1 -0
- package/lib/without-h1.mjs +19 -4
- package/lib/without-h1.test.mjs +30 -1
- package/package.json +3 -2
- package/scripts/app-screenshots.mjs +2 -1
- package/scripts/check-content.mjs +2 -1
- package/scripts/check-site-types.mjs +26 -0
- package/scripts/site-types.mjs +14 -0
- package/scripts/site-types.test.mjs +21 -0
- package/site/activeTheme.test.mjs +52 -0
- package/site/appSelf.d.mts +2 -0
- package/site/appSelf.mjs +23 -0
- package/site/appSelf.test.mjs +35 -0
- package/site/content.ts +5 -2
- package/site/siteModules.mjs +2 -1
- package/site/theme.mjs +19 -8
- package/src/App.tsx +1 -1
- package/src/AppList.tsx +24 -9
- package/src/BlockDialog.tsx +38 -17
- package/src/CreateFab.tsx +8 -0
- package/src/DataForm.tsx +7 -17
- package/src/EntryForm.tsx +1 -1
- package/src/FormFrame.tsx +4 -3
- package/src/Library.tsx +4 -11
- package/src/ListFab.tsx +13 -0
- package/src/MarkdownEditor.tsx +88 -47
- package/src/Media.tsx +4 -13
- package/src/Redirects.tsx +6 -14
- package/src/Screen.tsx +13 -3
- package/src/StartFab.tsx +5 -7
- package/src/ThemeStore.tsx +33 -12
- package/src/apps.ts +2 -3
- package/src/drawerYield.d.mts +3 -0
- package/src/drawerYield.mjs +10 -0
- package/src/drawerYield.test.mjs +22 -0
- package/src/editor.css +55 -22
- package/src/fabScreens.test.mjs +28 -0
- package/src/fabScreens.ts +16 -0
- package/src/legacyMove.test.mjs +137 -0
- package/src/legacyMove.ts +131 -0
- package/src/markdown.mjs +10 -7
- package/src/markdown.test.mjs +16 -2
- package/src/menuOpen.ts +20 -4
- package/src/movedRoutes.mjs +2 -2
- package/src/movedRoutes.test.mjs +5 -0
- package/src/pageH1Panel.d.mts +4 -0
- package/src/pageH1Panel.mjs +31 -0
- package/src/pageH1Panel.test.mjs +56 -0
- package/src/pagePreview.tsx +22 -12
- package/src/sectionFrame.ts +3 -3
- package/src/sectionState.d.mts +3 -3
- package/src/sectionState.mjs +6 -6
- package/src/sectionState.test.mjs +14 -2
- package/src/sheetPlacement.d.mts +6 -4
- package/src/sheetPlacement.mjs +15 -10
- package/src/sheetPlacement.test.mjs +16 -9
- package/src/sidePanel.ts +38 -0
- package/src/store.ts +32 -27
- package/src/topBarSlot.tsx +7 -15
- package/src/ui.tsx +26 -15
- package/src/virtual.d.ts +6 -0
- package/starter/package.json +1 -1
- package/theme/components/PageHeading.astro +22 -0
- package/theme/layouts/PageLayout.astro +37 -20
- package/theme/styles/global.css +2 -1
- package/theme/styles/ui.css +40 -10
package/src/BlockDialog.tsx
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// (the standard blocks, each app's under its name), the one picked as a chip. Also the sheet of a section's
|
|
8
8
|
// settings (its colour, SectionSettings.tsx), which goes the same way as a block's fields: Annuller, Gem, the question when closed changed.
|
|
9
9
|
// The fields and the section settings are in the theme's side sheet (sheetPlacement.mjs): a standard one beside the page from
|
|
10
|
-
//
|
|
10
|
+
// expanded (not modal: the page stays usable, .editor-side-sheet in editor.css), a modal one over the page below it
|
|
11
11
|
// (fields only; the section settings are a sheet from the bottom there). Both end with Gem (Indsæt) and Annuller.
|
|
12
12
|
// Their open, close, focus and Escape are the theme's (sideSheet() and standardSideSheet(), sideSheetControl.ts); leave() is their beforeClose.
|
|
13
|
-
// Another block tapped while one is open closes the open one first (the question when changed).
|
|
14
|
-
|
|
13
|
+
// Another block tapped while one is open closes the open one first (the question when changed). In the standard side sheet a
|
|
14
|
+
// card already in the text also has "Remove" after Cancel in the actions (the answer 'remove'). Exposes useBlockDialog, IMAGE.
|
|
15
|
+
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactElement } from 'react';
|
|
15
16
|
import { ArrowsDownUp, Image } from '@phosphor-icons/react';
|
|
16
17
|
import {
|
|
17
18
|
Button,
|
|
@@ -46,6 +47,7 @@ import { snackbar } from '../lib/snackbar';
|
|
|
46
47
|
import { closeChanged } from './ask';
|
|
47
48
|
import SectionSettings from './SectionSettings';
|
|
48
49
|
import { sheetLook } from './sheetPlacement.mjs';
|
|
50
|
+
import { setPanelOpen } from './sidePanel';
|
|
49
51
|
import { SiteSideSheet, siteSideSheet, siteSideSheetDialogClass, siteStandardSideSheet } from './themeUi';
|
|
50
52
|
|
|
51
53
|
export type BlockAsk = {
|
|
@@ -58,13 +60,17 @@ export type BlockAsk = {
|
|
|
58
60
|
* than once, unless the template says how many (have: the types in the text now, one for each, IMAGE for a picture).
|
|
59
61
|
* image: a picture is offered first, and chosen as the type IMAGE. */
|
|
60
62
|
choose: (offer?: { image?: boolean; have?: string[] }) => Promise<{ type: string; props: BlockProps } | null>;
|
|
61
|
-
/** New props for a block in the text; null when cancelled. isNew: one about to be put in ("Indsæt").
|
|
62
|
-
|
|
63
|
+
/** New props for a block in the text; null when cancelled. isNew: one about to be put in ("Indsæt"). removable: the
|
|
64
|
+
* card may be removed, so the side panel has "Remove"; 'remove' when it is tapped (never when removable is not true). */
|
|
65
|
+
edit: (type: string, props: BlockProps, isNew?: boolean, removable?: boolean) => Promise<BlockProps | 'remove' | null>;
|
|
63
66
|
/** The settings of the section the cursor is in (now its colour; null: none); the new ones, or null when cancelled. */
|
|
64
67
|
section: (color: string | null) => Promise<{ color: string | null } | null>;
|
|
65
68
|
};
|
|
66
69
|
|
|
67
|
-
type Open =
|
|
70
|
+
type Open =
|
|
71
|
+
| { step: 'choose'; image?: boolean; have?: string[] }
|
|
72
|
+
| { step: 'fields'; type: string; isNew: boolean; removable?: boolean }
|
|
73
|
+
| { step: 'section' };
|
|
68
74
|
|
|
69
75
|
/** The type a section's settings are answered with (not a block's). */
|
|
70
76
|
const SECTION = 'section';
|
|
@@ -99,7 +105,7 @@ const picture: SiteBlock = {
|
|
|
99
105
|
},
|
|
100
106
|
],
|
|
101
107
|
};
|
|
102
|
-
type Answer = { type: string; props: BlockProps } | null;
|
|
108
|
+
type Answer = { type: string; props: BlockProps } | 'remove' | null;
|
|
103
109
|
|
|
104
110
|
// Text as searched: lower case, without accents (as in PagePicker.tsx).
|
|
105
111
|
const plain = (s: string) =>
|
|
@@ -124,13 +130,13 @@ export function useBlockDialog(
|
|
|
124
130
|
): [BlockAsk, ReactElement] {
|
|
125
131
|
const blockOf = (type: string) => (type === IMAGE ? picture : type === HTML ? htmlBlock : list.find((b) => b.type === type));
|
|
126
132
|
const dialog = useRef<HTMLDialogElement>(null);
|
|
127
|
-
// The modal side sheet's <dialog> (below
|
|
133
|
+
// The modal side sheet's <dialog> (below expanded) and its controls; the standard one's box (from expanded).
|
|
128
134
|
const sideDialog = useRef<HTMLDialogElement>(null);
|
|
129
135
|
const sideControls = useRef<ReturnType<typeof siteSideSheet> | null>(null);
|
|
130
136
|
const standard = useRef<HTMLDivElement>(null);
|
|
131
137
|
const standardControls = useRef<ReturnType<typeof siteStandardSideSheet> | null>(null);
|
|
132
138
|
const headingId = useId();
|
|
133
|
-
const
|
|
139
|
+
const expanded = useAtLeast('expanded');
|
|
134
140
|
const answer = useRef<(a: Answer) => void>(() => {});
|
|
135
141
|
const [open, setOpen] = useState<Open | null>(null);
|
|
136
142
|
// What is open now (set at once, where `open` follows a render later), the look it has, when the last one has closed
|
|
@@ -186,7 +192,7 @@ export function useBlockDialog(
|
|
|
186
192
|
};
|
|
187
193
|
|
|
188
194
|
// Which dialog is open follows the look of the step: the sheet from the bottom, or the modal side sheet (below).
|
|
189
|
-
const look: string = open ? sheetLook(open.step,
|
|
195
|
+
const look: string = open ? sheetLook(open.step, expanded) : 'sheet';
|
|
190
196
|
lookNow.current = look;
|
|
191
197
|
useEffect(() => {
|
|
192
198
|
const sheet = dialog.current;
|
|
@@ -202,7 +208,7 @@ export function useBlockDialog(
|
|
|
202
208
|
return false;
|
|
203
209
|
};
|
|
204
210
|
|
|
205
|
-
// The modal side sheet (below
|
|
211
|
+
// The modal side sheet (below expanded).
|
|
206
212
|
useEffect(() => {
|
|
207
213
|
const d = sideDialog.current;
|
|
208
214
|
if (look !== 'modal-side-sheet' || !d) return;
|
|
@@ -214,11 +220,12 @@ export function useBlockDialog(
|
|
|
214
220
|
};
|
|
215
221
|
}, [look]);
|
|
216
222
|
|
|
217
|
-
// The standard side sheet (from
|
|
223
|
+
// The standard side sheet (from expanded): the page makes room for it (editor.css: [data-side-panel]), the focus moves
|
|
218
224
|
// into it and back to what had it before when it closes.
|
|
219
225
|
useEffect(() => {
|
|
220
226
|
if (look !== 'side-sheet') return;
|
|
221
227
|
document.documentElement.setAttribute('data-side-panel', '');
|
|
228
|
+
setPanelOpen(true);
|
|
222
229
|
const box = standard.current?.querySelector<HTMLElement>('[data-side-sheet-container]');
|
|
223
230
|
if (box) {
|
|
224
231
|
standardControls.current = siteStandardSideSheet(box, { beforeClose });
|
|
@@ -227,6 +234,7 @@ export function useBlockDialog(
|
|
|
227
234
|
return () => {
|
|
228
235
|
standardControls.current = null;
|
|
229
236
|
document.documentElement.removeAttribute('data-side-panel');
|
|
237
|
+
setPanelOpen(false);
|
|
230
238
|
};
|
|
231
239
|
}, [look]);
|
|
232
240
|
|
|
@@ -254,16 +262,23 @@ export function useBlockDialog(
|
|
|
254
262
|
list,
|
|
255
263
|
template,
|
|
256
264
|
data,
|
|
257
|
-
choose: (offer) =>
|
|
265
|
+
choose: async (offer) => {
|
|
266
|
+
const answered = await ask({ step: 'choose', image: offer?.image, have: offer?.have });
|
|
267
|
+
return answered === 'remove' ? null : answered;
|
|
268
|
+
},
|
|
258
269
|
section: async (color) => {
|
|
259
270
|
const answered = await ask({ step: 'section' }, { color: color ?? '' });
|
|
260
|
-
|
|
271
|
+
if (!answered || answered === 'remove') return null;
|
|
272
|
+
return { color: answered.props.color ? String(answered.props.color) : null };
|
|
261
273
|
},
|
|
262
|
-
edit: async (type, props, isNew = false) => {
|
|
274
|
+
edit: async (type, props, isNew = false, removable = false) => {
|
|
263
275
|
const fields = blockOf(type)?.fields ?? [];
|
|
264
276
|
// A block on the page with what a field it has no value for means; a new one with the defaults.
|
|
265
|
-
const a = await ask(
|
|
266
|
-
|
|
277
|
+
const a = await ask(
|
|
278
|
+
{ step: 'fields', type, isNew, removable },
|
|
279
|
+
isNew ? { ...newValue(fields), ...props } : savedValue(fields, props),
|
|
280
|
+
);
|
|
281
|
+
return a === 'remove' ? a : a && a.props;
|
|
267
282
|
},
|
|
268
283
|
};
|
|
269
284
|
|
|
@@ -378,6 +393,12 @@ export function useBlockDialog(
|
|
|
378
393
|
<Button kind="outlined" onClick={() => void leave()}>
|
|
379
394
|
{t.common.cancel}
|
|
380
395
|
</Button>
|
|
396
|
+
{open.step === 'fields' && open.removable && !open.isNew && look === 'side-sheet' && (
|
|
397
|
+
// After Save and Cancel in the start-aligned row, in the error colour (the theme's text button with its colour variable).
|
|
398
|
+
<Button kind="text" style={{ '--button-text-text': 'var(--error)' } as CSSProperties} onClick={() => done('remove')}>
|
|
399
|
+
{t.common.remove}
|
|
400
|
+
</Button>
|
|
401
|
+
)}
|
|
381
402
|
</>
|
|
382
403
|
}
|
|
383
404
|
>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// A screen's one create action as a FAB (Files, Redirects, a single-file list form): a button, busy and
|
|
2
|
+
// disabled as the screen says. Exposes CreateFab. Uses FabMenu (ui.tsx).
|
|
3
|
+
import { Plus } from '@phosphor-icons/react';
|
|
4
|
+
import { FabMenu } from './ui';
|
|
5
|
+
|
|
6
|
+
export function CreateFab({ label, onClick, busy, disabled }: { label: string; onClick: () => void; busy?: boolean; disabled?: boolean }) {
|
|
7
|
+
return <FabMenu icon={Plus} label={label} items={[{ icon: Plus, label, onClick, busy, disabled }]} />;
|
|
8
|
+
}
|
package/src/DataForm.tsx
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// is opened as changes that are not saved. Exposes DataForm (default). Its values from the file and back: dataFileValues.ts;
|
|
5
5
|
// a menu's fields: DataFileFields.tsx.
|
|
6
6
|
import { useMemo, useState } from 'react';
|
|
7
|
-
import { Plus } from '@phosphor-icons/react';
|
|
8
7
|
import { entryName, slugify, slugOf, type DataFile, type FilesCollection, type FolderCollection } from './schema';
|
|
9
8
|
import { config, header, keptBy, sharing, siteName, usesOf } from '@site/editor';
|
|
10
9
|
import { apps, asWritten, collections } from './site';
|
|
@@ -12,6 +11,7 @@ import { kindDraw } from './apps';
|
|
|
12
11
|
import { ConflictError, type RepoFile } from './github';
|
|
13
12
|
import { clean, newItem, newValue, problemAt } from './check';
|
|
14
13
|
import { FieldInput, FieldList, Fields, useWrongField, WrongField, type FieldContext } from './fields';
|
|
14
|
+
import { CreateFab } from './CreateFab';
|
|
15
15
|
import { DataFileFields } from './DataFileFields';
|
|
16
16
|
import { dataOf, fieldsOf, listOnly, valuesOf } from './dataFileValues';
|
|
17
17
|
import SectionsField from './SectionsField';
|
|
@@ -21,7 +21,7 @@ import { splitPicture } from '../lib/crop.mjs';
|
|
|
21
21
|
import { useMediaPicker } from './Media';
|
|
22
22
|
import FormFrame, { savedStatus, useSavedAt } from './FormFrame';
|
|
23
23
|
import { savedOn, VersionNotice, VersionsButton, WithVersions, type ShownVersion } from './Versions';
|
|
24
|
-
import { Button, CheckList, FoldSection, FormColumns, Group,
|
|
24
|
+
import { Button, CheckList, FoldSection, FormColumns, Group, Preview, FormHeading, SharePreview, Notice, ListItem } from './ui';
|
|
25
25
|
import { SearchPreview } from './SearchPreview';
|
|
26
26
|
import { searchedAs } from './googleResult.mjs';
|
|
27
27
|
import { plain, SEO_FIELDS, seoRating, SHARE_FIELDS, sharedAs } from './seo';
|
|
@@ -34,7 +34,7 @@ type Props = {
|
|
|
34
34
|
dataFile?: DataFile;
|
|
35
35
|
file: RepoFile;
|
|
36
36
|
env: Env;
|
|
37
|
-
/** A new entry of this kind (FolderCollection.kinds), picked
|
|
37
|
+
/** A new entry of this kind (FolderCollection.kinds), picked in the list's create FAB menu. */
|
|
38
38
|
kind?: string;
|
|
39
39
|
};
|
|
40
40
|
|
|
@@ -294,7 +294,7 @@ function Form({
|
|
|
294
294
|
label: v.label,
|
|
295
295
|
node: draw({ ...values, ...v.props }, { lists: env.lists, testimonials: env.testimonials, relations: env.relations, site: env.site }),
|
|
296
296
|
}));
|
|
297
|
-
// A file that is a list (the menus) is added to with
|
|
297
|
+
// A file that is a list (the menus) is added to with the FAB, like the other lists; its own fields
|
|
298
298
|
// ("Aktiveret") stand above it.
|
|
299
299
|
const root = dataFile?.root;
|
|
300
300
|
// Where the entry is used on the site (a tag: its posts, testimonials, promotions and blocks), each opening it.
|
|
@@ -318,19 +318,7 @@ function Form({
|
|
|
318
318
|
}
|
|
319
319
|
status={fixed ? savedStatus({ savedAt, saving, deleting }) : undefined}
|
|
320
320
|
view={view}
|
|
321
|
-
actions={
|
|
322
|
-
<>
|
|
323
|
-
{!isNew && <VersionsButton env={env} path={base.path} onPick={onVersion} />}
|
|
324
|
-
{root && (
|
|
325
|
-
<IconButton
|
|
326
|
-
icon={Plus}
|
|
327
|
-
label={t.forms.addOne(root.singular!)}
|
|
328
|
-
|
|
329
|
-
onClick={() => setItems([...items, newItem(root)])}
|
|
330
|
-
/>
|
|
331
|
-
)}
|
|
332
|
-
</>
|
|
333
|
-
}
|
|
321
|
+
actions={!isNew && <VersionsButton env={env} path={base.path} onPick={onVersion} />}
|
|
334
322
|
dirty={dirty}
|
|
335
323
|
saving={saving || deleting}
|
|
336
324
|
deleting={deleting}
|
|
@@ -424,6 +412,8 @@ function Form({
|
|
|
424
412
|
))}
|
|
425
413
|
</Group>
|
|
426
414
|
)}
|
|
415
|
+
{/* A single-file list: a new item in it. */}
|
|
416
|
+
{root && <CreateFab label={t.forms.addOne(root.singular!)} onClick={() => setItems([...items, newItem(root)])} />}
|
|
427
417
|
</FormFrame>
|
|
428
418
|
{picker}
|
|
429
419
|
</WrongField.Provider>
|
package/src/EntryForm.tsx
CHANGED
|
@@ -641,7 +641,7 @@ function Form({
|
|
|
641
641
|
<>
|
|
642
642
|
<FoldSection {...sectionProps('content')}>
|
|
643
643
|
{collection.fields.filter((f) => TOP.includes(f.name) && !STATUS.includes(f.name)).map(field)}
|
|
644
|
-
<div ref={textBox} className="scroll-mt-[calc(
|
|
644
|
+
<div ref={textBox} className="scroll-mt-[calc(1rem+env(safe-area-inset-top))]">
|
|
645
645
|
<span className="mb-1 block font-medium">{t.forms.text}</span>
|
|
646
646
|
<MarkdownEditor
|
|
647
647
|
ref={text}
|
package/src/FormFrame.tsx
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// of the screen with "Gem" (across the bar on a phone, at its end from medium), which is only there when something
|
|
4
4
|
// has been changed. Messages are shown by the app (Env.notify); the first "Gem" asks whether the editor may also send notifications.
|
|
5
5
|
// Leaving with changes that are not saved asks first, to save them or not (in the editor's dialog;
|
|
6
|
-
// closing or reloading the page asks in the browser's own). "Forhåndsvis" beside "Gem" and
|
|
6
|
+
// closing or reloading the page asks in the browser's own). "Forhåndsvis" beside "Gem" and, as a page action (Screen.tsx `pageActions`:
|
|
7
|
+
// the end of the title row from expanded, the top bar below it), shows the page with
|
|
7
8
|
// what is in the form, before it is saved (pagePreview.tsx).
|
|
8
9
|
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
9
10
|
import { ArrowSquareOut, Binoculars, Trash } from '@phosphor-icons/react';
|
|
@@ -177,9 +178,9 @@ export default function FormFrame<T extends string>({
|
|
|
177
178
|
drawer={drawer}
|
|
178
179
|
tabs={tabs}
|
|
179
180
|
back={{ guard: leaveTo, ...back }}
|
|
180
|
-
actions={
|
|
181
|
+
actions={actions}
|
|
182
|
+
pageActions={
|
|
181
183
|
<>
|
|
182
|
-
{actions}
|
|
183
184
|
{onPreview && <IconButton icon={Binoculars} label={t.preview.button} aria-haspopup="dialog" onClick={onPreview} />}
|
|
184
185
|
{view && <IconLink href={view} target="_blank" rel="noopener" label={t.app.viewOnSite} icon={ArrowSquareOut} />}
|
|
185
186
|
</>
|
package/src/Library.tsx
CHANGED
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
ListBullets,
|
|
22
22
|
Moon,
|
|
23
23
|
Palette,
|
|
24
|
-
Plus,
|
|
25
24
|
Ruler,
|
|
26
25
|
Shapes,
|
|
27
26
|
SignOut,
|
|
@@ -33,6 +32,7 @@ import { config, countsOf, header, relationsOf, siteName } from '@site/editor';
|
|
|
33
32
|
import { iconPack, iconPacks } from './iconPacks';
|
|
34
33
|
import { theme, themes, type ThemeInfo } from './themes';
|
|
35
34
|
import { StartFab } from './StartFab';
|
|
35
|
+
import { ListFab } from './ListFab';
|
|
36
36
|
import { StartStatus } from './StartStatus';
|
|
37
37
|
import {
|
|
38
38
|
appearance,
|
|
@@ -60,14 +60,12 @@ import {
|
|
|
60
60
|
Group,
|
|
61
61
|
IconButton,
|
|
62
62
|
LeadingIcon,
|
|
63
|
-
NewChoice,
|
|
64
63
|
SearchRow,
|
|
65
64
|
useKept,
|
|
66
65
|
SegmentedButton,
|
|
67
66
|
UnfoldButton,
|
|
68
67
|
GroupTitle,
|
|
69
68
|
SwitchRow,
|
|
70
|
-
IconLink,
|
|
71
69
|
ScoreRing,
|
|
72
70
|
scoreOf,
|
|
73
71
|
Button,
|
|
@@ -178,7 +176,7 @@ function PartRow({ id, label, href, count }: { id: string } & ReturnType<typeof
|
|
|
178
176
|
}
|
|
179
177
|
|
|
180
178
|
// The start screen: the site's name, the CMS version and when the site was last updated (built on Cloudflare, App.tsx), as StartStatus.tsx shows them, under it, and the FAB
|
|
181
|
-
// menu for a new page or post (StartFab.tsx). The parts of the site are in the main drawer (MainMenu.tsx); the rest is left empty for now.
|
|
179
|
+
// menu for a new page or post (StartFab.tsx); each list has its own FAB for a new entry (ListFab.tsx). The parts of the site are in the main drawer (MainMenu.tsx); the rest is left empty for now.
|
|
182
180
|
export function Start({ builtAt }: { builtAt: Date | null }) {
|
|
183
181
|
return (
|
|
184
182
|
<Screen
|
|
@@ -1374,13 +1372,6 @@ export function List({ collection, tab, files, env }: { collection: Collection;
|
|
|
1374
1372
|
onClick={() => (location.hash = `slettet/${collection.id}`)}
|
|
1375
1373
|
/>
|
|
1376
1374
|
)}
|
|
1377
|
-
{/* Of more than one kind (a pop-up: Standard or an app's, e.g. Mailchimp): a sheet to pick it. */}
|
|
1378
|
-
{collection.create &&
|
|
1379
|
-
(kinds.length > 1 ? (
|
|
1380
|
-
<NewChoice title={collection.create} options={kinds} onChange={(kind) => (location.hash = `#ny/${collection.id}/${kind}`)} />
|
|
1381
|
-
) : (
|
|
1382
|
-
<IconLink href={`#ny/${collection.id}`} label={collection.create!} icon={Plus} />
|
|
1383
|
-
))}
|
|
1384
1375
|
</>
|
|
1385
1376
|
}
|
|
1386
1377
|
>
|
|
@@ -1479,6 +1470,8 @@ export function List({ collection, tab, files, env }: { collection: Collection;
|
|
|
1479
1470
|
) : (
|
|
1480
1471
|
<p className="mb-8 px-1 text-muted">{t.common.noMatch}</p>
|
|
1481
1472
|
)}
|
|
1473
|
+
{/* A new entry: the list's FAB, or a FAB menu of its kinds when it has several (a pop-up: Standard or an app's, e.g. Mailchimp). */}
|
|
1474
|
+
<ListFab collection={collection} kinds={kinds} />
|
|
1482
1475
|
</Screen>
|
|
1483
1476
|
);
|
|
1484
1477
|
}
|
package/src/ListFab.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The create FAB of a list (List in Library.tsx): its own create action, or a menu of its kinds when it has several.
|
|
2
|
+
// Exposes ListFab. Uses FabMenu (ui.tsx), listFab (fabScreens.ts) and the icons of site.ts.
|
|
3
|
+
import { Plus } from '@phosphor-icons/react';
|
|
4
|
+
import { listFab } from './fabScreens';
|
|
5
|
+
import { icons } from './site';
|
|
6
|
+
import { FabMenu } from './ui';
|
|
7
|
+
|
|
8
|
+
export function ListFab({ collection, kinds }: { collection: { id: string; create?: string }; kinds: { value: string; label: string }[] }) {
|
|
9
|
+
// One action is the FAB's (the +); each of several kinds has its list's icon.
|
|
10
|
+
const actions = listFab(collection, kinds);
|
|
11
|
+
const icon = actions.length > 1 ? (icons[collection.id] ?? Plus) : Plus;
|
|
12
|
+
return <FabMenu icon={Plus} label={collection.create ?? ''} items={actions.map((a) => ({ ...a, icon }))} />;
|
|
13
|
+
}
|
package/src/MarkdownEditor.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The text of a page or blog post: Milkdown (packages/editor/src/markdown.mjs) with a small toolbar.
|
|
2
2
|
// The text is shown in the site's own style (the "prose" classes of the pages). On pages the site's
|
|
3
3
|
// blocks (src/apps/<app>/blocks/) can be put in the text with a button; each is a card with the block as it looks
|
|
4
|
-
// on the site, which opens a menu (
|
|
4
|
+
// on the site, which opens a menu (hide on the site, move; below expanded also Ret, Fjern; from expanded a tap opens the
|
|
5
|
+
// side panel instead), with a free line kept between
|
|
5
6
|
// two cards to write in. A fixed page's sections are such a text too (SectionsField.tsx), so both look
|
|
6
7
|
// and work the same. The last tool, "Section settings", opens the section's sheet (BlockDialog.tsx).
|
|
7
8
|
import {
|
|
@@ -64,7 +65,8 @@ import { sectionFrame } from './sectionFrame';
|
|
|
64
65
|
import { IMAGE, type BlockAsk } from './BlockDialog';
|
|
65
66
|
import SectionSettingsIcon from './SectionSettingsIcon';
|
|
66
67
|
import { askLink } from './LinkSheet';
|
|
67
|
-
import { Menu, MenuItem, Toolbar, ToolbarButton } from './ui';
|
|
68
|
+
import { atLeastNow, Menu, MenuItem, Toolbar, ToolbarButton } from './ui';
|
|
69
|
+
import { editsInPanel } from './sheetPlacement.mjs';
|
|
68
70
|
import { shownAt } from './images';
|
|
69
71
|
import { cropStyle, joinPicture, splitPicture } from '../lib/crop.mjs';
|
|
70
72
|
import { describe, htmlBlock, type BlockProps } from './blocks';
|
|
@@ -283,8 +285,16 @@ const moves = (view: EditorView, i: number | null, locked: string[] = []): CardI
|
|
|
283
285
|
};
|
|
284
286
|
|
|
285
287
|
// The row of icons on a card (a block or a picture) in the text, opened by tapping it: items, as
|
|
286
|
-
// they are when it opens.
|
|
287
|
-
|
|
288
|
+
// they are when it opens (no row when there are none). `tap` runs first on a tap that opens the row (not on one that
|
|
289
|
+
// closes it): from expanded it opens the card's side panel. Returns how to close it.
|
|
290
|
+
function cardMenu(
|
|
291
|
+
view: EditorView,
|
|
292
|
+
dom: HTMLElement,
|
|
293
|
+
card: HTMLElement,
|
|
294
|
+
name: string,
|
|
295
|
+
items: () => (CardItem | undefined | false | '')[],
|
|
296
|
+
tap?: () => void,
|
|
297
|
+
) {
|
|
288
298
|
let menu: HTMLElement | null = null;
|
|
289
299
|
function close() {
|
|
290
300
|
menu?.remove();
|
|
@@ -304,12 +314,13 @@ function cardMenu(view: EditorView, dom: HTMLElement, card: HTMLElement, name: s
|
|
|
304
314
|
// `focus` (the arrow used to move the card, when it opens again on the moved card).
|
|
305
315
|
function open(keyboard: boolean, focus?: string) {
|
|
306
316
|
closeOpenMenu?.();
|
|
317
|
+
const shown = items().filter((it): it is CardItem => !!it);
|
|
318
|
+
if (!shown.length) return;
|
|
307
319
|
menu = dom.appendChild(document.createElement('div'));
|
|
308
320
|
menu.className = 'site-block-menu';
|
|
309
321
|
menu.setAttribute('role', 'menu');
|
|
310
322
|
menu.setAttribute('aria-label', name);
|
|
311
|
-
for (const it of
|
|
312
|
-
if (!it) continue;
|
|
323
|
+
for (const it of shown) {
|
|
313
324
|
const b = menu.appendChild(document.createElement('button'));
|
|
314
325
|
b.type = 'button';
|
|
315
326
|
b.setAttribute('role', 'menuitem');
|
|
@@ -356,7 +367,11 @@ function cardMenu(view: EditorView, dom: HTMLElement, card: HTMLElement, name: s
|
|
|
356
367
|
document.addEventListener('pointercancel', unlock, { capture: true, once: true });
|
|
357
368
|
timer = window.setTimeout(unlock, 1000);
|
|
358
369
|
});
|
|
359
|
-
card.addEventListener('click', (e) =>
|
|
370
|
+
card.addEventListener('click', (e) => {
|
|
371
|
+
if (menu) return close();
|
|
372
|
+
tap?.();
|
|
373
|
+
open(e.detail === 0);
|
|
374
|
+
});
|
|
360
375
|
return close;
|
|
361
376
|
}
|
|
362
377
|
|
|
@@ -397,7 +412,8 @@ function pictureCard(node: Node, view: EditorView, getPos: () => number | undefi
|
|
|
397
412
|
if (cut) img.style.cssText = cut.img;
|
|
398
413
|
if (node.attrs.title) img.title = node.attrs.title;
|
|
399
414
|
const describe = async () => {
|
|
400
|
-
const props = await sheet()?.edit(IMAGE, { src, alt: node.attrs.alt ?? '', format, crop, decorative });
|
|
415
|
+
const props = await sheet()?.edit(IMAGE, { src, alt: node.attrs.alt ?? '', format, crop, decorative }, false, true);
|
|
416
|
+
if (props === 'remove') return remove();
|
|
401
417
|
const pos = getPos();
|
|
402
418
|
if (!props || pos === undefined) return;
|
|
403
419
|
view.dispatch(
|
|
@@ -408,14 +424,23 @@ function pictureCard(node: Node, view: EditorView, getPos: () => number | undefi
|
|
|
408
424
|
const pos = getPos();
|
|
409
425
|
if (pos !== undefined) view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize));
|
|
410
426
|
};
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
427
|
+
// From expanded a picture's sheet is the side panel: a tap opens it, and the menu has no pen or trash (Remove is in the panel).
|
|
428
|
+
const inPanelNow = () => editsInPanel(atLeastNow('expanded'), true);
|
|
429
|
+
const close = cardMenu(
|
|
430
|
+
view,
|
|
431
|
+
dom,
|
|
432
|
+
card,
|
|
433
|
+
t.text.image,
|
|
434
|
+
() => {
|
|
435
|
+
const pos = getPos();
|
|
436
|
+
return [
|
|
437
|
+
!inPanelNow() && { icon: pencilIcon, label: t.common.edit, action: describe },
|
|
438
|
+
...moves(view, pos === undefined ? null : view.state.doc.resolve(pos).index(0), sheet()?.template?.locked),
|
|
439
|
+
!inPanelNow() && { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
|
|
440
|
+
];
|
|
441
|
+
},
|
|
442
|
+
() => inPanelNow() && void describe(),
|
|
443
|
+
);
|
|
419
444
|
return { dom, update: () => false, stopEvent: () => true, ignoreMutation: () => true, destroy: close };
|
|
420
445
|
}
|
|
421
446
|
|
|
@@ -468,7 +493,7 @@ async function editLink(view: EditorView) {
|
|
|
468
493
|
/** What applies where the cursor is, or to all of the selection: 'strong', 'emphasis', 'link',
|
|
469
494
|
* 'h2', 'h3', 'text' (a paragraph, not in a list or quote), 'bullet', 'ordered' and 'quote'; with a colour
|
|
470
495
|
* (text-colors.mjs) 'textColor' and the colour's class ('color-accent'). For the toolbar and the selection's menu,
|
|
471
|
-
* 'section' and 'section-<colour>' in a top-level H2 (sectionState.mjs), which mark them. */
|
|
496
|
+
* 'section' and 'section-<colour>' in a top-level H1 or H2 (sectionState.mjs), which mark them. */
|
|
472
497
|
export function activeOf(state: EditorView['state']): Set<string> {
|
|
473
498
|
const { selection, storedMarks, schema } = state;
|
|
474
499
|
const { $from, from, to, empty } = selection;
|
|
@@ -528,11 +553,11 @@ type Look = {
|
|
|
528
553
|
// The levels of the top-level headings of a document, in order.
|
|
529
554
|
const levelsOf = (doc: EditorView['state']['doc']): number[] => topHeadings(doc).map(({ node }) => node.attrs.level);
|
|
530
555
|
|
|
531
|
-
// Whether a heading (H2) is selected, or a part of one.
|
|
532
|
-
function
|
|
556
|
+
// Whether a heading that starts a section (H1 or H2) is selected, or a part of one.
|
|
557
|
+
function inSectionHeading(state: EditorView['state']) {
|
|
533
558
|
let found = false;
|
|
534
559
|
const { from, to } = state.selection;
|
|
535
|
-
state.doc.nodesBetween(from, to, (n) => void (found ||= n.type.name === 'heading' && n.attrs.level
|
|
560
|
+
state.doc.nodesBetween(from, to, (n) => void (found ||= n.type.name === 'heading' && n.attrs.level <= 2));
|
|
536
561
|
return found;
|
|
537
562
|
}
|
|
538
563
|
|
|
@@ -544,7 +569,7 @@ const inLabel = (state: EditorView['state']) => state.selection.$from.parent.typ
|
|
|
544
569
|
// page's headings (`h1`: only the levels headingChoices gives for the text), a heading or a subheading, a label above the heading; the ones that apply are marked. At a still
|
|
545
570
|
// cursor, bold, italic, link and colour act on the whole word, the headings on the line; a move or a keypress hides the
|
|
546
571
|
// row. On a phone, where the phone's own menu is over the selection and its handles under it, the row is at the bottom
|
|
547
|
-
// of the screen, over the keyboard. sections: no colour in a heading (H2), the name of a section.
|
|
572
|
+
// of the screen, over the keyboard. sections: no colour in a heading (H1 or H2), the name of a section.
|
|
548
573
|
function selectionMenu(ctx: Ctx, h1: boolean, sections: boolean, colors: (button: HTMLElement) => void) {
|
|
549
574
|
return new Plugin({
|
|
550
575
|
view(view) {
|
|
@@ -588,7 +613,7 @@ function selectionMenu(ctx: Ctx, h1: boolean, sections: boolean, colors: (button
|
|
|
588
613
|
label: t.text.color,
|
|
589
614
|
on: (s) => marked(s, 'textColor'),
|
|
590
615
|
action: colors,
|
|
591
|
-
off: (s) => inLabel(s) || (sections &&
|
|
616
|
+
off: (s) => inLabel(s) || (sections && inSectionHeading(s)),
|
|
592
617
|
word: true,
|
|
593
618
|
},
|
|
594
619
|
...(h1 ? [1, 2, 3] : [2, 3]).map((level) => ({
|
|
@@ -783,8 +808,20 @@ function blockCard(
|
|
|
783
808
|
const pos = getPos();
|
|
784
809
|
return pos === undefined ? null : view.state.doc.resolve(pos).index(0);
|
|
785
810
|
};
|
|
811
|
+
// What the template (collections.ts) says of this block: a locked one is only edited, and the last shown one of a block
|
|
812
|
+
// the page must have cannot be hidden or removed (stays).
|
|
813
|
+
const keeps = () => {
|
|
814
|
+
const rules = asker()?.template;
|
|
815
|
+
const type = node.attrs.type;
|
|
816
|
+
const locked = !!rules?.locked?.includes(type);
|
|
817
|
+
const shownOfType = blocksOf(view.state.doc).filter((b) => b.type === type && b.hidden === false).length;
|
|
818
|
+
// (Not written with "!" before "hidden", which Tailwind would take for a class for the site.)
|
|
819
|
+
const last = hidden === false && !!rules?.required?.includes(type) && shownOfType <= 1;
|
|
820
|
+
return { rules, locked, stays: locked || last };
|
|
821
|
+
};
|
|
786
822
|
const edit = async () => {
|
|
787
|
-
const props = await asker()?.edit(node.attrs.type, node.attrs.props);
|
|
823
|
+
const props = await asker()?.edit(node.attrs.type, node.attrs.props, false, !keeps().stays);
|
|
824
|
+
if (props === 'remove') return remove();
|
|
788
825
|
const here = at();
|
|
789
826
|
// The block's fields as changed; still hidden if it was.
|
|
790
827
|
if (props && here)
|
|
@@ -806,26 +843,29 @@ function blockCard(
|
|
|
806
843
|
// The choices that can be made here: no "Ret" without fields (but to where it is edited instead,
|
|
807
844
|
// Block.edit), no move past the start or end. By the template (collections.ts): a locked block is
|
|
808
845
|
// only edited, and the last shown one of a block the page must have cannot be hidden or removed.
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
846
|
+
// From expanded the block's fields are in the side panel: a tap opens it, and the menu has no pen or trash (Remove is in the panel).
|
|
847
|
+
const inPanelNow = () => editsInPanel(atLeastNow('expanded'), !!block?.fields.length);
|
|
848
|
+
const close = cardMenu(
|
|
849
|
+
view,
|
|
850
|
+
dom,
|
|
851
|
+
card,
|
|
852
|
+
name,
|
|
853
|
+
() => {
|
|
854
|
+
const i = top();
|
|
855
|
+
const { rules, locked, stays } = keeps();
|
|
856
|
+
return [
|
|
857
|
+
block?.fields.length
|
|
858
|
+
? !inPanelNow() && { icon: pencilIcon, label: t.common.edit, action: edit }
|
|
859
|
+
: block?.edit && { icon: pencilIcon, label: block.edit.text, action: () => leaveTo(`#${block.edit!.to}`) },
|
|
860
|
+
block?.shown || (stays && hidden === false)
|
|
861
|
+
? undefined
|
|
862
|
+
: { icon: hidden ? eyeIcon : eyeSlashIcon, label: hidden ? t.text.showOnPage : t.text.hideOnPage, action: toggle },
|
|
863
|
+
...(locked ? [] : moves(view, i, rules?.locked)),
|
|
864
|
+
stays || inPanelNow() ? undefined : { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
|
|
865
|
+
];
|
|
866
|
+
},
|
|
867
|
+
() => inPanelNow() && void edit(),
|
|
868
|
+
);
|
|
829
869
|
// A changed block is drawn anew (update false); taps on the card and its menu are not ProseMirror's.
|
|
830
870
|
return {
|
|
831
871
|
dom,
|
|
@@ -1039,7 +1079,7 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
|
|
|
1039
1079
|
if (!src) return;
|
|
1040
1080
|
const sheet = blocks ?? sheetProp;
|
|
1041
1081
|
const picked = await sheet?.edit(IMAGE, { src, alt: '' }, true);
|
|
1042
|
-
if (picked) run(insertImageCommand, { src: pictureSrc(picked), alt: String(picked.alt ?? '').trim() });
|
|
1082
|
+
if (picked && picked !== 'remove') run(insertImageCommand, { src: pictureSrc(picked), alt: String(picked.alt ?? '').trim() });
|
|
1043
1083
|
}
|
|
1044
1084
|
|
|
1045
1085
|
// Puts the chosen block where the cursor is (a paragraph is split around it), or after the block
|
|
@@ -1113,8 +1153,9 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
|
|
|
1113
1153
|
: []),
|
|
1114
1154
|
{ label: t.text.undo, icon: ArrowUUpLeft, action: () => run(undoCommand) },
|
|
1115
1155
|
{ label: t.text.redo, icon: ArrowUUpRight, action: () => run(redoCommand) },
|
|
1116
|
-
// The settings of the section the cursor's H2 starts (not a fixed page's text, whose H2s are its sections' names; the front
|
|
1117
|
-
|
|
1156
|
+
// The settings of the section the cursor's H1 or H2 starts (not a fixed page's text, whose H2s are its sections' names; the front
|
|
1157
|
+
// page has it for its H2s, but not for its H1, which stays the banner without a colour).
|
|
1158
|
+
...(framed && active.includes('section') && !(sections && active.includes('h1')) && (blocks ?? sheetProp)
|
|
1118
1159
|
? [{ label: t.text.sectionSettings, icon: SectionSettingsIcon, action: sectionSettings }]
|
|
1119
1160
|
: []),
|
|
1120
1161
|
];
|
|
@@ -1168,7 +1209,7 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
|
|
|
1168
1209
|
<Toolbar
|
|
1169
1210
|
ref={toolbar}
|
|
1170
1211
|
label={t.text.tools}
|
|
1171
|
-
className="editor-tools sticky top-[
|
|
1212
|
+
className="editor-tools sticky top-[env(safe-area-inset-top)] z-10 m-2 justify-center-safe overflow-x-auto rounded-panel border border-line medium:flex-wrap"
|
|
1172
1213
|
>
|
|
1173
1214
|
{tools.map((tool) => (
|
|
1174
1215
|
<ReactFragment key={tool.label}>
|
package/src/Media.tsx
CHANGED
|
@@ -7,27 +7,18 @@
|
|
|
7
7
|
// "Slet billede" (…) when nothing uses it (also not the site's own code, /editor/code-images.json). Its
|
|
8
8
|
// name, under the title, can be changed with the pencil after it (not one the site's code uses),
|
|
9
9
|
// saved with the tick: the file moves, the pages that use it get the new address in the same commit,
|
|
10
|
-
// and the old address sends on to it (addresses.ts).
|
|
10
|
+
// and the old address sends on to it (addresses.ts). The FAB uploads a new file, saved at once. The same
|
|
11
11
|
// pictures (not the other files) are the gallery a picture is chosen from in the forms (useMediaPicker).
|
|
12
12
|
// The buttons that run (add, rename, delete, upload new) show the loading indicator while they do.
|
|
13
13
|
// The funnel in Filer also filters by a tag (fileTagFilter.mjs; only tags some file has).
|
|
14
14
|
// The clock in the top bar, "Slettede filer", opens the files that were deleted (Deleted.tsx).
|
|
15
15
|
import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
|
|
16
|
-
import {
|
|
17
|
-
ArrowsDownUp,
|
|
18
|
-
CalendarBlank,
|
|
19
|
-
Check,
|
|
20
|
-
ClockCounterClockwise,
|
|
21
|
-
PencilSimple,
|
|
22
|
-
Plus,
|
|
23
|
-
Trash,
|
|
24
|
-
UploadSimple,
|
|
25
|
-
X,
|
|
26
|
-
} from '@phosphor-icons/react';
|
|
16
|
+
import { ArrowsDownUp, CalendarBlank, Check, ClockCounterClockwise, PencilSimple, Trash, UploadSimple, X } from '@phosphor-icons/react';
|
|
27
17
|
import { moveRedirects, REDIRECTS } from './addresses';
|
|
28
18
|
import { changesOf, flatten, readAll, REDIRECT_FILES } from './redirectData';
|
|
29
19
|
import { ask } from './ask';
|
|
30
20
|
import { dateName, headed, letterOf } from './Sheet';
|
|
21
|
+
import { CreateFab } from './CreateFab';
|
|
31
22
|
import Screen from './Screen';
|
|
32
23
|
import {
|
|
33
24
|
Button,
|
|
@@ -689,7 +680,6 @@ export default function Media({ files, env, open }: { files: RepoFile[]; env: En
|
|
|
689
680
|
|
|
690
681
|
onClick={() => (location.hash = 'slettet/filer')}
|
|
691
682
|
/>
|
|
692
|
-
<IconButton icon={Plus} label={t.media.add} busy={adding} disabled={adding} onClick={add} />
|
|
693
683
|
</>
|
|
694
684
|
}
|
|
695
685
|
>
|
|
@@ -727,6 +717,7 @@ export default function Media({ files, env, open }: { files: RepoFile[]; env: En
|
|
|
727
717
|
<SheetTitle title={src ? fileName(src) : ''} onClose={() => dialog.current?.close()} />
|
|
728
718
|
{!wide && src && <SheetBody>{details(src)}</SheetBody>}
|
|
729
719
|
</BottomSheet>
|
|
720
|
+
<CreateFab label={t.media.add} busy={adding} disabled={adding} onClick={add} />
|
|
730
721
|
</Screen>
|
|
731
722
|
);
|
|
732
723
|
}
|