diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d639e4709..fba229ab2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,7 @@ jobs: # This is needed when playwright is used in tests #70 - run: pnpm lint-fix - run: pnpm build + - run: pnpm typecheck # Mayb we can do without these? # - run: npx playwright install-deps # - run: npx playwright install @@ -50,7 +51,7 @@ jobs: run: nohup pnpm start & - name: Run atomic-server docker image in background (for testing) run: nohup docker run -p 80:80 -p 443:443 -v atomic-storage:/atomic-storage joepmeneer/atomic-server --initialize & - - run: pnpx playwright install + - run: pnpm playwright-install - run: SERVER_URL=http://localhost pnpm test - name: Upload failed e2e test screenshots uses: actions/upload-artifact@v3 diff --git a/.vscode/settings.json b/.vscode/settings.json index 979ce910e..e0b5786fb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "editor.rulers": [ 80 ], - "files.exclude": { + "search.exclude": { "**/.git": true, "**/node_modules": true, "**/build": true, diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe6b1a62..d9a20aad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ This changelog covers all three packages, as they are (for now) updated as a whole +## UNRELEASED + +- Add folders with list & grid views, allow drag & drop uploads #228 +- Show icons in sidebar + ## v0.32.1 - Lock ed25519 version #230 diff --git a/data-browser/index.html b/data-browser/index.html index 9611a6a7b..0247496ce 100644 --- a/data-browser/index.html +++ b/data-browser/index.html @@ -92,11 +92,19 @@ diff --git a/data-browser/package.json b/data-browser/package.json index 9b534daa2..2b726f1ae 100644 --- a/data-browser/package.json +++ b/data-browser/package.json @@ -57,7 +57,7 @@ "url": "https://github.com/atomicdata-dev/atomic-data-browser/" }, "scripts": { - "build": "tsc && vite build", + "build": "vite build", "deploy": "gh-pages -d build", "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", "lint-fix": "eslint ./src --ext .js,.jsx,.ts,.tsx --fix", diff --git a/data-browser/public/sw.js b/data-browser/public/sw.js new file mode 100644 index 000000000..49284b905 --- /dev/null +++ b/data-browser/public/sw.js @@ -0,0 +1,3 @@ +self.addEventListener('install', () => { + // TODO: Do something. +}); diff --git a/data-browser/src/components/AllPropsSimple.tsx b/data-browser/src/components/AllPropsSimple.tsx new file mode 100644 index 000000000..b13159d91 --- /dev/null +++ b/data-browser/src/components/AllPropsSimple.tsx @@ -0,0 +1,88 @@ +import { + datatypes, + JSONValue, + properties, + Resource, + useResource, + useSubject, + useTitle, +} from '@tomic/react'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +export interface AllPropsSimpleProps { + resource: Resource; +} + +/** Renders a simple list of all properties on the resource. Will not render any link or other interactive element. */ +export function AllPropsSimple({ resource }: AllPropsSimpleProps): JSX.Element { + return ( + + ); +} + +interface RowProps { + prop: string; + val: JSONValue; +} + +function Row({ prop, val }: RowProps): JSX.Element { + const propResource = useResource(prop); + const [propName] = useTitle(propResource); + const [dataType] = useSubject(propResource, properties.datatype); + + const value = useMemo(() => { + if (dataType === datatypes.atomicUrl) { + return ; + } + + if (dataType === datatypes.resourceArray) { + return ; + } + + return <>{val as string}; + }, [val, dataType]); + + return ( + + {propName}: {value} + + ); +} + +const Key = styled.span` + font-weight: bold; +`; + +const List = styled.ul` + list-style: none; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${p => p.theme.colors.textLight}; +`; + +function ResourceArray({ val }: { val: string[] }): JSX.Element { + return ( + <> + {val.map((v, i) => ( + <> + + {i === val.length - 1 ? '' : ', '} + + ))} + + ); +} + +function Value({ val }: { val: string }): JSX.Element { + const valueResource = useResource(val); + const [valueName] = useTitle(valueResource); + + return <>{valueName}; +} diff --git a/data-browser/src/components/AtomicLink.tsx b/data-browser/src/components/AtomicLink.tsx index 2692793e9..2d620a22b 100644 --- a/data-browser/src/components/AtomicLink.tsx +++ b/data-browser/src/components/AtomicLink.tsx @@ -6,7 +6,8 @@ import { FaExternalLinkAlt } from 'react-icons/fa'; import { ErrorLook } from '../components/ErrorLook'; import { isRunningInTauri } from '../helpers/tauri'; -export interface AtomicLinkProps { +export interface AtomicLinkProps + extends React.AnchorHTMLAttributes { children?: ReactNode; /** An http URL to an Atomic Data resource, opened in this app and fetched as JSON-AD */ subject?: string; @@ -115,7 +116,6 @@ export const LinkView = styled.a` pointer-events: ${props => (props.disabled ? 'none' : 'inherit')}; svg { - margin-left: 0.3rem; font-size: 60%; } diff --git a/data-browser/src/components/ButtonGroup.tsx b/data-browser/src/components/ButtonGroup.tsx new file mode 100644 index 000000000..51beb595b --- /dev/null +++ b/data-browser/src/components/ButtonGroup.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useId, useState } from 'react'; +import styled from 'styled-components'; + +export interface ButtonGroupOption { + label: string; + icon: React.ReactNode; + value: string; + checked?: boolean; +} + +export interface ButtonGroupProps { + options: ButtonGroupOption[]; + name: string; + onChange: (value: string) => void; +} + +export function ButtonGroup({ + options, + name, + onChange, +}: ButtonGroupProps): JSX.Element { + const [selected, setSelected] = useState( + () => options.find(o => o.checked)?.value, + ); + + const handleChange = useCallback( + (checked: boolean, value: string) => { + if (checked) { + onChange(value); + setSelected(value); + } + }, + [onChange], + ); + + return ( + + {options.map(option => ( + + ))} + + ); +} + +interface ButtonGroupItemProps extends ButtonGroupOption { + onChange: (checked: boolean, value: string) => void; + name: string; +} + +function ButtonGroupItem({ + onChange, + icon, + label, + name, + value, + checked, +}: ButtonGroupItemProps): JSX.Element { + const id = useId(); + + const handleChange = (event: React.ChangeEvent) => { + onChange(event.target.checked, value); + }; + + return ( + + + + + ); +} + +const Group = styled.form` + display: flex; + height: 2rem; + gap: 0.5rem; +`; + +const Item = styled.div` + position: relative; + width: 2rem; + aspect-ratio: 1/1; +`; + +const Label = styled.label` + position: absolute; + inset: 0; + width: 100%; + aspect-ratio: 1/1; + display: flex; + align-items: center; + justify-content: center; + border-radius: ${p => p.theme.radius}; + color: ${p => p.theme.colors.textLight}; + cursor: pointer; + + transition: background-color 0.1s ease-in-out, color 0.1s ease-in-out; + + input:checked + & { + background-color: ${p => p.theme.colors.bg1}; + color: ${p => p.theme.colors.text}; + } + + :hover { + background-color: ${p => p.theme.colors.bg1}; + } +`; + +const Input = styled.input` + position: absolute; + inset: 0; + width: 100%; + aspect-ratio: 1/1; + visibility: hidden; +`; diff --git a/data-browser/src/components/EditableTitle.tsx b/data-browser/src/components/EditableTitle.tsx index 23a9cad37..d33e4377e 100644 --- a/data-browser/src/components/EditableTitle.tsx +++ b/data-browser/src/components/EditableTitle.tsx @@ -1,10 +1,4 @@ -import { - properties, - Resource, - useCanWrite, - useString, - useTitle, -} from '@tomic/react'; +import { Resource, useCanWrite, useTitle } from '@tomic/react'; import React, { useEffect, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { FaEdit } from 'react-icons/fa'; @@ -13,28 +7,26 @@ import styled, { css } from 'styled-components'; export interface EditableTitleProps { resource: Resource; /** Uses `name` by default */ - propertyURL?: string; parentRef?: React.RefObject; } +const opts = { + commit: true, + validate: false, +}; + export function EditableTitle({ resource, - propertyURL, parentRef, ...props }: EditableTitleProps): JSX.Element { - propertyURL = propertyURL || properties.name; - const [text, setText] = useString(resource, propertyURL, { - commit: true, - validate: false, - }); + const [text, setText] = useTitle(resource, Infinity, opts); const [isEditing, setIsEditing] = useState(false); const innerRef = useRef(null); const ref = parentRef || innerRef; const [canEdit] = useCanWrite(resource); - const [starndardTitle] = useTitle(resource); useHotkeys( 'enter', @@ -48,7 +40,7 @@ export function EditableTitle({ setIsEditing(true); } - const placeholder = 'set a title'; + const placeholder = canEdit ? 'set a title' : 'Untitled'; useEffect(() => { ref.current?.focus(); @@ -65,7 +57,6 @@ export function EditableTitle({ onChange={e => setText(e.target.value)} value={text || ''} onBlur={() => setIsEditing(false)} - style={{ visibility: isEditing ? 'visible' : 'hidden' }} /> ) : ( <> - {text ? text : canEdit ? placeholder : starndardTitle || 'Untitled'} + {text || placeholder} {canEdit && <Icon />} </> @@ -86,7 +76,6 @@ export function EditableTitle({ const TitleShared = css` line-height: 1.1; - width: 100%; `; interface TitleProps { @@ -98,6 +87,7 @@ const Title = styled.h1` ${TitleShared} display: flex; align-items: center; + gap: ${p => p.theme.margin}rem; justify-content: space-between; cursor: pointer; cursor: ${props => (props.canEdit ? 'pointer' : 'initial')}; @@ -129,8 +119,7 @@ const TitleInput = styled.input` const Icon = styled(FaEdit)` opacity: 0; - margin-left: auto; - + font-size: 0.8em; ${Title}:hover & { opacity: 0.5; diff --git a/data-browser/src/components/NavBarSpacer.tsx b/data-browser/src/components/NavBarSpacer.tsx new file mode 100644 index 000000000..d36664302 --- /dev/null +++ b/data-browser/src/components/NavBarSpacer.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import styled from 'styled-components'; +import { useSettings } from '../helpers/AppSettings'; + +const NAVBAR_HEIGHT = '2rem'; +const NAVBAR_CALC_PART = ` + ${NAVBAR_HEIGHT}`; + +export interface NavBarSpacerProps { + position: 'top' | 'bottom'; + baseMargin?: string; +} + +const size = (base = '0rem', withNav: boolean) => + `calc(${base}${withNav ? NAVBAR_CALC_PART : ''})`; + +/** Makes room for the navbar when it is present at the given position. Animates its height. */ +export function NavBarSpacer({ + position, + baseMargin, +}: NavBarSpacerProps): JSX.Element { + const { navbarFloating, navbarTop } = useSettings(); + + const getSize = () => { + if (position === 'top') { + return size(baseMargin, navbarTop); + } + + return size(baseMargin, !navbarFloating && !navbarTop); + }; + + return ; +} + +interface SpacingProps { + size: string; +} + +const Spacing = styled.div` + height: ${p => p.size}; + transition: height 0.2s ease-out; +`; diff --git a/data-browser/src/components/Navigation.tsx b/data-browser/src/components/Navigation.tsx index e405cd351..52b32ab0c 100644 --- a/data-browser/src/components/Navigation.tsx +++ b/data-browser/src/components/Navigation.tsx @@ -21,6 +21,7 @@ import ResourceContextMenu from './ResourceContextMenu'; import { isRunningInTauri } from '../helpers/tauri'; import { shortcuts } from './HotKeyWrapper'; import { MenuBarDropdownTrigger } from './ResourceContextMenu/MenuBarDropdownTrigger'; +import { NavBarSpacer } from './NavBarSpacer'; interface NavWrapperProps { children: React.ReactNode; @@ -46,6 +47,7 @@ export function NavWrapper({ children }: NavWrapperProps): JSX.Element { navbarTop={navbarTop} navbarFloating={navbarFloating} > + {children} @@ -62,12 +64,7 @@ interface ContentProps { const Content = styled.div` display: block; flex: 1; - margin-top: ${props => (props.navbarTop ? '2rem' : '0')}; - margin-bottom: ${props => - props.navbarTop || props.navbarFloating ? '0' : '2rem'}; overflow-y: auto; - /* For smooth navbar position adjustments */ - transition: margin 0.2s; `; /** Persistently shown navigation bar */ diff --git a/data-browser/src/components/NewInstanceButton/Base.tsx b/data-browser/src/components/NewInstanceButton/Base.tsx index d63806f0e..597e00836 100644 --- a/data-browser/src/components/NewInstanceButton/Base.tsx +++ b/data-browser/src/components/NewInstanceButton/Base.tsx @@ -1,6 +1,7 @@ import { useStore } from '@tomic/react'; import React, { useCallback } from 'react'; import toast from 'react-hot-toast'; +import { IconType } from 'react-icons'; import { FaPlus } from 'react-icons/fa'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; @@ -12,6 +13,7 @@ export interface InstanceButtonBaseProps { subtle?: boolean; title: string; icon?: boolean; + IconComponent?: IconType; label?: string; className?: string; } @@ -22,6 +24,7 @@ export function Base({ title, icon, onClick, + IconComponent, label, className, }: React.PropsWithChildren): JSX.Element { @@ -41,6 +44,8 @@ export function Base({ onClick(); }, [agent, navigate]); + const Icon = IconComponent ?? FaPlus; + return ( + + + + + ); +} diff --git a/data-browser/src/components/NewInstanceButton/NewInstanceButtonDefault.tsx b/data-browser/src/components/NewInstanceButton/NewInstanceButtonDefault.tsx index e9e2e8590..79c300cd9 100644 --- a/data-browser/src/components/NewInstanceButton/NewInstanceButtonDefault.tsx +++ b/data-browser/src/components/NewInstanceButton/NewInstanceButtonDefault.tsx @@ -9,6 +9,7 @@ export function NewInstanceButtonDefault({ klass, subtle, icon, + IconComponent, parent, children, label, @@ -23,6 +24,7 @@ export function NewInstanceButtonDefault({ JSX.Element; /** If your New Instance button requires custom logic, such as a custom dialog */ const classMap = new Map([ [classes.bookmark, NewBookmarkButton], + [classes.folder, NewFolderButton], ]); /** A button for creating a new instance of some thing */ diff --git a/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx b/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx index 1c95245d7..feb60307b 100644 --- a/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx +++ b/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx @@ -34,6 +34,7 @@ export function useDefaultNewInstanceHandler(klass: string, parent?: string) { case classes.document: { createResourceAndNavigate('documents', { [properties.isA]: [classes.document], + [properties.name]: 'Untitled Document', }); break; } diff --git a/data-browser/src/components/Parent.tsx b/data-browser/src/components/Parent.tsx index 31b2871fd..afc6d01e7 100644 --- a/data-browser/src/components/Parent.tsx +++ b/data-browser/src/components/Parent.tsx @@ -43,6 +43,7 @@ function Parent({ resource }: ParentProps): JSX.Element { } const ParentWrapper = styled.nav` + height: ${p => p.theme.heights.breadCrumbBar}; padding: 0.2rem; padding-left: 0.5rem; color: ${props => props.theme.colors.textLight2}; diff --git a/data-browser/src/components/SideBar/About.tsx b/data-browser/src/components/SideBar/About.tsx new file mode 100644 index 000000000..ba9cbe1dc --- /dev/null +++ b/data-browser/src/components/SideBar/About.tsx @@ -0,0 +1,84 @@ +import styled from 'styled-components'; +import { AtomicLink } from '../AtomicLink'; +import { Logo } from '../Logo'; +import { SideBarHeader } from './SideBarHeader'; +import React from 'react'; +import { FaGithub, FaDiscord, FaBook } from 'react-icons/fa'; + +interface AboutItem { + icon: React.ReactNode; + helper: string; + href: string; +} + +const aboutMenuItems: AboutItem[] = [ + { + icon: , + helper: 'Github; View the source code for this application', + href: 'https://github.com/atomicdata-dev/atomic-data-browser', + }, + { + icon: , + helper: 'Discord; Chat with the Atomic Data community', + href: 'https://discord.gg/a72Rv2P', + }, + { + icon: , + helper: 'Docs; Read the Atomic Data documentation', + href: 'https://docs.atomicdata.dev', + }, +]; + +export function About() { + return ( + <> + + + + + {aboutMenuItems.map(p => ( + + ))} + + + ); +} + +const AboutWrapper = styled.div` + --inner-padding: 0.5rem; + display: flex; + /* flex-direction: column; */ + align-items: center; + gap: 0.5rem; + margin-left: calc(1rem - var(--inner-padding)); +`; + +interface AboutIconProps { + href?: string; + icon: React.ReactNode; + helper: string; +} + +function AboutIcon({ icon, helper, href }: AboutIconProps) { + return ( + + {icon} + + ); +} + +const StyledAtomicLink = styled(AtomicLink)` + padding: 0.5rem; + display: grid; + place-items: center; + aspect-ratio: 1 / 1; + border-radius: ${p => p.theme.radius}; + color: ${p => p.theme.colors.textLight}; + font-size: 1.6rem; + transition: color 0.1s ease-in-out, background-color 0.1s ease-in-out; + &:hover, + &:focus { + background: ${p => p.theme.colors.bg1}; + color: ${p => p.theme.colors.text}; + } +`; diff --git a/data-browser/src/components/SideBar/DriveSwitcher.tsx b/data-browser/src/components/SideBar/DriveSwitcher.tsx index 596944fc2..9841a7c46 100644 --- a/data-browser/src/components/SideBar/DriveSwitcher.tsx +++ b/data-browser/src/components/SideBar/DriveSwitcher.tsx @@ -2,10 +2,10 @@ import { classes, Resource, urls, useResources } from '@tomic/react'; import React, { useMemo } from 'react'; import { FaCog, + FaHdd, FaPlus, FaRegCheckCircle, FaRegCircle, - FaServer, } from 'react-icons/fa'; import { useNavigate } from 'react-router-dom'; import { useSettings } from '../../helpers/AppSettings'; @@ -19,7 +19,7 @@ import { useDefaultNewInstanceHandler } from '../NewInstanceButton'; import { SideBarButton } from './ResourceSideBar/FloatingActions'; const Trigger = buildDefaultTrigger( - , + , SideBarButton, 'Open Drive Settings', ); diff --git a/data-browser/src/components/SideBar/ResourceSideBar/FloatingActions.tsx b/data-browser/src/components/SideBar/ResourceSideBar/FloatingActions.tsx index b39b3e61c..4bf5e14c3 100644 --- a/data-browser/src/components/SideBar/ResourceSideBar/FloatingActions.tsx +++ b/data-browser/src/components/SideBar/ResourceSideBar/FloatingActions.tsx @@ -1,9 +1,8 @@ import { useResource, useTitle } from '@tomic/react'; -import React, { useCallback } from 'react'; +import React from 'react'; import { FaEllipsisV, FaPlus } from 'react-icons/fa'; -import { useNavigate } from 'react-router-dom'; import styled, { css } from 'styled-components'; -import { paths } from '../../../routes/paths'; +import { useNewRoute } from '../../../helpers/useNewRoute'; import { Button } from '../../Button'; import { buildDefaultTrigger } from '../../Dropdown/DefaultTrigger'; import ResourceContextMenu from '../../ResourceContextMenu'; @@ -13,27 +12,15 @@ export interface FloatingActionsProps { className?: string; } -function buildURL(subject: string) { - const params = new URLSearchParams({ - parentSubject: subject, - }); - - return `${paths.new}?${params.toString()}`; -} - /** Contains actions for a SideBarResource, such as a context menu and a new item button */ export function FloatingActions({ subject, className, }: FloatingActionsProps): JSX.Element { - const navigate = useNavigate(); const parentResource = useResource(subject); const [parentName] = useTitle(parentResource); - const handleAddClick = useCallback(() => { - const url = buildURL(subject); - navigate(url); - }, [subject]); + const handleAddClick = useNewRoute(subject); return ( diff --git a/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx b/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx index c3bd27aa0..4ac9f5043 100644 --- a/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx +++ b/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx @@ -8,6 +8,7 @@ import { Details } from '../../Details'; import { FloatingActions, floatingHoverStyles } from './FloatingActions'; import { ErrorLook } from '../../ErrorLook'; import { LoaderInline } from '../../Loader'; +import { getIconForClass } from '../../../views/FolderPage/iconMap'; interface ResourceSideBarProps { subject: string; @@ -38,6 +39,9 @@ export function ResourceSideBar({ const [subResources] = useArray(resource, urls.properties.subResources); const hasSubResources = subResources.length > 0; + const [classType] = useString(resource, urls.properties.isA); + const Icon = getIconForClass(classType!); + const handleDetailsToggle = useCallback((state: boolean) => { setOpen(state); }, []); @@ -101,7 +105,10 @@ export function ResourceSideBar({ title={description} ref={spanRef} > - {title} + + + {title} + @@ -137,3 +144,14 @@ const Title = styled(AtomicLink)` overflow: hidden; white-space: nowrap; `; + +const TextWrapper = styled.span` + display: inline-flex; + align-items: center; + gap: 0.4rem; + + svg { + /* color: ${p => p.theme.colors.text}; */ + font-size: 0.8em; + } +`; diff --git a/data-browser/src/components/SideBar/index.tsx b/data-browser/src/components/SideBar/index.tsx index 8924a044b..d2172129c 100644 --- a/data-browser/src/components/SideBar/index.tsx +++ b/data-browser/src/components/SideBar/index.tsx @@ -3,13 +3,14 @@ import * as React from 'react'; import { useHover } from '../../helpers/useHover'; import { useSettings } from '../../helpers/AppSettings'; import { useWindowSize } from '../../helpers/useWindowSize'; -import { Logo } from '../Logo'; -import { aboutMenuItems, appMenuItems } from './menuItems'; +import { appMenuItems } from './menuItems'; import { SideBarMenuItem } from './SideBarMenuItem'; import { SideBarDrive } from './SideBarDrive'; import { SideBarHeader } from './SideBarHeader'; import { DragAreaBase, useResizable } from '../../hooks/useResizable'; import { useCombineRefs } from '../../hooks/useCombineRefs'; +import { About } from './About'; +import { NavBarSpacer } from '../NavBarSpacer'; /** Amount of pixels where the sidebar automatically shows */ export const SIDEBAR_TOGGLE_WIDTH = 600; @@ -17,7 +18,7 @@ export const SIDEBAR_TOGGLE_WIDTH = 600; const SideBarDriveMemo = React.memo(SideBarDrive); export function SideBar(): JSX.Element { - const { drive, navbarTop, sideBarLocked, setSideBarLocked } = useSettings(); + const { drive, sideBarLocked, setSideBarLocked } = useSettings(); const [ref, hoveringOverSideBar] = useHover(sideBarLocked); const windowSize = useWindowSize(); @@ -53,7 +54,7 @@ export function SideBar(): JSX.Element { locked={isWideScreen && sideBarLocked} exposed={sideBarLocked || (hoveringOverSideBar && isWideScreen)} > - {navbarTop && } + {/* The key is set to make sure the component is re-loaded when the baseURL changes */} @@ -65,18 +66,9 @@ export function SideBar(): JSX.Element { handleClickItem={closeSideBar} /> ))}{' '} - - - - {aboutMenuItems.map(p => ( - - ))} + - + ` - min-height: ${p => (p.small ? 1 : 3)}rem; -`; - // eslint-disable-next-line prettier/prettier const SideBarStyled = styled('nav').attrs(p => ({ style: { diff --git a/data-browser/src/components/SideBar/menuItems.tsx b/data-browser/src/components/SideBar/menuItems.tsx index 3e4debb98..e4fb6c552 100644 --- a/data-browser/src/components/SideBar/menuItems.tsx +++ b/data-browser/src/components/SideBar/menuItems.tsx @@ -1,38 +1,8 @@ -import { - FaCog, - FaExternalLinkAlt, - FaInfo, - FaKeyboard, - FaUser, -} from 'react-icons/fa'; +import { FaCog, FaInfo, FaKeyboard, FaUser } from 'react-icons/fa'; import React from 'react'; import { paths } from '../../routes/paths'; import { SideBarMenuItemProps } from './SideBarMenuItem'; -export const aboutMenuItems: SideBarMenuItemProps[] = [ - { - // icon: , - icon: , - label: 'github', - helper: 'View the source code for this application', - href: 'https://github.com/atomicdata-dev/atomic-data-browser', - }, - { - // icon: , - icon: , - label: 'discord', - helper: 'Chat with the Atomic Data community', - href: 'https://discord.gg/a72Rv2P', - }, - { - // icon: , - icon: , - label: 'docs', - helper: 'View the Atomic Data documentation', - href: 'https://docs.atomicdata.dev', - }, -]; - export const appMenuItems: SideBarMenuItemProps[] = [ { icon: , diff --git a/data-browser/src/components/forms/FileDropzone/FileDropzone.tsx b/data-browser/src/components/forms/FileDropzone/FileDropzone.tsx new file mode 100644 index 000000000..54a96afd8 --- /dev/null +++ b/data-browser/src/components/forms/FileDropzone/FileDropzone.tsx @@ -0,0 +1,104 @@ +import { Resource } from '@tomic/react'; +import React, { useCallback, useEffect } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { FaUpload } from 'react-icons/fa'; +import styled, { keyframes } from 'styled-components'; +import { ErrMessage } from '../InputStyles'; +import { useUpload } from './useUpload'; + +export interface FileDropZoneProps { + parentResource: Resource; + onFilesUploaded?: (files: string[]) => void; +} + +/** + * A dropzone for adding files. Renders its children by default, unless you're + * holding a file, an error occurred, or it's uploading. + */ +export function FileDropZone({ + parentResource, + children, + onFilesUploaded, +}: React.PropsWithChildren): JSX.Element { + const { upload, isUploading, error } = useUpload(parentResource); + const dropzoneRef = React.useRef(null); + const onDrop = useCallback( + async (files: File[]) => { + const uploaded = await upload(files); + onFilesUploaded?.(uploaded); + }, + [upload], + ); + + const { getRootProps, isDragActive } = useDropzone({ onDrop }); + + // Move the dropzone down if the user has scrolled down. + useEffect(() => { + if (isDragActive && dropzoneRef.current) { + const rect = dropzoneRef.current.getBoundingClientRect(); + + if (rect.top < 0) { + dropzoneRef.current.style.top = `calc(${Math.abs(rect.top)}px + 1rem)`; + } + } + }, [isDragActive]); + + return ( + + {isUploading &&

