Outstanding routing cleanup #2405

Merged
neb-b merged 3 commits from fixes into master 2019-04-04 05:27:21 +02:00
25 changed files with 202 additions and 276 deletions
Showing only changes of commit dcafe9ab9e - Show all commits

3
flow-typed/web.js vendored Normal file
View file

@ -0,0 +1,3 @@
// @flow
declare var IS_WEB: boolean;

View file

@ -1,6 +1,6 @@
// @flow
import React from 'react';
const LbcSymbol = () => <span> LBC</span>; //
const LbcSymbol = () => <span>LBC</span>; //
export default LbcSymbol;

View file

@ -202,8 +202,8 @@ class MediaPlayer extends React.PureComponent<Props, State> {
}
// @if TARGET='app'
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
sleep(ms: number) {
return new Promise<void>(resolve => setTimeout(resolve, ms));
}
refreshMetadata() {

View file

@ -47,6 +47,7 @@ type Props = {
nextFileToPlay: ?string,
navigate: (string, {}) => void,
costInfo: ?{ cost: number },
insufficientCredits: boolean,
};
class FileViewer extends React.PureComponent<Props> {
@ -150,7 +151,11 @@ class FileViewer extends React.PureComponent<Props> {
}
playContent() {
const { play, uri, fileInfo, isDownloading, isLoading } = this.props;
const { play, uri, fileInfo, isDownloading, isLoading, insufficientCredits } = this.props;
if (insufficientCredits) {
return;
}
// @if TARGET='app'
if (fileInfo || isDownloading || isLoading) {
@ -220,6 +225,7 @@ class FileViewer extends React.PureComponent<Props> {
className,
obscureNsfw,
mediaType,
insufficientCredits,
} = this.props;
const isPlaying = playingUri === uri;
@ -246,7 +252,10 @@ class FileViewer extends React.PureComponent<Props> {
}
const poster = metadata && metadata.thumbnail;
const layoverClass = classnames('content__cover', { 'card__media--nsfw': shouldObscureNsfw });
const layoverClass = classnames('content__cover', {
'card__media--nsfw': shouldObscureNsfw,
'card__media--disabled': insufficientCredits,
});
const layoverStyle =
!shouldObscureNsfw && poster ? { backgroundImage: `url("${poster}")` } : {};

View file

@ -85,8 +85,7 @@ const Header = (props: Props) => {
title={`Your balance is ${balance} LBRY Credits`}
label={
<React.Fragment>
<span>{roundedBalance}</span>
<LbcSymbol />
{roundedBalance} <LbcSymbol />
</React.Fragment>
}
navigate="/$/wallet"
@ -94,6 +93,7 @@ const Header = (props: Props) => {
<Button
className="header__navigation-item header__navigation-item--right-action"
activeClass="header__navigation-item--active"
description={__('Publish content')}
icon={ICONS.UPLOAD}
iconSize={24}

View file

@ -332,6 +332,7 @@ class PublishForm extends React.PureComponent<Props> {
resetThumbnailStatus,
isStillEditing,
amountNeededForTakeover,
balance,
} = this.props;
const formDisabled = (!filePath && !editingURI) || publishing;
@ -352,7 +353,7 @@ class PublishForm extends React.PureComponent<Props> {
<Form onSubmit={this.handlePublish}>
<section
className={classnames('card card--section', {
'card--disabled': IS_WEB || publishing,
'card--disabled': IS_WEB || publishing || balance === 0,
})}
>
<header className="card__header">

View file

@ -41,6 +41,7 @@ export default function AppRouter() {
<DiscoverPage path="/" />
<ShowPage path="/:claimName/:claimId" />
<ShowPage path="/:claimName" />
{/* <ShowPage path="/" uri="five" /> */}
<AuthPage path={`$/${PAGES.AUTH}`} />
<BackupPage path={`$/${PAGES.BACKUP}`} />

View file

@ -27,12 +27,12 @@ export default class extends React.PureComponent<Props> {
const image = yrblTypes[type];
return (
<div className="yrbl-wrap">
<img alt="Friendly gerbil" className={classnames('yrbl', className)} src={image} />
<div className="yrbl__wrap">
<img alt="Friendly gerbil" className={classnames('yrbl', className)} src={`/${image}`} />
{title && subtitle && (
<div className="card__content">
<div className="yrbl__content">
<h2 className="card__title">{title}</h2>
<div className="card__subtitle">{subtitle}</div>
<div className="card__content">{subtitle}</div>
</div>
)}
</div>

View file

@ -6,7 +6,6 @@ export const DOWNLOADING = 'downloading';
export const AUTO_UPDATE_DOWNLOADED = 'auto_update_downloaded';
export const AUTO_UPDATE_CONFIRM = 'auto_update_confirm';
export const ERROR = 'error';
export const INSUFFICIENT_CREDITS = 'insufficient_credits';
export const UPGRADE = 'upgrade';
export const WELCOME = 'welcome';
export const EMAIL_COLLECTION = 'email_collection';

View file

@ -1,31 +0,0 @@
import { connect } from 'react-redux';
import { doSetClientSetting } from 'redux/actions/settings';
import { selectUserIsRewardApproved, selectUnclaimedRewardValue } from 'lbryinc';
import { selectBalance } from 'lbry-redux';
import { doHideModal } from 'redux/actions/app';
import * as settings from 'constants/settings';
import ModalCreditIntro from './view';
import { navigate } from '@reach/router';
const select = state => ({
currentBalance: selectBalance(state),
isRewardApproved: selectUserIsRewardApproved(state),
totalRewardValue: selectUnclaimedRewardValue(state),
});
const perform = dispatch => () => ({
addBalance: () => {
navigate('/$/getcredits');
dispatch(doSetClientSetting(settings.CREDIT_REQUIRED_ACKNOWLEDGED, true));
dispatch(doHideModal());
},
closeModal: () => {
dispatch(doSetClientSetting(settings.CREDIT_REQUIRED_ACKNOWLEDGED, true));
dispatch(doHideModal());
},
});
export default connect(
select,
perform
)(ModalCreditIntro);

View file

@ -1,56 +0,0 @@
// @flow
import React from 'react';
import { Modal } from 'modal/modal';
import CurrencySymbol from 'component/common/lbc-symbol';
import CreditAmount from 'component/common/credit-amount';
import Button from 'component/button';
type Props = {
totalRewardValue: number,
currentBalance: number,
closeModal: () => void,
addBalance: () => void,
};
const ModalCreditIntro = (props: Props) => {
const { closeModal, totalRewardValue, currentBalance, addBalance } = props;
const totalRewardRounded = Math.floor(totalRewardValue / 10) * 10;
return (
<Modal type="custom" isOpen contentLabel="Welcome to LBRY" title={__('LBRY Credits Needed')}>
<section className="card__content">
<p>
Some actions require LBRY credits (
<em>
<CurrencySymbol />
</em>
), the blockchain token that powers the LBRY network.
</p>
{currentBalance <= 0 && (
<p>
You currently have <CreditAmount inheritStyle amount={currentBalance} />, so the actions
you can take are limited.
</p>
)}
{Boolean(totalRewardValue) && (
<p>
{__(' There are a variety of ways to get credits, including more than')}{' '}
<CreditAmount inheritStyle amount={totalRewardRounded} />{' '}
{__('in free rewards for participating in the LBRY beta.')}
</p>
)}
</section>
<div className="card__actions">
<Button button="primary" onClick={addBalance} label={__('Get Credits')} />
<Button
button="link"
onClick={closeModal}
label={currentBalance <= 0 ? __('Use Without LBC') : __('Meh, Not Now')}
/>
</div>
</Modal>
);
};
export default ModalCreditIntro;

View file

@ -1,28 +1,15 @@
import { connect } from 'react-redux';
import * as settings from 'constants/settings';
import { selectBalance, makeSelectCostInfoForUri, selectError, doToast } from 'lbry-redux';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { selectUser, selectUserIsVerificationCandidate } from 'lbryinc';
import { selectModal } from 'redux/selectors/app';
import { doOpenModal } from 'redux/actions/app';
import { selectError } from 'lbry-redux';
import ModalRouter from './view';
const select = (state, props) => ({
balance: selectBalance(state),
showPageCost: makeSelectCostInfoForUri(props.uri)(state),
isVerificationCandidate: selectUserIsVerificationCandidate(state),
isCreditIntroAcknowledged: makeSelectClientSetting(settings.CREDIT_REQUIRED_ACKNOWLEDGED)(state),
isEmailCollectionAcknowledged: makeSelectClientSetting(settings.EMAIL_COLLECTION_ACKNOWLEDGED)(
state
),
isWelcomeAcknowledged: makeSelectClientSetting(settings.NEW_USER_ACKNOWLEDGED)(state),
user: selectUser(state),
modal: selectModal(state),
error: selectError(state),
});
const perform = dispatch => ({
showToast: props => dispatch(doToast(props)),
openModal: props => dispatch(doOpenModal(props)),
});

View file

@ -10,7 +10,6 @@ import ModalUpgrade from 'modal/modalUpgrade';
import ModalWelcome from 'modal/modalWelcome';
import ModalFirstReward from 'modal/modalFirstReward';
import ModalRewardApprovalRequired from 'modal/modalRewardApprovalRequired';
import ModalCreditIntro from 'modal/modalCreditIntro';
import ModalRemoveFile from 'modal/modalRemoveFile';
import ModalTransactionFailed from 'modal/modalTransactionFailed';
import ModalFileTimeout from 'modal/modalFileTimeout';
@ -32,162 +31,76 @@ import ModalRewardCode from 'modal/modalRewardCode';
type Props = {
modal: { id: string, modalProps: {} },
error: { message: string },
openModal: string => void,
page: string,
isWelcomeAcknowledged: boolean,
isEmailCollectionAcknowledged: boolean,
isVerificationCandidate: boolean,
isCreditIntroAcknowledged: boolean,
balance: number,
showPageCost: number,
user: {
is_reward_approved: boolean,
is_identity_verified: boolean,
has_verified_email: boolean,
},
};
type State = {
lastTransitionModal: ?string,
lastTransitionPage: ?string,
};
function ModalRouter(props: Props) {
const { modal, error } = props;
class ModalRouter extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
lastTransitionModal: null,
lastTransitionPage: null,
};
if (error) {
return <ModalError {...error} />;
}
componentWillMount() {
this.showTransitionModals(this.props);
if (!modal) {
return null;
}
componentWillReceiveProps(nextProps: Props) {
this.showTransitionModals(nextProps);
}
const { id, modalProps } = modal;
showTransitionModals(props: Props) {
const { modal, openModal, page } = props;
if (modal) {
return;
}
const transitionModal = [this.checkShowCreditIntro].reduce(
(acc, func) => (!acc ? func.bind(this)(props) : acc),
false
);
if (
transitionModal &&
(transitionModal !== this.state.lastTransitionModal || page !== this.state.lastTransitionPage)
) {
openModal(transitionModal);
this.setState({
lastTransitionModal: transitionModal,
lastTransitionPage: page,
});
}
}
checkShowCreditIntro(props: Props) {
// @if TARGET='app'
// This doesn't make sense to show until the web has wallet support
const { balance, page, isCreditIntroAcknowledged } = props;
if (
balance === 0 &&
!isCreditIntroAcknowledged &&
(['send', 'publish'].includes(page) || this.isPaidShowPage(props))
) {
return MODALS.INSUFFICIENT_CREDITS;
}
// @endif
return undefined;
}
isPaidShowPage(props: Props) {
const { page, showPageCost } = props;
// Fix me
return page === 'show' && showPageCost > 0;
}
render() {
const { modal, error } = this.props;
if (error) {
return <ModalError {...error} />;
}
if (!modal) {
switch (id) {
case MODALS.UPGRADE:
return <ModalUpgrade {...modalProps} />;
case MODALS.DOWNLOADING:
return <ModalDownloading {...modalProps} />;
case MODALS.AUTO_UPDATE_DOWNLOADED:
return <ModalAutoUpdateDownloaded {...modalProps} />;
case MODALS.AUTO_UPDATE_CONFIRM:
return <ModalAutoUpdateConfirm {...modalProps} />;
case MODALS.ERROR:
return <ModalError {...modalProps} />;
case MODALS.FILE_TIMEOUT:
return <ModalFileTimeout {...modalProps} />;
case MODALS.WELCOME:
return <ModalWelcome {...modalProps} />;
case MODALS.FIRST_REWARD:
return <ModalFirstReward {...modalProps} />;
case MODALS.AUTHENTICATION_FAILURE:
return <ModalAuthFailure {...modalProps} />;
case MODALS.TRANSACTION_FAILED:
return <ModalTransactionFailed {...modalProps} />;
case MODALS.REWARD_APPROVAL_REQUIRED:
return <ModalRewardApprovalRequired {...modalProps} />;
case MODALS.CONFIRM_FILE_REMOVE:
return <ModalRemoveFile {...modalProps} />;
case MODALS.AFFIRM_PURCHASE:
return <ModalAffirmPurchase {...modalProps} />;
case MODALS.CONFIRM_CLAIM_REVOKE:
return <ModalRevokeClaim {...modalProps} />;
case MODALS.PHONE_COLLECTION:
return <ModalPhoneCollection {...modalProps} />;
case MODALS.FIRST_SUBSCRIPTION:
return <ModalFirstSubscription {...modalProps} />;
case MODALS.SEND_TIP:
return <ModalSendTip {...modalProps} />;
case MODALS.SOCIAL_SHARE:
return <ModalSocialShare {...modalProps} />;
case MODALS.PUBLISH:
return <ModalPublish {...modalProps} />;
case MODALS.CONFIRM_EXTERNAL_LINK:
return <ModalOpenExternalLink {...modalProps} />;
case MODALS.CONFIRM_TRANSACTION:
return <ModalConfirmTransaction {...modalProps} />;
case MODALS.CONFIRM_THUMBNAIL_UPLOAD:
return <ModalConfirmThumbnailUpload {...modalProps} />;
case MODALS.WALLET_ENCRYPT:
return <ModalWalletEncrypt {...modalProps} />;
case MODALS.WALLET_DECRYPT:
return <ModalWalletDecrypt {...modalProps} />;
case MODALS.WALLET_UNLOCK:
return <ModalWalletUnlock {...modalProps} />;
case MODALS.REWARD_GENERATED_CODE:
return <ModalRewardCode {...modalProps} />;
default:
return null;
}
const { id, modalProps } = modal;
switch (id) {
case MODALS.UPGRADE:
return <ModalUpgrade {...modalProps} />;
case MODALS.DOWNLOADING:
return <ModalDownloading {...modalProps} />;
case MODALS.AUTO_UPDATE_DOWNLOADED:
return <ModalAutoUpdateDownloaded {...modalProps} />;
case MODALS.AUTO_UPDATE_CONFIRM:
return <ModalAutoUpdateConfirm {...modalProps} />;
case MODALS.ERROR:
return <ModalError {...modalProps} />;
case MODALS.FILE_TIMEOUT:
return <ModalFileTimeout {...modalProps} />;
case MODALS.INSUFFICIENT_CREDITS:
return <ModalCreditIntro {...modalProps} />;
case MODALS.WELCOME:
return <ModalWelcome {...modalProps} />;
case MODALS.FIRST_REWARD:
return <ModalFirstReward {...modalProps} />;
case MODALS.AUTHENTICATION_FAILURE:
return <ModalAuthFailure {...modalProps} />;
case MODALS.TRANSACTION_FAILED:
return <ModalTransactionFailed {...modalProps} />;
case MODALS.REWARD_APPROVAL_REQUIRED:
return <ModalRewardApprovalRequired {...modalProps} />;
case MODALS.CONFIRM_FILE_REMOVE:
return <ModalRemoveFile {...modalProps} />;
case MODALS.AFFIRM_PURCHASE:
return <ModalAffirmPurchase {...modalProps} />;
case MODALS.CONFIRM_CLAIM_REVOKE:
return <ModalRevokeClaim {...modalProps} />;
case MODALS.PHONE_COLLECTION:
return <ModalPhoneCollection {...modalProps} />;
case MODALS.FIRST_SUBSCRIPTION:
return <ModalFirstSubscription {...modalProps} />;
case MODALS.SEND_TIP:
return <ModalSendTip {...modalProps} />;
case MODALS.SOCIAL_SHARE:
return <ModalSocialShare {...modalProps} />;
case MODALS.PUBLISH:
return <ModalPublish {...modalProps} />;
case MODALS.CONFIRM_EXTERNAL_LINK:
return <ModalOpenExternalLink {...modalProps} />;
case MODALS.CONFIRM_TRANSACTION:
return <ModalConfirmTransaction {...modalProps} />;
case MODALS.CONFIRM_THUMBNAIL_UPLOAD:
return <ModalConfirmThumbnailUpload {...modalProps} />;
case MODALS.WALLET_ENCRYPT:
return <ModalWalletEncrypt {...modalProps} />;
case MODALS.WALLET_DECRYPT:
return <ModalWalletDecrypt {...modalProps} />;
case MODALS.WALLET_UNLOCK:
return <ModalWalletUnlock {...modalProps} />;
case MODALS.REWARD_GENERATED_CODE:
return <ModalRewardCode {...modalProps} />;
default:
return null;
}
}
}

View file

@ -12,6 +12,7 @@ import {
makeSelectContentTypeForUri,
makeSelectMetadataForUri,
makeSelectChannelForClaimUri,
selectBalance,
} from 'lbry-redux';
import {
doFetchViewCount,
@ -39,6 +40,7 @@ const select = (state, props) => ({
isSubscribed: makeSelectIsSubscribed(props.uri)(state),
channelUri: makeSelectChannelForClaimUri(props.uri, true)(state),
viewCount: makeSelectViewCountForUri(props.uri)(state),
balance: selectBalance(state),
});
const perform = dispatch => ({

View file

@ -42,6 +42,7 @@ type Props = {
openModal: (id: string, { uri: string }) => void,
markSubscriptionRead: (string, string) => void,
fetchViewCount: string => void,
balance: number,
};
class FilePage extends React.Component<Props> {
@ -135,6 +136,7 @@ class FilePage extends React.Component<Props> {
fileInfo,
channelUri,
viewCount,
balance,
} = this.props;
// File info
@ -147,6 +149,7 @@ class FilePage extends React.Component<Props> {
const mediaType = getMediaType(contentType, fileName);
const showFile =
PLAYABLE_MEDIA_TYPES.includes(mediaType) || PREVIEW_MEDIA_TYPES.includes(mediaType);
const speechShareable =
costInfo &&
costInfo.cost === 0 &&
@ -168,11 +171,32 @@ class FilePage extends React.Component<Props> {
editUri = buildURI(uriObject);
}
const insufficientCredits = costInfo && costInfo.cost > balance;
return (
<Page notContained className="main--file-page">
<div className="grid-area--content">
<h1 className="media__uri">{uri}</h1>
{showFile && <FileViewer className="content__embedded" uri={uri} mediaType={mediaType} />}
<h1 className="media__uri">
<Button navigate={uri} label={uri} />
</h1>
{insufficientCredits && (
<div className="media__insufficient-credits help--warning">
{__(
'The publisher has chosen to charge LBC to view this content. Your balance is currently to low to view it.'
)}{' '}
{__('Checkout')}{' '}
<Button button="link" navigate="/$/rewards" label={__('the rewards page')} />{' '}
{__('or send more LBC to your wallet.')}
</div>
)}
{showFile && (
<FileViewer
insufficientCredits={insufficientCredits}
className="content__embedded"
uri={uri}
mediaType={mediaType}
/>
)}
{!showFile &&
(thumbnail ? (
<Thumbnail shouldObscure={shouldObscureThumbnail} src={thumbnail} />

View file

@ -14,6 +14,7 @@ import {
doPublish,
doPrepareEdit,
} from 'redux/actions/publish';
import { selectUnclaimedRewardValue } from 'lbryinc';
import PublishPage from './view';
const select = state => ({
@ -27,6 +28,7 @@ const select = state => ({
isStillEditing: selectIsStillEditing(state),
balance: selectBalance(state),
isResolvingUri: selectIsResolvingPublishUris(state),
totalRewardValue: selectUnclaimedRewardValue(state),
});
const perform = dispatch => ({

View file

@ -1,19 +1,65 @@
import React from 'react';
import React, { Fragment } from 'react';
import PublishForm from 'component/publishForm';
import Page from 'component/page';
import Yrbl from 'component/yrbl';
import LbcSymbol from 'component/common/lbc-symbol';
import CreditAmount from 'component/common/credit-amount';
import Button from 'component/button';
class PublishPage extends React.PureComponent {
scrollToTop = () => {
// #content wraps every <Page>
const mainContent = document.getElementById('content');
const mainContent = document.querySelector('main');
if (mainContent) {
mainContent.scrollTop = 0; // It would be nice to animate this
}
};
render() {
const { balance, totalRewardValue } = this.props;
const totalRewardRounded = Math.floor(totalRewardValue / 10) * 10;
return (
<Page>
{balance === 0 && (
<Fragment>
<Yrbl
title={__("You can't publish things quite yet")}
subtitle={
<Fragment>
<p>
{__(
'LBRY uses a blockchain, which is a fancy way of saying that users (you) are in control of your data.'
)}
</p>
<p>
{__('Because of the blockchain, some actions require LBRY credits')} (
<LbcSymbol />
).
</p>
<p>
<LbcSymbol />{' '}
{__(
'allows you to do some neat things, like paying your favorite creators for their content. And no company can stop you.'
)}
</p>
</Fragment>
}
/>
<section className="card card--section">
<header className="card__header">
<h1 className="card__title">{__('LBRY Credits Required')}</h1>
</header>
<p className="card__subtitle">
{__(' There are a variety of ways to get credits, including more than')}{' '}
<CreditAmount inheritStyle amount={totalRewardRounded} />{' '}
{__('in free rewards for participating in the LBRY beta.')}
</p>
<div className="card__actions">
<Button button="link" navigate="/$/rewards" label={__('Checkout the rewards')} />
</div>
</section>
</Fragment>
)}
<PublishForm {...this.props} scrollToTop={this.scrollToTop} />
</Page>
);

View file

@ -272,7 +272,6 @@ export function doPurchaseUri(
if (cost > balance) {
dispatch(doSetPlayingUri(null));
dispatch(doOpenModal(MODALS.INSUFFICIENT_CREDITS));
Promise.resolve();
return;
}

View file

@ -1,7 +1,7 @@
.card {
background-color: $lbry-white;
border: 1px solid $lbry-gray-1;
margin-bottom: var(--spacing-vertical-large);
margin-bottom: var(--spacing-vertical-xlarge);
position: relative;
html[data-mode='dark'] & {
@ -84,6 +84,11 @@
p:not(:last-child) {
margin-bottom: var(--spacing-vertical-medium);
}
.badge {
bottom: -0.15rem;
position: relative;
}
}
// C A R D
@ -92,10 +97,6 @@
.card__header {
position: relative;
+ .card__content {
margin-top: var(--spacing-vertical-medium);
}
&:not(.card__header--flat) {
margin-bottom: var(--spacing-vertical-small);
}
@ -195,6 +196,10 @@
.button {
font-size: 1.2rem;
}
+ .card__content {
margin-top: var(--spacing-vertical-medium);
}
}
.card__title--flex {

View file

@ -67,6 +67,11 @@
background-color: $lbry-grape-3;
}
.card__media--disabled {
opacity: 0.5;
pointer-events: none;
}
.content__loading {
width: 100%;
height: 100%;

View file

@ -81,6 +81,14 @@
}
}
.header__navigation-item--active {
background-image: linear-gradient(
to bottom,
transparent 0%,
mix(transparent, $lbry-teal-3, 70%) 90%
);
}
.header__navigation-item--back,
.header__navigation-item--forward,
.header__navigation-item--home,

View file

@ -155,6 +155,10 @@
opacity: 0.6;
}
.media__insufficient-credits {
padding: 10px;
}
// M E D I A
// A C T I O N S

View file

@ -1,10 +1,10 @@
.yrbl-wrap {
.yrbl__wrap {
align-items: center;
display: flex;
justify-content: center;
vertical-align: middle;
text-align: left;
margin-bottom: var(--spacing-vertical-large);
margin-bottom: var(--spacing-vertical-xlarge);
}
.yrbl {
@ -12,6 +12,10 @@
margin-right: var(--spacing-vertical-large);
}
.yrbl__content {
max-width: 500px;
}
.yrbl--first-run {
align-self: center;
height: 250px;

View file

@ -170,7 +170,7 @@ code {
}
html[data-mode='dark'] & {
background-color: $lbry-yellow-4;
background-color: $lbry-yellow-3;
color: $lbry-black;
}
}

View file

@ -17,6 +17,7 @@ $large-breakpoint: 1921px;
--spacing-vertical-small: calc(2rem / 3);
--spacing-vertical-medium: calc(2rem / 2);
--spacing-vertical-large: 2rem;
--spacing-vertical-xlarge: 3rem;
--file-page-max-width: 1787px;
--file-max-height: 788px;