Add 80+ additional icons from FontAwesome (#6803)

* Add 80+ additional icons from FontAwesome

* fix: color switch transition, add 6 more icons to fill out grid

* Add strict validation for collection icon

* fix: Avoid import from app in server
This commit is contained in:
Tom Moor
2024-04-13 12:33:07 -06:00
committed by GitHub
parent 689886797c
commit 765ae7b298
18 changed files with 429 additions and 155 deletions

View File

@@ -5,13 +5,13 @@ import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker";
import { IconLibrary } from "~/components/Icons/IconLibrary";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";

View File

@@ -7,6 +7,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
@@ -17,7 +18,6 @@ import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Squircle from "./Squircle";
import Text from "./Text";
import Tooltip from "./Tooltip";

View File

@@ -1,38 +1,3 @@
import { CSSProperties } from "react";
import styled from "styled-components";
type JustifyValues = CSSProperties["justifyContent"];
type AlignValues = CSSProperties["alignItems"];
const Flex = styled.div<{
auto?: boolean;
column?: boolean;
align?: AlignValues;
justify?: JustifyValues;
wrap?: boolean;
shrink?: boolean;
reverse?: boolean;
gap?: number;
}>`
display: flex;
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
flex-direction: ${({ column, reverse }) =>
reverse
? column
? "column-reverse"
: "row-reverse"
: column
? "column"
: "row"};
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-wrap: ${({ wrap }) => (wrap ? "wrap" : "initial")};
flex-shrink: ${({ shrink }) =>
shrink === true ? 1 : shrink === false ? 0 : "initial"};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
min-height: 0;
min-width: 0;
`;
import Flex from "@shared/components/Flex";
export default Flex;

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import { MenuItem } from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
@@ -10,7 +11,7 @@ import Text from "~/components/Text";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DelayedMount from "./DelayedMount";
import { IconLibrary } from "./Icons/IconLibrary";
import InputSearch from "./InputSearch";
import Popover from "./Popover";
const icons = IconLibrary.mapping;
@@ -38,11 +39,12 @@ function IconPicker({
onChange,
className,
}: Props) {
const [query, setQuery] = React.useState("");
const { t } = useTranslation();
const theme = useTheme();
const popover = usePopoverState({
gutter: 0,
placement: "bottom",
placement: "right",
modal: true,
});
@@ -51,9 +53,15 @@ function IconPicker({
onOpen?.();
} else {
onClose?.();
setQuery("");
}
}, [onOpen, onClose, popover.visible]);
const filteredIcons = IconLibrary.findIcons(query);
const handleFilter = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value.toLowerCase());
};
const styles = React.useMemo(
() => ({
default: {
@@ -92,6 +100,9 @@ function IconPicker({
{ capture: true }
);
const iconNames = Object.keys(icons);
const delayPerIcon = 250 / iconNames.length;
return (
<>
<PopoverDisclosure {...popover}>
@@ -112,69 +123,77 @@ function IconPicker({
</PopoverDisclosure>
<Popover
{...popover}
width={388}
aria-label={t("Choose icon")}
width={552}
aria-label={t("Choose an icon")}
hideOnClickOutside={false}
>
<Icons>
{Object.keys(icons).map((name, index) => (
<MenuItem key={name} onClick={() => onChange(color, name)}>
{(props) => (
<IconButton
style={
{
"--delay": `${index * 8}ms`,
} as React.CSSProperties
}
{...props}
>
<Icon
as={IconLibrary.getComponent(name)}
color={color}
size={30}
<Flex column gap={12}>
<Text size="large" weight="xbold">
{t("Choose an icon")}
</Text>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleFilter}
autoFocus
/>
<div>
{iconNames.map((name, index) => (
<MenuItem key={name} onClick={() => onChange(color, name)}>
{(props) => (
<IconButton
style={
{
opacity: query
? filteredIcons.includes(name)
? 1
: 0.3
: undefined,
"--delay": `${Math.round(index * delayPerIcon)}ms`,
} as React.CSSProperties
}
{...props}
>
{initial}
</Icon>
</IconButton>
)}
</MenuItem>
))}
</Icons>
<Colors>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
triangle="hide"
styles={styles}
/>
</React.Suspense>
</Colors>
<Icon
as={IconLibrary.getComponent(name)}
color={color}
size={30}
>
{initial}
</Icon>
</IconButton>
)}
</MenuItem>
))}
</div>
<Flex>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
triangle="hide"
styles={styles}
/>
</React.Suspense>
</Flex>
</Flex>
</Popover>
</>
);
}
const Icon = styled.svg`
transition: fill 150ms ease-in-out;
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
const Colors = styled(Flex)`
padding: 8px;
`;
const Icons = styled.div`
padding: 8px;
`;
const IconButton = styled(NudeButton)`
vertical-align: top;
border-radius: 4px;

View File

@@ -2,10 +2,10 @@ import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import { IconLibrary } from "./IconLibrary";
type Props = {
/** The collection to show an icon for */

View File

@@ -1,315 +0,0 @@
import intersection from "lodash/intersection";
import {
BookmarkedIcon,
BicycleIcon,
AcademicCapIcon,
BeakerIcon,
BuildingBlocksIcon,
BrowserIcon,
CollectionIcon,
CoinsIcon,
CameraIcon,
CarrotIcon,
FlameIcon,
HashtagIcon,
GraphIcon,
InternetIcon,
LibraryIcon,
PlaneIcon,
RamenIcon,
CloudIcon,
CodeIcon,
EditIcon,
EmailIcon,
EyeIcon,
GlobeIcon,
InfoIcon,
IceCreamIcon,
ImageIcon,
LeafIcon,
LightBulbIcon,
MathIcon,
MoonIcon,
NotepadIcon,
TeamIcon,
PadlockIcon,
PaletteIcon,
PromoteIcon,
QuestionMarkIcon,
SportIcon,
SunIcon,
ShapesIcon,
TargetIcon,
TerminalIcon,
ToolsIcon,
VehicleIcon,
WarningIcon,
DatabaseIcon,
SmileyIcon,
LightningIcon,
ClockIcon,
DoneIcon,
FeedbackIcon,
ServerRackIcon,
ThumbsUpIcon,
TruckIcon,
} from "outline-icons";
import LetterIcon from "./LetterIcon";
export class IconLibrary {
/**
* Get the component for a given icon name
*
* @param icon The name of the icon
* @returns The component for the icon
*/
public static getComponent(icon: string) {
return this.mapping[icon].component;
}
/**
* Find an icon by keyword. This is useful for searching for an icon based on a user's input.
*
* @param keyword The keyword to search for
* @returns The name of the icon that matches the keyword, or undefined if no match is found
*/
public static findIconByKeyword(keyword: string) {
const keys = Object.keys(this.mapping);
for (const key of keys) {
const icon = this.mapping[key];
const keywords = icon.keywords.split(" ");
const namewords = keyword.toLocaleLowerCase().split(" ");
const matches = intersection(namewords, keywords);
if (matches.length > 0) {
return key;
}
}
return undefined;
}
/**
* A map of all icons available to end users in the app. This does not include icons that are used
* internally only, which can be imported from `outline-icons` directly.
*/
public static mapping = {
academicCap: {
component: AcademicCapIcon,
keywords: "learn teach lesson guide tutorial onboarding training",
},
bicycle: {
component: BicycleIcon,
keywords: "bicycle bike cycle",
},
beaker: {
component: BeakerIcon,
keywords: "lab research experiment test",
},
buildingBlocks: {
component: BuildingBlocksIcon,
keywords: "app blocks product prototype",
},
bookmark: {
component: BookmarkedIcon,
keywords: "bookmark",
},
browser: {
component: BrowserIcon,
keywords: "browser web app",
},
collection: {
component: CollectionIcon,
keywords: "collection",
},
coins: {
component: CoinsIcon,
keywords: "coins money finance sales income revenue cash",
},
camera: {
component: CameraIcon,
keywords: "photo picture",
},
carrot: {
component: CarrotIcon,
keywords: "food vegetable produce",
},
clock: {
component: ClockIcon,
keywords: "time",
},
cloud: {
component: CloudIcon,
keywords: "cloud service aws infrastructure",
},
code: {
component: CodeIcon,
keywords: "developer api code development engineering programming",
},
database: {
component: DatabaseIcon,
keywords: "server ops database",
},
done: {
component: DoneIcon,
keywords: "checkmark success complete finished",
},
email: {
component: EmailIcon,
keywords: "email at",
},
eye: {
component: EyeIcon,
keywords: "eye view",
},
feedback: {
component: FeedbackIcon,
keywords: "faq help support",
},
flame: {
component: FlameIcon,
keywords: "fire flame hot",
},
graph: {
component: GraphIcon,
keywords: "chart analytics data",
},
globe: {
component: GlobeIcon,
keywords: "world translate",
},
hashtag: {
component: HashtagIcon,
keywords: "social media tag",
},
info: {
component: InfoIcon,
keywords: "info information",
},
icecream: {
component: IceCreamIcon,
keywords: "food dessert cone scoop",
},
image: {
component: ImageIcon,
keywords: "image photo picture",
},
internet: {
component: InternetIcon,
keywords: "network global globe world",
},
leaf: {
component: LeafIcon,
keywords: "leaf plant outdoors nature ecosystem climate",
},
library: {
component: LibraryIcon,
keywords: "library collection archive",
},
lightbulb: {
component: LightBulbIcon,
keywords: "lightbulb idea",
},
lightning: {
component: LightningIcon,
keywords: "lightning fast zap",
},
letter: {
component: LetterIcon,
keywords: "letter",
},
math: {
component: MathIcon,
keywords: "math formula",
},
moon: {
component: MoonIcon,
keywords: "night moon dark",
},
notepad: {
component: NotepadIcon,
keywords: "journal notepad write notes",
},
padlock: {
component: PadlockIcon,
keywords: "padlock private security authentication authorization auth",
},
palette: {
component: PaletteIcon,
keywords: "design palette art brand",
},
pencil: {
component: EditIcon,
keywords: "copy writing post blog",
},
plane: {
component: PlaneIcon,
keywords: "airplane travel flight trip vacation",
},
promote: {
component: PromoteIcon,
keywords: "marketing promotion",
},
ramen: {
component: RamenIcon,
keywords: "soup food noodle bowl meal",
},
question: {
component: QuestionMarkIcon,
keywords: "question help support faq",
},
server: {
component: ServerRackIcon,
keywords: "ops infra server",
},
sun: {
component: SunIcon,
keywords: "day sun weather",
},
shapes: {
component: ShapesIcon,
keywords: "blocks toy",
},
sport: {
component: SportIcon,
keywords: "sport outdoor racket game",
},
smiley: {
component: SmileyIcon,
keywords: "emoji smiley happy",
},
target: {
component: TargetIcon,
keywords: "target goal sales",
},
team: {
component: TeamIcon,
keywords: "team building organization office",
},
terminal: {
component: TerminalIcon,
keywords: "terminal code",
},
thumbsup: {
component: ThumbsUpIcon,
keywords: "like social favorite upvote",
},
truck: {
component: TruckIcon,
keywords: "truck transport vehicle",
},
tools: {
component: ToolsIcon,
keywords: "tool settings",
},
vehicle: {
component: VehicleIcon,
keywords: "truck car travel transport",
},
warning: {
component: WarningIcon,
keywords: "warning alert error",
},
};
}

View File

@@ -1,35 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Squircle from "../Squircle";
type Props = {
/** The width and height of the icon, including standard padding. */
size?: number;
children: React.ReactNode;
};
/**
* A squircle shaped icon with a letter inside, used for collections.
*/
const LetterIcon = ({ children, size = 24, ...rest }: Props) => (
<LetterIconWrapper $size={size}>
<Squircle size={Math.round(size * 0.66)} {...rest}>
{children}
</Squircle>
</LetterIconWrapper>
);
const LetterIconWrapper = styled.div<{ $size: number }>`
display: inline-flex;
align-items: center;
justify-content: center;
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
font-weight: 700;
font-size: ${({ $size }) => $size / 2}px;
color: ${s("background")};
`;
export default LetterIcon;

View File

@@ -3,6 +3,7 @@ import { MoreIcon, QuestionMarkIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { CollectionPermission } from "@shared/types";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
@@ -14,7 +15,6 @@ import useStores from "~/hooks/useStores";
import Avatar from "../Avatar";
import { AvatarSize } from "../Avatar/Avatar";
import CollectionIcon from "../Icons/CollectionIcon";
import Squircle from "../Squircle";
import Tooltip from "../Tooltip";
import { StyledListItem } from "./MemberListItem";

View File

@@ -8,6 +8,7 @@ import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import Document from "~/models/Document";
@@ -21,7 +22,6 @@ import { AvatarSize } from "../Avatar/Avatar";
import CopyToClipboard from "../CopyToClipboard";
import NudeButton from "../NudeButton";
import { ResizingHeightContainer } from "../ResizingHeightContainer";
import Squircle from "../Squircle";
import Text from "../Text";
import Tooltip from "../Tooltip";
import { StyledListItem } from "./MemberListItem";

View File

@@ -1,47 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import Flex from "./Flex";
type Props = {
/** The width and height of the squircle */
size?: number;
/** The color of the squircle */
color?: string;
children?: React.ReactNode;
className?: string;
};
const Squircle: React.FC<Props> = ({
color,
size = 28,
children,
className,
}: Props) => (
<Wrapper size={size} align="center" justify="center" className={className}>
<svg width={size} height={size} fill={color} viewBox="0 0 28 28">
<path d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z" />
</svg>
<Content>{children}</Content>
</Wrapper>
);
const Wrapper = styled(Flex)<{ size: number }>`
position: relative;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
svg {
transition: fill 150ms ease-in-out;
transition-delay: var(--delay);
}
`;
const Content = styled.div`
display: flex;
transform: translate(-50%, -50%);
position: absolute;
top: 50%;
left: 50%;
`;
export default Squircle;

View File

@@ -11,7 +11,7 @@ type Props = {
/** Whether the text should be selectable (defaults to false) */
selectable?: boolean;
/** The font weight of the text */
weight?: "bold" | "normal";
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
};
@@ -47,7 +47,9 @@ const Text = styled.span<Props>`
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "bold"
font-weight: ${props.weight === "xbold"
? 600
: props.weight === "bold"
? 500
: props.weight === "normal"
? 400