{'Uploading...'}

} + {error && {error.message}} + {children} + {isDragActive && ( + + + Drop files here to upload. + + + )} +
+ ); +} + +const Root = styled.div` + height: 100%; + position: relative; +`; + +const fadeIn = keyframes` + from { + opacity: 0; + backdrop-filter: blur(0px); + } + to { + opacity: 1; + backdrop-filter: blur(10px); + } +`; + +const VisualDropzone = styled.div` + position: absolute; + inset: 0; + height: 90vh; + background-color: ${p => + p.theme.darkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)'}; + backdrop-filter: blur(10px); + border: 3px dashed ${p => p.theme.colors.textLight}; + border-radius: ${p => p.theme.radius}; + display: grid; + place-items: center; + font-size: 1.8rem; + color: ${p => p.theme.colors.textLight}; + animation: 0.1s ${fadeIn} ease-in; +`; + +const TextWrapper = styled.div` + display: flex; + align-items: center; + gap: 1rem; + padding: ${p => p.theme.margin}rem; +`; diff --git a/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx b/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx new file mode 100644 index 000000000..0291e3184 --- /dev/null +++ b/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx @@ -0,0 +1,75 @@ +import { Resource } from '@tomic/react'; +import React, { useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { FaUpload } from 'react-icons/fa'; +import styled from 'styled-components'; +import { ErrMessage } from '../InputStyles'; +import { useUpload } from './useUpload'; + +export interface FileDropzoneInputProps { + parentResource: Resource; + onFilesUploaded?: (files: string[]) => void; +} + +/** + * A dropzone for adding files. Renders its children by default, unless you're + * holding a file, an error occurred, or it's uploading. + */ +export function FileDropzoneInput({ + parentResource, + onFilesUploaded, +}: FileDropzoneInputProps): JSX.Element { + const { upload, isUploading, error } = useUpload(parentResource); + + const onFileSelect = useCallback( + async (files: File[]) => { + const uploaded = await upload(files); + onFilesUploaded?.(uploaded); + }, + [upload], + ); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop: onFileSelect, + }); + + return ( + <> + + {error && {error.message}} + + + {' '} + {isUploading ? 'Uploading...' : 'Drop files or click here to upload.'} + + + + ); +} + +const VisualDropZone = styled.div` + background-color: ${p => + p.theme.darkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)'}; + backdrop-filter: blur(10px); + border: 2px dashed ${p => p.theme.colors.bg2}; + border-radius: ${p => p.theme.radius}; + display: grid; + place-items: center; + font-size: 1.3rem; + color: ${p => p.theme.colors.textLight}; + min-height: 10rem; + cursor: pointer; + + &:hover, + &focus { + color: ${p => p.theme.colors.main}; + border-color: ${p => p.theme.colors.main}; + } +`; + +const TextWrapper = styled.div` + display: flex; + align-items: center; + padding: ${p => p.theme.margin}rem; + gap: 1rem; +`; diff --git a/data-browser/src/components/forms/FileDropzone/useUpload.ts b/data-browser/src/components/forms/FileDropzone/useUpload.ts new file mode 100644 index 000000000..b57e7ae2e --- /dev/null +++ b/data-browser/src/components/forms/FileDropzone/useUpload.ts @@ -0,0 +1,56 @@ +import { + properties, + Resource, + uploadFiles, + useArray, + useStore, +} from '@tomic/react'; +import { useCallback, useState } from 'react'; + +export interface UseUploadResult { + /** Uploads files to the upload endpoint and returns the created subjects. */ + upload: (acceptedFiles: File[]) => Promise; + isUploading: boolean; + error: Error | undefined; +} + +export function useUpload(parentResource: Resource): UseUploadResult { + const store = useStore(); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(undefined); + const [subResources, setSubResources] = useArray( + parentResource, + properties.subResources, + ); + + const upload = useCallback( + async (acceptedFiles: File[]) => { + try { + setError(undefined); + setIsUploading(true); + const netUploaded = await uploadFiles( + acceptedFiles, + store, + parentResource.getSubject(), + ); + const allUploaded = [...netUploaded]; + setIsUploading(false); + setSubResources([...subResources, ...allUploaded]); + + return allUploaded; + } catch (e) { + setError(e); + setIsUploading(false); + + return []; + } + }, + [parentResource], + ); + + return { + upload, + isUploading, + error, + }; +} diff --git a/data-browser/src/components/forms/UploadForm.tsx b/data-browser/src/components/forms/UploadForm.tsx index 5d5e215fb..9c5064299 100644 --- a/data-browser/src/components/forms/UploadForm.tsx +++ b/data-browser/src/components/forms/UploadForm.tsx @@ -2,8 +2,6 @@ import React, { useCallback, useState } from 'react'; import { Resource, uploadFiles } from '@tomic/react'; import { useStore } from '@tomic/react'; import { useDropzone } from 'react-dropzone'; -import styled from 'styled-components'; - import { Button } from '../Button'; import FilePill from '../FilePill'; import { ErrMessage } from './InputStyles'; @@ -77,63 +75,3 @@ export default function UploadForm({ ); } - -interface UploadWrapperProps extends UploadFormProps { - children: React.ReactNode; - onFilesUploaded: (filesSubjects: string[]) => unknown; -} - -/** - * A dropzone for adding files. Renders its children by default, unless you're - * holding a file, an error occurred, or it's uploading. - */ -export function UploadWrapper({ - parentResource, - children, - onFilesUploaded, -}: UploadWrapperProps) { - const store = useStore(); - // const [uploadedFiles, setUploadedFiles] = useState([]); - const [isUploading, setIsUploading] = useState(false); - const [err, setErr] = useState(undefined); - const onDrop = useCallback( - async (acceptedFiles: File[]) => { - try { - setErr(undefined); - setIsUploading(true); - const netUploaded = await uploadFiles( - acceptedFiles, - store, - parentResource.getSubject(), - ); - const allUploaded = [...netUploaded]; - onFilesUploaded(allUploaded); - setIsUploading(false); - } catch (e) { - setErr(e); - setIsUploading(false); - } - }, - [onFilesUploaded], - ); - const { getRootProps, isDragActive } = useDropzone({ onDrop }); - - return ( -
- {isUploading &&

{'Uploading...'}

} - {err && {err.message}} - {isDragActive ? {'Drop the files here ...'} : children} -
- ); -} - -const Fill = styled.div` - height: 100%; - width: 100%; - min-height: 4rem; -`; diff --git a/data-browser/src/helpers/useNewRoute.ts b/data-browser/src/helpers/useNewRoute.ts new file mode 100644 index 000000000..39ab91f33 --- /dev/null +++ b/data-browser/src/helpers/useNewRoute.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; +import { paths } from '../routes/paths'; + +function buildURL(parent?: string) { + const params = new URLSearchParams({ + ...(parent ? { parentSubject: parent } : {}), + }); + + return `${paths.new}?${params.toString()}`; +} + +export function useNewRoute(parent?: string) { + const navigate = useNavigate(); + + const navigateToNewRoute = useCallback(() => { + const url = buildURL(parent); + navigate(url); + }, [parent]); + + return navigateToNewRoute; +} diff --git a/data-browser/src/routes/NewRoute.tsx b/data-browser/src/routes/NewRoute.tsx index 457585abe..315d2c699 100644 --- a/data-browser/src/routes/NewRoute.tsx +++ b/data-browser/src/routes/NewRoute.tsx @@ -1,9 +1,13 @@ -import { useResource, useString } from '@tomic/react'; +import { useResource, useString, useTitle } from '@tomic/react'; import { urls } from '@tomic/react'; -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useNavigate } from 'react-router'; -import { newURL, useQueryString } from '../helpers/navigation'; +import { + constructOpenURL, + newURL, + useQueryString, +} from '../helpers/navigation'; import { ContainerNarrow } from '../components/Containers'; import NewIntanceButton from '../components/NewInstanceButton'; import { ResourceSelector } from '../components/forms/ResourceSelector'; @@ -13,6 +17,9 @@ import { Row } from '../components/Row'; import { NewFormFullPage } from '../components/forms/NewForm/index'; import { ResourceInline } from '../views/ResourceInline'; import styled from 'styled-components'; +import { FileDropzoneInput } from '../components/forms/FileDropzone/FileDropzoneInput'; +import toast from 'react-hot-toast'; +import { getIconForClass } from '../views/FolderPage/iconMap'; /** Start page for instantiating a new Resource from some Class */ function New(): JSX.Element { @@ -27,6 +34,17 @@ function New(): JSX.Element { const { drive } = useSettings(); const calculatedParent = parentSubject || drive; + const parentResource = useResource(calculatedParent); + + const buttons = [ + urls.classes.folder, + urls.classes.document, + urls.classes.chatRoom, + urls.classes.bookmark, + urls.classes.class, + urls.classes.property, + urls.classes.importer, + ]; function handleClassSet(e) { if (!classInput) { @@ -39,6 +57,17 @@ function New(): JSX.Element { navigate(newURL(classInput, calculatedParent)); } + const onUploadComplete = useCallback( + (files: string[]) => { + toast.success(`Uploaded ${files.length} files.`); + + if (parentSubject) { + navigate(constructOpenURL(parentSubject)); + } + }, + [parentSubject, navigate], + ); + return ( {classSubject ? ( @@ -69,39 +98,20 @@ function New(): JSX.Element { )} {!classInput && ( <> - - - - - - + {buttons.map(classType => ( + + ))} )} + )} @@ -115,3 +125,24 @@ const StyledForm = styled.form` `; export default New; + +interface WrappedButtonProps { + classType: string; + parent: string; +} + +function WrappedButton({ classType, parent }: WrappedButtonProps): JSX.Element { + const classResource = useResource(classType); + const [label] = useTitle(classResource); + + return ( + + ); +} diff --git a/data-browser/src/styling.tsx b/data-browser/src/styling.tsx index 28ee6e6d9..f36f7dc03 100644 --- a/data-browser/src/styling.tsx +++ b/data-browser/src/styling.tsx @@ -45,6 +45,9 @@ export const zIndex = { /** Default animation duration in ms */ export const animationDuration = 100; +const breadCrumbBarHeight = '2.2rem'; +const floatingSearchBarPadding = '4.2rem'; + /** Construct a StyledComponents theme object */ export const buildTheme = (darkMode: boolean, mainIn: string): DefaultTheme => { const main = darkMode ? lighten(0.2, mainIn) : mainIn; @@ -74,6 +77,11 @@ export const buildTheme = (darkMode: boolean, mainIn: string): DefaultTheme => { sideBarWidth: 15, margin: 1, radius: '9px', + heights: { + breadCrumbBar: breadCrumbBarHeight, + floatingSearchBarPadding: floatingSearchBarPadding, + fullPage: `calc(100% - ${breadCrumbBarHeight})`, + }, colors: { main, mainLight: darkMode ? lighten(0.08)(main) : lighten(0.08)(main), @@ -121,6 +129,11 @@ declare module 'styled-components' { /** Roundness of some elements / Border radius */ radius: string; /** All theme colors */ + heights: { + breadCrumbBar: string; + fullPage: string; + floatingSearchBarPadding: string; + }; colors: { /** Main accent color, used for links */ main: string; diff --git a/data-browser/src/views/BookmarkPage/BookmarkPage.tsx b/data-browser/src/views/BookmarkPage/BookmarkPage.tsx index 3bd345660..b0d040c87 100644 --- a/data-browser/src/views/BookmarkPage/BookmarkPage.tsx +++ b/data-browser/src/views/BookmarkPage/BookmarkPage.tsx @@ -32,10 +32,7 @@ export function BookmarkPage({ resource }: ResourcePageProps): JSX.Element { <> - + @@ -106,4 +103,5 @@ const ControlBar = styled.div` const PreviewWrapper = styled.div` background-color: ${props => props.theme.colors.bg}; flex: 1; + padding-bottom: ${p => p.theme.heights.floatingSearchBarPadding}; `; diff --git a/data-browser/src/views/BookmarkPage/usePreview.ts b/data-browser/src/views/BookmarkPage/usePreview.ts index c01b1d67c..37b1390c0 100644 --- a/data-browser/src/views/BookmarkPage/usePreview.ts +++ b/data-browser/src/views/BookmarkPage/usePreview.ts @@ -53,11 +53,18 @@ const debouncedFetch = debounce( setName: AtomicSetter, setError: Setter, setLoading: Setter, + setImageUrl: AtomicSetter, + setDescription: AtomicSetter, ) => { startTransition(() => { fetchBookmarkData(url, name, store) .then(async res => { - await Promise.all([setPreview(res.preview), setName(res.name)]); + await Promise.all([ + setPreview(res.preview), + setName(res.name), + setImageUrl(res['image-url']), + setDescription(res.description), + ]); setError(undefined); setLoading(false); @@ -81,6 +88,11 @@ export function usePreview(resource: Resource): UsePreviewReturnType { const [url] = useString(resource, urls.properties.bookmark.url); const [name, setName] = useString(resource, urls.properties.name); + const [_, setImageUrl] = useString( + resource, + urls.properties.bookmark.imageUrl, + ); + const [__, setDescription] = useString(resource, urls.properties.description); const [error, setHasError] = useState(undefined); const [loading, setLoading] = useState(false); @@ -106,6 +118,8 @@ export function usePreview(resource: Resource): UsePreviewReturnType { setName, setHasError, setLoading, + setImageUrl, + setDescription, ); }, [name, resource, store], diff --git a/data-browser/src/views/Card/BookmarkCard.tsx b/data-browser/src/views/Card/BookmarkCard.tsx index 91be1371e..01c9ffa09 100644 --- a/data-browser/src/views/Card/BookmarkCard.tsx +++ b/data-browser/src/views/Card/BookmarkCard.tsx @@ -7,7 +7,7 @@ import { ExternalLink, ExternalLinkVariant, } from '../../components/ExternalLink'; -import { CardViewProps } from './ResourceCard'; +import { CardViewProps } from './CardViewProps'; export function BookmarkCard({ resource }: CardViewProps): JSX.Element { const [title] = useTitle(resource); diff --git a/data-browser/src/views/Card/CardViewProps.tsx b/data-browser/src/views/Card/CardViewProps.tsx new file mode 100644 index 000000000..98c12201b --- /dev/null +++ b/data-browser/src/views/Card/CardViewProps.tsx @@ -0,0 +1,21 @@ +import { Resource } from '@tomic/react'; + +export interface CardViewPropsBase { + /** Maximum height, only basic details are shown */ + small?: boolean; + /** Show a highlight border */ + highlight?: boolean; + /** An HTML reference */ + ref?: React.RefObject; + /** + * If you expect to render this card in the initial view (e.g. it's in the top + * of some list) + */ + initialInView?: boolean; +} + +/** The properties passed to every CardView */ +export interface CardViewProps extends CardViewPropsBase { + /** The full Resource to be displayed */ + resource: Resource; +} diff --git a/data-browser/src/views/Card/CollectionCard.tsx b/data-browser/src/views/Card/CollectionCard.tsx index 4ee842ae9..ae90881a3 100644 --- a/data-browser/src/views/Card/CollectionCard.tsx +++ b/data-browser/src/views/Card/CollectionCard.tsx @@ -5,7 +5,7 @@ import Markdown from '../../components/datatypes/Markdown'; import { AtomicLink } from '../../components/AtomicLink'; import { CardInsideFull, CardRow } from '../../components/Card'; import { ResourceInline } from '../ResourceInline'; -import { CardViewProps } from './ResourceCard'; +import { CardViewProps } from './CardViewProps'; import { Button } from '../../components/Button'; const MAX_COUNT = 5; diff --git a/data-browser/src/views/Card/FileCard.tsx b/data-browser/src/views/Card/FileCard.tsx index 2cda13c5f..0fbf59061 100644 --- a/data-browser/src/views/Card/FileCard.tsx +++ b/data-browser/src/views/Card/FileCard.tsx @@ -2,7 +2,7 @@ import { useTitle } from '@tomic/react'; import React from 'react'; import { AtomicLink } from '../../components/AtomicLink'; -import { CardViewProps } from './ResourceCard'; +import { CardViewProps } from './CardViewProps'; import { FileInner } from '../FilePage'; function FileCard({ resource }: CardViewProps): JSX.Element { diff --git a/data-browser/src/views/Card/ResourceCard.tsx b/data-browser/src/views/Card/ResourceCard.tsx index 5b6eff3d0..623934156 100644 --- a/data-browser/src/views/Card/ResourceCard.tsx +++ b/data-browser/src/views/Card/ResourceCard.tsx @@ -4,7 +4,6 @@ import { useString, useResource, useTitle, - Resource, properties, urls, } from '@tomic/react'; @@ -18,37 +17,20 @@ import FileCard from './FileCard'; import { defaultHiddenProps } from '../ResourcePageDefault'; import { MessageCard } from './MessageCard'; import { BookmarkCard } from './BookmarkCard.jsx'; +import { CardViewPropsBase } from './CardViewProps'; -interface Props extends CardPropsBase { +interface ResourceCardProps extends CardViewPropsBase { /** The subject URL - the identifier of the resource. */ subject: string; } -interface CardPropsBase { - /** Maximum height, only basic details are shown */ - small?: boolean; - /** Show a highlight border */ - highlight?: boolean; - /** An HTML reference */ - ref?: React.RefObject; - /** - * If you expect to render this card in the initial view (e.g. it's in the top - * of some list) - */ - initialInView?: boolean; -} - -/** The properties passed to every CardView */ -export interface CardViewProps extends CardPropsBase { - /** The full Resource to be displayed */ - resource: Resource; -} - /** * Renders a Resource and all its Properties in a random order. Title * (shortname) is rendered prominently at the top. */ -function ResourceCard(props: Props): JSX.Element { +function ResourceCard( + props: ResourceCardProps & JSX.IntrinsicElements['div'], +): JSX.Element { const { subject, initialInView } = props; const [isShown, setIsShown] = useState(false); // The (more expensive) ResourceCardInner is only rendered when the component has been in View @@ -64,8 +46,6 @@ function ResourceCard(props: Props): JSX.Element { }, [inView, isShown]); return ( - // eslint-disable-next-line - // @ts-ignore ref is not compatible {isShown ? ( @@ -85,7 +65,7 @@ function ResourceCard(props: Props): JSX.Element { * The expensive view logic for a default Resource. This should only be rendered * if the card is in the viewport */ -function ResourceCardInner(props: Props): JSX.Element { +function ResourceCardInner(props: ResourceCardProps): JSX.Element { const { small, subject } = props; const resource = useResource(subject); const [title] = useTitle(resource); diff --git a/data-browser/src/views/DocumentPage.tsx b/data-browser/src/views/DocumentPage.tsx index dca1b9b6f..88a1d03d6 100644 --- a/data-browser/src/views/DocumentPage.tsx +++ b/data-browser/src/views/DocumentPage.tsx @@ -33,10 +33,10 @@ import { ErrorLook } from '../components/ErrorLook'; import { ElementEdit, ElementEditPropsBase, ElementShow } from './Element'; import { Button } from '../components/Button'; import { ResourcePageProps } from './ResourcePage'; -import { UploadWrapper } from '../components/forms/UploadForm'; import toast from 'react-hot-toast'; import { shortcuts } from '../components/HotKeyWrapper'; import { EditableTitle } from '../components/EditableTitle'; +import { FileDropZone } from '../components/forms/FileDropzone/FileDropzone'; /** A full page, editable document, consisting of Elements */ export function DocumentPage({ resource }: ResourcePageProps): JSX.Element { @@ -317,7 +317,7 @@ function DocumentPageEdit({ {err?.message && {err.message}} - @@ -349,7 +349,7 @@ function DocumentPageEdit({ - + ); } @@ -449,7 +449,7 @@ const FullPageWrapper = styled.div` display: flex; flex: 1; flex-direction: column; - min-height: 100%; + min-height: ${p => p.theme.heights.fullPage}; box-sizing: border-box; `; diff --git a/data-browser/src/views/FolderPage/DisplayStyleButton.tsx b/data-browser/src/views/FolderPage/DisplayStyleButton.tsx new file mode 100644 index 000000000..27edfff31 --- /dev/null +++ b/data-browser/src/views/FolderPage/DisplayStyleButton.tsx @@ -0,0 +1,38 @@ +import { classes } from '@tomic/react'; +import React, { useMemo } from 'react'; +import { FaList, FaTh } from 'react-icons/fa'; +import { ButtonGroup } from '../../components/ButtonGroup'; + +export interface DisplayStyleButtonProps { + displayStyle: string | undefined; + onClick: (displayStyle: string) => void; +} + +const { grid, list } = classes.displayStyles; + +export function DisplayStyleButton({ + displayStyle, + onClick, +}: DisplayStyleButtonProps): JSX.Element { + const options = useMemo( + () => [ + { + icon: , + label: 'List View', + value: list, + checked: displayStyle === list, + }, + { + icon: , + label: 'Grid View', + value: grid, + checked: displayStyle === grid, + }, + ], + [displayStyle], + ); + + return ( + + ); +} diff --git a/data-browser/src/views/FolderPage/FolderDisplayStyle.ts b/data-browser/src/views/FolderPage/FolderDisplayStyle.ts new file mode 100644 index 000000000..a6feeac3c --- /dev/null +++ b/data-browser/src/views/FolderPage/FolderDisplayStyle.ts @@ -0,0 +1,7 @@ +import { Resource } from '@tomic/react'; + +export interface ViewProps { + subResources: Map; + onNewClick: () => void; + showNewButton: boolean; +} diff --git a/data-browser/src/views/FolderPage/GridItem/BasicGridItem.tsx b/data-browser/src/views/FolderPage/GridItem/BasicGridItem.tsx new file mode 100644 index 000000000..2c356cecb --- /dev/null +++ b/data-browser/src/views/FolderPage/GridItem/BasicGridItem.tsx @@ -0,0 +1,15 @@ +import { properties, useString } from '@tomic/react'; +import React from 'react'; +import { GridItemDescription, InnerWrapper } from './components'; +import { GridItemViewProps } from './GridItemViewProps'; + +/** A simple view that only renders the description */ +export function BasicGridItem({ resource }: GridItemViewProps): JSX.Element { + const [description] = useString(resource, properties.description); + + return ( + + {description} + + ); +} diff --git a/data-browser/src/views/FolderPage/GridItem/BookmarkGridItem.tsx b/data-browser/src/views/FolderPage/GridItem/BookmarkGridItem.tsx new file mode 100644 index 000000000..1152fdd3f --- /dev/null +++ b/data-browser/src/views/FolderPage/GridItem/BookmarkGridItem.tsx @@ -0,0 +1,27 @@ +import { properties, useString } from '@tomic/react'; +import React from 'react'; +import styled from 'styled-components'; +import { BasicGridItem } from './BasicGridItem'; +import { InnerWrapper } from './components'; +import { GridItemViewProps } from './GridItemViewProps'; + +export function BookmarkGridItem({ resource }: GridItemViewProps): JSX.Element { + const [imageUrl] = useString(resource, properties.bookmark.imageUrl); + + if (!imageUrl) { + return ; + } + + return ( + + + + ); +} + +const Image = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +`; diff --git a/data-browser/src/views/FolderPage/GridItem/ChatRoomGridItem.tsx b/data-browser/src/views/FolderPage/GridItem/ChatRoomGridItem.tsx new file mode 100644 index 000000000..65f58277c --- /dev/null +++ b/data-browser/src/views/FolderPage/GridItem/ChatRoomGridItem.tsx @@ -0,0 +1,98 @@ +import { + properties, + useArray, + useResource, + useString, + useSubject, + useTitle, +} from '@tomic/react'; +import React from 'react'; +import styled from 'styled-components'; +import { GridItemDescription, InnerWrapper } from './components'; +import { GridItemViewProps } from './GridItemViewProps'; + +export function ChatRoomGridItem({ resource }: GridItemViewProps): JSX.Element { + const [messages] = useArray(resource, properties.chatRoom.messages); + + return ( + + {messages.length > 0 ? ( + <> + + + + ) : ( + Empty Chat + )} + + ); +} + +type Alignment = 'left' | 'right'; + +interface LastMessageProps { + subject: string; + alignment?: Alignment; +} + +const Message = ({ subject, alignment }: LastMessageProps): JSX.Element => { + const messageResource = useResource(subject); + const [lastCommit] = useSubject( + messageResource, + properties.commit.lastCommit, + ); + const lastCommitResource = useResource(lastCommit); + const [signer] = useSubject(lastCommitResource, properties.commit.signer); + const signerResource = useResource(signer); + + const [signerName] = useTitle(signerResource); + const [text] = useString(messageResource, properties.description); + + return ( + + {signerName} + {text} + + ); +}; + +interface MessageWrapperProps { + alignment?: Alignment; +} + +const TextWrapper = styled.div` + background-color: ${p => p.theme.colors.bg}; + padding: 0.5rem; + border-radius: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${p => p.theme.colors.text}; +`; + +const MessageWrapper = styled.div` + padding-inline: ${p => p.theme.margin}rem; + width: 100%; + text-align: ${p => p.alignment ?? 'left'}; + + ${TextWrapper} { + border-bottom-left-radius: ${p => (p.alignment !== 'right' ? '0' : '15px')}; + border-bottom-right-radius: ${p => + p.alignment === 'right' ? '0' : '15px'}; + } +`; + +const CommitWrapper = styled.div` + color: ${p => p.theme.colors.textLight}; + padding-inline: 0.5rem; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const ChatWrapper = styled(InnerWrapper)` + display: flex; + flex-direction: column; + justify-content: space-evenly; +`; diff --git a/data-browser/src/views/FolderPage/GridItem/DefaultGridItem.tsx b/data-browser/src/views/FolderPage/GridItem/DefaultGridItem.tsx new file mode 100644 index 000000000..af40b142c --- /dev/null +++ b/data-browser/src/views/FolderPage/GridItem/DefaultGridItem.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import styled from 'styled-components'; +import { AllPropsSimple } from '../../../components/AllPropsSimple'; +import { GridItemViewProps } from './GridItemViewProps'; + +export function DefaultGridItem({ resource }: GridItemViewProps): JSX.Element { + return ( + + + + ); +} + +const DefaultGridWrapper = styled.div` + padding: ${p => p.theme.margin}rem; + pointer-events: none; +`; diff --git a/data-browser/src/views/FolderPage/GridItem/DocumentGridItem.tsx b/data-browser/src/views/FolderPage/GridItem/DocumentGridItem.tsx new file mode 100644 index 000000000..70996b919 --- /dev/null +++ b/data-browser/src/views/FolderPage/GridItem/DocumentGridItem.tsx @@ -0,0 +1,19 @@ +import { properties, useArray, useResource, useString } from '@tomic/react'; +import React from 'react'; +import Markdown from '../../../components/datatypes/Markdown'; +import { GridItemDescription, InnerWrapper } from './components'; +import { GridItemViewProps } from './GridItemViewProps'; + +export function DocumentGridItem({ resource }: GridItemViewProps): JSX.Element { + const [elements] = useArray(resource, properties.document.elements); + const firstElementResource = useResource(elements[0]); + const [text] = useString(firstElementResource, properties.description); + + return ( + + + + + + ); +} diff --git a/data-browser/src/views/FolderPage/GridItem/FileGridItem.tsx b/data-browser/src/views/FolderPage/GridItem/FileGridItem.tsx new file mode 100644 index 000000000..37f66f18d --- /dev/null +++ b/data-browser/src/views/FolderPage/GridItem/FileGridItem.tsx @@ -0,0 +1,42 @@ +import { properties, useString } from '@tomic/react'; +import React from 'react'; +import styled from 'styled-components'; +import { InnerWrapper } from './components'; +import { GridItemViewProps } from './GridItemViewProps'; + +const imageMimeTypes = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'image/avif', +]); + +export function FileGridItem({ resource }: GridItemViewProps): JSX.Element { + const [fileUrl] = useString(resource, properties.file.downloadUrl); + const [mimetype] = useString(resource, properties.file.mimetype); + + if (imageMimeTypes.has(mimetype!)) { + return ( + + + + ); + } + + return No preview available; +} + +const Image = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; +`; + +const TextWrapper = styled(InnerWrapper)` + display: grid; + place-items: center; + color: ${p => p.theme.colors.textLight}; +`; diff --git a/data-browser/src/views/FolderPage/GridItem/GridItemViewProps.tsx b/data-browser/src/views/FolderPage/GridItem/GridItemViewProps.tsx new file mode 100644 index 000000000..43bbb5a2f --- /dev/null +++ b/data-browser/src/views/FolderPage/GridItem/GridItemViewProps.tsx @@ -0,0 +1,5 @@ +import { Resource } from '@tomic/react'; + +export interface GridItemViewProps { + resource: Resource; +} diff --git a/data-browser/src/views/FolderPage/GridItem/ResourceGridItem.tsx b/data-browser/src/views/FolderPage/GridItem/ResourceGridItem.tsx new file mode 100644 index 000000000..0428a6ea9 --- /dev/null +++ b/data-browser/src/views/FolderPage/GridItem/ResourceGridItem.tsx @@ -0,0 +1,109 @@ +import { + classes, + properties, + useResource, + useString, + useTitle, +} from '@tomic/react'; +import React, { useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router'; +import styled from 'styled-components'; +import { constructOpenURL } from '../../../helpers/navigation'; +import { getIconForClass } from '../iconMap'; +import { BookmarkGridItem } from './BookmarkGridItem'; +import { BasicGridItem } from './BasicGridItem'; +import { GridCard, GridItemTitle, GridItemWrapper } from './components'; +import { DefaultGridItem } from './DefaultGridItem'; +import { GridItemViewProps } from './GridItemViewProps'; +import { FaFolder } from 'react-icons/fa'; +import { ChatRoomGridItem } from './ChatRoomGridItem'; +import { DocumentGridItem } from './DocumentGridItem'; +import { FileGridItem } from './FileGridItem'; + +export interface ResourceGridItemProps { + subject: string; +} + +const gridItemMap = new Map>([ + [classes.bookmark, BookmarkGridItem], + [classes.class, BasicGridItem], + [classes.property, BasicGridItem], + [classes.chatRoom, ChatRoomGridItem], + [classes.document, DocumentGridItem], + [classes.file, FileGridItem], +]); + +function getResourceRenderer( + classSubject: string, +): React.FC { + return gridItemMap.get(classSubject) ?? DefaultGridItem; +} + +export function ResourceGridItem({ + subject, +}: ResourceGridItemProps): JSX.Element { + const navigate = useNavigate(); + const resource = useResource(subject); + const [title] = useTitle(resource); + + const [classTypeSubject] = useString(resource, properties.isA); + const classType = useResource(classTypeSubject); + const [classTypeName] = useTitle(classType); + + const Icon = getIconForClass(classTypeSubject ?? ''); + + const handleClick = useCallback(() => { + navigate(constructOpenURL(subject)); + }, [subject]); + + const Resource = useMemo(() => { + return getResourceRenderer(classTypeSubject ?? ''); + }, [classTypeSubject]); + + const isFolder = classTypeSubject === classes.folder; + + return ( + + {title} + {isFolder ? ( + + ) : ( + + + + {classTypeName} + + + + )} + + ); +} + +const ClassBanner = styled.div` + display: flex; + background-color: ${p => p.theme.colors.bg}; + border-top-left-radius: ${p => p.theme.radius}; + border-top-right-radius: ${p => p.theme.radius}; + align-items: center; + gap: 0.5rem; + justify-content: center; + padding-block: var(--card-banner-padding); + color: ${p => p.theme.colors.textLight}; + + border-bottom: 1px solid ${p => p.theme.colors.bg2}; + span { + text-transform: capitalize; + } +`; + +const FolderIcon = styled(FaFolder)` + height: 100%; + width: 100%; + color: ${p => p.theme.colors.textLight}; + transition: color 0.1s ease-in-out; + + ${GridItemWrapper}:hover & { + color: ${p => p.theme.colors.main}; + } +`; diff --git a/data-browser/src/views/FolderPage/GridItem/components.tsx b/data-browser/src/views/FolderPage/GridItem/components.tsx new file mode 100644 index 000000000..9943c30b5 --- /dev/null +++ b/data-browser/src/views/FolderPage/GridItem/components.tsx @@ -0,0 +1,67 @@ +import styled from 'styled-components'; + +export const GridCard = styled.div` + grid-area: card; + background-color: ${p => p.theme.colors.bg1}; + border-radius: ${p => p.theme.radius}; + overflow: hidden; + box-shadow: var(--shadow), var(--interaction-shadow); + border: 1px solid ${p => p.theme.colors.bg2}; + transition: border 0.1s ease-in-out, box-shadow 0.1s ease-in-out; +`; + +export const GridItemWrapper = styled.a` + --shadow: 0px 0.7px 1.3px rgba(0, 0, 0, 0.06), + 0px 1.8px 3.2px rgba(0, 0, 0, 0.043), 0px 3.4px 6px rgba(0, 0, 0, 0.036), + 0px 6px 10.7px rgba(0, 0, 0, 0.03), 0px 11.3px 20.1px rgba(0, 0, 0, 0.024), + 0px 27px 48px rgba(0, 0, 0, 0.017); + --interaction-shadow: 0px 0px 0px 0px ${p => p.theme.colors.main}; + --card-banner-padding: 1rem; + --card-banner-height: calc(var(--card-banner-padding) * 2 + 1.5em); + outline: none; + text-decoration: none; + color: ${p => p.theme.colors.text1}; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 1fr 2rem; + grid-template-areas: 'card' 'title'; + width: 100%; + aspect-ratio: 1 / 1; + cursor: pointer; + gap: 1rem; + + &:hover ${GridCard}, &:focus ${GridCard} { + --interaction-shadow: 0px 0px 0px 1px ${p => p.theme.colors.main}; + border: 1px solid ${p => p.theme.colors.main}; + } + + &:hover, + &:focus { + color: ${p => p.theme.colors.main}; + } +`; + +export const GridItemTitle = styled.div` + grid-area: title; + font-size: 1rem; + text-align: center; + white-space: nowrap; + overflow-x: hidden; + text-overflow: ellipsis; + padding-inline: 0.5rem; + transition: color 0.1s ease-in-out; +`; + +export const GridItemDescription = styled.div` + font-size: 1.1rem; + color: ${p => p.theme.colors.textLight}; + margin: ${p => p.theme.margin}rem; + overflow: hidden; + height: calc(100% - ${p => p.theme.margin * 2}rem); +`; + +export const InnerWrapper = styled.div` + pointer-events: none; + width: 100%; + height: calc(100% - var(--card-banner-height)); +`; diff --git a/data-browser/src/views/FolderPage/GridView.tsx b/data-browser/src/views/FolderPage/GridView.tsx new file mode 100644 index 000000000..863d0d09c --- /dev/null +++ b/data-browser/src/views/FolderPage/GridView.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { FaPlus } from 'react-icons/fa'; +import styled from 'styled-components'; +import { ViewProps } from './FolderDisplayStyle'; +import { + GridCard, + GridItemTitle, + GridItemWrapper, +} from './GridItem/components'; +import { ResourceGridItem } from './GridItem/ResourceGridItem'; + +export function GridView({ + subResources, + onNewClick, + showNewButton, +}: ViewProps): JSX.Element { + return ( + + {Array.from(subResources.values()).map(resource => ( + + ))} + {showNewButton && ( + + + + + New Resource + + )} + + ); +} + +const Grid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + width: var(--container-width); + margin-inline: auto; + gap: 3rem; +`; + +const NewCard = styled(GridCard)` + background-color: ${p => p.theme.colors.bg1}; + border: 1px solid ${p => p.theme.colors.bg2}; + cursor: pointer; + display: grid; + place-items: center; + font-size: 3rem; + color: ${p => p.theme.colors.textLight}; + transition: color 0.1s ease-in-out, font-size 0.1s ease-out, + box-shadow 0.1s ease-in-out; + ${GridItemWrapper}:hover &, + ${GridItemWrapper}:focus & { + color: ${p => p.theme.colors.main}; + font-size: 3.8rem; + } + + :active { + font-size: 3rem; + } +`; diff --git a/data-browser/src/views/FolderPage/ListView.tsx b/data-browser/src/views/FolderPage/ListView.tsx new file mode 100644 index 000000000..00743a6a6 --- /dev/null +++ b/data-browser/src/views/FolderPage/ListView.tsx @@ -0,0 +1,189 @@ +import { + properties, + Resource, + useResource, + useString, + useTitle, +} from '@tomic/react'; +import React from 'react'; +import styled from 'styled-components'; +import { AtomicLink } from '../../components/AtomicLink'; +import { CommitDetail } from '../../components/CommitDetail'; +import { ViewProps } from './FolderDisplayStyle'; +import { getIconForClass } from './iconMap'; +import { FaPlus } from 'react-icons/fa'; +import { Button } from '../../components/Button'; + +export function ListView({ + subResources, + onNewClick, + showNewButton, +}: ViewProps): JSX.Element { + return ( + + + <> + + + + Title + + Class + Last Modified + + + + {Array.from(subResources.values()).map(resource => ( + + + + </td> + <td> + <ClassType resource={resource} /> + </td> + <AlignRight> + <LastCommit resource={resource} /> + </AlignRight> + </TableRow> + ))} + </tbody> + </> + </StyledTable> + {showNewButton && ( + <NewButton clean onClick={onNewClick}> + <span> + <FaPlus /> New Resource + </span> + </NewButton> + )} + </Wrapper> + ); +} + +interface CellProps { + resource: Resource; +} + +function Title({ resource }: CellProps): JSX.Element { + const [title] = useTitle(resource); + const [classType] = useString(resource, properties.isA); + const Icon = getIconForClass(classType ?? ''); + + return ( + <TitleWrapper> + <IconWrapper> + <Icon /> + </IconWrapper> + <AtomicLink subject={resource.getSubject()}>{title}</AtomicLink> + </TitleWrapper> + ); +} + +function LastCommit({ resource }: CellProps): JSX.Element { + const [commit] = useString(resource, properties.commit.lastCommit); + + return ( + <LinkWrapper> + <CommitDetail commitSubject={commit} /> + </LinkWrapper> + ); +} + +function ClassType({ resource }: CellProps): JSX.Element { + const [classType] = useString(resource, properties.isA); + const classTypeResource = useResource(classType); + const [title] = useTitle(classTypeResource); + + return ( + <LinkWrapper> + <AtomicLink subject={classType}>{title}</AtomicLink> + </LinkWrapper> + ); +} + +const Wrapper = styled.div` + --icon-width: 1rem; + --icon-title-spacing: 1rem; + --cell-padding: 0.4rem; + width: var(--container-width); + margin-inline: auto; +`; + +const StyledTable = styled.table` + text-align: left; + border-collapse: collapse; + width: 100%; + th { + padding-bottom: 1rem; + } + + th:last-child { + padding-right: 2rem; + } +`; + +const IconWrapper = styled.span` + width: var(--icon-width); + display: inline-flex; + align-items: center; +`; + +const TitleWrapper = styled.div` + display: flex; + align-items: center; + gap: var(--icon-title-spacing); + + svg { + color: ${p => p.theme.colors.textLight}; + } +`; + +const TitleHeaderWrapper = styled.span` + margin-inline-start: calc( + var(--icon-width) + var(--icon-title-spacing) + var(--cell-padding) + ); +`; + +const AlignRight = styled.td` + text-align: right; +`; + +const LinkWrapper = styled.span` + a { + color: ${p => p.theme.colors.textLight}; + } +`; + +const TableRow = styled.tr` + :nth-child(odd) { + td { + background-color: ${p => p.theme.colors.bg1}; + } + + td:first-child { + border-top-left-radius: ${p => p.theme.radius}; + border-bottom-left-radius: ${p => p.theme.radius}; + } + + td:last-child { + border-top-right-radius: ${p => p.theme.radius}; + border-bottom-right-radius: ${p => p.theme.radius}; + } + } + + td { + padding: var(--cell-padding); + } +`; + +const NewButton = styled(Button)` + margin-top: 1rem; + margin-inline-start: calc( + var(--icon-width) + var(--icon-title-spacing) + var(--cell-padding) + ); + > span { + display: flex; + align-items: center; + gap: 0.5rem; + } +`; diff --git a/data-browser/src/views/FolderPage/iconMap.ts b/data-browser/src/views/FolderPage/iconMap.ts new file mode 100644 index 000000000..9f78d3136 --- /dev/null +++ b/data-browser/src/views/FolderPage/iconMap.ts @@ -0,0 +1,29 @@ +import { classes } from '@tomic/react'; +import { IconType } from 'react-icons'; +import { + FaAtom, + FaBook, + FaClock, + FaComment, + FaFile, + FaFileAlt, + FaFolder, + FaHdd, +} from 'react-icons/fa'; + +const iconMap = new Map<string, IconType>([ + [classes.folder, FaFolder], + [classes.bookmark, FaBook], + [classes.chatRoom, FaComment], + [classes.document, FaFileAlt], + [classes.file, FaFile], + [classes.drive, FaHdd], + [classes.commit, FaClock], +]); + +export function getIconForClass( + classSubject: string, + fallback: IconType = FaAtom, +): IconType { + return iconMap.get(classSubject) ?? fallback; +} diff --git a/data-browser/src/views/FolderPage/index.tsx b/data-browser/src/views/FolderPage/index.tsx new file mode 100644 index 000000000..8d2ea6e9a --- /dev/null +++ b/data-browser/src/views/FolderPage/index.tsx @@ -0,0 +1,104 @@ +import { + classes, + properties, + useArray, + useCanWrite, + useResources, + useString, +} from '@tomic/react'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { EditableTitle } from '../../components/EditableTitle'; +import { FileDropZone } from '../../components/forms/FileDropzone/FileDropzone'; +import { useNewRoute } from '../../helpers/useNewRoute'; +import { ResourcePageProps } from '../ResourcePage'; +import { DisplayStyleButton } from './DisplayStyleButton'; +import { GridView } from './GridView'; +import { ListView } from './ListView'; + +const displayStyleOpts = { + commit: true, +}; + +const viewMap = new Map([ + [classes.displayStyles.list, ListView], + [classes.displayStyles.grid, GridView], +]); + +const subResourceOpts = { + commit: true, +}; + +export function FolderPage({ resource }: ResourcePageProps) { + const [subResourceSubjects] = useArray( + resource, + properties.subResources, + subResourceOpts, + ); + const [displayStyle, setDisplayStyle] = useString( + resource, + properties.displayStyle, + displayStyleOpts, + ); + + const View = useMemo( + () => viewMap.get(displayStyle!) ?? ListView, + [displayStyle], + ); + + const subResources = useResources(subResourceSubjects); + const navigateToNewRoute = useNewRoute(resource.getSubject()); + const [canEdit] = useCanWrite(resource); + + return ( + <FullPageWrapper view={displayStyle!}> + <TitleBar> + <TitleBarInner> + <EditableTitle resource={resource} /> + <DisplayStyleButton + onClick={setDisplayStyle} + displayStyle={displayStyle} + /> + </TitleBarInner> + </TitleBar> + <Wrapper> + <FileDropZone parentResource={resource}> + <View + subResources={subResources} + onNewClick={navigateToNewRoute} + showNewButton={canEdit!} + /> + </FileDropZone> + </Wrapper> + </FullPageWrapper> + ); +} + +const TitleBar = styled.div` + padding: ${p => p.theme.margin}rem; +`; + +const TitleBarInner = styled.div` + display: flex; + width: var(--container-width); + margin-inline: auto; + justify-content: space-between; +`; + +const Wrapper = styled.div` + width: 100%; + padding: ${p => p.theme.margin}rem; + flex: 1; +`; + +interface FullPageWrapperProps { + view: string; +} + +const FullPageWrapper = styled.div<FullPageWrapperProps>` + --container-width: min(1300px, 100%); + min-height: ${p => p.theme.heights.fullPage}; + padding-bottom: ${p => p.theme.heights.floatingSearchBarPadding}; + display: flex; + flex-direction: column; +`; diff --git a/data-browser/src/views/ResourcePage.tsx b/data-browser/src/views/ResourcePage.tsx index 2d8627d22..ca2a61069 100644 --- a/data-browser/src/views/ResourcePage.tsx +++ b/data-browser/src/views/ResourcePage.tsx @@ -25,6 +25,7 @@ import { BookmarkPage } from './BookmarkPage/BookmarkPage'; import { ImporterPage } from './ImporterPage.jsx'; import Parent from '../components/Parent'; import styled from 'styled-components'; +import { FolderPage } from './FolderPage'; type Props = { subject: string; @@ -100,6 +101,8 @@ function selectComponent(klass: string) { return BookmarkPage; case urls.classes.importer: return ImporterPage; + case urls.classes.folder: + return FolderPage; default: return ResourcePageDefault; } diff --git a/data-browser/tests/e2e.spec.ts b/data-browser/tests/e2e.spec.ts index 884b81032..2af80a89b 100644 --- a/data-browser/tests/e2e.spec.ts +++ b/data-browser/tests/e2e.spec.ts @@ -327,11 +327,39 @@ test.describe('data-browser', async () => { const input = page.locator('[placeholder="https\\:\\/\\/example\\.com"]'); await input.click(); await input.fill('https://example.com'); - await page.locator('footer >> text=Ok').click(); + await page.locator('dialog[open] >> footer >> text=Ok').click(); await expect(page.locator('text=This domain is ')).toBeVisible(); }); + test('folder', async ({ page }) => { + await signIn(page); + await newDrive(page); + + // Create a new folder + await newResource('folder', page); + + // Fetch `example.com + const input = page.locator('[placeholder="New Folder"]'); + await input.click(); + await input.fill('RAM Downloads'); + await page.locator('dialog[open] >> footer >> text=Ok').click(); + + await expect(page.locator('h1:text("Ram Downloads")')).toBeVisible(); + + await page.click('text=New Resource'); + await page.click('button:has-text("Document")'); + await page.locator(editableTitle).click(); + await page.keyboard.type('RAM Downloading Strategies'); + await page.keyboard.press('Enter'); + await page.click('[data-test="sidebar"] >> text=RAM Downloads'); + await expect( + page.locator( + '[data-test="folder-list"] >> text=RAM Downloading Strategies', + ), + ).toBeVisible(); + }); + test('drive switcher', async ({ page }) => { await signIn(page); await page.locator(`${currentDriveTitle} > text=localhost`); diff --git a/lib/src/urls.ts b/lib/src/urls.ts index 51b8479cf..fc4eab8a3 100644 --- a/lib/src/urls.ts +++ b/lib/src/urls.ts @@ -21,6 +21,12 @@ export const classes = { file: 'https://atomicdata.dev/classes/File', message: 'https://atomicdata.dev/classes/Message', importer: 'https://atomicdata.dev/classes/Importer', + folder: 'https://atomicdata.dev/classes/Folder', + displayStyle: 'https://atomicdata.dev/class/DisplayStyle', + displayStyles: { + grid: 'https://atomicdata.dev/display-style/grid', + list: 'https://atomicdata.dev/display-style/list', + }, }; /** List of commonly used Atomic Data Properties. */ @@ -45,6 +51,7 @@ export const properties = { shortname: 'https://atomicdata.dev/properties/shortname', subResources: 'https://atomicdata.dev/properties/subresources', write: 'https://atomicdata.dev/properties/write', + displayStyle: 'https://atomicdata.dev/property/display-style', agent: { publicKey: 'https://atomicdata.dev/properties/publicKey', }, @@ -108,6 +115,7 @@ export const properties = { bookmark: { url: 'https://atomicdata.dev/property/url', preview: 'https://atomicdata.dev/property/preview', + imageUrl: 'https://atomicdata.dev/properties/imageUrl', }, }; diff --git a/lib/src/value.ts b/lib/src/value.ts index 79ca5e519..fa873fe3c 100644 --- a/lib/src/value.ts +++ b/lib/src/value.ts @@ -63,7 +63,7 @@ export function valToNumber(val?: JSONValue): number { /** Returns a default string representation of the value. */ export function valToString(val: JSONValue): string { // val && val.toString(); - return val?.toString() || 'undefined'; + return val?.toString() ?? 'undefined'; } /** Returns either the URL of the resource, or the NestedResource itself. */ diff --git a/package.json b/package.json index 2f0168e1f..12e4e8033 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "test-query": "pnpm run --filter @tomic/data-browser test-query", "start": "pnpm run -r --parallel start", "typedoc": "typedoc . --options typedoc.json", - "typecheck": "pnpm run -r --parallel typecheck" + "typecheck": "pnpm run -r --parallel typecheck", + "playwright-install": "playwright install" }, "workspaces": { "packages": [ @@ -51,7 +52,7 @@ "data-browser" ] }, - "packageManager": "pnpm@7.11.0", + "packageManager": "pnpm@7.13.3", "dependencies": { "eslint-plugin-import": "^2.26.0" } diff --git a/pull_request_template.md b/pull_request_template.md index 6ec74e9b6..ab40dda5c 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -1,6 +1,6 @@ PR Checklist: -- [ ] Link to related issue +- [ ] Link to related issues: - [ ] Add changelog entry linking to issue -- [ ] Added tests (if needed) +- [ ] Add tests (if needed) - [ ] (If new feature) added in description / readme diff --git a/react/src/hooks.ts b/react/src/hooks.ts index f4e7933b7..6671e78bb 100644 --- a/react/src/hooks.ts +++ b/react/src/hooks.ts @@ -275,7 +275,7 @@ export function useValue( async function setAsync() { try { await resource.set(propertyURL, newVal, store, validate); - handleValidationError && handleValidationError(undefined); + handleValidationError?.(undefined); // Clone resource to force hooks to re-evaluate due to shallow comparison. store.notify(resource.clone()); } catch (e) { @@ -330,7 +330,11 @@ export function useString( ): [string | undefined, (string: string | undefined) => Promise<void>] { const [val, setVal] = useValue(resource, propertyURL, opts); - if (!val) { + if (typeof val === 'string') { + return [val, setVal]; + } + + if (val === undefined) { return [undefined, setVal]; } @@ -375,21 +379,18 @@ const titleHookOpts: useValueOptions = { export function useTitle( resource: Resource, truncateLength = 40, + opts: useValueOptions = titleHookOpts, ): [string, (string: string) => Promise<void>] { - const [name, setName] = useString( - resource, - urls.properties.name, - titleHookOpts, - ); + const [name, setName] = useString(resource, urls.properties.name, opts); const [shortname, setShortname] = useString( resource, urls.properties.shortname, - titleHookOpts, + opts, ); const [filename, setFileName] = useString( resource, urls.properties.file.filename, - titleHookOpts, + opts, ); if (resource.loading) {