Merge pull request #5223 from urbit/hm/grid-tweaks

grid: various fixes and tweaks
This commit is contained in:
Liam Fitzgerald 2021-09-17 12:49:17 +10:00 committed by GitHub
commit e515314215
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 12636 additions and 5913 deletions

16
.vercelignore Normal file
View File

@ -0,0 +1,16 @@
bin
doc
extras
nix
pkg/arvo
pkg/base-dev
pkg/docker-image
pkg/ent
pkg/garden
pkg/garden-dev
pkg/ge-additions
pkg/herb
pkg/hs
pkg/libaes_siv
pkg/urbit
sh

View File

@ -2,7 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<link rel="icon" href="/src/assets/favicon.png" />
<link rel="icon" href="/src/assets/favicon.svg" sizes="any" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Landscape • Home</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />

17829
pkg/grid/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import React, { useEffect } from 'react';
import Mousetrap from 'mousetrap';
import { BrowserRouter, Switch, Route, useHistory } from 'react-router-dom';
import { Grid } from './pages/Grid';
@ -7,6 +7,7 @@ import { PermalinkRoutes } from './pages/PermalinkRoutes';
import useKilnState from './state/kiln';
import useContactState from './state/contact';
import api from './state/api';
import { useMedia } from './logic/useMedia';
import { useHarkStore } from './state/hark';
import { useTheme } from './state/settings';
import { useLocalState } from './state/local';
@ -14,29 +15,17 @@ import { useLocalState } from './state/local';
const AppRoutes = () => {
const { push } = useHistory();
const theme = useTheme();
const updateThemeClass = useCallback(
(e: MediaQueryListEvent) => {
if ((e.matches && theme === 'auto') || theme === 'dark') {
document.body.classList.add('dark');
useLocalState.setState({ currentTheme: 'dark' });
} else {
document.body.classList.remove('dark');
useLocalState.setState({ currentTheme: 'light' });
}
},
[theme]
);
const isDarkMode = useMedia('(prefers-color-scheme: dark)');
useEffect(() => {
const query = window.matchMedia('(prefers-color-scheme: dark)');
query.addEventListener('change', updateThemeClass);
updateThemeClass({ matches: query.matches } as MediaQueryListEvent);
return () => {
query.removeEventListener('change', updateThemeClass);
};
}, []);
if ((isDarkMode && theme === 'auto') || theme === 'dark') {
document.body.classList.add('dark');
useLocalState.setState({ currentTheme: 'dark' });
} else {
document.body.classList.remove('dark');
useLocalState.setState({ currentTheme: 'light' });
}
}, [isDarkMode, theme]);
useEffect(() => {
window.name = 'grid';

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,11 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
@media (prefers-color-scheme: dark) {
rect { fill: #ffffff; }
}
</style>
<rect x="51" y="51" width="173" height="173" rx="24" fill="#444444"/>
<rect x="51" y="288" width="173" height="173" rx="24" fill="#444444"/>
<rect x="288" y="51" width="173" height="173" rx="24" fill="#444444"/>
<rect x="288" y="288" width="173" height="173" rx="24" fill="#444444"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

View File

@ -2,6 +2,8 @@ import classNames from 'classnames';
import React, { useMemo } from 'react';
import { sigil, reactRenderer } from '@tlon/sigil-js';
import { deSig, Contact } from '@urbit/api';
import { darken, lighten, parseToHsla } from 'color2k';
import { useCurrentTheme } from '../state/local';
export type AvatarSizes = 'xs' | 'small' | 'default';
@ -45,10 +47,27 @@ const emptyContact: Contact = {
'last-updated': 0
};
function themeAdjustColor(color: string, theme: 'light' | 'dark'): string {
const hsla = parseToHsla(color);
const lightness = hsla[2];
if (lightness <= 0.1 && theme === 'dark') {
return lighten(color, 0.1 - lightness);
}
if (lightness >= 0.9 && theme === 'light') {
return darken(color, lightness - 0.9);
}
return color;
}
export const Avatar = ({ size, className, ...ship }: AvatarProps) => {
const currentTheme = useCurrentTheme();
const { shipName, color, avatar } = { ...emptyContact, ...ship };
const { classes, size: sigilSize } = sizeMap[size];
const foregroundColor = foregroundFromBackground(color);
const adjustedColor = themeAdjustColor(color, currentTheme);
const foregroundColor = foregroundFromBackground(adjustedColor);
const sigilElement = useMemo(() => {
if (shipName.match(/[_^]/)) {
return null;
@ -59,9 +78,9 @@ export const Avatar = ({ size, className, ...ship }: AvatarProps) => {
renderer: reactRenderer,
size: sigilSize,
icon: true,
colors: [color, foregroundColor]
colors: [adjustedColor, foregroundColor]
});
}, [shipName, color, foregroundColor]);
}, [shipName, adjustedColor, foregroundColor]);
if (avatar) {
return <img className={classNames('', classes)} src={avatar} alt="" />;
@ -77,7 +96,7 @@ export const Avatar = ({ size, className, ...ship }: AvatarProps) => {
size === 'default' && 'p-3',
className
)}
style={{ backgroundColor: color }}
style={{ backgroundColor: adjustedColor }}
>
{sigilElement}
</div>

View File

@ -22,7 +22,7 @@ const variants: Record<ButtonVariant, string> = {
secondary: 'text-black bg-gray-100',
caution: 'text-white bg-orange-400',
destructive: 'text-white bg-red-500',
'alt-primary': 'text-white bg-blue-400',
'alt-primary': 'text-white bg-blue-400 ring-blue-300',
'alt-secondary': 'text-blue-400 bg-blue-50'
};

View File

@ -1,15 +0,0 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
export const useMedia = (mediaQuery: string) => {
const [match, setMatch] = useState(false);
const update = useCallback((e: MediaQueryListEvent) => {
setMatch(e.matches);
}, []);
useEffect(() => {
const query = window.matchMedia(mediaQuery);
query.addEventListener('change', update);
update({ matches: query.matches } as MediaQueryListEvent);
return () => {
query.removeEventListener('change', update);
};
}, [update]);
return match;
};

View File

@ -35,212 +35,229 @@ type LeapProps = {
menu: MenuState;
dropdown: string;
navOpen: boolean;
shouldDim: boolean;
} & HTMLAttributes<HTMLDivElement>;
export const Leap = React.forwardRef(({ menu, dropdown, navOpen, className }: LeapProps, ref) => {
const { push } = useHistory();
const match = useRouteMatch<{ menu?: MenuState; query?: string; desk?: string }>(
`/leap/${menu}/:query?/(apps)?/:desk?`
);
const appsMatch = useRouteMatch(`/leap/${menu}/${match?.params.query}/apps`);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current);
const { rawInput, selectedMatch, matches, selection, select } = useLeapStore();
function normalizeMatchString(match: string, keepAltChars: boolean): string {
let normalizedString = match.toLocaleLowerCase().trim();
useEffect(() => {
if (selection && rawInput === '') {
inputRef.current?.focus();
}
}, [selection, rawInput]);
if (!keepAltChars) {
normalizedString = normalizedString.replace(/[^\w]/, '');
}
const toggleSearch = useCallback(() => {
if (selection || menu === 'search') {
return;
}
return normalizedString;
}
push('/leap/search');
}, [selection, menu]);
export const Leap = React.forwardRef(
({ menu, dropdown, navOpen, shouldDim, className }: LeapProps, ref) => {
const { push } = useHistory();
const match = useRouteMatch<{ menu?: MenuState; query?: string; desk?: string }>(
`/leap/${menu}/:query?/(apps)?/:desk?`
);
const appsMatch = useRouteMatch(`/leap/${menu}/${match?.params.query}/apps`);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current);
const { rawInput, selectedMatch, matches, selection, select } = useLeapStore();
const onFocus = useCallback(
(e: FocusEvent<HTMLInputElement>) => {
// refocusing tab with input focused is false trigger
const windowFocus = e.nativeEvent.currentTarget === document.body;
if (windowFocus) {
useEffect(() => {
if (selection && rawInput === '') {
inputRef.current?.focus();
}
}, [selection, rawInput]);
const toggleSearch = useCallback(() => {
if (selection || menu === 'search') {
return;
}
toggleSearch();
},
[toggleSearch]
);
push('/leap/search');
}, [selection, menu]);
const getMatch = useCallback(
(value: string) => {
const normValue = value.toLocaleLowerCase();
return matches.find(
(m) =>
m.display?.toLocaleLowerCase().startsWith(normValue) ||
m.value.toLocaleLowerCase().startsWith(normValue)
);
},
[matches]
);
const onFocus = useCallback(
(e: FocusEvent<HTMLInputElement>) => {
// refocusing tab with input focused is false trigger
const windowFocus = e.nativeEvent.currentTarget === document.body;
if (windowFocus) {
return;
}
const navigateByInput = useCallback(
(input: string) => {
const normalizedValue = input.trim().replace(/(~?[\w^_-]{3,13})\//, '$1/apps/');
push(`/leap/${menu}/${normalizedValue}`);
},
[menu]
);
toggleSearch();
},
[toggleSearch]
);
const debouncedSearch = useDebounce(
(input: string) => {
if (!match || appsMatch) {
return;
}
const getMatch = useCallback(
(value: string) => {
const onlySymbols = !value.match(/[\w]/g);
const normValue = normalizeMatchString(value, onlySymbols);
return matches.find(
(m) =>
(m.display && normalizeMatchString(m.display, onlySymbols).startsWith(normValue)) ||
normalizeMatchString(m.value, onlySymbols).startsWith(normValue)
);
},
[matches]
);
useLeapStore.setState({ searchInput: input });
navigateByInput(input);
},
300,
{ leading: true }
);
const navigateByInput = useCallback(
(input: string) => {
const normalizedValue = input.trim().replace(/(~?[\w^_-]{3,13})\//, '$1/apps/');
push(`/leap/${menu}/${normalizedValue}`);
},
[menu]
);
const handleSearch = useCallback(debouncedSearch, [match]);
const debouncedSearch = useDebounce(
(input: string) => {
if (!match || appsMatch) {
return;
}
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
const isDeletion = (e.nativeEvent as InputEvent).inputType === 'deleteContentBackward';
const inputMatch = getMatch(value);
const matchValue = inputMatch?.display || inputMatch?.value;
useLeapStore.setState({ searchInput: input });
navigateByInput(input);
},
300,
{ leading: true }
);
if (matchValue && inputRef.current && !isDeletion) {
inputRef.current.value = matchValue;
inputRef.current.setSelectionRange(value.length, matchValue.length);
useLeapStore.setState({
rawInput: matchValue,
selectedMatch: inputMatch
});
} else {
useLeapStore.setState({
rawInput: value,
selectedMatch: matches[0]
});
}
const handleSearch = useCallback(debouncedSearch, [match]);
handleSearch(value);
},
[matches]
);
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
const isDeletion = (e.nativeEvent as InputEvent).inputType === 'deleteContentBackward';
const inputMatch = getMatch(value);
const matchValue = inputMatch?.display || inputMatch?.value;
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (matchValue && inputRef.current && !isDeletion) {
inputRef.current.value = matchValue;
const start = matchValue.startsWith(value)
? value.length
: matchValue.substring(0, matchValue.indexOf(value)).length + value.length;
inputRef.current.setSelectionRange(start, matchValue.length);
useLeapStore.setState({
rawInput: matchValue,
selectedMatch: inputMatch
});
} else {
useLeapStore.setState({
rawInput: value,
selectedMatch: matches[0]
});
}
const value = inputRef.current?.value.trim();
const currentMatch = selectedMatch || (value && getMatch(value));
handleSearch(value);
},
[matches]
);
if (!currentMatch) {
return;
}
if (currentMatch?.openInNewTab) {
window.open(currentMatch.url, currentMatch.value);
return;
}
push(currentMatch.url);
useLeapStore.setState({ rawInput: '' });
},
[match, selectedMatch]
);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const deletion = e.key === 'Backspace' || e.key === 'Delete';
const arrow = e.key === 'ArrowDown' || e.key === 'ArrowUp';
if (deletion && !rawInput && selection) {
e.preventDefault();
select(null, appsMatch && !appsMatch.isExact ? undefined : match?.params.query);
const pathBack = createPreviousPath(match?.url || '');
push(pathBack);
}
if (arrow) {
const onSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const currentIndex = selectedMatch
? matches.findIndex((m) => {
const matchValue = m.display || m.value;
const searchValue = selectedMatch.display || selectedMatch.value;
return matchValue === searchValue;
})
: 0;
const unsafeIndex = e.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1;
const index = (unsafeIndex + matches.length) % matches.length;
const value = inputRef.current?.value.trim();
const currentMatch = selectedMatch || (value && getMatch(value));
const newMatch = matches[index];
const matchValue = newMatch.display || newMatch.value;
useLeapStore.setState({
rawInput: matchValue,
// searchInput: matchValue,
selectedMatch: newMatch
});
}
},
[selection, rawInput, match, matches, selectedMatch]
);
if (!currentMatch) {
return;
}
return (
<div className="relative z-50 w-full">
<form
className={classNames(
'flex items-center h-full w-full px-2 rounded-full bg-white default-ring focus-within:ring-2',
navOpen && menu !== 'search' && 'opacity-60',
!navOpen ? 'bg-gray-50' : '',
className
)}
onSubmit={onSubmit}
>
<label
htmlFor="leap"
if (currentMatch?.openInNewTab) {
window.open(currentMatch.url, currentMatch.value);
return;
}
push(currentMatch.url);
useLeapStore.setState({ rawInput: '' });
},
[match, selectedMatch]
);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const deletion = e.key === 'Backspace' || e.key === 'Delete';
const arrow = e.key === 'ArrowDown' || e.key === 'ArrowUp';
if (deletion && !rawInput && selection) {
e.preventDefault();
select(null, appsMatch && !appsMatch.isExact ? undefined : match?.params.query);
const pathBack = createPreviousPath(match?.url || '');
push(pathBack);
}
if (arrow) {
e.preventDefault();
const currentIndex = selectedMatch
? matches.findIndex((m) => {
const matchValue = m.display || m.value;
const searchValue = selectedMatch.display || selectedMatch.value;
return matchValue === searchValue;
})
: 0;
const unsafeIndex = e.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1;
const index = (unsafeIndex + matches.length) % matches.length;
const newMatch = matches[index];
const matchValue = newMatch.display || newMatch.value;
useLeapStore.setState({
rawInput: matchValue,
// searchInput: matchValue,
selectedMatch: newMatch
});
}
},
[selection, rawInput, match, matches, selectedMatch]
);
return (
<div className="relative z-50 w-full">
<form
className={classNames(
'inline-block flex-none p-2 h4 text-blue-400',
!selection && 'sr-only'
'flex items-center h-full w-full px-2 rounded-full bg-white default-ring focus-within:ring-2',
shouldDim && 'opacity-60',
!navOpen ? 'bg-gray-50' : '',
className
)}
onSubmit={onSubmit}
>
{selection || 'Search Landscape'}
</label>
<input
id="leap"
type="text"
ref={inputRef}
placeholder={selection ? '' : 'Search Landscape'}
className="flex-1 w-full h-full px-2 h4 rounded-full bg-transparent outline-none"
value={rawInput}
onClick={toggleSearch}
onFocus={onFocus}
onChange={onChange}
onKeyDown={onKeyDown}
aria-autocomplete="both"
aria-controls={dropdown}
aria-activedescendant={selectedMatch?.display || selectedMatch?.value}
/>
</form>
{navOpen && (
<Link
to="/"
className="absolute top-1/2 right-2 flex-none circle-button w-8 h-8 text-gray-400 bg-gray-50 default-ring -translate-y-1/2"
onClick={() => select(null)}
>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</Link>
)}
</div>
);
});
<label
htmlFor="leap"
className={classNames(
'inline-block flex-none p-2 h4 text-blue-400',
!selection && 'sr-only'
)}
>
{selection || 'Search Landscape'}
</label>
<input
id="leap"
type="text"
ref={inputRef}
placeholder={selection ? '' : 'Search Landscape'}
className="flex-1 w-full h-full px-2 h4 text-base rounded-full bg-transparent outline-none"
value={rawInput}
onClick={toggleSearch}
onFocus={onFocus}
onChange={onChange}
onKeyDown={onKeyDown}
aria-autocomplete="both"
aria-controls={dropdown}
aria-activedescendant={selectedMatch?.display || selectedMatch?.value}
/>
</form>
{menu === 'search' && (
<Link
to="/"
className="absolute top-1/2 right-2 flex-none circle-button w-8 h-8 text-gray-400 bg-gray-50 default-ring -translate-y-1/2"
onClick={() => select(null)}
>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</Link>
)}
</div>
);
}
);

