@morphemecms/cms 0.11.0 → 0.13.0-next-20261106203613
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 +31 -0
- package/astro.mjs +2 -1
- package/defaults/blockTexts.ts +9 -9
- package/defaults/collections.ts +257 -257
- package/defaults/colors.ts +74 -74
- package/defaults/company.ts +9 -9
- package/defaults/editor.ts +65 -65
- package/defaults/i18n/da-DK/blockTexts.ts +12 -0
- package/defaults/i18n/da-DK/collections.ts +220 -0
- package/defaults/i18n/da-DK/colors.ts +102 -0
- package/defaults/i18n/da-DK/company.ts +7 -0
- package/defaults/i18n/da-DK/editor.ts +13 -0
- package/defaults/i18n/da-DK/index.ts +31 -0
- package/defaults/i18n/da-DK/internalLinks.ts +12 -0
- package/defaults/i18n/da-DK/opens.ts +7 -0
- package/defaults/i18n/da-DK/popup.ts +9 -0
- package/defaults/i18n/da-DK/privacy.ts +38 -0
- package/defaults/i18n/da-DK/rules.ts +56 -0
- package/defaults/i18n/da-DK/sitedata.ts +8 -0
- package/defaults/i18n/da-DK/standard.ts +8 -0
- package/defaults/i18n/da-DK/standardPages.ts +17 -0
- package/defaults/internalLinks.ts +8 -8
- package/defaults/opens.ts +2 -2
- package/defaults/popup.tsx +7 -7
- package/defaults/privacy.ts +37 -37
- package/defaults/rules.ts +68 -66
- package/defaults/schema.ts +1 -1
- package/defaults/sitedata.ts +4 -4
- package/defaults/standard.ts +9 -6
- package/defaults/standardPages.ts +14 -14
- package/defaults/texts.ts +7 -510
- package/lib/appId.d.mts +1 -0
- package/lib/appId.mjs +6 -0
- package/lib/appRoute.d.mts +2 -2
- package/lib/appRoute.mjs +3 -3
- package/lib/appRoute.test.mjs +8 -8
- 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/licenses.mjs +2 -2
- package/lib/licenses.owner.test.mjs +9 -0
- package/lib/menu.mjs +4 -4
- package/lib/menu.test.mjs +101 -101
- package/lib/scheduled.mjs +1 -1
- 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 +59 -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/translate.d.mts +11 -0
- package/lib/translate.mjs +13 -1
- package/lib/translate.test.mjs +23 -2
- 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 +9 -8
- package/scripts/check-content.mjs +12 -2
- package/scripts/check-language.mjs +0 -1
- package/scripts/check-site-types.mjs +26 -0
- package/scripts/editor-screens.mjs +3 -3
- 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/defineSite.mjs +2 -2
- package/site/schema.ts +3 -3
- package/site/siteModules.mjs +2 -1
- package/site/theme.mjs +19 -8
- package/src/Advanced.tsx +2 -2
- package/src/App.tsx +79 -78
- package/src/AppAdds.tsx +1 -1
- package/src/AppList.tsx +39 -24
- package/src/BlockDialog.tsx +39 -18
- package/src/BlockParts.tsx +6 -6
- package/src/BlockSettings.tsx +2 -2
- package/src/BrandForm.tsx +2 -2
- package/src/Colors.tsx +6 -6
- package/src/CompanyForm.tsx +1 -1
- package/src/Consents.tsx +3 -3
- package/src/CreateFab.tsx +8 -0
- package/src/DataForm.tsx +10 -20
- package/src/Deleted.tsx +3 -3
- package/src/EditorFooter.tsx +2 -2
- package/src/EntryForm.tsx +3 -3
- package/src/FooterSettings.tsx +2 -2
- package/src/FormFrame.tsx +4 -3
- package/src/HeaderSettings.tsx +3 -3
- package/src/Keywords.tsx +5 -5
- package/src/Library.tsx +75 -82
- package/src/Licenses.tsx +8 -8
- package/src/ListFab.tsx +13 -0
- package/src/MainDrawerParts.tsx +3 -3
- package/src/MainMenu.tsx +1 -1
- package/src/MarkdownEditor.tsx +88 -47
- package/src/Media.tsx +11 -20
- package/src/MediaTags.tsx +2 -2
- package/src/Notifications.tsx +2 -2
- package/src/Parts.tsx +17 -17
- package/src/Redirects.tsx +14 -22
- package/src/Screen.tsx +16 -6
- package/src/SeoScores.tsx +6 -6
- package/src/SideColumn.tsx +2 -2
- package/src/StartFab.tsx +6 -8
- package/src/ThemeStore.tsx +37 -16
- package/src/Tokens.tsx +2 -2
- package/src/apps.ts +7 -8
- package/src/checkSite.ts +86 -20
- package/src/drawerYield.d.mts +3 -0
- package/src/drawerYield.mjs +10 -0
- package/src/drawerYield.test.mjs +22 -0
- package/src/editor.css +59 -38
- package/src/examples.ts +1 -1
- package/src/fabScreens.test.mjs +28 -0
- package/src/fabScreens.ts +16 -0
- package/src/i18n/da-DK/library.ts +2 -2
- package/src/i18n/da-DK/text.ts +1 -1
- package/src/i18n/en-US/apps.ts +1 -1
- package/src/i18n/en-US/common.ts +8 -8
- package/src/i18n/en-US/library.ts +5 -5
- package/src/i18n/en-US/parts.ts +3 -3
- package/src/i18n/en-US/text.ts +1 -1
- package/src/i18n/index.ts +4 -8
- package/src/images.ts +2 -2
- package/src/legacyMove.test.mjs +137 -0
- package/src/legacyMove.ts +131 -0
- package/src/licenseData.ts +3 -3
- package/src/markdown.mjs +10 -7
- package/src/markdown.test.mjs +16 -2
- package/src/menuChildren.ts +5 -5
- package/src/menuOpen.ts +20 -4
- package/src/movedRoutes.d.mts +8 -1
- package/src/movedRoutes.mjs +102 -21
- package/src/movedRoutes.test.mjs +128 -6
- package/src/nav.ts +1 -1
- package/src/notificationData.ts +1 -1
- 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/schema.ts +2 -2
- 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/site.ts +37 -13
- package/src/standard/accordion/index.tsx +1 -1
- package/src/standard/accordion/shared.ts +1 -1
- package/src/standard/buttons/index.tsx +1 -1
- package/src/standard/cards/index.tsx +1 -1
- package/src/standard/cards/shared.ts +1 -1
- package/src/standard/cookies/index.tsx +1 -1
- package/src/standard/posts/index.tsx +1 -1
- package/src/standard/testimonials/index.tsx +1 -1
- package/src/standard/testimonials/list.ts +1 -1
- package/src/store.ts +36 -31
- package/src/tagUses.d.mts +1 -1
- package/src/tagUses.mjs +2 -2
- package/src/tagUses.test.mjs +1 -1
- package/src/topBarSlot.tsx +7 -15
- package/src/ui.tsx +33 -22
- package/src/virtual.d.ts +6 -0
- package/starter/package.json +1 -1
- package/theme/components/PageHeading.astro +22 -0
- package/theme/layouts/Base.astro +2 -2
- package/theme/layouts/PageLayout.astro +37 -20
- package/theme/styles/global.css +2 -1
- package/theme/styles/ui.css +40 -10
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,
|
|
@@ -229,8 +220,8 @@ function Gallery({
|
|
|
229
220
|
// All the files, not only pictures (Filer), with their type.
|
|
230
221
|
const all = !!used;
|
|
231
222
|
// In Filer, kept for coming back to it (from a file's page); in a picker, from the start each time.
|
|
232
|
-
const [kind, setKind] = useKept<'' | Kind>(all ? '
|
|
233
|
-
const [query, setQuery] = useKept(all ? '
|
|
223
|
+
const [kind, setKind] = useKept<'' | Kind>(all ? 'files-type' : null, '');
|
|
224
|
+
const [query, setQuery] = useKept(all ? 'files-search' : null, '');
|
|
234
225
|
const [sort, setSort] = useState<Sort>(() => {
|
|
235
226
|
try {
|
|
236
227
|
const saved = localStorage.getItem(SORT_KEY);
|
|
@@ -238,9 +229,9 @@ function Gallery({
|
|
|
238
229
|
} catch {}
|
|
239
230
|
return 'new';
|
|
240
231
|
});
|
|
241
|
-
const [date, setDate] = useKept(all ? '
|
|
242
|
-
const [use, setUse] = useKept<'' | 'used' | 'unused'>(all ? '
|
|
243
|
-
const [tag, setTag] = useKept(all ? '
|
|
232
|
+
const [date, setDate] = useKept(all ? 'files-date' : null, '');
|
|
233
|
+
const [use, setUse] = useKept<'' | 'used' | 'unused'>(all ? 'files-use' : null, '');
|
|
234
|
+
const [tag, setTag] = useKept(all ? 'files-tag' : null, '');
|
|
244
235
|
|
|
245
236
|
function pickSort(next: Sort) {
|
|
246
237
|
setSort(next);
|
|
@@ -419,7 +410,7 @@ const codeImages = () =>
|
|
|
419
410
|
.then((r) => (r.ok ? (r.json() as Promise<string[]>) : null))
|
|
420
411
|
.catch(() => null));
|
|
421
412
|
|
|
422
|
-
// open: the address of a file to show picked at once (the route
|
|
413
|
+
// open: the address of a file to show picked at once (the route files/<address>); ignored when no such file exists.
|
|
423
414
|
export default function Media({ files, env, open }: { files: RepoFile[]; env: Env; open?: string }) {
|
|
424
415
|
// The pictures and the other files.
|
|
425
416
|
const pictures = [...mediaOf(files), ...filesOf(files)];
|
|
@@ -687,9 +678,8 @@ export default function Media({ files, env, open }: { files: RepoFile[]; env: En
|
|
|
687
678
|
icon={ClockCounterClockwise}
|
|
688
679
|
label={t.history.deletedOf(t.history.files)}
|
|
689
680
|
|
|
690
|
-
onClick={() => (location.hash = '
|
|
681
|
+
onClick={() => (location.hash = 'deleted/files')}
|
|
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
|
}
|
package/src/MediaTags.tsx
CHANGED
|
@@ -74,11 +74,11 @@ export default function MediaTags({ src, files, env }: { src: string; files: Rep
|
|
|
74
74
|
keep="categories"
|
|
75
75
|
footer={(close) => (
|
|
76
76
|
<SheetLink
|
|
77
|
-
href="#
|
|
77
|
+
href="#new/tags"
|
|
78
78
|
onClick={(e) => {
|
|
79
79
|
e.preventDefault();
|
|
80
80
|
close();
|
|
81
|
-
leaveTo('#
|
|
81
|
+
leaveTo('#new/tags');
|
|
82
82
|
}}
|
|
83
83
|
>
|
|
84
84
|
{t.media.createTag}
|
package/src/Notifications.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// "Notifikationer" (#
|
|
1
|
+
// "Notifikationer" (#notifications), which the bell in the top bar opens (Screen.tsx NotificationsLink): the
|
|
2
2
|
// notifications of the editor and the apps (notificationData.ts), newest first, each with when it came and, when it had
|
|
3
3
|
// one, a link to what it is about; an app's with the app's icon. Opening it marks them seen; at the top the bin clears
|
|
4
4
|
// them and the sliders open "Konto", where each type is turned on or off.
|
|
@@ -41,7 +41,7 @@ export default function Notifications() {
|
|
|
41
41
|
disabled={!items.length}
|
|
42
42
|
onClick={clearNotifications}
|
|
43
43
|
/>
|
|
44
|
-
<IconLink href="#
|
|
44
|
+
<IconLink href="#account" icon={SlidersHorizontal} label={t.library.notificationChoices} />
|
|
45
45
|
</>
|
|
46
46
|
}
|
|
47
47
|
>
|
package/src/Parts.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// "Udseende" › "Dele" › "Editor" (#
|
|
1
|
+
// "Udseende" › "Dele" › "Editor" (#appearance/parts/editor): the parts only the editor has (ui.tsx: the SEO rating, the
|
|
2
2
|
// colour field, the choices of pages and icons, the form's layout …) in every state, on one screen, to see and agree on
|
|
3
3
|
// how the editor looks, and below them the form fields (fields.tsx), one of each type with the settings it can have.
|
|
4
4
|
// The parts the theme has too (buttons, fields, switches, lists, sheets …) are the theme's, shown under "UI-komponenter"
|
|
@@ -118,7 +118,7 @@ const GROUPS = (['buttons', 'fields', 'choices', 'lists', 'sheets', 'messages',
|
|
|
118
118
|
}));
|
|
119
119
|
|
|
120
120
|
export default function Parts({ env }: { env: Env }) {
|
|
121
|
-
const [sort, setSort] = useState('
|
|
121
|
+
const [sort, setSort] = useState('newest');
|
|
122
122
|
const [filter, setFilter] = useState('');
|
|
123
123
|
const [content, setContent] = useState('');
|
|
124
124
|
const [picked, setPicked] = useState('a');
|
|
@@ -155,7 +155,7 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
155
155
|
const page = useMemo(() => exampleData().relations.pages[1].value, []);
|
|
156
156
|
|
|
157
157
|
return (
|
|
158
|
-
<Screen title={t.parts.screens.editor} back={{ href: '#
|
|
158
|
+
<Screen title={t.parts.screens.editor} back={{ href: '#appearance/parts', label: t.common.parts.editorParts }}>
|
|
159
159
|
<div className="space-y-10">
|
|
160
160
|
<p className="text-muted">{t.parts.intro('packages/editor/src/ui.tsx')}</p>
|
|
161
161
|
<PartsShown groups={GROUPS} keep="parts">
|
|
@@ -169,7 +169,7 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
169
169
|
</Part>
|
|
170
170
|
|
|
171
171
|
<Part name="ArrowLink" use={use.arrowLink}>
|
|
172
|
-
<ArrowLink href="#
|
|
172
|
+
<ArrowLink href="#appearance/colors">{sample.editColors}</ArrowLink>
|
|
173
173
|
</Part>
|
|
174
174
|
</PartGroup>
|
|
175
175
|
|
|
@@ -199,7 +199,7 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
199
199
|
{ value: 'top', label: sample.top },
|
|
200
200
|
{ value: 'list', label: sample.inList },
|
|
201
201
|
]}
|
|
202
|
-
href="#
|
|
202
|
+
href="#appearance/parts/editor"
|
|
203
203
|
onChange={setPlacement}
|
|
204
204
|
/>
|
|
205
205
|
</div>
|
|
@@ -273,7 +273,7 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
273
273
|
max={3}
|
|
274
274
|
footer={(close) => (
|
|
275
275
|
<SheetLink
|
|
276
|
-
href="#
|
|
276
|
+
href="#appearance/parts/editor"
|
|
277
277
|
onClick={(e) => {
|
|
278
278
|
e.preventDefault();
|
|
279
279
|
close();
|
|
@@ -327,8 +327,8 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
327
327
|
value={sort}
|
|
328
328
|
disabled={asMenu}
|
|
329
329
|
options={[
|
|
330
|
-
{ value: '
|
|
331
|
-
{ value: '
|
|
330
|
+
{ value: 'newest', label: sample.newest },
|
|
331
|
+
{ value: 'oldest', label: sample.oldest },
|
|
332
332
|
]}
|
|
333
333
|
onChange={setSort}
|
|
334
334
|
/>
|
|
@@ -393,10 +393,10 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
393
393
|
{ icon: FolderSimple, label: sample.media, count: 42 },
|
|
394
394
|
{ icon: PencilSimple, label: sample.pages, count: 12 },
|
|
395
395
|
].map((p) => (
|
|
396
|
-
<ListItem key={p.label} href="#
|
|
396
|
+
<ListItem key={p.label} href="#appearance/parts/editor" icon={p.icon} headline={p.label} count={p.count} arrow />
|
|
397
397
|
))}
|
|
398
398
|
{/* With a text under its name: the icon beside the first two lines. */}
|
|
399
|
-
<ListItem href="#
|
|
399
|
+
<ListItem href="#appearance/parts/editor" icon={Gear} headline={sample.cookieBanner} supporting={sample.cookieHint} arrow />
|
|
400
400
|
{/* With a picture before its name (the picture a page's colours came from). */}
|
|
401
401
|
<ListItem
|
|
402
402
|
leading={<LeadingImage src={examplePicture} />}
|
|
@@ -459,7 +459,7 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
459
459
|
<Part name="UnfoldButton, subListClass" use={use.unfold}>
|
|
460
460
|
<ul className={listClass}>
|
|
461
461
|
<ListItem
|
|
462
|
-
href="#
|
|
462
|
+
href="#appearance/parts/editor"
|
|
463
463
|
headline={sample.pages}
|
|
464
464
|
actions={
|
|
465
465
|
<span className="shrink-0 pr-1.5">
|
|
@@ -477,7 +477,7 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
477
477
|
<li className="pb-2">
|
|
478
478
|
<ul className={subListClass}>
|
|
479
479
|
{[1, 2, 3].map((n) => (
|
|
480
|
-
<ListItem key={n} tone="sub" href="#
|
|
480
|
+
<ListItem key={n} tone="sub" href="#appearance/parts/editor" headline={sample.subpage(n)} />
|
|
481
481
|
))}
|
|
482
482
|
</ul>
|
|
483
483
|
</li>
|
|
@@ -534,8 +534,8 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
534
534
|
icon={ArrowsDownUp}
|
|
535
535
|
value={sort}
|
|
536
536
|
options={[
|
|
537
|
-
{ value: '
|
|
538
|
-
{ value: '
|
|
537
|
+
{ value: 'newest', label: sample.newest },
|
|
538
|
+
{ value: 'oldest', label: sample.oldest },
|
|
539
539
|
]}
|
|
540
540
|
onChange={setSort}
|
|
541
541
|
/>
|
|
@@ -572,8 +572,8 @@ export default function Parts({ env }: { env: Env }) {
|
|
|
572
572
|
search={t.common.search}
|
|
573
573
|
value={sort}
|
|
574
574
|
options={[
|
|
575
|
-
{ value: '
|
|
576
|
-
{ value: '
|
|
575
|
+
{ value: 'newest', label: sample.newest },
|
|
576
|
+
{ value: 'oldest', label: sample.oldest },
|
|
577
577
|
]}
|
|
578
578
|
onChange={setSort}
|
|
579
579
|
/>
|
|
@@ -811,7 +811,7 @@ function fieldTypes(): FieldType[] {
|
|
|
811
811
|
{ name: 'phone', label: sample.phone, type: 'string', phone: '+45', digitsInPairs: 8 },
|
|
812
812
|
{ name: 'keyword', label: f.keyword, type: 'string', chips: 'one' },
|
|
813
813
|
{ name: 'keywords', label: f.keywords, type: 'string', chips: 'many' },
|
|
814
|
-
{ name: 'linkText', label: f.linkText, type: 'string', edit: { to: '
|
|
814
|
+
{ name: 'linkText', label: f.linkText, type: 'string', edit: { to: 'appearance/colors', text: f.editColors } },
|
|
815
815
|
],
|
|
816
816
|
},
|
|
817
817
|
{
|
package/src/Redirects.tsx
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
// "Videresendelser" under Indhold (#seo/
|
|
1
|
+
// "Videresendelser" under Indhold (#seo/redirects): two rows with how many
|
|
2
2
|
// each has, each opening a screen of its own.
|
|
3
|
-
// "Alle videresendelser" (#seo/
|
|
3
|
+
// "Alle videresendelser" (#seo/redirects/all): every address of a page the site sends on elsewhere
|
|
4
4
|
// (redirectData.ts), by address, with where it sends to, and search. Tapping one opens a sheet where
|
|
5
|
-
// where it sends to can be changed, or the redirect deleted;
|
|
5
|
+
// where it sends to can be changed, or the redirect deleted; the FAB adds a new one (in the editor's own,
|
|
6
6
|
// src/data/redirects.json). Each save is one commit, and none sends on through another afterwards
|
|
7
7
|
// (flatten): a redirect to an address that is sent on goes straight to where that ends, and those
|
|
8
8
|
// that went on through a deleted one go on to where it sent them.
|
|
9
|
-
// "Ikke fundet" (#seo/
|
|
9
|
+
// "Ikke fundet" (#seo/redirects/not-found): the addresses visitors came to that do not exist (notFound.ts), most visits
|
|
10
10
|
// first. Tapping one opens the same sheet, to send it on to a page (then it is off that list) or take
|
|
11
11
|
// it off the list. One that has become a page or sends on since is not shown. Over each list, as over
|
|
12
12
|
// the lists under "Indhold", the search, the order (kept in this browser; by address under the first
|
|
13
13
|
// letter) and, over the redirects, the funnel that shows only those to one page or post, picked as a
|
|
14
14
|
// chip. The Save and Delete buttons show the loading indicator while their save runs (Forget does not).
|
|
15
15
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
-
import { ArrowRight, ArrowsDownUp, Funnel, LinkBreak,
|
|
16
|
+
import { ArrowRight, ArrowsDownUp, Funnel, LinkBreak, Signpost, Trash, X } from '@phosphor-icons/react';
|
|
17
17
|
import { addressOf, escape, REDIRECTS } from './addresses';
|
|
18
18
|
import { ask } from './ask';
|
|
19
|
+
import { CreateFab } from './CreateFab';
|
|
19
20
|
import Screen from './Screen';
|
|
20
21
|
import {
|
|
21
22
|
Button,
|
|
22
23
|
InputChip,
|
|
23
24
|
Choice,
|
|
24
25
|
Group,
|
|
25
|
-
IconButton,
|
|
26
26
|
listClass,
|
|
27
27
|
SupportingText,
|
|
28
28
|
SearchRow,
|
|
@@ -66,15 +66,15 @@ type Editing = { from: string; to: string; isNew: boolean; missing?: Missing };
|
|
|
66
66
|
export type Tab = 'redirects' | 'missing';
|
|
67
67
|
|
|
68
68
|
// Where each list opens, under "Videresendelser".
|
|
69
|
-
export const TAB_ROUTES: Record<Tab, string> = { redirects: 'seo/
|
|
69
|
+
export const TAB_ROUTES: Record<Tab, string> = { redirects: 'seo/redirects/all', missing: 'seo/redirects/not-found' };
|
|
70
70
|
/** Where "Videresendelser" opens, under "Søgemaskineoptimering". */
|
|
71
|
-
export const REDIRECTS_ROUTE = 'seo/
|
|
71
|
+
export const REDIRECTS_ROUTE = 'seo/redirects';
|
|
72
72
|
|
|
73
73
|
// The orders of each list: the redirects by their address or by the page they send to, the addresses
|
|
74
74
|
// that do not exist by the visits, the last visit or the address. Kept in this browser, each its own.
|
|
75
75
|
type Sort = 'az' | 'za' | 'page' | 'visits' | 'last';
|
|
76
76
|
const SORTS: Record<Tab, Sort[]> = { redirects: ['az', 'za', 'page'], missing: ['visits', 'last', 'az', 'za'] };
|
|
77
|
-
const sortKey = (tab: Tab) => `editor-sort-${tab === 'redirects' ? '
|
|
77
|
+
const sortKey = (tab: Tab) => `editor-sort-${tab === 'redirects' ? 'redirects' : 'not-found'}`;
|
|
78
78
|
|
|
79
79
|
function savedSort(tab: Tab): Sort {
|
|
80
80
|
try {
|
|
@@ -99,7 +99,7 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
|
|
|
99
99
|
}, []);
|
|
100
100
|
const all = useMemo(() => (files ? readAll(files) : null), [files]);
|
|
101
101
|
const entries = useMemo(() => (all ? entriesOf(all) : []), [all]);
|
|
102
|
-
const [query, setQuery] = useKept('
|
|
102
|
+
const [query, setQuery] = useKept('redirects-search', '');
|
|
103
103
|
const q = query.trim().toLowerCase();
|
|
104
104
|
const name = (to: string) => whatIsAt(to, env.relations);
|
|
105
105
|
// The pages and posts the redirects send to (not a picture or another site), by their names as in the
|
|
@@ -255,7 +255,7 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
|
|
|
255
255
|
{ tab: 'missing' as const, label: t.redirects.missing.title, icon: LinkBreak, count: missing && files ? stillMissing.length : null },
|
|
256
256
|
];
|
|
257
257
|
return (
|
|
258
|
-
<Screen title={t.common.parts.redirects} wide back={backFrom('
|
|
258
|
+
<Screen title={t.common.parts.redirects} wide back={backFrom('redirects')}>
|
|
259
259
|
{problem && <ProblemNote>{problem}</ProblemNote>}
|
|
260
260
|
<Group>
|
|
261
261
|
{rows.map((r) => (
|
|
@@ -279,17 +279,6 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
|
|
|
279
279
|
? t.redirects.count(entries.length)
|
|
280
280
|
: undefined
|
|
281
281
|
}
|
|
282
|
-
actions={
|
|
283
|
-
tab === 'redirects' && (
|
|
284
|
-
<IconButton
|
|
285
|
-
icon={Plus}
|
|
286
|
-
label={t.redirects.new}
|
|
287
|
-
|
|
288
|
-
disabled={!files}
|
|
289
|
-
onClick={() => open({ from: '', to: '', isNew: true })}
|
|
290
|
-
/>
|
|
291
|
-
)
|
|
292
|
-
}
|
|
293
282
|
>
|
|
294
283
|
<p className="mb-4 text-muted">{tab === 'redirects' ? t.redirects.intro : t.redirects.missing.intro}</p>
|
|
295
284
|
<SearchRow
|
|
@@ -449,6 +438,9 @@ export default function Redirects({ env, tab }: { env: Env; tab?: Tab }) {
|
|
|
449
438
|
</SheetBody>
|
|
450
439
|
)}
|
|
451
440
|
</BottomSheet>
|
|
441
|
+
{tab === 'redirects' && (
|
|
442
|
+
<CreateFab label={t.redirects.new} disabled={!files} onClick={() => open({ from: '', to: '', isNew: true })} />
|
|
443
|
+
)}
|
|
452
444
|
</Screen>
|
|
453
445
|
);
|
|
454
446
|
}
|