diff --git a/app/client/lib/markdown.ts b/app/client/lib/markdown.ts index 816a37032b..1456369438 100644 --- a/app/client/lib/markdown.ts +++ b/app/client/lib/markdown.ts @@ -1,5 +1,6 @@ import { sanitizeHTML } from 'app/client/ui/sanitizeHTML'; -import { BindableValue, DomElementMethod, subscribeElem } from 'grainjs'; +import {theme} from 'app/client/ui2018/cssVars'; +import { BindableValue, DomElementMethod, IDomArgs, styled, subscribeElem } from 'grainjs'; import { marked } from 'marked'; /** @@ -24,6 +25,34 @@ export function markdown(markdownObs: BindableValue): DomElementMethod { return elem => subscribeElem(elem, markdownObs, value => setMarkdownValue(elem, value)); } +/** + * HTML span element that creates a span element with the given markdown string, without + * any surrounding paragraph tags. This is useful when you want to include markdown inside + * a larger element as a single line. + */ +export function cssMarkdownSpan( + markdownObs: BindableValue, + ...args: IDomArgs +): HTMLSpanElement { + return cssMarkdownLine(markdown(markdownObs), ...args); +} + +const cssMarkdownLine = styled('span', ` + & p { + margin: 0; + } + & a { + color: ${theme.link}; + --icon-color: ${theme.link}; + text-decoration: none; + } + & a:hover, & a:focus { + color: ${theme.linkHover}; + --icon-color: ${theme.linkHover}; + text-decoration: underline; + } +`); + function setMarkdownValue(elem: Element, markdownValue: string): void { elem.innerHTML = sanitizeHTML(marked(markdownValue, {async: false})); } diff --git a/app/client/models/entities/DocInfoRec.ts b/app/client/models/entities/DocInfoRec.ts index ed1e2f33cf..98c58c57b7 100644 --- a/app/client/models/entities/DocInfoRec.ts +++ b/app/client/models/entities/DocInfoRec.ts @@ -1,6 +1,6 @@ import {DocModel, IRowModel} from 'app/client/models/DocModel'; import * as modelUtil from 'app/client/models/modelUtil'; -import {jsonObservable} from 'app/client/models/modelUtil'; +import {jsonObservable, savingComputed} from 'app/client/models/modelUtil'; import {DocumentSettings} from 'app/common/DocumentSettings'; import * as ko from 'knockout'; @@ -9,6 +9,10 @@ export interface DocInfoRec extends IRowModel<"_grist_DocInfo"> { documentSettingsJson: modelUtil.SaveableObjObservable defaultViewId: ko.Computed; newDefaultViewId: ko.Computed; + attachmentStorage: modelUtil.KoSaveableObservable<'internal'|'external'>; + attachmentTransfer: ko.Observable<'done'|'not-started'|'in-progress'|'failed'>; + + beginTransfer(): Promise; } export function createDocInfoRec(this: DocInfoRec, docModel: DocModel): void { @@ -21,4 +25,23 @@ export function createDocInfoRec(this: DocInfoRec, docModel: DocModel): void { const page = docModel.visibleDocPages()[0]; return page ? page.viewRef() : 0; })); + + const storage = this.autoDispose(ko.observable('internal') as any); + + this.attachmentStorage = savingComputed({ + read: () => storage(), + write: (setter, val) => storage(val), + }); + + this.attachmentTransfer = this.autoDispose(ko.observable('done') as any); + + this.autoDispose(this.attachmentStorage.subscribe((newVal) => { + this.attachmentTransfer('not-started'); + })); + + this.beginTransfer = async () => { + this.attachmentTransfer('in-progress'); + const timeout = setTimeout(() => this.attachmentTransfer('done'), 3000); + this.autoDisposeCallback(() => clearTimeout(timeout)); + }; } diff --git a/app/client/ui/AdminPanelCss.ts b/app/client/ui/AdminPanelCss.ts index 2853a3fa36..d207c3f3e7 100644 --- a/app/client/ui/AdminPanelCss.ts +++ b/app/client/ui/AdminPanelCss.ts @@ -30,6 +30,7 @@ export function AdminSectionItem(owner: IDisposableOwner, options: { options.name, testId(`admin-panel-item-name-${options.id}`), prefix.length ? cssItemName.cls('-prefixed') : null, + cssItemName.cls('-full', options.description === undefined), ), cssItemDescription(options.description), cssItemValue(options.value, @@ -72,7 +73,7 @@ export function AdminSectionItem(owner: IDisposableOwner, options: { const cssSection = styled('div', ` padding: 24px; - max-width: 600px; + max-width: 700px; width: 100%; margin: 16px auto; border: 1px solid ${theme.widgetBorder}; @@ -137,15 +138,11 @@ const cssItemName = styled('div', ` align-items: center; margin-right: 14px; font-size: ${vars.largeFontSize}; - padding-left: 24px; &-prefixed { padding-left: 0; } - - @container line (max-width: 500px) { - & { - padding-left: 0; - } + &-full { + width: unset; } @media ${mediaSmall} { & { diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index b2c9ba287b..92ecf8a517 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -8,12 +8,13 @@ import {ACIndexImpl} from 'app/client/lib/ACIndex'; import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect'; import {copyToClipboard} from 'app/client/lib/clipboardUtils'; import {makeT} from 'app/client/lib/localization'; +import {cssMarkdownSpan} from 'app/client/lib/markdown'; import {reportError} from 'app/client/models/AppModel'; import type {DocPageModel} from 'app/client/models/DocPageModel'; import {urlState} from 'app/client/models/gristUrlState'; import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {AdminSection, AdminSectionItem} from 'app/client/ui/AdminPanelCss'; -import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips'; +import {hoverTooltip, showTransientTooltip, withInfoTooltip} from 'app/client/ui/tooltips'; import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox'; import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars'; @@ -61,6 +62,29 @@ export class DocSettingsPage extends Disposable { const isTimingOn = this._gristDoc.isTimingOn; const isDocOwner = isOwner(docPageModel.currentDoc.get()); const isDocEditor = isOwnerOrEditor(docPageModel.currentDoc.get()); + const storage = Computed.create(this, use => { + return use(this._gristDoc.docInfo.attachmentStorage); + }); + storage.onWrite(async (val) => { + await this._gristDoc.docInfo.attachmentStorage.setAndSave(val); + }); + const options = [{value: 'internal', label: 'Internal'}, {value: 'external', label: 'External'}]; + + + const inProgress = Computed.create(this, use => use(this._gristDoc.docInfo.attachmentTransfer) === 'in-progress'); + const notStarted = Computed.create(this, use => use(this._gristDoc.docInfo.attachmentTransfer) === 'not-started'); + + const stillInternal = Computed.create(this, use => { + const isExternal = use(storage) === 'external'; + return isExternal && (use(inProgress) || use(notStarted)); + }); + + const stillExternal = Computed.create(this, use => { + const isInternal = use(storage) === 'internal'; + return isInternal && (use(inProgress) || use(notStarted)); + }); + + (window as any).storage = storage; return cssContainer( dom.create(AdminSection, t('Document Settings'), [ @@ -194,9 +218,45 @@ export class DocSettingsPage extends Disposable { value: cssSmallLinkButton(t('Manage webhooks'), urlState().setLinkUrl({docPage: 'webhook'})), }), ]), + + dom.create(AdminSection, t('Attachment storage'), [ + dom.create(AdminSectionItem, { + id: 'preferredStorage', + name: withInfoTooltip( + dom('span', t('Preferred storage for this document')), + 'attachmentStorage', + ), + value: cssFlex( + dom.maybe(notStarted, () => [ + cssButton(t('Start transfer'), dom.on('click', () => this._beginTransfer())), + ]), + dom.maybe(inProgress, () => [ + cssButton( + cssLoadingSpinner( + loadingSpinner.cls('-inline'), + cssLoadingSpinner.cls('-disabled'), + ), + t('Being transfer'), + dom.prop('disabled', true), + ), + ]), + cssSmallSelect(storage, options, { + disabled: inProgress, + }), + ) + }), + dom('div', + dom.maybe(stillInternal, () => stillInternalCopy(inProgress)), + dom.maybe(stillExternal, () => stillExternalCopy(inProgress)), + ), + ]), ); } + private async _beginTransfer() { + this._gristDoc.docInfo.beginTransfer().catch(reportError); + } + private async _reloadEngine(ask = true) { const docPageModel = this._gristDoc.docPageModel; const handler = async () => { @@ -343,6 +403,59 @@ function buildLocaleSelect( ); } + +const learnMore = () => t( + '[learn more]({{learnLink}})', + {learnLink: commonUrls.attachmentStorage} +); + +function stillExternalCopy(inProgress: Observable) { + const someExternal = () => t( + '**Some existing attachments are still [external]({{externalLink}})**.', + {externalLink: commonUrls.attachmentStorage} + ); + + const startToInternal = () => t( + 'Click "Start transfer" to transfer those to Internal storage (stored in the document SQLite file).' + ); + + const newInInternal = () => t( + 'Newly uploaded attachments will be placed in Internal storage.' + ); + + return dom.domComputed(inProgress, (yes) => { + if (yes) { + return cssMarkdownSpan(`${someExternal()} ${newInInternal()} ${learnMore()}`); + } else { + return cssMarkdownSpan(`${someExternal()} ${startToInternal()} ${newInInternal()} ${learnMore()}`); + } + }); +} + +function stillInternalCopy(inProgress: Observable) { + const someInternal = () => t( + '**Some existing attachments are still [internal]({{internalLink}})** (stored in SQLite file).', + {internalLink: commonUrls.attachmentStorage} + ); + + const startToExternal = () => t( + 'Click "Start transfer" to transfer those to External storage.' + ); + + const newInExternal = () => t( + 'Newly uploaded attachments will be placed in External storage.' + ); + + return dom.domComputed(inProgress, (yes) => { + if (yes) { + return cssMarkdownSpan(`${someInternal()} ${newInExternal()} ${learnMore()}`); + } else { + return cssMarkdownSpan(`${someInternal()} ${startToExternal()} ${newInExternal()} ${learnMore()}`); + } + }); +} + + const cssContainer = styled('div', ` overflow-y: auto; position: relative; @@ -413,10 +526,6 @@ export function getSupportedEngineChoices(): EngineCode[] { return gristConfig.supportEngines || []; } -const cssSelect = styled(select, ` - min-width: 170px; /* to match the width of the timezone picker */ -`); - const TOOLTIP_KEY = 'copy-on-settings'; @@ -509,3 +618,33 @@ const cssWrap = styled('p', ` const cssRedText = styled('span', ` color: ${theme.errorText}; `); + +const cssFlex = styled('div', ` + display: flex; + align-items: center; + gap: 8px; +`); + +const cssButton = styled(cssSmallButton, ` + white-space: nowrap; +`); + +const cssSmallSelect = styled(select, ` + width: 100px; +`); + +const cssSelect = styled(select, ` + min-width: 170px; /* to match the width of the timezone picker */ +`); + +const cssLoadingSpinner = styled(loadingSpinner, ` + &-disabled { + --loader-bg: ${theme.loaderBg}; + --loader-fg: white; + } + @media (prefers-color-scheme: dark) { + &-disabled { + --loader-bg: #adadad; + } + } +`); \ No newline at end of file diff --git a/app/client/ui/GristTooltips.ts b/app/client/ui/GristTooltips.ts index 651ef4f1cb..e4385d1e07 100644 --- a/app/client/ui/GristTooltips.ts +++ b/app/client/ui/GristTooltips.ts @@ -1,5 +1,6 @@ import * as commands from 'app/client/components/commands'; import {makeT} from 'app/client/lib/localization'; +import {cssMarkdownSpan} from 'app/client/lib/markdown'; import {buildHighlightedCode} from 'app/client/ui/CodeHighlight'; import {ShortcutKey, ShortcutKeyContent} from 'app/client/ui/ShortcutKey'; import {icon} from 'app/client/ui2018/icons'; @@ -46,7 +47,8 @@ export type Tooltip = | 'communityWidgets' | 'twoWayReferences' | 'twoWayReferencesDisabled' - | 'reasignTwoWayReference'; + | 'reasignTwoWayReference' + | 'attachmentStorage'; export type TooltipContentFunc = (...domArgs: DomElementArg[]) => DomContents; @@ -187,6 +189,16 @@ see or edit which parts of your document.') ), ...args, ), + attachmentStorage: (...args: DomElementArg[]) => cssTooltipContent( + cssMarkdownSpan(t( + "Internal storage means all attachments are stored in the document SQLite file, " + + "while external storage indicates all attachments are stored in the same " + + "external storage. [Learn more]({{link}}).", { + link: commonUrls.attachmentStorage + } + )), + ...args, + ), }; export interface BehavioralPromptContent { diff --git a/app/client/ui/tooltips.ts b/app/client/ui/tooltips.ts index 5b92f310cf..bcdc5f1a10 100644 --- a/app/client/ui/tooltips.ts +++ b/app/client/ui/tooltips.ts @@ -491,6 +491,7 @@ const cssInfoTooltip = styled('div', ` display: flex; align-items: center; column-gap: 8px; + font-weight: unset; `); const cssTooltipCorner = styled('div', ` @@ -580,6 +581,7 @@ const cssInfoTooltipIcon = styled('div', ` color: ${theme.controlSecondaryFg}; border-radius: 50%; user-select: none; + font-weight: initial; .${cssMenuItem.className}-sel & { color: ${theme.menuItemSelectedFg}; diff --git a/app/client/ui2018/loaders.ts b/app/client/ui2018/loaders.ts index 6ea489bdca..0589acb6f4 100644 --- a/app/client/ui2018/loaders.ts +++ b/app/client/ui2018/loaders.ts @@ -20,14 +20,23 @@ const flash = keyframes(` * Creates a 32x32 pixel loading spinner. Use by calling `loadingSpinner()`. */ export const loadingSpinner = styled('div', ` + --loader-fg: ${theme.loaderFg}; + --loader-bg: ${theme.loaderBg}; display: inline-block; box-sizing: border-box; width: 32px; height: 32px; border-radius: 32px; - border: 4px solid ${theme.loaderBg}; - border-top-color: ${theme.loaderFg}; + border: 4px solid var(--loader-bg); + border-top-color: var(--loader-fg); animation: ${rotate360} 1s ease-out infinite; + &-inline { + width: 1em; + height: 1em; + line-height: inherit; + border-radius: 50%; + border-width: 1px; + } `); /** diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 0baa41d76e..e3aa0ef61d 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -107,6 +107,7 @@ export const commonUrls = { formulaSheet: 'https://support.getgrist.com/formula-cheat-sheet', formulas: 'https://support.getgrist.com/formulas', forms: 'https://www.getgrist.com/forms/?utm_source=grist-forms&utm_medium=grist-forms&utm_campaign=forms-footer', + attachmentStorage: 'https://support.getgrist.com/todo', gristLabsCustomWidgets: 'https://gristlabs.github.io/grist-widget/', gristLabsWidgetRepository: 'https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json',