mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Add new home page cards
Summary: New cards on the home page link to useful resources like the welcome video, tutorial, webinars, and the Help Center. They are shown by default to new and exisiting users, and may be hidden via a toggle. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4340
This commit is contained in:
@@ -14,8 +14,10 @@ function shouldShowAddNewTip(home: HomeModel): boolean {
|
||||
home.app.isOwnerOrEditor() &&
|
||||
// And the tip hasn't been shown before.
|
||||
home.shouldShowAddNewTip.get() &&
|
||||
// And the intro isn't being shown.
|
||||
!home.showIntro.get() &&
|
||||
// And the site isn't empty.
|
||||
!home.empty.get() &&
|
||||
// And home page cards aren't being shown.
|
||||
!(home.currentPage.get() === 'all' && !home.onlyShowDocuments.get()) &&
|
||||
// And the workspace loaded correctly.
|
||||
home.available.get() &&
|
||||
// And the current page isn't /p/trash; the Add New button is limited there.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {autoFocus} from 'app/client/lib/domUtils';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {ValidationGroup, Validator} from 'app/client/lib/Validator';
|
||||
import {AppModel, getHomeUrl} from 'app/client/models/AppModel';
|
||||
import {reportError, UserError} from 'app/client/models/errors';
|
||||
@@ -11,11 +12,7 @@ import {TEAM_PLAN} from 'app/common/Features';
|
||||
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
||||
import {UserAPIImpl} from 'app/common/UserAPI';
|
||||
import {PlanSelection} from 'app/common/BillingAPI';
|
||||
import {
|
||||
Disposable, dom, DomArg, DomContents, DomElementArg, IDisposableOwner, input, makeTestId,
|
||||
Observable, styled
|
||||
} from 'grainjs';
|
||||
import { makeT } from '../lib/localization';
|
||||
import {Disposable, dom, DomContents, DomElementArg, input, makeTestId, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('CreateTeamModal');
|
||||
const testId = makeTestId('test-create-team-');
|
||||
@@ -87,16 +84,12 @@ export function buildUpgradeModal(owner: Disposable, options: {
|
||||
throw new UserError(t(`Billing is not supported in grist-core`));
|
||||
}
|
||||
|
||||
export interface IUpgradeButton {
|
||||
showUpgradeCard(...args: DomArg<HTMLElement>[]): DomContents;
|
||||
showUpgradeButton(...args: DomArg<HTMLElement>[]): DomContents;
|
||||
}
|
||||
export class UpgradeButton extends Disposable {
|
||||
constructor(_appModel: AppModel) {
|
||||
super();
|
||||
}
|
||||
|
||||
export function buildUpgradeButton(owner: IDisposableOwner, app: AppModel): IUpgradeButton {
|
||||
return {
|
||||
showUpgradeCard: () => null,
|
||||
showUpgradeButton: () => null,
|
||||
};
|
||||
public buildDom() { return null; }
|
||||
}
|
||||
|
||||
export function buildConfirm({
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Orgs, workspaces and docs are fetched asynchronously on build via the passed in API.
|
||||
*/
|
||||
import {loadUserManager} from 'app/client/lib/imports';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {getTimeFromNow} from 'app/client/lib/timeUtils';
|
||||
import {reportError} from 'app/client/models/AppModel';
|
||||
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
@@ -12,17 +13,18 @@ import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||
import {attachAddNewTip} from 'app/client/ui/AddNewTip';
|
||||
import * as css from 'app/client/ui/DocMenuCss';
|
||||
import {buildHomeIntro, buildWorkspaceIntro} from 'app/client/ui/HomeIntro';
|
||||
import {buildUpgradeButton} from 'app/client/ui/ProductUpgrades';
|
||||
import {newDocMethods} from 'app/client/ui/NewDocMethods';
|
||||
import {buildPinnedDoc, createPinnedDocs} from 'app/client/ui/PinnedDocs';
|
||||
import {shadowScroll} from 'app/client/ui/shadowScroll';
|
||||
import {makeShareDocUrl} from 'app/client/ui/ShareMenu';
|
||||
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
|
||||
import {transition} from 'app/client/ui/transitions';
|
||||
import {shouldShowWelcomeCoachingCall, showWelcomeCoachingCall} from 'app/client/ui/WelcomeCoachingCall';
|
||||
import {createVideoTourTextButton} from 'app/client/ui/OpenVideoTour';
|
||||
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect';
|
||||
import {isNarrowScreenObs, theme} from 'app/client/ui2018/cssVars';
|
||||
import {buildOnboardingCards} from 'app/client/ui/OnboardingCards';
|
||||
import {theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {loadingSpinner} from 'app/client/ui2018/loaders';
|
||||
import {menu, menuItem, menuText, select} from 'app/client/ui2018/menus';
|
||||
import {confirmModal, saveModal} from 'app/client/ui2018/modals';
|
||||
@@ -30,12 +32,17 @@ import {IHomePage} from 'app/common/gristUrls';
|
||||
import {SortPref, ViewPref} from 'app/common/Prefs';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {Document, Workspace} from 'app/common/UserAPI';
|
||||
import {computed, Computed, dom, DomArg, DomContents, DomElementArg, IDisposableOwner,
|
||||
makeTestId, observable, Observable} from 'grainjs';
|
||||
import {buildTemplateDocs} from 'app/client/ui/TemplateDocs';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
|
||||
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
||||
import {
|
||||
computed,
|
||||
Computed,
|
||||
dom,
|
||||
DomArg,
|
||||
DomElementArg,
|
||||
IDisposableOwner,
|
||||
makeTestId,
|
||||
observable,
|
||||
Observable,
|
||||
} from 'grainjs';
|
||||
import sortBy = require('lodash/sortBy');
|
||||
|
||||
const t = makeT(`DocMenu`);
|
||||
@@ -70,10 +77,7 @@ function attachWelcomePopups(home: HomeModel): (el: Element) => void {
|
||||
|
||||
function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
const flashDocId = observable<string|null>(null);
|
||||
const upgradeButton = buildUpgradeButton(owner, home.app);
|
||||
return css.docList( /* vbox */
|
||||
/* first line */
|
||||
dom.create(buildOnboardingCards, {homeModel: home}),
|
||||
/* hbox */
|
||||
css.docListContent(
|
||||
/* left column - grow 1 */
|
||||
@@ -85,24 +89,16 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
dom('span', t("(The organization needs a paid plan)")),
|
||||
]),
|
||||
|
||||
// currentWS and showIntro observables change together. We capture both in one domComputed call.
|
||||
dom.domComputed<[IHomePage, Workspace|undefined, boolean]>(
|
||||
(use) => [use(home.currentPage), use(home.currentWS), use(home.showIntro)],
|
||||
([page, workspace, showIntro]) => {
|
||||
dom.domComputed<[IHomePage, Workspace|undefined]>(
|
||||
(use) => [use(home.currentPage), use(home.currentWS)],
|
||||
([page, workspace]) => {
|
||||
const viewSettings: ViewSettings =
|
||||
page === 'trash' ? makeLocalViewSettings(home, 'trash') :
|
||||
page === 'templates' ? makeLocalViewSettings(home, 'templates') :
|
||||
workspace ? makeLocalViewSettings(home, workspace.id) :
|
||||
home;
|
||||
return [
|
||||
buildPrefs(
|
||||
viewSettings,
|
||||
// Hide the sort and view options when showing the intro.
|
||||
{hideSort: showIntro, hideView: showIntro && page === 'all'},
|
||||
['all', 'workspace'].includes(page)
|
||||
? upgradeButton.showUpgradeButton(css.upgradeButton.cls(''))
|
||||
: null,
|
||||
),
|
||||
page !== 'all' ? null : buildHomeIntro(home),
|
||||
|
||||
// Build the pinned docs dom. Builds nothing if the selectedOrg is unloaded.
|
||||
// TODO: this is shown on all pages, but there is a hack in currentWSPinnedDocs that
|
||||
@@ -124,9 +120,8 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
|
||||
dom.maybe(home.available, () => [
|
||||
buildOtherSites(home),
|
||||
(showIntro && page === 'all' ?
|
||||
null :
|
||||
css.docListHeader(
|
||||
page === 'all' && home.app.isPersonal && !home.app.currentValidUser ? null : css.docListHeaderWrap(
|
||||
css.listHeader(
|
||||
(
|
||||
page === 'all' ? t("All Documents") :
|
||||
page === 'templates' ?
|
||||
@@ -137,24 +132,20 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
|
||||
),
|
||||
testId('doc-header'),
|
||||
)
|
||||
),
|
||||
buildPrefs(viewSettings),
|
||||
),
|
||||
(
|
||||
(page === 'all') ?
|
||||
dom('div',
|
||||
showIntro ? buildHomeIntro(home) : null,
|
||||
buildAllDocsBlock(home, home.workspaces, showIntro, flashDocId, viewSettings),
|
||||
shouldShowTemplates(home, showIntro) ? buildAllDocsTemplates(home, viewSettings) : null,
|
||||
) :
|
||||
(page === 'trash') ?
|
||||
page === 'all' ? buildAllDocumentsDocsBlock({home, flashDocId, viewSettings}) :
|
||||
page === 'trash' ?
|
||||
dom('div',
|
||||
css.docBlock(t("Documents stay in Trash for 30 days, after which they get deleted permanently.")),
|
||||
dom.maybe((use) => use(home.trashWorkspaces).length === 0, () =>
|
||||
css.docBlock(t("Trash is empty."))
|
||||
),
|
||||
buildAllDocsBlock(home, home.trashWorkspaces, false, flashDocId, viewSettings),
|
||||
buildAllDocsBlock(home, home.trashWorkspaces, flashDocId, viewSettings),
|
||||
) :
|
||||
(page === 'templates') ?
|
||||
page === 'templates' ?
|
||||
dom('div',
|
||||
buildAllTemplates(home, home.templateWorkspaces, viewSettings)
|
||||
) :
|
||||
@@ -172,30 +163,21 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
|
||||
}),
|
||||
testId('doclist')
|
||||
),
|
||||
dom.maybe(use => !use(isNarrowScreenObs()) && ['all', 'workspace'].includes(use(home.currentPage)),
|
||||
() => {
|
||||
// TODO: These don't currently clash (grist-core stubs the upgradeButton), but a way to
|
||||
// manage card popups will be needed if more are added later.
|
||||
return [
|
||||
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
|
||||
home.app.supportGristNudge.buildNudgeCard(),
|
||||
];
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAllDocsBlock(
|
||||
home: HomeModel, workspaces: Observable<Workspace[]>,
|
||||
showIntro: boolean, flashDocId: Observable<string|null>, viewSettings: ViewSettings,
|
||||
home: HomeModel,
|
||||
workspaces: Observable<Workspace[]>,
|
||||
flashDocId: Observable<string|null>,
|
||||
viewSettings: ViewSettings
|
||||
) {
|
||||
return dom.forEach(workspaces, (ws) => {
|
||||
// Don't show the support workspace -- examples/templates are now retrieved from a special org.
|
||||
// TODO: Remove once support workspaces are removed from the backend.
|
||||
if (ws.isSupportWorkspace) { return null; }
|
||||
// Show docs in regular workspaces. For empty orgs, we show the intro and skip
|
||||
// the empty workspace headers. Workspaces are still listed in the left panel.
|
||||
if (showIntro) { return null; }
|
||||
|
||||
return css.docBlock(
|
||||
css.docBlockHeaderLink(
|
||||
css.wsLeft(
|
||||
@@ -224,42 +206,50 @@ function buildAllDocsBlock(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the collapsible examples and templates section at the bottom of
|
||||
* the All Documents page.
|
||||
*
|
||||
* If there are no featured templates, builds nothing.
|
||||
*/
|
||||
function buildAllDocsTemplates(home: HomeModel, viewSettings: ViewSettings) {
|
||||
return dom.domComputed(home.featuredTemplates, templates => {
|
||||
if (templates.length === 0) { return null; }
|
||||
function buildAllDocumentsDocsBlock(options: {
|
||||
home: HomeModel,
|
||||
flashDocId: Observable<string|null>,
|
||||
viewSettings: ViewSettings
|
||||
}) {
|
||||
const {home, flashDocId, viewSettings} = options;
|
||||
if (home.app.isPersonal && !home.app.currentValidUser) { return null; }
|
||||
|
||||
const hideTemplatesObs = localStorageBoolObs('hide-examples');
|
||||
return css.allDocsTemplates(css.templatesDocBlock(
|
||||
dom.autoDispose(hideTemplatesObs),
|
||||
css.templatesHeaderWrap(
|
||||
css.templatesHeader(
|
||||
t("Examples & Templates"),
|
||||
dom.domComputed(hideTemplatesObs, (collapsed) =>
|
||||
collapsed ? css.templatesHeaderIcon('Expand') : css.templatesHeaderIcon('Collapse')
|
||||
),
|
||||
dom.on('click', () => hideTemplatesObs.set(!hideTemplatesObs.get())),
|
||||
testId('all-docs-templates-header'),
|
||||
),
|
||||
createVideoTourTextButton(),
|
||||
return dom('div',
|
||||
dom.maybe(use => use(home.empty) && !home.app.isOwnerOrEditor(), () => css.docBlock(
|
||||
css.introLine(
|
||||
t("You have read-only access to this site. Currently there are no documents."),
|
||||
dom('br'),
|
||||
t("Any documents created in this site will appear here."),
|
||||
testId('readonly-no-docs-message'),
|
||||
),
|
||||
dom.maybe((use) => !use(hideTemplatesObs), () => [
|
||||
buildTemplateDocs(home, templates, viewSettings),
|
||||
bigBasicButton(
|
||||
t("Discover More Templates"),
|
||||
urlState().setLinkUrl({homePage: 'templates'}),
|
||||
testId('all-docs-templates-discover-more'),
|
||||
)
|
||||
]),
|
||||
css.docBlock.cls((use) => '-' + use(home.currentView)),
|
||||
testId('all-docs-templates'),
|
||||
));
|
||||
});
|
||||
css.introLine(
|
||||
t(
|
||||
"Interested in using Grist outside of your team? Visit your free "
|
||||
+ "{{personalSiteLink}}.",
|
||||
{
|
||||
personalSiteLink: dom.maybe(use =>
|
||||
use(home.app.topAppModel.orgs).find(o => o.owner), org => cssLink(
|
||||
urlState().setLinkUrl({org: org.domain ?? undefined}),
|
||||
t("personal site"),
|
||||
testId('readonly-personal-site-link')
|
||||
)),
|
||||
}
|
||||
),
|
||||
testId('readonly-personal-site-message'),
|
||||
),
|
||||
)),
|
||||
dom.maybe(use => use(home.empty) && home.app.isOwnerOrEditor(), () => css.createFirstDocument(
|
||||
css.createFirstDocumentImage({src: 'img/create-document.svg'}),
|
||||
bigPrimaryButton(
|
||||
t('Create my first document'),
|
||||
dom.on('click', () => newDocMethods.createDocAndOpen(home)),
|
||||
dom.boolAttr('disabled', use => !use(home.newDocWorkspace)),
|
||||
),
|
||||
)),
|
||||
dom.maybe(use => !use(home.empty), () =>
|
||||
buildAllDocsBlock(home, home.workspaces, flashDocId, viewSettings),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,20 +323,11 @@ function buildOtherSites(home: HomeModel) {
|
||||
|
||||
/**
|
||||
* Build the widget for selecting sort and view mode options.
|
||||
*
|
||||
* Options hideSort and hideView control which options are shown; they should have no effect
|
||||
* on the list of examples, so best to hide when those are the only docs shown.
|
||||
*/
|
||||
function buildPrefs(
|
||||
viewSettings: ViewSettings,
|
||||
options: {
|
||||
hideSort: boolean,
|
||||
hideView: boolean,
|
||||
},
|
||||
...args: DomArg<HTMLElement>[]): DomContents {
|
||||
function buildPrefs(viewSettings: ViewSettings, ...args: DomArg<HTMLElement>[]) {
|
||||
return css.prefSelectors(
|
||||
// The Sort selector.
|
||||
options.hideSort ? null : dom.update(
|
||||
dom.update(
|
||||
select<SortPref>(viewSettings.currentSort, [
|
||||
{value: 'name', label: t("By Name")},
|
||||
{value: 'date', label: t("By Date Modified")},
|
||||
@@ -357,7 +338,7 @@ function buildPrefs(
|
||||
),
|
||||
|
||||
// The View selector.
|
||||
options.hideView ? null : buttonSelect<ViewPref>(viewSettings.currentView, [
|
||||
buttonSelect<ViewPref>(viewSettings.currentView, [
|
||||
{value: 'icons', icon: 'TypeTable'},
|
||||
{value: 'list', icon: 'TypeCardList'},
|
||||
],
|
||||
@@ -617,13 +598,3 @@ function scrollIntoViewIfNeeded(target: Element) {
|
||||
target.scrollIntoView(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if templates should be shown in All Documents.
|
||||
*/
|
||||
function shouldShowTemplates(home: HomeModel, showIntro: boolean): boolean {
|
||||
const org = home.app.currentOrg;
|
||||
const isPersonalOrg = Boolean(org && org.owner);
|
||||
// Show templates for all personal orgs, and for non-personal orgs when showing intro.
|
||||
return isPersonalOrg || showIntro;
|
||||
}
|
||||
|
||||
@@ -36,14 +36,24 @@ export const docList = styled('div', `
|
||||
|
||||
export const docListContent = styled('div', `
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 1340px;
|
||||
margin: 0 auto;
|
||||
`);
|
||||
|
||||
export const docMenu = styled('div', `
|
||||
flex-grow: 1;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
`);
|
||||
|
||||
const listHeader = styled('div', `
|
||||
export const docListHeaderWrap = styled('div', `
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
`);
|
||||
|
||||
export const listHeader = styled('div', `
|
||||
min-height: 32px;
|
||||
line-height: 32px;
|
||||
color: ${theme.text};
|
||||
@@ -358,3 +368,28 @@ export const upgradeButton = styled('div', `
|
||||
export const upgradeCard = styled('div', `
|
||||
margin-left: 64px;
|
||||
`);
|
||||
|
||||
export const createFirstDocument = styled('div', `
|
||||
margin: 32px 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`);
|
||||
|
||||
export const createFirstDocumentImage = styled('img', `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`);
|
||||
|
||||
export const paragraph = styled(docBlock, `
|
||||
color: ${theme.text};
|
||||
line-height: 1.6;
|
||||
`);
|
||||
|
||||
export const introLine = styled(paragraph, `
|
||||
font-size: ${vars.introFontSize};
|
||||
margin-bottom: 8px;
|
||||
`);
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {getLoginOrSignupUrl, getLoginUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState';
|
||||
import {HomeModel} from 'app/client/models/HomeModel';
|
||||
import {productPill} from 'app/client/ui/AppHeader';
|
||||
import * as css from 'app/client/ui/DocMenuCss';
|
||||
import {buildHomeIntroCards} from 'app/client/ui/HomeIntroCards';
|
||||
import {newDocMethods} from 'app/client/ui/NewDocMethods';
|
||||
import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
||||
import {bigBasicButton, cssButton} from 'app/client/ui2018/buttons';
|
||||
import {bigBasicButton} from 'app/client/ui2018/buttons';
|
||||
import {testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
|
||||
import {menu, menuCssClass} from 'app/client/ui2018/menus';
|
||||
import {toggleSwitch} from 'app/client/ui2018/toggleSwitch';
|
||||
import {FullUser} from 'app/common/LoginSessionAPI';
|
||||
import * as roles from 'app/common/roles';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Computed, dom, DomContents, styled} from 'grainjs';
|
||||
import {dom, DomContents, styled} from 'grainjs';
|
||||
import {defaultMenuOptions} from 'popweasel';
|
||||
|
||||
const t = makeT('HomeIntro');
|
||||
|
||||
export function buildHomeIntro(homeModel: HomeModel): DomContents {
|
||||
const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER;
|
||||
const user = homeModel.app.currentValidUser;
|
||||
const isAnonym = !user;
|
||||
const isPersonal = !homeModel.app.isTeamSite;
|
||||
@@ -26,185 +24,104 @@ export function buildHomeIntro(homeModel: HomeModel): DomContents {
|
||||
return makeAnonIntro(homeModel);
|
||||
} else if (isPersonal) {
|
||||
return makePersonalIntro(homeModel, user);
|
||||
} else { // isTeamSite
|
||||
if (isViewer) {
|
||||
return makeViewerTeamSiteIntro(homeModel);
|
||||
} else {
|
||||
return makeTeamSiteIntro(homeModel);
|
||||
}
|
||||
} else {
|
||||
return makeTeamSiteIntro(homeModel);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorkspaceIntro(homeModel: HomeModel): DomContents {
|
||||
const isViewer = homeModel.currentWS.get()?.access === roles.VIEWER;
|
||||
const isAnonym = !homeModel.app.currentValidUser;
|
||||
const emptyLine = cssIntroLine(testId('empty-workspace-info'), t("This workspace is empty."));
|
||||
const emptyLine = css.introLine(testId('empty-workspace-info'), t("This workspace is empty."));
|
||||
if (isAnonym || isViewer) {
|
||||
return emptyLine;
|
||||
} else {
|
||||
return [
|
||||
emptyLine,
|
||||
buildButtons(homeModel, {
|
||||
invite: false,
|
||||
templates: false,
|
||||
import: true,
|
||||
empty: true
|
||||
})
|
||||
cssBtnGroup(
|
||||
cssBtn(cssBtnIcon('Import'), t("Import Document"), testId('intro-import-doc'),
|
||||
dom.on('click', () => newDocMethods.importDocAndOpen(homeModel)),
|
||||
),
|
||||
cssBtn(cssBtnIcon('Page'), t("Create Empty Document"), testId('intro-create-doc'),
|
||||
dom.on('click', () => newDocMethods.createDocAndOpen(homeModel)),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function makeViewerTeamSiteIntro(homeModel: HomeModel) {
|
||||
const personalOrg = Computed.create(null, use => use(homeModel.app.topAppModel.orgs).find(o => o.owner));
|
||||
const docLink = (dom.maybe(personalOrg, org => {
|
||||
return cssLink(
|
||||
urlState().setLinkUrl({org: org.domain ?? undefined}),
|
||||
t("personal site"),
|
||||
testId('welcome-personal-url'));
|
||||
}));
|
||||
return [
|
||||
css.docListHeader(
|
||||
dom.autoDispose(personalOrg),
|
||||
t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
|
||||
productPill(homeModel.app.currentOrg, {large: true}),
|
||||
testId('welcome-title')
|
||||
),
|
||||
cssIntroLine(
|
||||
testId('welcome-info'),
|
||||
t("You have read-only access to this site. Currently there are no documents."),
|
||||
dom('br'),
|
||||
t("Any documents created in this site will appear here."),
|
||||
),
|
||||
cssIntroLine(
|
||||
t("Interested in using Grist outside of your team? Visit your free "), docLink, '.',
|
||||
testId('welcome-text')
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function makeTeamSiteIntro(homeModel: HomeModel) {
|
||||
return [
|
||||
css.docListHeader(
|
||||
t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
|
||||
productPill(homeModel.app.currentOrg, {large: true}),
|
||||
testId('welcome-title')
|
||||
css.docListHeaderWrap(
|
||||
cssHeader(
|
||||
t("Welcome to {{- orgName}}", {orgName: homeModel.app.currentOrgName}),
|
||||
productPill(homeModel.app.currentOrg, {large: true}),
|
||||
testId('welcome-title')
|
||||
),
|
||||
buildPreferencesMenu(homeModel),
|
||||
),
|
||||
cssIntroLine(t("Get started by inviting your team and creating your first Grist document.")),
|
||||
(!isFeatureEnabled('helpCenter') ? null :
|
||||
cssIntroLine(
|
||||
t(
|
||||
'Learn more in our {{helpCenterLink}}.',
|
||||
{helpCenterLink: helpCenterLink()}
|
||||
),
|
||||
testId('welcome-text')
|
||||
)
|
||||
),
|
||||
makeCreateButtons(homeModel)
|
||||
dom.create(buildHomeIntroCards, {homeModel}),
|
||||
];
|
||||
}
|
||||
|
||||
function makePersonalIntro(homeModel: HomeModel, user: FullUser) {
|
||||
return [
|
||||
css.docListHeader(t("Welcome to Grist, {{- name}}!", {name: user.name}), testId('welcome-title')),
|
||||
cssIntroLine(t("Get started by creating your first Grist document.")),
|
||||
(!isFeatureEnabled('helpCenter') ? null :
|
||||
cssIntroLine(t("Visit our {{link}} to learn more.", { link: helpCenterLink() }),
|
||||
testId('welcome-text'))
|
||||
css.docListHeaderWrap(
|
||||
cssHeader(
|
||||
t("Welcome to Grist, {{- name}}!", {name: user.name}),
|
||||
testId('welcome-title'),
|
||||
),
|
||||
buildPreferencesMenu(homeModel),
|
||||
),
|
||||
makeCreateButtons(homeModel),
|
||||
dom.create(buildHomeIntroCards, {homeModel}),
|
||||
];
|
||||
}
|
||||
|
||||
function makeAnonIntroWithoutPlayground(homeModel: HomeModel) {
|
||||
return [
|
||||
(!isFeatureEnabled('helpCenter') ? null : cssIntroLine(t("Visit our {{link}} to learn more about Grist.", {
|
||||
link: helpCenterLink()
|
||||
}), testId('welcome-text-no-playground'))),
|
||||
cssIntroLine(t("To use Grist, please either sign up or sign in.")),
|
||||
cssBtnGroup(
|
||||
cssBtn(t("Sign up"), cssButton.cls('-primary'), testId('intro-sign-up'),
|
||||
dom.on('click', () => location.href = getSignupUrl())
|
||||
),
|
||||
cssBtn(t("Sign in"), testId('intro-sign-in'),
|
||||
dom.on('click', () => location.href = getLoginUrl())
|
||||
)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
function makeAnonIntro(homeModel: HomeModel) {
|
||||
const welcomeToGrist = css.docListHeader(t("Welcome to Grist!"), testId('welcome-title'));
|
||||
const welcomeToGrist = css.docListHeaderWrap(
|
||||
cssHeader(
|
||||
t("Welcome to Grist!"),
|
||||
testId('welcome-title'),
|
||||
),
|
||||
);
|
||||
|
||||
if (!getGristConfig().enableAnonPlayground) {
|
||||
return [
|
||||
welcomeToGrist,
|
||||
...makeAnonIntroWithoutPlayground(homeModel)
|
||||
];
|
||||
}
|
||||
|
||||
const signUp = cssLink({href: getLoginOrSignupUrl()}, t("Sign up"));
|
||||
return [
|
||||
return cssIntro(
|
||||
welcomeToGrist,
|
||||
cssIntroLine(t("Get started by exploring templates, or creating your first Grist document.")),
|
||||
cssIntroLine(t("{{signUp}} to save your work. ", {signUp}),
|
||||
(!isFeatureEnabled('helpCenter') ? null : t("Visit our {{link}} to learn more.", { link: helpCenterLink() })),
|
||||
testId('welcome-text')),
|
||||
makeCreateButtons(homeModel),
|
||||
];
|
||||
}
|
||||
|
||||
function helpCenterLink() {
|
||||
return cssLink({href: commonUrls.help, target: '_blank'}, cssInlineIcon('Help'), t("Help Center"));
|
||||
}
|
||||
|
||||
function buildButtons(homeModel: HomeModel, options: {
|
||||
invite: boolean,
|
||||
templates: boolean,
|
||||
import: boolean,
|
||||
empty: boolean,
|
||||
}) {
|
||||
return cssBtnGroup(
|
||||
!options.invite ? null :
|
||||
cssBtn(cssBtnIcon('Help'), t("Invite Team Members"), testId('intro-invite'),
|
||||
cssButton.cls('-primary'),
|
||||
dom.on('click', () => manageTeamUsersApp({app: homeModel.app})),
|
||||
),
|
||||
!options.templates ? null :
|
||||
cssBtn(cssBtnIcon('FieldTable'), t("Browse Templates"), testId('intro-templates'),
|
||||
cssButton.cls('-primary'),
|
||||
dom.show(isFeatureEnabled("templates")),
|
||||
urlState().setLinkUrl({homePage: 'templates'}),
|
||||
),
|
||||
!options.import ? null :
|
||||
cssBtn(cssBtnIcon('Import'), t("Import Document"), testId('intro-import-doc'),
|
||||
dom.on('click', () => newDocMethods.importDocAndOpen(homeModel)),
|
||||
),
|
||||
!options.empty ? null :
|
||||
cssBtn(cssBtnIcon('Page'), t("Create Empty Document"), testId('intro-create-doc'),
|
||||
dom.on('click', () => newDocMethods.createDocAndOpen(homeModel)),
|
||||
),
|
||||
dom.create(buildHomeIntroCards, {homeModel}),
|
||||
);
|
||||
}
|
||||
|
||||
function makeCreateButtons(homeModel: HomeModel) {
|
||||
const canManageTeam = homeModel.app.isTeamSite &&
|
||||
roles.canEditAccess(homeModel.app.currentOrg?.access || null);
|
||||
return buildButtons(homeModel, {
|
||||
invite: canManageTeam,
|
||||
templates: !canManageTeam,
|
||||
import: true,
|
||||
empty: true
|
||||
});
|
||||
function buildPreferencesMenu(homeModel: HomeModel) {
|
||||
const {onlyShowDocuments} = homeModel;
|
||||
|
||||
return cssDotsMenu(
|
||||
cssDots(icon('Dots')),
|
||||
menu(
|
||||
() => [
|
||||
toggleSwitch(onlyShowDocuments, {
|
||||
label: t('Only show documents'),
|
||||
args: [
|
||||
testId('welcome-menu-only-show-documents'),
|
||||
],
|
||||
}),
|
||||
],
|
||||
{
|
||||
...defaultMenuOptions,
|
||||
menuCssClass: `${menuCssClass} ${cssPreferencesMenu.className}`,
|
||||
placement: 'bottom-end',
|
||||
}
|
||||
),
|
||||
testId('welcome-menu'),
|
||||
);
|
||||
}
|
||||
|
||||
const cssParagraph = styled(css.docBlock, `
|
||||
color: ${theme.text};
|
||||
line-height: 1.6;
|
||||
const cssIntro = styled('div', `
|
||||
margin-bottom: 24px;
|
||||
`);
|
||||
|
||||
const cssIntroLine = styled(cssParagraph, `
|
||||
font-size: ${vars.introFontSize};
|
||||
margin-bottom: 8px;
|
||||
const cssHeader = styled(css.listHeader, `
|
||||
font-size: 24px;
|
||||
line-height: 36px;
|
||||
`);
|
||||
|
||||
const cssBtnGroup = styled('div', `
|
||||
@@ -225,6 +142,21 @@ const cssBtnIcon = styled(icon, `
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
const cssInlineIcon = styled(icon, `
|
||||
margin: -2px 4px 2px 4px;
|
||||
const cssPreferencesMenu = styled('div', `
|
||||
padding: 10px 16px;
|
||||
`);
|
||||
|
||||
const cssDotsMenu = styled('div', `
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
border-radius: ${vars.controlBorderRadius};
|
||||
|
||||
&:hover, &.weasel-popup-open {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssDots = styled('div', `
|
||||
--icon-color: ${theme.lightText};
|
||||
padding: 8px;
|
||||
`);
|
||||
|
||||
386
app/client/ui/HomeIntroCards.ts
Normal file
386
app/client/ui/HomeIntroCards.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {HomeModel} from 'app/client/models/HomeModel';
|
||||
import {newDocMethods} from 'app/client/ui/NewDocMethods';
|
||||
import {openVideoTour} from 'app/client/ui/OpenVideoTour';
|
||||
import {basicButtonLink, bigPrimaryButton, primaryButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Computed, dom, IDisposableOwner, makeTestId, styled, subscribeElem} from 'grainjs';
|
||||
|
||||
interface BuildHomeIntroCardsOptions {
|
||||
homeModel: HomeModel;
|
||||
}
|
||||
|
||||
const t = makeT('HomeIntroCards');
|
||||
|
||||
const testId = makeTestId('test-intro-');
|
||||
|
||||
export function buildHomeIntroCards(
|
||||
owner: IDisposableOwner,
|
||||
{homeModel}: BuildHomeIntroCardsOptions
|
||||
) {
|
||||
const {onboardingTutorialDocId, templateOrg} = getGristConfig();
|
||||
|
||||
const percentComplete = Computed.create(owner, (use) => {
|
||||
if (!homeModel.app.currentValidUser) { return 0; }
|
||||
|
||||
const tutorial = use(homeModel.onboardingTutorial);
|
||||
if (!tutorial) { return undefined; }
|
||||
|
||||
return tutorial.forks?.[0]?.options?.tutorial?.percentComplete ?? 0;
|
||||
});
|
||||
|
||||
let videoPlayButtonElement: HTMLElement;
|
||||
|
||||
return dom.maybe(use => !use(homeModel.onlyShowDocuments), () => cssHomeIntroCards(
|
||||
cssVideoTour(
|
||||
cssVideoTourThumbnail(
|
||||
cssVideoTourThumbnailSpacer(),
|
||||
videoPlayButtonElement = cssVideoTourPlayButton(
|
||||
cssVideoTourPlayIcon('VideoPlay2'),
|
||||
),
|
||||
cssVideoTourThumbnailText(t('3 minute video tour')),
|
||||
),
|
||||
dom.on('click', () => openVideoTour(videoPlayButtonElement)),
|
||||
testId('video-tour'),
|
||||
),
|
||||
cssTutorial(
|
||||
dom.hide(() => !isFeatureEnabled('tutorials') || !templateOrg || !onboardingTutorialDocId),
|
||||
cssTutorialHeader(t('Finish our basics tutorial')),
|
||||
cssTutorialBody(
|
||||
cssTutorialProgress(
|
||||
cssTutorialProgressText(
|
||||
cssTutorialProgressPercentage(
|
||||
dom.domComputed(percentComplete, (percent) => percent !== undefined ? `${percent}%` : null),
|
||||
testId('tutorial-percent-complete'),
|
||||
),
|
||||
),
|
||||
cssTutorialProgressBar(
|
||||
(elem) => subscribeElem(elem, percentComplete, (val) => {
|
||||
elem.style.setProperty('--percent-complete', String(val ?? 0));
|
||||
})
|
||||
),
|
||||
),
|
||||
dom('div',
|
||||
primaryButtonLink(
|
||||
t('Tutorial'),
|
||||
urlState().setLinkUrl({org: templateOrg!, doc: onboardingTutorialDocId}),
|
||||
),
|
||||
)
|
||||
),
|
||||
testId('tutorial'),
|
||||
),
|
||||
cssNewDocument(
|
||||
cssNewDocumentHeader(t('Start a new document')),
|
||||
cssNewDocumentBody(
|
||||
cssNewDocumentButton(
|
||||
cssNewDocumentButtonIcon('Page'),
|
||||
t('Blank document'),
|
||||
dom.on('click', () => newDocMethods.createDocAndOpen(homeModel)),
|
||||
dom.boolAttr('disabled', use => !use(homeModel.newDocWorkspace)),
|
||||
testId('create-doc'),
|
||||
),
|
||||
cssNewDocumentButton(
|
||||
cssNewDocumentButtonIcon('Import'),
|
||||
t('Import file'),
|
||||
dom.on('click', () => newDocMethods.importDocAndOpen(homeModel)),
|
||||
dom.boolAttr('disabled', use => !use(homeModel.newDocWorkspace)),
|
||||
testId('import-doc'),
|
||||
),
|
||||
cssNewDocumentButton(
|
||||
dom.show(isFeatureEnabled("templates") && Boolean(templateOrg)),
|
||||
cssNewDocumentButtonIcon('FieldTable'),
|
||||
t('Templates'),
|
||||
urlState().setLinkUrl({homePage: 'templates'}),
|
||||
testId('templates'),
|
||||
),
|
||||
),
|
||||
),
|
||||
cssWebinars(
|
||||
dom.show(isFeatureEnabled('helpCenter')),
|
||||
cssWebinarsImage({src: 'img/webinars.svg'}),
|
||||
t('Learn more {{webinarsLinks}}', {
|
||||
webinarsLinks: cssWebinarsButton(
|
||||
t('Webinars'),
|
||||
{href: commonUrls.webinars, target: '_blank'},
|
||||
testId('webinars'),
|
||||
),
|
||||
}),
|
||||
),
|
||||
cssHelpCenter(
|
||||
dom.show(isFeatureEnabled('helpCenter')),
|
||||
cssHelpCenterImage({src: 'img/help-center.svg'}),
|
||||
t('Find solutions and explore more resources {{helpCenterLink}}', {
|
||||
helpCenterLink: cssHelpCenterButton(
|
||||
t('Help center'),
|
||||
{href: commonUrls.help, target: '_blank'},
|
||||
testId('help-center'),
|
||||
),
|
||||
}),
|
||||
),
|
||||
testId('cards'),
|
||||
));
|
||||
}
|
||||
|
||||
// Cards are hidden at specific breakpoints; we use non-standard ones
|
||||
// here, as they work better than the ones defined in `cssVars.ts`.
|
||||
const mediaXLarge = `(max-width: ${1440 - 0.02}px)`;
|
||||
const mediaLarge = `(max-width: ${1280 - 0.02}px)`;
|
||||
const mediaMedium = `(max-width: ${1048 - 0.02}px)`;
|
||||
const mediaSmall = `(max-width: ${828 - 0.02}px)`;
|
||||
|
||||
const cssHomeIntroCards = styled('div', `
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 239px minmax(0, 437px) minmax(196px, 1fr) minmax(196px, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
|
||||
@media ${mediaLarge} {
|
||||
& {
|
||||
grid-template-columns: 239px minmax(0, 437px) minmax(196px, 1fr);
|
||||
}
|
||||
}
|
||||
@media ${mediaMedium} {
|
||||
& {
|
||||
grid-template-columns: 239px minmax(0, 437px);
|
||||
}
|
||||
}
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssVideoTour = styled('div', `
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
flex-shrink: 0;
|
||||
width: 239px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
width: unset;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssVideoTourThumbnail = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 36px 32px;
|
||||
background-image: url("img/youtube-screenshot.png");
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
background-blend-mode: multiply;
|
||||
background-size: cover;
|
||||
transform: scale(1.2);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`);
|
||||
|
||||
const cssVideoTourThumbnailSpacer = styled('div', ``);
|
||||
|
||||
const cssVideoTourPlayButton = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: ${theme.controlPrimaryBg};
|
||||
border-radius: 50%;
|
||||
|
||||
.${cssVideoTourThumbnail.className}:hover & {
|
||||
background-color: ${theme.controlPrimaryHoverBg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssVideoTourPlayIcon = styled(icon, `
|
||||
--icon-color: ${theme.controlPrimaryFg};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`);
|
||||
|
||||
const cssVideoTourThumbnailText = styled('div', `
|
||||
color: ${colors.light};
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
`);
|
||||
|
||||
const cssTutorial = styled('div', `
|
||||
grid-area: 1 / 2 / 2 / 3;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
color: ${theme.announcementPopupFg};
|
||||
background-color: ${theme.announcementPopupBg};
|
||||
padding: 16px;
|
||||
`);
|
||||
|
||||
const cssTutorialHeader = styled('div', `
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
`);
|
||||
|
||||
const cssTutorialBody = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
`);
|
||||
|
||||
const cssTutorialProgress = styled('div', `
|
||||
display
|
||||
flex: auto;
|
||||
min-width: 120px;
|
||||
`);
|
||||
|
||||
const cssTutorialProgressText = styled('div', `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`);
|
||||
|
||||
const cssTutorialProgressPercentage = styled('div', `
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
min-height: 21.5px;
|
||||
`);
|
||||
|
||||
const cssTutorialProgressBar = styled('div', `
|
||||
margin-top: 4px;
|
||||
height: 10px;
|
||||
border-radius: 8px;
|
||||
background: ${theme.mainPanelBg};
|
||||
--percent-complete: 0;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
border-radius: 8px;
|
||||
background: ${theme.progressBarFg};
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: calc((var(--percent-complete) / 100) * 100%);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssNewDocument = styled('div', `
|
||||
grid-area: 2 / 1 / 3 / 3;
|
||||
grid-column: span 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
color: ${theme.announcementPopupFg};
|
||||
background-color: ${theme.announcementPopupBg};
|
||||
padding: 24px;
|
||||
`);
|
||||
|
||||
const cssNewDocumentHeader = styled('div', `
|
||||
font-weight: 500;
|
||||
font-size: ${vars.xxlargeFontSize};
|
||||
`);
|
||||
|
||||
const cssNewDocumentBody = styled('div', `
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssNewDocumentButton = styled(bigPrimaryButton, `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
padding: 6px;
|
||||
`);
|
||||
|
||||
const cssNewDocumentButtonIcon = styled(icon, `
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
|
||||
@media ${mediaXLarge} {
|
||||
& {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSecondaryCard = styled('div', `
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
min-width: 196px;
|
||||
color: ${theme.text};
|
||||
background-color: ${theme.popupSecondaryBg};
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
`);
|
||||
|
||||
const cssSecondaryCardImage = styled('img', `
|
||||
display: block;
|
||||
height: auto;
|
||||
`);
|
||||
|
||||
const cssSecondaryCardButton = styled(basicButtonLink, `
|
||||
font-weight: 400;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
margin-top: 8px;
|
||||
`);
|
||||
|
||||
const cssWebinars = styled(cssSecondaryCard, `
|
||||
grid-area: 2 / 3 / 3 / 4;
|
||||
|
||||
@media ${mediaMedium} {
|
||||
& {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssWebinarsImage = styled(cssSecondaryCardImage, `
|
||||
width: 105.78px;
|
||||
margin-bottom: 8px;
|
||||
`);
|
||||
|
||||
const cssWebinarsButton = cssSecondaryCardButton;
|
||||
|
||||
const cssHelpCenter = styled(cssSecondaryCard, `
|
||||
grid-area: 2 / 4 / 3 / 5;
|
||||
|
||||
@media ${mediaLarge} {
|
||||
& {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHelpCenterImage = styled(cssSecondaryCardImage, `
|
||||
width: 67.77px;
|
||||
`);
|
||||
|
||||
const cssHelpCenterButton = cssSecondaryCardButton;
|
||||
@@ -1,232 +0,0 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {HomeModel} from 'app/client/models/HomeModel';
|
||||
import {openVideoTour} from 'app/client/ui/OpenVideoTour';
|
||||
import {bigPrimaryButtonLink} from 'app/client/ui2018/buttons';
|
||||
import {colors, theme} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {isFeatureEnabled} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Computed, dom, IDisposableOwner, makeTestId, styled, subscribeElem} from 'grainjs';
|
||||
|
||||
interface BuildOnboardingCardsOptions {
|
||||
homeModel: HomeModel;
|
||||
}
|
||||
|
||||
const t = makeT('OnboardingCards');
|
||||
|
||||
const testId = makeTestId('test-onboarding-');
|
||||
|
||||
export function buildOnboardingCards(
|
||||
owner: IDisposableOwner,
|
||||
{homeModel}: BuildOnboardingCardsOptions
|
||||
) {
|
||||
const {templateOrg, onboardingTutorialDocId} = getGristConfig();
|
||||
if (!isFeatureEnabled('tutorials') || !templateOrg || !onboardingTutorialDocId) { return null; }
|
||||
|
||||
const percentComplete = Computed.create(owner, (use) => {
|
||||
if (!homeModel.app.currentValidUser) { return 0; }
|
||||
|
||||
const tutorial = use(homeModel.onboardingTutorial);
|
||||
if (!tutorial) { return undefined; }
|
||||
|
||||
return tutorial.forks?.[0]?.options?.tutorial?.percentComplete ?? 0;
|
||||
});
|
||||
|
||||
const shouldShowCards = Computed.create(owner, (use) =>
|
||||
!use(homeModel.app.dismissedPopups).includes('onboardingCards'));
|
||||
|
||||
let videoPlayButtonElement: HTMLElement;
|
||||
|
||||
return dom.maybe(shouldShowCards, () =>
|
||||
cssOnboardingCards(
|
||||
cssTutorialCard(
|
||||
cssDismissCardsButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => homeModel.app.dismissPopup('onboardingCards', true)),
|
||||
testId('dismiss-cards'),
|
||||
),
|
||||
cssTutorialCardHeader(
|
||||
t('Complete our basics tutorial'),
|
||||
),
|
||||
cssTutorialCardSubHeader(
|
||||
t('Learn the basics of reference columns, linked widgets, column types, & cards.')
|
||||
),
|
||||
cssTutorialCardBody(
|
||||
cssTutorialProgress(
|
||||
cssTutorialProgressText(
|
||||
cssProgressPercentage(
|
||||
dom.domComputed(percentComplete, (percent) => percent !== undefined ? `${percent}%` : null),
|
||||
testId('tutorial-percent-complete'),
|
||||
),
|
||||
cssStarIcon('Star'),
|
||||
),
|
||||
cssTutorialProgressBar(
|
||||
(elem) => subscribeElem(elem, percentComplete, (val) => {
|
||||
elem.style.setProperty('--percent-complete', String(val ?? 0));
|
||||
})
|
||||
),
|
||||
),
|
||||
bigPrimaryButtonLink(
|
||||
t('Complete the tutorial'),
|
||||
urlState().setLinkUrl({org: templateOrg, doc: onboardingTutorialDocId}),
|
||||
),
|
||||
),
|
||||
testId('tutorial-card'),
|
||||
),
|
||||
cssVideoCard(
|
||||
cssVideoThumbnail(
|
||||
cssVideoThumbnailSpacer(),
|
||||
videoPlayButtonElement = cssVideoPlayButton(
|
||||
cssPlayIcon('VideoPlay2'),
|
||||
),
|
||||
cssVideoThumbnailText(t('3 minute video tour')),
|
||||
),
|
||||
dom.on('click', () => openVideoTour(videoPlayButtonElement)),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const cssOnboardingCards = styled('div', `
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, max-content));
|
||||
gap: 24px;
|
||||
margin: 24px 0;
|
||||
`);
|
||||
|
||||
const cssTutorialCard = styled('div', `
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
color: ${theme.announcementPopupFg};
|
||||
background-color: ${theme.announcementPopupBg};
|
||||
padding: 16px 24px;
|
||||
`);
|
||||
|
||||
const cssTutorialCardHeader = styled('div', `
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
font-size: 18px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
`);
|
||||
|
||||
const cssDismissCardsButton = styled('div', `
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${theme.popupCloseButtonFg};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.hover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssTutorialCardSubHeader = styled('div', `
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
margin: 8px 0;
|
||||
`);
|
||||
|
||||
const cssTutorialCardBody = styled('div', `
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin: 16px 0;
|
||||
align-items: end;
|
||||
`);
|
||||
|
||||
const cssTutorialProgress = styled('div', `
|
||||
flex: auto;
|
||||
min-width: 120px;
|
||||
`);
|
||||
|
||||
const cssTutorialProgressText = styled('div', `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`);
|
||||
|
||||
const cssProgressPercentage = styled('div', `
|
||||
font-size: 20px;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
`);
|
||||
|
||||
const cssStarIcon = styled(icon, `
|
||||
--icon-color: ${theme.accentIcon};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`);
|
||||
|
||||
const cssTutorialProgressBar = styled('div', `
|
||||
margin-top: 4px;
|
||||
height: 10px;
|
||||
border-radius: 8px;
|
||||
background: ${theme.mainPanelBg};
|
||||
--percent-complete: 0;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
border-radius: 8px;
|
||||
background: ${theme.progressBarFg};
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: calc((var(--percent-complete) / 100) * 100%);
|
||||
}
|
||||
`);
|
||||
|
||||
const cssVideoCard = styled('div', `
|
||||
width: 220px;
|
||||
height: 158px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
`);
|
||||
|
||||
const cssVideoThumbnail = styled('div', `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 36px 32px;
|
||||
background-image: url("img/youtube-screenshot.png");
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
background-blend-mode: multiply;
|
||||
background-size: cover;
|
||||
transform: scale(1.2);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`);
|
||||
|
||||
const cssVideoThumbnailSpacer = styled('div', ``);
|
||||
|
||||
const cssVideoPlayButton = styled('div', `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: ${theme.controlPrimaryBg};
|
||||
border-radius: 50%;
|
||||
|
||||
.${cssVideoThumbnail.className}:hover & {
|
||||
background-color: ${theme.controlPrimaryHoverBg};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssPlayIcon = styled(icon, `
|
||||
--icon-color: ${theme.controlPrimaryFg};
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`);
|
||||
|
||||
const cssVideoThumbnailText = styled('div', `
|
||||
color: ${colors.light};
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
`);
|
||||
@@ -1,45 +1,32 @@
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {localStorageObs} from 'app/client/lib/localStorageObs';
|
||||
import {getStorage} from 'app/client/lib/storage';
|
||||
import {tokenFieldStyles} from 'app/client/lib/TokenField';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
|
||||
import {basicButton, basicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
|
||||
import {colors, isNarrowScreenObs, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {modal} from 'app/client/ui2018/modals';
|
||||
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import {Computed, Disposable, dom, DomContents, Observable, styled, UseCB} from 'grainjs';
|
||||
import {Computed, Disposable, dom, DomContents, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('SupportGristNudge');
|
||||
|
||||
type ButtonState =
|
||||
| 'collapsed'
|
||||
| 'expanded';
|
||||
|
||||
/**
|
||||
* Nudges users to support Grist by opting in to telemetry or sponsoring on Github.
|
||||
* Button that nudges users to support Grist by opting in to telemetry or sponsoring on Github.
|
||||
*
|
||||
* For installation admins, this includes a card with a nudge which collapses into a "Support
|
||||
* For installation admins, this includes a modal with a nudge which collapses into a "Support
|
||||
* Grist" button in the top bar. When that's not applicable, it is only a "Support Grist" button
|
||||
* that links to the Github sponsorship page.
|
||||
*
|
||||
* Users can dismiss these nudges.
|
||||
* Users can dismiss this button.
|
||||
*/
|
||||
export class SupportGristNudge extends Disposable {
|
||||
export class SupportGristButton extends Disposable {
|
||||
private readonly _showButton: Computed<null|'link'|'expand'>;
|
||||
private readonly _telemetryModel: TelemetryModel = TelemetryModelImpl.create(this, this._appModel);
|
||||
|
||||
private readonly _buttonStateKey = `u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`;
|
||||
private _buttonState = localStorageObs(this._buttonStateKey, 'expanded') as Observable<ButtonState>;
|
||||
|
||||
// Whether the nudge just got accepted, and we should temporarily show the "Thanks" version.
|
||||
private _justAccepted = Observable.create(this, false);
|
||||
|
||||
private _showButton: Computed<null|'link'|'expand'>;
|
||||
private _showNudge: Computed<null|'normal'|'accepted'>;
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
const {deploymentType, telemetry} = getGristConfig();
|
||||
@@ -48,28 +35,16 @@ export class SupportGristNudge extends Disposable {
|
||||
const isTelemetryOn = (telemetry && telemetry.telemetryLevel !== 'off');
|
||||
const isAdminNudgeApplicable = isAdmin && !isTelemetryOn;
|
||||
|
||||
const generallyHide = (use: UseCB) => (
|
||||
!isEnabled ||
|
||||
use(_appModel.dismissedPopups).includes('supportGrist') ||
|
||||
use(isNarrowScreenObs())
|
||||
);
|
||||
|
||||
this._showButton = Computed.create(this, use => {
|
||||
if (generallyHide(use)) { return null; }
|
||||
if (!isAdminNudgeApplicable) { return 'link'; }
|
||||
if (use(this._buttonState) !== 'expanded') { return 'expand'; }
|
||||
return null;
|
||||
});
|
||||
if (!isEnabled || use(_appModel.dismissedPopups).includes('supportGrist')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this._showNudge = Computed.create(this, use => {
|
||||
if (use(this._justAccepted)) { return 'accepted'; }
|
||||
if (generallyHide(use)) { return null; }
|
||||
if (isAdminNudgeApplicable && use(this._buttonState) === 'expanded') { return 'normal'; }
|
||||
return null;
|
||||
return isAdminNudgeApplicable ? 'expand' : 'link';
|
||||
});
|
||||
}
|
||||
|
||||
public buildTopBarButton(): DomContents {
|
||||
public buildDom(): DomContents {
|
||||
return dom.domComputed(this._showButton, (which) => {
|
||||
if (!which) { return null; }
|
||||
const elemType = (which === 'link') ? basicButtonLink : basicButton;
|
||||
@@ -77,7 +52,7 @@ export class SupportGristNudge extends Disposable {
|
||||
elemType(cssHeartIcon('💛 '), t('Support Grist'),
|
||||
(which === 'link' ?
|
||||
{href: commonUrls.githubSponsorGristLabs, target: '_blank'} :
|
||||
dom.on('click', () => this._buttonState.set('expanded'))
|
||||
dom.on('click', () => this._buildNudgeModal())
|
||||
),
|
||||
|
||||
cssContributeButtonCloseButton(
|
||||
@@ -85,7 +60,7 @@ export class SupportGristNudge extends Disposable {
|
||||
dom.on('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this._dismissAndClose();
|
||||
this._markDismissed();
|
||||
}),
|
||||
testId('support-grist-button-dismiss'),
|
||||
),
|
||||
@@ -95,26 +70,31 @@ export class SupportGristNudge extends Disposable {
|
||||
});
|
||||
}
|
||||
|
||||
public buildNudgeCard() {
|
||||
return dom.domComputed(this._showNudge, nudge => {
|
||||
if (!nudge) { return null; }
|
||||
return cssCard(
|
||||
(nudge === 'normal' ?
|
||||
this._buildSupportGristCardContent() :
|
||||
this._buildOptedInCardContent()
|
||||
private _buildNudgeModal() {
|
||||
return modal((ctl, owner) => {
|
||||
const currentStep = Observable.create<'opt-in' | 'opted-in'>(owner, 'opt-in');
|
||||
|
||||
return [
|
||||
cssModal.cls(''),
|
||||
cssCloseButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => ctl.close()),
|
||||
testId('support-nudge-close'),
|
||||
),
|
||||
testId('support-nudge'),
|
||||
);
|
||||
});
|
||||
dom.domComputed(currentStep, (step) => {
|
||||
return step === 'opt-in'
|
||||
? this._buildOptInScreen(async () => {
|
||||
await this._optInToTelemetry();
|
||||
currentStep.set('opted-in');
|
||||
})
|
||||
: this._buildOptedInScreen(() => ctl.close());
|
||||
}),
|
||||
];
|
||||
}, {});
|
||||
}
|
||||
|
||||
private _buildSupportGristCardContent() {
|
||||
private _buildOptInScreen(onOptIn: () => Promise<void>) {
|
||||
return [
|
||||
cssCloseButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => this._buttonState.set('collapsed')),
|
||||
testId('support-nudge-close'),
|
||||
),
|
||||
cssLeftAlignedHeader(t('Support Grist')),
|
||||
cssParagraph(t(
|
||||
'Opt in to telemetry to help us understand how the product ' +
|
||||
@@ -132,19 +112,14 @@ export class SupportGristNudge extends Disposable {
|
||||
),
|
||||
cssFullWidthButton(
|
||||
t('Opt in to Telemetry'),
|
||||
dom.on('click', () => this._optInToTelemetry()),
|
||||
dom.on('click', () => onOptIn()),
|
||||
testId('support-nudge-opt-in'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private _buildOptedInCardContent() {
|
||||
private _buildOptedInScreen(onClose: () => void) {
|
||||
return [
|
||||
cssCloseButton(
|
||||
icon('CrossBig'),
|
||||
dom.on('click', () => this._justAccepted.set(false)),
|
||||
testId('support-nudge-close'),
|
||||
),
|
||||
cssCenteredFlex(cssSparks()),
|
||||
cssCenterAlignedHeader(t('Opted In')),
|
||||
cssParagraph(
|
||||
@@ -157,7 +132,7 @@ export class SupportGristNudge extends Disposable {
|
||||
cssCenteredFlex(
|
||||
cssPrimaryButton(
|
||||
t('Close'),
|
||||
dom.on('click', () => this._justAccepted.set(false)),
|
||||
dom.on('click', () => onClose()),
|
||||
testId('support-nudge-close-button'),
|
||||
),
|
||||
),
|
||||
@@ -166,19 +141,11 @@ export class SupportGristNudge extends Disposable {
|
||||
|
||||
private _markDismissed() {
|
||||
this._appModel.dismissPopup('supportGrist', true);
|
||||
// Also cleanup the no-longer-needed button state from localStorage.
|
||||
getStorage().removeItem(this._buttonStateKey);
|
||||
}
|
||||
|
||||
private _dismissAndClose() {
|
||||
this._markDismissed();
|
||||
this._justAccepted.set(false);
|
||||
}
|
||||
|
||||
private async _optInToTelemetry() {
|
||||
await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'});
|
||||
this._markDismissed();
|
||||
this._justAccepted.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,10 +169,7 @@ const cssCenteredFlex = styled('div', `
|
||||
align-items: center;
|
||||
`);
|
||||
|
||||
const cssContributeButton = styled('div', `
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
`);
|
||||
const cssContributeButton = styled('div', ``);
|
||||
|
||||
const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `
|
||||
margin-left: 4px;
|
||||
@@ -233,18 +197,6 @@ const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton,
|
||||
}
|
||||
`);
|
||||
|
||||
const cssCard = styled('div', `
|
||||
width: 297px;
|
||||
padding: 24px;
|
||||
color: ${theme.announcementPopupFg};
|
||||
background: ${theme.announcementPopupBg};
|
||||
border-radius: 4px;
|
||||
align-self: flex-start;
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
top: 0px;
|
||||
`);
|
||||
|
||||
const cssHeader = styled('div', `
|
||||
font-size: ${vars.xxxlargeFontSize};
|
||||
font-weight: 600;
|
||||
@@ -303,3 +255,10 @@ const cssSparks = styled('div', `
|
||||
const cssHeartIcon = styled('span', `
|
||||
line-height: 1;
|
||||
`);
|
||||
|
||||
const cssModal = styled('div', `
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
min-width: 0px;
|
||||
`);
|
||||
@@ -8,7 +8,9 @@ import {workspaceName} from 'app/client/models/WorkspaceInfo';
|
||||
import {AccountWidget} from 'app/client/ui/AccountWidget';
|
||||
import {buildNotifyMenuButton} from 'app/client/ui/NotifyUI';
|
||||
import {manageTeamUsersApp} from 'app/client/ui/OpenUserManager';
|
||||
import {UpgradeButton} from 'app/client/ui/ProductUpgrades';
|
||||
import {buildShareMenuButton} from 'app/client/ui/ShareMenu';
|
||||
import {SupportGristButton} from 'app/client/ui/SupportGristButton';
|
||||
import {hoverTooltip} from 'app/client/ui/tooltips';
|
||||
import {cssHoverCircle, cssTopBarBtn} from 'app/client/ui/TopBarCss';
|
||||
import {buildLanguageMenu} from 'app/client/ui/LanguageMenu';
|
||||
@@ -28,20 +30,20 @@ export function createTopBarHome(appModel: AppModel, onSave?: (personal: boolean
|
||||
|
||||
return [
|
||||
cssFlexSpace(),
|
||||
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
|
||||
[
|
||||
basicButton(
|
||||
t("Manage Team"),
|
||||
dom.on('click', () => manageTeamUsersApp({app: appModel, onSave})),
|
||||
testId('topbar-manage-team')
|
||||
),
|
||||
cssSpacer()
|
||||
] :
|
||||
null
|
||||
cssButtons(
|
||||
dom.create(UpgradeButton, appModel),
|
||||
dom.create(SupportGristButton, appModel),
|
||||
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
|
||||
[
|
||||
basicButton(
|
||||
t("Manage team"),
|
||||
dom.on('click', () => manageTeamUsersApp({app: appModel, onSave})),
|
||||
testId('topbar-manage-team')
|
||||
),
|
||||
] :
|
||||
null
|
||||
),
|
||||
),
|
||||
|
||||
appModel.supportGristNudge.buildTopBarButton(),
|
||||
|
||||
buildLanguageMenu(appModel),
|
||||
isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel),
|
||||
dom('div', dom.create(AccountWidget, appModel)),
|
||||
@@ -197,6 +199,12 @@ function topBarUndoBtn(iconName: IconName, ...domArgs: DomElementArg[]): Element
|
||||
);
|
||||
}
|
||||
|
||||
const cssButtons = styled('div', `
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-right: 8px;
|
||||
`);
|
||||
|
||||
const cssTopBarUndoBtn = styled(cssTopBarBtn, `
|
||||
background-color: ${theme.topBarButtonSecondaryFg};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user