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/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/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..7890ad6a8 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(); @@ -77,7 +69,7 @@ export function EditableTitle({ subtle={!!canEdit && !text} > <> - {text ? text : canEdit ? placeholder : starndardTitle || 'Untitled'} + {text || placeholder} {canEdit && } @@ -86,7 +78,6 @@ export function EditableTitle({ const TitleShared = css` line-height: 1.1; - width: 100%; `; interface TitleProps { @@ -98,6 +89,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 +121,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/NewInstanceButton/NewFolderButton.tsx b/data-browser/src/components/NewInstanceButton/NewFolderButton.tsx index 205251a15..17dd9826d 100644 --- a/data-browser/src/components/NewInstanceButton/NewFolderButton.tsx +++ b/data-browser/src/components/NewInstanceButton/NewFolderButton.tsx @@ -36,7 +36,7 @@ export function NewFolderButton({ createResourceAndNavigate('Folder', { [properties.name]: name, - [properties.displayStyle]: 'list', + [properties.displayStyle]: classes.displayStyles.list, [properties.isA]: [classes.folder], }); }, diff --git a/data-browser/src/styling.tsx b/data-browser/src/styling.tsx index 7643e6ea1..f36f7dc03 100644 --- a/data-browser/src/styling.tsx +++ b/data-browser/src/styling.tsx @@ -46,6 +46,7 @@ export const zIndex = { 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 => { @@ -78,6 +79,7 @@ export const buildTheme = (darkMode: boolean, mainIn: string): DefaultTheme => { radius: '9px', heights: { breadCrumbBar: breadCrumbBarHeight, + floatingSearchBarPadding: floatingSearchBarPadding, fullPage: `calc(100% - ${breadCrumbBarHeight})`, }, colors: { @@ -130,6 +132,7 @@ declare module 'styled-components' { heights: { breadCrumbBar: string; fullPage: string; + floatingSearchBarPadding: string; }; colors: { /** Main accent color, used for links */ diff --git a/data-browser/src/views/BookmarkPage/BookmarkPage.tsx b/data-browser/src/views/BookmarkPage/BookmarkPage.tsx index 3bd345660..ff7b66f1e 100644 --- a/data-browser/src/views/BookmarkPage/BookmarkPage.tsx +++ b/data-browser/src/views/BookmarkPage/BookmarkPage.tsx @@ -106,4 +106,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/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 index 85837363b..a6feeac3c 100644 --- a/data-browser/src/views/FolderPage/FolderDisplayStyle.ts +++ b/data-browser/src/views/FolderPage/FolderDisplayStyle.ts @@ -1,11 +1,7 @@ import { Resource } from '@tomic/react'; -export enum FolderDisplayStyle { - List = 'list', - Grid = 'grid', -} - 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 index 3f4923e4d..0823ac3f3 100644 --- a/data-browser/src/views/FolderPage/ListView.tsx +++ b/data-browser/src/views/FolderPage/ListView.tsx @@ -10,11 +10,15 @@ import styled from 'styled-components'; import { AtomicLink } from '../../components/AtomicLink'; import { CommitDetail } from '../../components/CommitDetail'; import { ViewProps } from './FolderDisplayStyle'; -import { iconMap } from './iconMap'; -import { FaAtom, FaPlus } from 'react-icons/fa'; +import { getIconForClass } from './iconMap'; +import { FaPlus } from 'react-icons/fa'; import { Button } from '../../components/Button'; -export function ListView({ subResources, onNewClick }: ViewProps): JSX.Element { +export function ListView({ + subResources, + onNewClick, + showNewButton, +}: ViewProps): JSX.Element { return ( @@ -45,11 +49,13 @@ export function ListView({ subResources, onNewClick }: ViewProps): JSX.Element { - - - New Resource - - + {showNewButton && ( + + + New Resource + + + )} ); } @@ -61,7 +67,7 @@ interface CellProps { function Title({ resource }: CellProps): JSX.Element { const [title] = useTitle(resource); const [classType] = useString(resource, properties.isA); - const Icon = iconMap.get(classType!) ?? FaAtom; + const Icon = getIconForClass(classType ?? ''); return ( diff --git a/data-browser/src/views/FolderPage/iconMap.ts b/data-browser/src/views/FolderPage/iconMap.ts index 3f6cf4d1d..9f78d3136 100644 --- a/data-browser/src/views/FolderPage/iconMap.ts +++ b/data-browser/src/views/FolderPage/iconMap.ts @@ -1,11 +1,29 @@ import { classes } from '@tomic/react'; import { IconType } from 'react-icons'; -import { FaBook, FaComment, FaFile, FaFileAlt, FaFolder } from 'react-icons/fa'; +import { + FaAtom, + FaBook, + FaClock, + FaComment, + FaFile, + FaFileAlt, + FaFolder, + FaHdd, +} from 'react-icons/fa'; -export const iconMap = new Map([ +const iconMap = new Map([ [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 index c14eb0de8..fc3cb8a5e 100644 --- a/data-browser/src/views/FolderPage/index.tsx +++ b/data-browser/src/views/FolderPage/index.tsx @@ -1,40 +1,77 @@ -import { properties, useArray, useResources } from '@tomic/react'; -import React from 'react'; +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 { 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], +]); + export function FolderPage({ resource }: ResourcePageProps) { const [subResourceSubjects] = useArray(resource, properties.subResources); + 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 ( - + + - + ); } const TitleBar = styled.div` - display: flex; padding: ${p => p.theme.margin}rem; - background: ${p => p.theme.colors.bgBody}; `; const TitleBarInner = styled.div` + display: flex; width: var(--container-width); margin-inline: auto; + justify-content: space-between; `; const Wrapper = styled.div` @@ -42,8 +79,12 @@ const Wrapper = styled.div` padding: ${p => p.theme.margin}rem; `; -const FullPageWrapper = styled.div` +interface FullPageWrapperProps { + view: string; +} + +const FullPageWrapper = styled.div` --container-width: min(1300px, 100%); - background-color: ${p => p.theme.colors.bg}; min-height: ${p => p.theme.heights.fullPage}; + padding-bottom: ${p => p.theme.heights.floatingSearchBarPadding}; `; diff --git a/lib/src/urls.ts b/lib/src/urls.ts index 92c8f2680..fc4eab8a3 100644 --- a/lib/src/urls.ts +++ b/lib/src/urls.ts @@ -23,6 +23,10 @@ export const classes = { 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. */ @@ -111,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/react/src/hooks.ts b/react/src/hooks.ts index f4e7933b7..7a83a56b7 100644 --- a/react/src/hooks.ts +++ b/react/src/hooks.ts @@ -375,21 +375,18 @@ const titleHookOpts: useValueOptions = { export function useTitle( resource: Resource, truncateLength = 40, + opts: useValueOptions = titleHookOpts, ): [string, (string: string) => Promise] { - 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) {