mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
ec157dc469
Summary: Adds initial implementation of dark mode. Preferences for dark mode are available on the account settings page. Dark mode is currently a beta feature as there are still some small bugs to squash and a few remaining UI elements to style. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Subscribers: paulfitz, jarek Differential Revision: https://phab.getgrist.com/D3587
278 lines
7.6 KiB
TypeScript
278 lines
7.6 KiB
TypeScript
import {docUrl, urlState} from 'app/client/models/gristUrlState';
|
|
import {getTimeFromNow, HomeModel} from 'app/client/models/HomeModel';
|
|
import {makeDocOptionsMenu, makeRemovedDocOptionsMenu} from 'app/client/ui/DocMenu';
|
|
import {transientInput} from 'app/client/ui/transientInput';
|
|
import {colors, theme, vars} from 'app/client/ui2018/cssVars';
|
|
import {icon} from 'app/client/ui2018/icons';
|
|
import {menu} from 'app/client/ui2018/menus';
|
|
import * as roles from 'app/common/roles';
|
|
import {Document, Workspace} from 'app/common/UserAPI';
|
|
import {computed, dom, makeTestId, Observable, observable, styled} from 'grainjs';
|
|
|
|
const testId = makeTestId('test-dm-');
|
|
|
|
/**
|
|
* PinnedDocs builds the dom at the top of the doclist showing all the pinned docs in the
|
|
* selectedOrg. Builds nothing if there are no pinned docs.
|
|
*
|
|
* Used only by DocMenu.
|
|
*/
|
|
export function createPinnedDocs(home: HomeModel, docs: Observable<Document[]>, isExample = false) {
|
|
return pinnedDocList(
|
|
dom.forEach(docs, doc => buildPinnedDoc(home, doc, doc.workspace, isExample)),
|
|
testId('pinned-doc-list'),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a single doc card with a preview and name. A misnomer because it's now used not only for
|
|
* pinned docs, but also for the thumnbails (aka "icons") view mode.
|
|
*/
|
|
export function buildPinnedDoc(home: HomeModel, doc: Document, workspace: Workspace, isExample = false): HTMLElement {
|
|
const renaming = observable<Document|null>(null);
|
|
const isRenamingDoc = computed((use) => use(renaming) === doc);
|
|
return pinnedDocWrapper(
|
|
dom.autoDispose(isRenamingDoc),
|
|
dom.domComputed(isRenamingDoc, (isRenaming) =>
|
|
pinnedDoc(
|
|
isRenaming || doc.removedAt ?
|
|
null :
|
|
urlState().setLinkUrl(docUrl(doc, isExample ? {org: workspace.orgDomain} : undefined)),
|
|
pinnedDoc.cls('-no-access', !roles.canView(doc.access)),
|
|
pinnedDocPreview(
|
|
(doc.options?.icon ?
|
|
cssImage({src: doc.options.icon}) :
|
|
[docInitials(doc.name), pinnedDocThumbnail()]
|
|
),
|
|
(doc.public && !isExample ? cssPublicIcon('PublicFilled', testId('public')) : null),
|
|
pinnedDocPreview.cls('-with-icon', Boolean(doc.options?.icon)),
|
|
),
|
|
pinnedDocFooter(
|
|
(isRenaming ?
|
|
pinnedDocEditorInput({
|
|
initialValue: doc.name || '',
|
|
save: async (val) => (val !== doc.name) ? home.renameDoc(doc.id, val) : undefined,
|
|
close: () => renaming.set(null),
|
|
}, testId('doc-name-editor'))
|
|
:
|
|
pinnedDocTitle(
|
|
dom.text(doc.name),
|
|
testId('pinned-doc-name'),
|
|
// Mostly for the sake of tests, allow .test-dm-pinned-doc-name to find documents in
|
|
// either 'list' or 'icons' views.
|
|
testId('doc-name')
|
|
)
|
|
),
|
|
doc.options?.description ?
|
|
cssPinnedDocDesc(doc.options.description, testId('pinned-doc-desc')) :
|
|
cssPinnedDocTimestamp(
|
|
capitalizeFirst(getTimeFromNow(doc.removedAt || doc.updatedAt)),
|
|
testId('pinned-doc-desc')
|
|
)
|
|
)
|
|
)
|
|
),
|
|
isExample ? null : (doc.removedAt ?
|
|
[
|
|
// For deleted documents, attach the menu to the entire doc icon, and include the
|
|
// "Dots" icon just to clarify that there are options.
|
|
menu(() => makeRemovedDocOptionsMenu(home, doc, workspace),
|
|
{placement: 'right-start'}),
|
|
pinnedDocOptions(icon('Dots'), testId('pinned-doc-options')),
|
|
] :
|
|
pinnedDocOptions(icon('Dots'),
|
|
menu(() => makeDocOptionsMenu(home, doc, renaming),
|
|
{placement: 'bottom-start'}),
|
|
// Clicks on the menu trigger shouldn't follow the link that it's contained in.
|
|
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
|
|
testId('pinned-doc-options'),
|
|
)
|
|
),
|
|
testId('pinned-doc')
|
|
);
|
|
}
|
|
|
|
function docInitials(docTitle: string) {
|
|
return cssDocInitials(docTitle.slice(0, 2), testId('pinned-initials'));
|
|
}
|
|
|
|
// Capitalizes the first letter in the given string.
|
|
function capitalizeFirst(str: string): string {
|
|
return str.replace(/^[a-z]/gi, c => c.toUpperCase());
|
|
}
|
|
|
|
const pinnedDocList = styled('div', `
|
|
display: flex;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
padding-bottom: 16px;
|
|
margin: 0 0 28px 0;
|
|
`);
|
|
|
|
const pinnedDocWrapper = styled('div', `
|
|
display: inline-block;
|
|
flex: 0 0 auto;
|
|
position: relative;
|
|
width: 210px;
|
|
margin: 16px 24px 16px 0;
|
|
border: 1px solid ${theme.pinnedDocBorder};
|
|
border-radius: 1px;
|
|
vertical-align: top;
|
|
&:hover {
|
|
border: 1px solid ${theme.pinnedDocBorderHover};
|
|
}
|
|
|
|
/* TODO: Specify a gap on flexbox parents of pinnedDocWrapper instead. */
|
|
&:last-child {
|
|
margin-right: 0px;
|
|
}
|
|
`);
|
|
|
|
const pinnedDoc = styled('a', `
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
color: ${theme.text};
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
color: ${theme.text};
|
|
text-decoration: none;
|
|
}
|
|
&-no-access, &-no-access:hover {
|
|
color: ${theme.disabledText};
|
|
cursor: not-allowed;
|
|
}
|
|
`);
|
|
|
|
const pinnedDocPreview = styled('div', `
|
|
position: relative;
|
|
flex: none;
|
|
width: 100%;
|
|
height: 131px;
|
|
background-color: ${colors.dark};
|
|
min-height: 0;
|
|
|
|
padding: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
.${pinnedDoc.className}-no-access > & {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
&-with-icon {
|
|
padding: 0;
|
|
}
|
|
`);
|
|
|
|
const pinnedDocThumbnail = styled('div', `
|
|
position: absolute;
|
|
right: 20px;
|
|
bottom: 20px;
|
|
height: 48px;
|
|
width: 48px;
|
|
background-image: var(--icon-ThumbPreview);
|
|
background-size: 48px 48px;
|
|
background-repeat: no-repeat;
|
|
background-position: center;
|
|
`);
|
|
|
|
const cssDocInitials = styled('div', `
|
|
position: absolute;
|
|
left: 20px;
|
|
bottom: 20px;
|
|
font-size: 32px;
|
|
border: 1px solid ${colors.lightGreen};
|
|
color: ${colors.mediumGreyOpaque};
|
|
border-radius: 3px;
|
|
padding: 4px 0;
|
|
width: 48px;
|
|
height: 48px;
|
|
text-align: center;
|
|
`);
|
|
|
|
const pinnedDocOptions = styled('div', `
|
|
position: absolute;
|
|
top: 12px;
|
|
right: 12px;
|
|
height: 24px;
|
|
width: 24px;
|
|
padding: 4px;
|
|
line-height: 0px;
|
|
border-radius: 3px;
|
|
cursor: default;
|
|
visibility: hidden;
|
|
background-color: ${colors.mediumGrey};
|
|
--icon-color: ${colors.light};
|
|
|
|
.${pinnedDocWrapper.className}:hover &, &.weasel-popup-open {
|
|
visibility: visible;
|
|
}
|
|
`);
|
|
|
|
const pinnedDocFooter = styled('div', `
|
|
width: 100%;
|
|
font-size: ${vars.mediumFontSize};
|
|
background-color: ${theme.pinnedDocFooterBg};
|
|
`);
|
|
|
|
const pinnedDocTitle = styled('div', `
|
|
margin: 16px 16px 0px 16px;
|
|
font-weight: bold;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`);
|
|
|
|
const pinnedDocEditorInput = styled(transientInput, `
|
|
margin: 16px 16px 0px 16px;
|
|
font-weight: bold;
|
|
min-width: 0px;
|
|
color: initial;
|
|
font-size: inherit;
|
|
line-height: inherit;
|
|
appearance: none;
|
|
-moz-appearance: none;
|
|
padding: 0;
|
|
border: none;
|
|
outline: none;
|
|
color: ${theme.text};
|
|
background-color: ${theme.pinnedDocEditorBg};
|
|
`);
|
|
|
|
const cssPinnedDocTimestamp = styled('div', `
|
|
margin: 8px 16px 16px 16px;
|
|
color: ${theme.lightText};
|
|
`);
|
|
|
|
const cssPinnedDocDesc = styled(cssPinnedDocTimestamp, `
|
|
margin: 8px 16px 16px 16px;
|
|
color: ${theme.lightText};
|
|
height: 48px;
|
|
line-height: 16px;
|
|
-webkit-box-orient: vertical;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 3;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
word-break: break-word;
|
|
`);
|
|
|
|
const cssImage = styled('img', `
|
|
position: relative;
|
|
background-color: ${colors.light};
|
|
height: 100%;
|
|
width: 100%;
|
|
object-fit: scale-down;
|
|
`);
|
|
|
|
const cssPublicIcon = styled(icon, `
|
|
position: absolute;
|
|
top: 16px;
|
|
left: 16px;
|
|
--icon-color: ${theme.accentIcon};
|
|
`);
|