diff --git a/packages/dashboard-v2/src/components/CopyButton.js b/packages/dashboard-v2/src/components/CopyButton.js new file mode 100644 index 00000000..db5e9c21 --- /dev/null +++ b/packages/dashboard-v2/src/components/CopyButton.js @@ -0,0 +1,50 @@ +import { useCallback, useRef, useState } from "react"; +import copy from "copy-text-to-clipboard"; +import styled from "styled-components"; +import { useClickAway } from "react-use"; + +import { CopyIcon } from "./Icons"; + +const Button = styled.button.attrs({ + className: "relative inline-flex items-center hover:text-primary", +})``; + +const TooltipContainer = styled.div.attrs(({ $visible }) => ({ + className: `absolute left-full top-1/2 z-10 + bg-white rounded border border-primary/30 shadow-md + pointer-events-none transition-opacity duration-150 ease-in-out + ${$visible ? "opacity-100" : "opacity-0"}`, +}))` + transform: translateY(-50%); +`; + +const TooltipContent = styled.div.attrs({ + className: "bg-primary-light/10 text-palette-600 py-2 px-4 ", +})``; + +export const CopyButton = ({ value }) => { + const containerRef = useRef(); + const [copied, setCopied] = useState(false); + const [timer, setTimer] = useState(null); + + const handleCopy = useCallback(() => { + clearTimeout(timer); + copy(value); + setCopied(true); + + setTimer(setTimeout(() => setCopied(false), 150000)); + }, [value, timer]); + + useClickAway(containerRef, () => setCopied(false)); + + return ( +
+ + + Copied to clipboard + +
+ ); +}; diff --git a/packages/dashboard-v2/src/components/FileList/FileList.js b/packages/dashboard-v2/src/components/FileList/FileList.js new file mode 100644 index 00000000..6342b970 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/FileList.js @@ -0,0 +1,74 @@ +import * as React from "react"; +import useSWR from "swr"; +import { useMedia } from "react-use"; + +import theme from "../../lib/theme"; + +import { ContainerLoadingIndicator } from "../LoadingIndicator"; +import { Select, SelectOption } from "../Select"; +import { Switch } from "../Switch"; +import { TextInputIcon } from "../TextInputIcon/TextInputIcon"; +import { SearchIcon } from "../Icons"; + +import FileTable from "./FileTable"; +import useFormattedFilesData from "./useFormattedFilesData"; + +const FileList = ({ type }) => { + const isMediumScreenOrLarger = useMedia(`(min-width: ${theme.screens.md})`); + const { data, error } = useSWR(`user/${type}?pageSize=10`); + const items = useFormattedFilesData(data?.items || []); + + const setFilter = (name, value) => console.log("filter", name, "set to", value); + + if (!items.length) { + return ( +
+ {/* TODO: proper error message */} + {!data && !error && } + {!data && error &&

An error occurred while loading this data.

} + {data &&

No {type} found.

} +
+ ); + } + + return ( +
+
+ } + onChange={console.log.bind(console)} + /> +
+ setFilter("showSmallFiles", value)} className="mr-8"> + + Show small files + + +
+ File type: + +
+
+ Sort: + +
+
+
+ {/* TODO: mobile view (it's not tabular) */} + {isMediumScreenOrLarger ? : "Mobile view"} +
+ ); +}; + +export default FileList; diff --git a/packages/dashboard-v2/src/components/FileList/FileTable.js b/packages/dashboard-v2/src/components/FileList/FileTable.js new file mode 100644 index 00000000..90c9600f --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/FileTable.js @@ -0,0 +1,111 @@ +import { CogIcon, ShareIcon } from "../Icons"; +import { PopoverMenu } from "../PopoverMenu/PopoverMenu"; +import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "../Table"; +import { CopyButton } from "../CopyButton"; + +const buildShareMenu = (item) => { + return [ + { + label: "Facebook", + callback: () => { + console.info("share to Facebook", item); + }, + }, + { + label: "Twitter", + callback: () => { + console.info("share to Twitter", item); + }, + }, + { + label: "Discord", + callback: () => { + console.info("share to Discord", item); + }, + }, + ]; +}; + +const buildOptionsMenu = (item) => { + return [ + { + label: "Preview", + callback: () => { + console.info("preview", item); + }, + }, + { + label: "Download", + callback: () => { + console.info("download", item); + }, + }, + { + label: "Unpin", + callback: () => { + console.info("unpin", item); + }, + }, + { + label: "Report", + callback: () => { + console.info("report", item); + }, + }, + ]; +}; + +export default function FileTable({ items }) { + return ( + + + + Name + Type + + Size + + Uploaded + Skylink + Activity + + + + {items.map((item) => { + const { id, name, type, size, date, skylink } = item; + + return ( + + {name} + {type} + + {size} + + {date} + +
+ + {skylink} +
+
+ +
+ + + + + + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/packages/dashboard-v2/src/components/FileList/index.js b/packages/dashboard-v2/src/components/FileList/index.js new file mode 100644 index 00000000..93296508 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/index.js @@ -0,0 +1 @@ +export * from "./FileList"; diff --git a/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js b/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js new file mode 100644 index 00000000..82d95090 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js @@ -0,0 +1,26 @@ +import { useMemo } from "react"; +import prettyBytes from "pretty-bytes"; +import dayjs from "dayjs"; + +const parseFileName = (fileName) => { + const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity; + + return [fileName.substr(0, lastDotIndex), fileName.substr(lastDotIndex)]; +}; + +const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest }) => { + const [name, type] = parseFileName(rawFileName); + const date = dayjs(uploadedOn || downloadedOn).format("MM/DD/YYYY; HH:MM"); + + return { + ...rest, + date, + size: prettyBytes(size), + type, + name, + }; +}; + +const useFormattedFilesData = (items) => useMemo(() => items.map(formatItem), [items]); + +export default useFormattedFilesData; diff --git a/packages/dashboard-v2/src/components/Icons/icons/CopyIcon.js b/packages/dashboard-v2/src/components/Icons/icons/CopyIcon.js new file mode 100644 index 00000000..c3ceb9ac --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/CopyIcon.js @@ -0,0 +1,10 @@ +import { withIconProps } from "../withIconProps"; + +export const CopyIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/SearchIcon.js b/packages/dashboard-v2/src/components/Icons/icons/SearchIcon.js new file mode 100644 index 00000000..f551dea6 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/SearchIcon.js @@ -0,0 +1,10 @@ +import { withIconProps } from "../withIconProps"; + +export const SearchIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/ShareIcon.js b/packages/dashboard-v2/src/components/Icons/icons/ShareIcon.js new file mode 100644 index 00000000..f25afeaf --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/ShareIcon.js @@ -0,0 +1,18 @@ +import { withIconProps } from "../withIconProps"; + +export const ShareIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/index.js b/packages/dashboard-v2/src/components/Icons/index.js index 41552e34..05d9b832 100644 --- a/packages/dashboard-v2/src/components/Icons/index.js +++ b/packages/dashboard-v2/src/components/Icons/index.js @@ -9,3 +9,6 @@ export * from "./icons/CircledErrorIcon"; export * from "./icons/CircledProgressIcon"; export * from "./icons/CircledArrowUpIcon"; export * from "./icons/PlusIcon"; +export * from "./icons/SearchIcon"; +export * from "./icons/CopyIcon"; +export * from "./icons/ShareIcon"; diff --git a/packages/dashboard-v2/src/components/Icons/withIconProps.js b/packages/dashboard-v2/src/components/Icons/withIconProps.js index d4267318..5da47331 100644 --- a/packages/dashboard-v2/src/components/Icons/withIconProps.js +++ b/packages/dashboard-v2/src/components/Icons/withIconProps.js @@ -4,7 +4,7 @@ const propTypes = { /** * Size of the icon's bounding box. */ - size: PropTypes.number, + size: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }; const defaultProps = { diff --git a/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js b/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js index e69de29b..1826cd92 100644 --- a/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js +++ b/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js @@ -0,0 +1,90 @@ +import { Children, cloneElement, useRef, useState } from "react"; +import PropTypes from "prop-types"; +import { useClickAway } from "react-use"; +import styled, { css, keyframes } from "styled-components"; + +const dropDown = keyframes` + 0% { + transform: scaleY(0); + } + 80% { + transform: scaleY(1.1); + } + 100% { + transform: scaleY(1); + } +`; + +const Container = styled.div.attrs({ className: "relative inline-flex" })``; + +const Flyout = styled.ul.attrs({ + className: `absolute right-0 z-10 py-2 + border rounded bg-white + overflow-hidden pointer-events-none + shadow-md shadow-palette-200/50 + pointer-events-auto h-auto overflow-visible border-primary`, +})` + top: calc(100% + 2px); + animation: ${css` + ${dropDown} 0.1s ease-in-out + `}; +`; + +const Option = styled.li.attrs({ + className: `font-sans text-xs uppercase + relative pl-3 pr-5 py-1 + text-palette-600 cursor-pointer + hover:text-primary hover:font-normal + active:text-primary active:font-normal + + before:content-[initial] before:absolute before:left-0 before:h-3 before:w-0.5 before:bg-primary before:top-1.5 + hover:before:content-['']`, +})``; + +export const PopoverMenu = ({ options, children, openClassName, ...props }) => { + const containerRef = useRef(); + const [open, setOpen] = useState(false); + + useClickAway(containerRef, () => setOpen(false)); + + const handleChoice = (callback) => () => { + setOpen(false); + callback(); + }; + + return ( + + {Children.only( + cloneElement(children, { + onClick: () => setOpen((open) => !open), + className: `${children.props.className ?? ""} ${open ? openClassName : ""}`, + }) + )} + {open && ( + + {options.map(({ label, callback }) => ( + + ))} + + )} + + ); +}; + +PopoverMenu.propTypes = { + /** + * Accepts a single child node that will become a menu toggle. + */ + children: PropTypes.element.isRequired, + /** + * Positions in the menu + */ + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + callback: PropTypes.func.isRequired, + }) + ).isRequired, +}; diff --git a/packages/dashboard-v2/src/components/Select/Select.js b/packages/dashboard-v2/src/components/Select/Select.js index 97d3d73b..0a59a581 100644 --- a/packages/dashboard-v2/src/components/Select/Select.js +++ b/packages/dashboard-v2/src/components/Select/Select.js @@ -21,9 +21,11 @@ const dropDown = keyframes` const Container = styled.div.attrs({ className: "relative inline-flex" })``; -const Trigger = styled.button.attrs(({ placeholder }) => ({ - className: `flex items-center cursor-pointer ${placeholder ? "text-palette-300" : ""}`, -}))``; +const Trigger = styled.button.attrs(({ $placeholder }) => ({ + className: `flex items-center cursor-pointer font-bold ${$placeholder ? "text-palette-300" : ""}`, +}))` + text-transform: inherit; +`; const TriggerIcon = styled(ChevronDownIcon).attrs({ className: "transition-transform text-primary", @@ -32,13 +34,14 @@ const TriggerIcon = styled(ChevronDownIcon).attrs({ `; const Flyout = styled.ul.attrs(({ open }) => ({ - className: `absolute top-[20px] right-0 - p-0 h-0 border rounded bg-white + className: `absolute right-0 z-10 + p-0 border rounded bg-white overflow-hidden pointer-events-none shadow-md shadow-palette-200/50 ${open ? "pointer-events-auto h-auto overflow-visible border-primary" : ""} ${open ? "visible" : "invisible"}`, }))` + top: calc(100% + 2px); animation: ${({ open }) => open ? css` @@ -47,7 +50,7 @@ const Flyout = styled.ul.attrs(({ open }) => ({ : "none"}; `; -export const Select = ({ defaultValue, children, onChange, placeholder }) => { +export const Select = ({ defaultValue, children, onChange, placeholder, ...props }) => { const selectRef = useRef(); const options = useMemo(() => Children.toArray(children).filter(({ type }) => type === SelectOption), [children]); const [state, dispatch] = useSelectReducer({ defaultValue, placeholder, options }); @@ -65,8 +68,8 @@ export const Select = ({ defaultValue, children, onChange, placeholder }) => { const activeLabel = activeOption?.props?.label ?? null; return ( - - + + {activeLabel ?? placeholder} diff --git a/packages/dashboard-v2/src/components/Switch/Switch.css b/packages/dashboard-v2/src/components/Switch/Switch.css deleted file mode 100644 index 4bd07cf2..00000000 --- a/packages/dashboard-v2/src/components/Switch/Switch.css +++ /dev/null @@ -1,40 +0,0 @@ -.react-switch-checkbox { - height: 0; - width: 0; - visibility: hidden; -} - -.react-switch-label { - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - width: 44px; - height: 22px; - background: white; - border-radius: 11px; - @apply border-palette-200; - border-width: 1px; - position: relative; - transition: background-color 0.2s; -} - -.react-switch-label .react-switch-button { - content: ""; - position: absolute; - top: 2px; - left: 2px; - width: 16px; - height: 16px; - border-radius: 8px; - transition: 0.2s; -} - -.react-switch-checkbox:checked + .react-switch-label .react-switch-button { - left: calc(100% - 2px); - transform: translateX(-100%); -} - -.react-switch-label:active .react-switch-button { - width: 20px; -} diff --git a/packages/dashboard-v2/src/components/Switch/Switch.js b/packages/dashboard-v2/src/components/Switch/Switch.js index 7709412b..ed24bc92 100644 --- a/packages/dashboard-v2/src/components/Switch/Switch.js +++ b/packages/dashboard-v2/src/components/Switch/Switch.js @@ -1,37 +1,86 @@ import PropTypes from "prop-types"; -import "./Switch.css"; +import { useEffect, useMemo, useState } from "react"; +import styled from "styled-components"; +import { nanoid } from "nanoid"; + +const Container = styled.div.attrs({ + className: "inline-flex items-center gap-1 cursor-pointer select-none", +})``; + +const Checkbox = styled.input.attrs({ + type: "checkbox", + className: `h-0 w-0 hidden`, +})``; + +const Label = styled.label.attrs({ + className: "cursor-pointer inline-flex items-center gap-2", +})` + &:active .toggle-pin { + width: 20px; + } +`; + +const Toggle = styled.span.attrs({ + className: `flex flex-row items-center justify-between + w-[44px] h-[22px] bg-white rounded-full + border border-palette-200 relative cursor-pointer`, +})` + &:active .toggle-pin { + width: 20px; + } +`; + +const TogglePin = styled.span.attrs(({ $checked }) => ({ + className: `toggle-pin + absolute top-[2px] w-4 h-4 rounded-full + transition-[width_left] active:w-5 + + ${$checked ? "checked bg-primary" : "bg-palette-200"}`, +}))` + left: 2px; + + &.checked { + left: calc(100% - 2px); + transform: translateX(-100%); + } +`; + +export const Switch = ({ children, defaultChecked, onChange, ...props }) => { + const id = useMemo(nanoid, [onChange]); + const [checked, setChecked] = useState(defaultChecked); + + useEffect(() => { + onChange(checked); + }, [checked, onChange]); -/** - * Primary UI component for user interaction - */ -export const Switch = ({ isOn, handleToggle }) => { return ( - <> - - - + + setChecked(ev.target.checked)} id={id} /> + + ); }; Switch.propTypes = { /** - * Switch's current value + * Should the checkbox be checked by default? */ - isOn: PropTypes.bool, + defaultChecked: PropTypes.bool, + /** + * Element to be rendered as the switch label + */ + children: PropTypes.element, /** * Function to execute on change */ - handleToggle: PropTypes.func, + onChange: PropTypes.func.isRequired, }; Switch.defaultProps = { - isOn: false, + defaultChecked: false, }; diff --git a/packages/dashboard-v2/src/components/Switch/Switch.stories.js b/packages/dashboard-v2/src/components/Switch/Switch.stories.js index 41d64f6b..8e0cba7d 100644 --- a/packages/dashboard-v2/src/components/Switch/Switch.stories.js +++ b/packages/dashboard-v2/src/components/Switch/Switch.stories.js @@ -13,10 +13,10 @@ const Template = (args) => ; export const SwitchTrue = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args SwitchTrue.args = { - isOn: true, + defaultChecked: true, }; export const SwitchFalse = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args SwitchFalse.args = { - isOn: false, + defaultChecked: false, }; diff --git a/packages/dashboard-v2/src/components/Table/Table.js b/packages/dashboard-v2/src/components/Table/Table.js index 8d55e119..e741da20 100644 --- a/packages/dashboard-v2/src/components/Table/Table.js +++ b/packages/dashboard-v2/src/components/Table/Table.js @@ -1,7 +1,7 @@ import styled from "styled-components"; const Container = styled.div.attrs({ - className: "p-1 max-w-full overflow-x-auto", + className: "p-1 max-w-full", })``; const StyledTable = styled.table.attrs({ diff --git a/packages/dashboard-v2/src/components/Table/TableHeadCell.js b/packages/dashboard-v2/src/components/Table/TableHeadCell.js index aeb65670..f16530f0 100644 --- a/packages/dashboard-v2/src/components/Table/TableHeadCell.js +++ b/packages/dashboard-v2/src/components/Table/TableHeadCell.js @@ -4,7 +4,8 @@ import styled from "styled-components"; * Accepts all HMTL attributes a `` element does. */ export const TableHeadCell = styled.th.attrs({ - className: `px-6 py-2.5 truncate h-tableRow + className: `first:pl-6 last:pr-6 px-2 py-4 + truncate h-tableRow text-palette-600 font-sans font-light text-xs first:rounded-l-sm last:rounded-r-sm`, })` diff --git a/packages/dashboard-v2/src/components/Tabs/Tabs.js b/packages/dashboard-v2/src/components/Tabs/Tabs.js index 4bf20ccf..eae51a65 100644 --- a/packages/dashboard-v2/src/components/Tabs/Tabs.js +++ b/packages/dashboard-v2/src/components/Tabs/Tabs.js @@ -34,7 +34,11 @@ const Body = styled.div.attrs({ className: "grow min-h-0" })``; export const Tabs = ({ defaultTab, children, variant }) => { const getTabId = usePrefixedTabIds(); const { tabs, panels, tabsRefs } = useTabsChildren(children, getTabId); - const defaultTabId = useMemo(() => getTabId(defaultTab || tabs[0].props.id), [getTabId, defaultTab, tabs]); + const defaultTabId = useMemo(() => { + const requestedTabIsPresent = tabs.find(({ props }) => props.id === defaultTab); + + return getTabId(requestedTabIsPresent ? defaultTab : tabs[0].props.id); + }, [getTabId, defaultTab, tabs]); const [activeTabId, setActiveTabId] = useState(defaultTabId); const [activeTabRef, setActiveTabRef] = useState(tabsRefs[activeTabId]); const isActive = (id) => id === activeTabId; diff --git a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js index 892da996..506ee1fc 100644 --- a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js +++ b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.js @@ -1,20 +1,45 @@ import PropTypes from "prop-types"; +import cn from "classnames"; +import { useEffect, useRef, useState } from "react"; +import { PlusIcon } from "../Icons"; + +export const TextInputIcon = ({ className, icon, placeholder, onChange }) => { + const inputRef = useRef(); + const [focused, setFocused] = useState(false); + const [value, setValue] = useState(""); + + useEffect(() => { + onChange(value); + }, [value, onChange]); -/** - * Primary UI component for user interaction - */ -export const TextInputIcon = ({ icon, position, placeholder }) => { return ( -
- {position === "left" ?
{icon}
: null} - +
{icon}
+ setFocused(true)} + onBlur={() => setFocused(false)} + onChange={(event) => setValue(event.target.value)} + placeholder={placeholder} + className="focus:outline-none bg-transparent placeholder:text-palette-400" /> - {position === "right" ?
{icon}
: null} + {value && ( + setValue("")} + /> + )}
); }; @@ -23,13 +48,13 @@ TextInputIcon.propTypes = { /** * Icon to place in text input */ - icon: PropTypes.element, - /** - * Side to place icon - */ - position: PropTypes.oneOf(["left", "right"]), + icon: PropTypes.element.isRequired, /** * Input placeholder */ placeholder: PropTypes.string, + /** + * Function to be called whenever value changes + */ + onChange: PropTypes.func.isRequired, }; diff --git a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js index 676ca9cf..521b90df 100644 --- a/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js +++ b/packages/dashboard-v2/src/components/TextInputIcon/TextInputIcon.stories.js @@ -1,5 +1,6 @@ import { TextInputIcon } from "./TextInputIcon"; -import { CogIcon } from "../Icons"; +import { SearchIcon } from "../Icons"; +import { Panel } from "../Panel"; // More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export export default { @@ -9,19 +10,21 @@ export default { }; // More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const Template = (args) => ; +const Template = (args) => ( + + + +); export const IconLeft = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args IconLeft.args = { - icon: , - position: "left", + icon: , placeholder: "Search", }; export const IconRight = Template.bind({}); IconRight.args = { - icon: , - position: "right", + icon: , placeholder: "Search", }; diff --git a/packages/dashboard-v2/src/pages/files.js b/packages/dashboard-v2/src/pages/files.js index 348a95b3..197cc031 100644 --- a/packages/dashboard-v2/src/pages/files.js +++ b/packages/dashboard-v2/src/pages/files.js @@ -2,8 +2,28 @@ import * as React from "react"; import DashboardLayout from "../layouts/DashboardLayout"; +import { Panel } from "../components/Panel"; +import { Tab, TabPanel, Tabs } from "../components/Tabs"; +import FileList from "../components/FileList/FileList"; +import { useSearchParam } from "react-use"; + const FilesPage = () => { - return <>FILES; + const defaultTab = useSearchParam("tab"); + + return ( + + + + + + + + + + + + + ); }; FilesPage.Layout = DashboardLayout; diff --git a/packages/dashboard-v2/tailwind.config.js b/packages/dashboard-v2/tailwind.config.js index 4afa0cb2..6f4242d9 100644 --- a/packages/dashboard-v2/tailwind.config.js +++ b/packages/dashboard-v2/tailwind.config.js @@ -28,6 +28,7 @@ module.exports = { borderColor: (theme) => ({ ...theme("colors"), ...colors }), textColor: (theme) => ({ ...theme("colors"), ...colors }), placeholderColor: (theme) => ({ ...theme("colors"), ...colors }), + outlineColor: (theme) => ({ ...theme("colors"), ...colors }), extend: { fontFamily: { sans: ["Sora", ...defaultTheme.fontFamily.sans],