View File

@ -52,33 +52,6 @@ export type MenuState =
| 'help-and-support'
| 'system-preferences';
export function createNextPath(current: string, nextPart?: string): string {
let end = nextPart;
const parts = current.split('/').reverse();
if (parts[1] === 'search') {
end = 'apps';
}
if (parts[0] === 'leap') {
end = `search/${nextPart}`;
}
return `${current}/${end}`;
}
export function createPreviousPath(current: string): string {
const parts = current.split('/');
parts.pop();
if (parts[parts.length - 1] === 'leap') {
parts.push('search');
}
if (parts[parts.length - 2] === 'apps') {
parts.pop();
}
return parts.join('/');
}
interface NavProps {
menu?: MenuState;
}
@ -123,6 +96,15 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
}
}, []);
const preventClose = useCallback((e) => {
const target = e.target as HTMLElement;
const hasNavAncestor = target.closest('#dialog-nav');
if (hasNavAncestor) {
e.preventDefault();
}
}, []);
return (
<>
{/* Using portal so that we can retain the same nav items both in the dialog and in the base header */}
@ -132,12 +114,22 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
>
<SystemMenu
open={!!systemMenuOpen}
menu={menuState}
navOpen={isOpen}
subMenuOpen={menu === 'system-preferences' || menu === 'help-and-support'}
shouldDim={isOpen && menu !== 'system-preferences' && menu !== 'help-and-support'}
className={classNames('relative z-50 flex-none', eitherOpen ? 'bg-white' : 'bg-gray-50')}
/>
<NotificationsLink menu={menuState} navOpen={isOpen} />
<Leap ref={inputRef} menu={menuState} dropdown="leap-items" navOpen={isOpen} />
<NotificationsLink
navOpen={isOpen}
notificationsOpen={menu === 'notifications'}
shouldDim={(isOpen && menu !== 'notifications') || !!systemMenuOpen}
/>
<Leap
ref={inputRef}
menu={menuState}
dropdown="leap-items"
navOpen={isOpen}
shouldDim={(isOpen && menu !== 'search') || !!systemMenuOpen}
/>
</Portal.Root>
<div
ref={navRef}
@ -152,6 +144,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
/>
<Dialog open={isOpen} onOpenChange={onDialogClose}>
<DialogContent
onInteractOutside={preventClose}
onOpenAutoFocus={onOpen}
className="fixed bottom-0 sm:top-0 sm:bottom-auto scroll-left-50 flex flex-col scroll-full-width max-w-[882px] px-4 sm:pb-4 text-gray-400 -translate-x-1/2 outline-none"
role="combobox"
@ -160,8 +153,9 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
aria-expanded={isOpen}
>
<header
id="dialog-nav"
ref={dialogNavRef}
className="max-w-[712px] w-full mx-auto mt-6 mb-3 order-last sm:order-none"
className="max-w-[712px] w-full mx-auto my-6 sm:mb-3 order-last sm:order-none"
/>
<div
id="leap-items"

View File

@ -2,16 +2,21 @@ import classNames from 'classnames';
import React from 'react';
import { Link, LinkProps } from 'react-router-dom';
import { Bullet } from '../components/icons/Bullet';
import { Cross } from '../components/icons/Cross';
import { Notification } from '../state/hark-types';
import { useNotifications } from '../state/notifications';
import { MenuState } from './Nav';
type NotificationsState = 'empty' | 'unread' | 'attention-needed';
type NotificationsState = 'empty' | 'unread' | 'attention-needed' | 'open';
function getNotificationsState(
notificationsOpen: boolean,
notifications: Notification[],
systemNotifications: Notification[]
): NotificationsState {
if (notificationsOpen) {
return 'open';
}
if (systemNotifications.length > 0) {
return 'attention-needed';
}
@ -25,21 +30,27 @@ function getNotificationsState(
}
type NotificationsLinkProps = Omit<LinkProps<HTMLAnchorElement>, 'to'> & {
menu: MenuState;
navOpen: boolean;
notificationsOpen: boolean;
shouldDim: boolean;
};
export const NotificationsLink = ({ navOpen, menu }: NotificationsLinkProps) => {
export const NotificationsLink = ({
navOpen,
notificationsOpen,
shouldDim
}: NotificationsLinkProps) => {
const { notifications, systemNotifications } = useNotifications();
const state = getNotificationsState(notifications, systemNotifications);
const state = getNotificationsState(notificationsOpen, notifications, systemNotifications);
return (
<Link
to="/leap/notifications"
to={state === 'open' ? '/' : '/leap/notifications'}
className={classNames(
'relative z-50 flex-none circle-button h4 default-ring',
navOpen && 'text-opacity-60',
navOpen && menu !== 'notifications' && 'opacity-60',
shouldDim && 'opacity-60',
state === 'open' && 'text-gray-400 bg-white',
state === 'empty' && !navOpen && 'text-gray-400 bg-gray-50',
state === 'empty' && navOpen && 'text-gray-400 bg-white',
state === 'unread' && 'bg-blue-400 text-white',
@ -53,6 +64,12 @@ export const NotificationsLink = ({ navOpen, menu }: NotificationsLinkProps) =>
! <span className="sr-only">Attention needed</span>
</span>
)}
{state === 'open' && (
<>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</>
)}
</Link>
);
};

View File

@ -7,12 +7,13 @@ import { Vat } from '@urbit/api/hood';
import { Adjust } from '../components/icons/Adjust';
import { useVat } from '../state/kiln';
import { disableDefault, handleDropdownLink } from '../state/util';
import { MenuState } from './Nav';
import { useMedia } from '../logic/useMedia';
import { Cross } from '../components/icons/Cross';
type SystemMenuProps = HTMLAttributes<HTMLButtonElement> & {
menu: MenuState;
open: boolean;
navOpen: boolean;
subMenuOpen: boolean;
shouldDim: boolean;
};
function getHash(vat: Vat): string {
@ -20,11 +21,12 @@ function getHash(vat: Vat): string {
return parts[parts.length - 1];
}
export const SystemMenu = ({ className, menu, open, navOpen }: SystemMenuProps) => {
export const SystemMenu = ({ className, open, subMenuOpen, shouldDim }: SystemMenuProps) => {
const { push } = useHistory();
const [copied, setCopied] = useState(false);
const garden = useVat('garden');
const hash = garden ? getHash(garden) : null;
const isMobile = useMedia('(max-width: 639px)');
const copyHash = useCallback((event: Event) => {
event.preventDefault();
@ -53,29 +55,37 @@ export const SystemMenu = ({ className, menu, open, navOpen }: SystemMenuProps)
open={open}
onOpenChange={(isOpen) => setTimeout(() => !isOpen && push('/'), 15)}
>
<DropdownMenu.Trigger
as={Link}
to="/system-menu"
<Link
to={open || subMenuOpen ? '/' : '/system-menu'}
className={classNames(
'appearance-none circle-button default-ring',
'relative appearance-none circle-button default-ring',
open && 'text-gray-300',
navOpen &&
menu !== 'system-preferences' &&
menu !== 'help-and-support' &&
'opacity-60',
shouldDim && 'opacity-60',
className
)}
>
<Adjust className="w-6 h-6 fill-current text-gray" />
<span className="sr-only">System Menu</span>
</DropdownMenu.Trigger>
{!open && !subMenuOpen && (
<>
<Adjust className="w-6 h-6 fill-current text-gray" />
<span className="sr-only">System Menu</span>
</>
)}
{(open || subMenuOpen) && (
<>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</>
)}
{/* trigger here just for anchoring the dropdown */}
<DropdownMenu.Trigger className="sr-only top-0 left-0 sm:top-auto sm:left-auto sm:bottom-0" />
</Link>
<Route path="/system-menu">
<DropdownMenu.Content
portalled={false}
onCloseAutoFocus={disableDefault}
onInteractOutside={preventFlash}
onFocusOutside={preventFlash}
onPointerDownOutside={preventFlash}
side={isMobile ? 'top' : 'bottom'}
sideOffset={12}
className="dropdown relative z-40 min-w-64 p-4 font-semibold text-gray-500 bg-white"
>

View File

@ -60,8 +60,8 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
const subUrl = useCallback((submenu: string) => `${match.url}/${submenu}`, [match]);
return (
<div className="flex h-[600px] max-h-full">
<aside className="flex-none min-w-60 py-8 font-semibold border-r-2 border-gray-50">
<div className="flex h-full overflow-y-auto">
<aside className="flex-none self-start min-w-60 py-8 font-semibold border-r-2 border-gray-50">
<nav className="px-6">
<ul>
<SystemPreferencesSection
@ -100,7 +100,7 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
</ul>
</nav>
</aside>
<section className="flex-1 p-8 text-black">
<section className="flex-1 min-h-[600px] p-8 text-black">
<Switch>
<Route path={`${match.url}/apps/:desk`} component={AppPrefs} />
<Route path={`${match.url}/system-updates`} component={SystemUpdatePrefs} />

View File

@ -6,6 +6,7 @@ import { ShipName } from '../../components/ShipName';
import useDocketState, { useAllyTreaties } from '../../state/docket';
import { useLeapStore } from '../Nav';
import { AppList } from '../../components/AppList';
import { addRecentDev } from './Home';
type AppsProps = RouteComponentProps<{ ship: string }>;
@ -68,6 +69,7 @@ export const Apps = ({ match }: AppsProps) => {
useEffect(() => {
if (provider) {
useDocketState.getState().fetchAllyTreaties(provider);
addRecentDev(provider);
}
}, [provider]);

View File

@ -97,7 +97,7 @@ export const Home = () => {
Recent Apps
</h2>
{recentApps.length === 0 && (
<div className="min-h-[150px] p-6 rounded-xl bg-gray-100">
<div className="min-h-[150px] p-6 rounded-xl bg-gray-50">
<p className="mb-4">Apps you use will be listed here, in the order you used them.</p>
<p className="mb-6">You can click/tap/keyboard on a listed app to open it.</p>
{groups && (
@ -122,7 +122,7 @@ export const Home = () => {
Recent Developers
</h2>
{recentDevs.length === 0 && (
<div className="min-h-[150px] p-6 rounded-xl bg-gray-100">
<div className="min-h-[150px] p-6 rounded-xl bg-gray-50">
<p className="mb-4">Urbit app developers you search for will be listed here.</p>
{zod && (
<>

View File

@ -25,7 +25,7 @@ export const Grid: FunctionComponent<GridProps> = ({ match }) => {
<main className="h-full w-full flex justify-center pt-4 md:pt-16 pb-32 relative z-0">
{!chargesLoaded && <span>Loading...</span>}
{chargesLoaded && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 px-4 md:px-8 w-full max-w-6xl">
<div className="grid justify-center grid-cols-2 sm:grid-cols-[repeat(auto-fit,minmax(auto,250px))] gap-4 px-4 md:px-8 w-full max-w-6xl">
{charges &&
map(omit(charges, window.desk), (charge, desk) => (
<Tile key={desk} charge={charge} desk={desk} />

View File

@ -192,7 +192,8 @@ export const mockContacts: Contacts = {
},
'~nalbel_litzod': {
...contact,
nickname: 'Queen'
nickname: 'Queen',
color: '#0a1b0a'
},
'~litmus^ritten': {
...contact
@ -206,7 +207,8 @@ export const mockContacts: Contacts = {
},
'~nalrys': {
...contact,
status: 'hosting coming soon'
status: 'hosting coming soon',
color: '#130c06'
}
};

View File

@ -27,7 +27,7 @@
}
.input {
@apply px-4 py-2 w-full bg-white rounded-xl;
@apply px-4 py-2 w-full text-base sm:text-sm bg-white rounded-xl;
}
.notification {

View File

@ -24,7 +24,7 @@ export const SuspendApp = () => {
<DialogContent showClose={false} className="max-w-[400px] space-y-6">
<h1 className="h4">Suspend &ldquo;{charge?.title || ''}&rdquo;</h1>
<p className="text-base tracking-tight pr-6">
Suspending an app will freeze its current state, and render it unable
Suspending an app will freeze its current state and render it unable
</p>
<div className="flex space-x-6">
<DialogClose as={Button} variant="secondary">

View File

@ -1,6 +1,5 @@
import classNames from 'classnames';
import React, { FunctionComponent } from 'react';
import { darken, hsla, lighten, parseToHsla, readableColorIsBlack } from 'color2k';
import { chadIsRunning } from '@urbit/api/docket';
import { TileMenu } from './TileMenu';
import { Spinner } from '../components/Spinner';
@ -14,28 +13,13 @@ type TileProps = {
desk: string;
};
function getMenuColor(color: string, lightText: boolean, active: boolean): string {
const hslaColor = parseToHsla(color);
const satAdjustedColor = hsla(
hslaColor[0],
active ? Math.max(0.2, hslaColor[1]) : 0,
hslaColor[2],
1
);
return lightText ? lighten(satAdjustedColor, 0.1) : darken(satAdjustedColor, 0.1);
}
export const Tile: FunctionComponent<TileProps> = ({ charge, desk }) => {
const addRecentApp = useRecentsStore((state) => state.addRecentApp);
const { title, image, color, chad, href } = charge;
const { theme, tileColor } = useTileColor(color);
const { lightText, tileColor, menuColor, suspendColor, suspendMenuColor } = useTileColor(color);
const loading = 'install' in chad;
const active = chadIsRunning(chad);
const lightText = !readableColorIsBlack(color);
const menuColor = getMenuColor(tileColor, theme === 'dark' ? !lightText : lightText, active);
const suspendColor = 'rgb(220,220,220)';
const suspended = 'suspend' in chad;
const active = chadIsRunning(chad);
const link = getAppHref(href);
const backgroundColor = active ? tileColor || 'purple' : suspendColor;
@ -54,20 +38,20 @@ export const Tile: FunctionComponent<TileProps> = ({ charge, desk }) => {
>
<div>
{loading ? (
<div className="absolute z-10 top-4 left-4 lg:top-8 lg:left-8 flex items-center justify-center">
<div className="absolute z-10 top-4 left-4 sm:top-8 sm:left-8 flex items-center justify-center">
<Spinner className="h-6 w-6" />
</div>
) : (
<TileMenu
desk={desk}
active={active}
menuColor={menuColor}
menuColor={active ? menuColor : suspendMenuColor}
lightText={lightText}
className="absolute z-10 top-2.5 right-2.5 sm:top-4 sm:right-4 opacity-0 hover-none:opacity-100 focus:opacity-100 group-hover:opacity-100"
className="absolute z-10 top-2.5 right-2.5 sm:top-4 sm:right-4 opacity-0 pointer-coarse:opacity-100 hover-none:opacity-100 focus:opacity-100 group-hover:opacity-100"
/>
)}
<div
className="h4 absolute z-10 bottom-3 left-1 lg:bottom-7 lg:left-5 py-1 px-3 rounded-lg"
className="h4 absolute z-10 bottom-[8%] left-[5%] sm:bottom-7 sm:left-5 py-1 px-3 rounded-lg"
style={{ backgroundColor }}
>
<h3 className="mix-blend-hard-light">{title}</h3>

View File

@ -11,6 +11,10 @@ export const TileInfo = () => {
const charge = useCharge(desk);
const vat = useVat(desk);
if (!charge) {
return null;
}
return (
<Dialog open onOpenChange={(open) => !open && push('/')}>
<DialogContent>

View File

@ -1,4 +1,4 @@
import { hsla, parseToHsla } from 'color2k';
import { darken, hsla, lighten, parseToHsla, readableColorIsBlack } from 'color2k';
import { useCurrentTheme } from '../state/local';
function getDarkColor(color: string): string {
@ -6,11 +6,31 @@ function getDarkColor(color: string): string {
return hsla(hslaColor[0], hslaColor[1], 1 - hslaColor[2], 1);
}
function bgAdjustedColor(color: string, darkBg: boolean): string {
return darkBg ? lighten(color, 0.1) : darken(color, 0.1);
}
function getMenuColor(color: string, darkBg: boolean): string {
const hslaColor = parseToHsla(color);
const satAdjustedColor = hsla(hslaColor[0], Math.max(0.2, hslaColor[1]), hslaColor[2], 1);
return bgAdjustedColor(satAdjustedColor, darkBg);
}
export const useTileColor = (color: string) => {
const theme = useCurrentTheme();
const darkTheme = theme === 'dark';
const tileColor = darkTheme ? getDarkColor(color) : color;
const darkBg = !readableColorIsBlack(tileColor);
const lightText = darkBg !== darkTheme; // if not same, light text
const suspendColor = darkTheme ? 'rgb(26,26,26)' : 'rgb(220,220,220)';
return {
theme,
tileColor: theme === 'dark' ? getDarkColor(color) : color
tileColor: theme === 'dark' ? getDarkColor(color) : color,
menuColor: getMenuColor(tileColor, darkBg),
suspendColor,
suspendMenuColor: bgAdjustedColor(suspendColor, darkBg),
lightText
};
};