diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index ef9c19793..c1a3ba062 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -7,6 +7,7 @@ This changelog covers all three packages, as they are (for now) updated as a who ### Atomic Browser - [#841](https://github.com/atomicdata-dev/atomic-server/issues/841) Add better inputs for `Timestamp` and `Date` datatypes. +- [#842](https://github.com/atomicdata-dev/atomic-server/issues/842) Add media picker for properties with classtype file. ## v0.37.0 diff --git a/browser/data-browser/src/components/Button.tsx b/browser/data-browser/src/components/Button.tsx index f203d0340..0fd53380b 100644 --- a/browser/data-browser/src/components/Button.tsx +++ b/browser/data-browser/src/components/Button.tsx @@ -20,6 +20,7 @@ export interface ButtonProps gutter?: boolean; onClick?: (e: React.MouseEvent) => unknown; className?: string; + as?: keyof HTMLElementTagNameMap; } interface ButtonPropsStyled { diff --git a/browser/data-browser/src/components/Dialog/index.tsx b/browser/data-browser/src/components/Dialog/index.tsx index 1cbbfbe4c..f5a35917c 100644 --- a/browser/data-browser/src/components/Dialog/index.tsx +++ b/browser/data-browser/src/components/Dialog/index.tsx @@ -9,6 +9,7 @@ import { createPortal } from 'react-dom'; import { useHotkeys } from 'react-hotkeys-hook'; import { FaTimes } from 'react-icons/fa'; import { styled, keyframes } from 'styled-components'; +import * as CSS from 'csstype'; import { effectTimeout } from '../../helpers/effectTimeout'; import { Button } from '../Button'; import { DropdownContainer } from '../Dropdown/DropdownContainer'; @@ -26,16 +27,17 @@ export interface InternalDialogProps { show: boolean; onClose: (success: boolean) => void; onClosed: () => void; + width?: CSS.Property.Width; } -export type WrappedDialogType = React.FC>; - export enum DialogSlot { Title = 'title', Content = 'content', Actions = 'actions', } +export const DIALOG_MEDIA_BREAK_POINT = '640px'; + const ANIM_MS = 80; const ANIM_SPEED = `${ANIM_MS}ms`; @@ -84,6 +86,7 @@ export function Dialog(props: React.PropsWithChildren) { const InnerDialog: React.FC> = ({ children, show, + width, onClose, onClosed, }) => { @@ -150,7 +153,11 @@ const InnerDialog: React.FC> = ({ }, [show, onClosed]); return ( - + @@ -252,7 +259,7 @@ const fadeInBackground = keyframes` } `; -const StyledDialog = styled.dialog` +const StyledDialog = styled.dialog<{ $width?: CSS.Property.Width }>` --animation-speed: 500ms; box-sizing: border-box; inset: 0px; @@ -263,8 +270,8 @@ const StyledDialog = styled.dialog` background-color: ${props => props.theme.colors.bg}; border-radius: ${props => props.theme.radius}; border: solid 1px ${props => props.theme.colors.bg2}; - max-inline-size: min(90vw, 100ch); - min-inline-size: min(90vw, 60ch); + max-inline-size: min(90vw, ${p => p.$width ?? '100ch'}); + min-inline-size: min(90vw, ${p => p.$width ?? '60ch'}); max-block-size: 100vh; height: fit-content; max-height: 90vh; @@ -310,7 +317,7 @@ const StyledDialog = styled.dialog` backdrop-filter: blur(0px); } - @media (max-width: ${props => props.theme.containerWidth}rem) { + @media (max-width: ${DIALOG_MEDIA_BREAK_POINT}) { max-inline-size: 100%; max-block-size: 100vh; } diff --git a/browser/data-browser/src/components/IconButton/IconButton.tsx b/browser/data-browser/src/components/IconButton/IconButton.tsx index 6751c93b4..6ef2aa452 100644 --- a/browser/data-browser/src/components/IconButton/IconButton.tsx +++ b/browser/data-browser/src/components/IconButton/IconButton.tsx @@ -24,6 +24,7 @@ type BaseProps = { color?: ColorProp; size?: string; title: string; + edgeAlign?: 'start' | 'end'; // eslint-disable-next-line @typescript-eslint/no-explicit-any as?: string | ComponentType; }; @@ -78,6 +79,7 @@ IconButtonLink.defaultProps = defaultProps as IconButtonLinkProps; interface ButtonBaseProps { size?: string; + edgeAlign?: 'start' | 'end'; } const IconButtonBase = styled.button` @@ -94,6 +96,11 @@ const IconButtonBase = styled.button` width: calc(${p => p.size} + var(--button-padding) * 2); height: calc(${p => p.size} + var(--button-padding) * 2); + margin-inline-start: ${p => + p.edgeAlign === 'start' ? 'calc(var(--button-padding) * -1)' : '0'}; + + margin-inline-end: ${p => + p.edgeAlign === 'end' ? 'calc(var(--button-padding) * -1)' : '0'}; &[disabled] { opacity: 0.5; cursor: not-allowed; diff --git a/browser/data-browser/src/components/SideBar/About.tsx b/browser/data-browser/src/components/SideBar/About.tsx index 482ec0de6..9da06936d 100644 --- a/browser/data-browser/src/components/SideBar/About.tsx +++ b/browser/data-browser/src/components/SideBar/About.tsx @@ -2,6 +2,8 @@ import { styled } from 'styled-components'; import { FaGithub, FaDiscord, FaBook } from 'react-icons/fa'; import { IconButtonLink, IconButtonVariant } from '../IconButton/IconButton'; +import { FaRadiation } from 'react-icons/fa6'; +import { isDev } from '../../config'; interface AboutItem { icon: React.ReactNode; @@ -30,9 +32,6 @@ const aboutMenuItems: AboutItem[] = [ export function About() { return ( <> - {/* - - */} {aboutMenuItems.map(({ href, icon, helper }) => ( ))} + {isDev() && ( + + + + )} ); diff --git a/browser/data-browser/src/views/FolderPage/GridItem/GridItemWithImage.tsx b/browser/data-browser/src/components/Thumbnail.tsx similarity index 76% rename from browser/data-browser/src/views/FolderPage/GridItem/GridItemWithImage.tsx rename to browser/data-browser/src/components/Thumbnail.tsx index d2ee53520..ddc82e8b9 100644 --- a/browser/data-browser/src/views/FolderPage/GridItem/GridItemWithImage.tsx +++ b/browser/data-browser/src/components/Thumbnail.tsx @@ -1,15 +1,12 @@ import { styled } from 'styled-components'; -import { InnerWrapper } from './components'; +import { InnerWrapper } from '../views/FolderPage/GridItem/components'; -interface GridItemWithImageProps { +interface ThumbnailProps { src: string | undefined; style?: React.CSSProperties | undefined; } -export function GridItemWithImage({ - src, - style, -}: GridItemWithImageProps): JSX.Element { +export function Thumbnail({ src, style }: ThumbnailProps): JSX.Element { if (src === undefined) { return No preview available; } diff --git a/browser/data-browser/src/components/forms/FilePicker/FIlePickerItem.tsx b/browser/data-browser/src/components/forms/FilePicker/FIlePickerItem.tsx new file mode 100644 index 000000000..0c47bb7f9 --- /dev/null +++ b/browser/data-browser/src/components/forms/FilePicker/FIlePickerItem.tsx @@ -0,0 +1,92 @@ +import { useResource } from '@tomic/react'; +import { styled } from 'styled-components'; +import { ErrorBoundary } from '../../../views/ErrorPage'; +import { FilePreviewThumbnail } from '../../../views/File/FilePreviewThumbnail'; + +interface FilePickerItemProps { + subject: string; + onClick?: () => void; +} + +export function FilePickerItem({ + subject, + onClick, +}: FilePickerItemProps): React.JSX.Element { + const resource = useResource(subject); + + if (resource.loading) { + return
loading
; + } + + return ( + + + + + + {resource.title} + + + ); +} + +const ItemCard = styled.div` + 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}; + height: 100%; + width: 100%; + touch-action: none; + pointer-events: none; + user-select: none; + transition: border 0.1s ease-in-out, box-shadow 0.1s ease-in-out; +`; + +const ItemWrapper = styled.button` + appearance: none; + text-align: start; + border: none; + padding: 0; + background-color: transparent; + --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-height: 0px; + display: flex; + gap: 0.5rem; + flex-direction: column; + align-items: center; + outline: none; + text-decoration: none; + color: ${p => p.theme.colors.text1}; + width: 100%; + aspect-ratio: 1 / 1; + cursor: pointer; + + &:hover ${ItemCard}, &:focus ${ItemCard} { + --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}; + } +`; + +interface ItemErrorProps { + error: Error; +} + +const ItemError: React.FC = ({ error }) => { + return {error.message}; +}; + +const ItemErrorWrapper = styled.div` + color: ${p => p.theme.colors.alert}; + text-align: center; +`; diff --git a/browser/data-browser/src/components/forms/FilePicker/FilePicker.tsx b/browser/data-browser/src/components/forms/FilePicker/FilePicker.tsx new file mode 100644 index 000000000..52112ee84 --- /dev/null +++ b/browser/data-browser/src/components/forms/FilePicker/FilePicker.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import { Button } from '../../Button'; +import { FilePickerDialog } from './FilePickerDialog'; +import { SelectedFileBlob, SelectedFileResource } from './SelectedFile'; +import { InputProps } from '../ResourceField'; +import { FaFileCirclePlus } from 'react-icons/fa6'; +import { StoreEvents, useStore, useSubject } from '@tomic/react'; +import { useUpload } from '../../../hooks/useUpload'; +import { VisuallyHidden } from '../../VisuallyHidden'; +import { styled } from 'styled-components'; + +/** + * Button that opens a dialog that lists all files in the drive and allows the user to upload a new file. + * Handles uploads and makes sure files are uploaded even when the parent resource is not saved yet. + */ +export function FilePicker({ + resource, + property, + disabled, + required, + commit, +}: InputProps): React.JSX.Element { + const store = useStore(); + const { upload } = useUpload(resource); + const [value, setValue] = useSubject(resource, property.subject, { + validate: false, + commit: commit, + }); + const [show, setShow] = useState(false); + const [selectedSubject, setSelectedSubject] = useState( + value, + ); + const [selectedFile, setSelectedFile] = useState(); + + const [unsubScheduledUpload, setUnsubScheduledUpload] = + useState<() => void | undefined>(); + + useEffect(() => { + if (selectedSubject) { + setValue(selectedSubject); + } else if (selectedFile) { + if (resource.new) { + // We can't upload the file yet because its parent has not saved yet so we set the value to a placeholder and then schedule an upload when the resource is saved. + setValue('https://placeholder'); + setUnsubScheduledUpload(prevUnsub => { + prevUnsub?.(); + + const thisUnsub = store.on( + StoreEvents.ResourceSaved, + async savedResource => { + if (savedResource.getSubject() === resource.getSubject()) { + thisUnsub(); + const [subject] = await upload([selectedFile]); + await setValue(subject); + resource.save(store); + } + }, + ); + + return thisUnsub; + }); + } else { + upload([selectedFile]).then(([subject]) => { + setValue(subject); + }); + } + } else { + setValue(undefined); + } + }, [selectedSubject, selectedFile]); + + return ( + + + {value} + + + {!selectedFile && !selectedSubject && ( + + )} + {selectedSubject && ( + setSelectedSubject(undefined)} + /> + )} + {selectedFile && ( + { + setSelectedFile(undefined); + unsubScheduledUpload?.(); + }} + /> + )} + + + ); +} + +const Wrapper = styled.div` + position: relative; +`; diff --git a/browser/data-browser/src/components/forms/FilePicker/FilePickerDialog.tsx b/browser/data-browser/src/components/forms/FilePicker/FilePickerDialog.tsx new file mode 100644 index 000000000..689135e0c --- /dev/null +++ b/browser/data-browser/src/components/forms/FilePicker/FilePickerDialog.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useState } from 'react'; +import { + DIALOG_MEDIA_BREAK_POINT, + Dialog, + DialogContent, + DialogTitle, + useDialog, +} from '../../Dialog'; +import { InputStyled, InputWrapper } from '../InputStyles'; +import { FaPlus, FaSearch } from 'react-icons/fa'; +import { core, server, useServerSearch } from '@tomic/react'; +import styled from 'styled-components'; +import { FilePickerItem } from './FIlePickerItem'; +import { Button } from '../../Button'; +import { Row } from '../../Row'; +import { useSettings } from '../../../helpers/AppSettings'; +import { useMediaQuery } from '../../../hooks/useMediaQuery'; + +interface FilePickerProps { + show: boolean; + onShowChange?: (show: boolean) => void; + onResourcePicked: (subject: string) => void; + onNewFilePicked: (file: File) => void; +} + +export function FilePickerDialog({ + show, + onShowChange, + onNewFilePicked, + onResourcePicked, +}: FilePickerProps): React.JSX.Element { + const { drive } = useSettings(); + const [dialogProps, showDialog, closeDialog] = useDialog({ + bindShow: onShowChange, + }); + + const isScreenSmall = useMediaQuery( + `(max-width: ${DIALOG_MEDIA_BREAK_POINT})`, + false, + ); + + const [query, setQuery] = useState(''); + + const { results } = useServerSearch(query, { + filters: { + [core.properties.isA]: server.classes.file, + }, + allowEmptyQuery: true, + parents: [drive], + }); + + const handleResourcePicked = (subject: string) => { + onResourcePicked(subject); + closeDialog(true); + }; + + const handleFileInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (file) { + onNewFilePicked(file); + closeDialog(true); + } + }; + + useEffect(() => { + if (show) { + showDialog(); + setQuery(''); + } + }, [show, showDialog]); + + return ( + + {show && ( + <> + + + + + setQuery(e.target.value)} + /> + + + + + + + + + {results.map(subject => ( + handleResourcePicked(subject)} + /> + ))} + + + )} + + ); +} + +const StyledDialogContent = styled(DialogContent)` + padding-top: 1px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: ${p => p.theme.margin * 2}rem; + height: 80dvh; +`; + +const StyledLabel = styled.label` + & div { + height: 100%; + } +`; diff --git a/browser/data-browser/src/components/forms/FilePicker/SelectedFile.tsx b/browser/data-browser/src/components/forms/FilePicker/SelectedFile.tsx new file mode 100644 index 000000000..0f67e6bd7 --- /dev/null +++ b/browser/data-browser/src/components/forms/FilePicker/SelectedFile.tsx @@ -0,0 +1,72 @@ +import { Server, useResource } from '@tomic/react'; +import { SelectedFileLayout } from './SelectedFileLayout'; +import { styled } from 'styled-components'; +import { FilePreviewThumbnail } from '../../../views/File/FilePreviewThumbnail'; +import { isImageFile } from '../../../views/File/fileTypeUtils'; + +interface SelectedFileResourceProps { + subject: string; + onClear: () => void; + disabled?: boolean; +} + +export function SelectedFileResource({ + subject, + disabled, + onClear, +}: SelectedFileResourceProps): React.JSX.Element { + const resource = useResource(subject); + + return ( + + + + ); +} + +interface SelectedFileBlobProps { + file: File; + disabled?: boolean; + onClear: () => void; +} + +export function SelectedFileBlob({ + file, + disabled, + onClear, +}: SelectedFileBlobProps): React.JSX.Element { + return ( + + {isImageFile(file.type) ? ( + {file.name} + ) : ( + File preview not available at this time + )} + + ); +} + +const Image = styled.img` + width: 100%; + height: 100%; + object-fit: cover; +`; + +const NoPreview = styled.div` + background-color: ${({ theme }) => theme.colors.bg1}; + display: grid; + padding: ${({ theme }) => theme.margin}rem; + place-items: center; + color: ${({ theme }) => theme.colors.textLight}; + text-wrap: balance; + text-align: center; +`; diff --git a/browser/data-browser/src/components/forms/FilePicker/SelectedFileLayout.tsx b/browser/data-browser/src/components/forms/FilePicker/SelectedFileLayout.tsx new file mode 100644 index 000000000..8cb428312 --- /dev/null +++ b/browser/data-browser/src/components/forms/FilePicker/SelectedFileLayout.tsx @@ -0,0 +1,62 @@ +import { PropsWithChildren } from 'react'; +import { FaTimes } from 'react-icons/fa'; +import styled from 'styled-components'; +import { IconButton } from '../../IconButton/IconButton'; +import { Row } from '../../Row'; + +interface SelectedFileLayoutProps { + title: string; + helperText?: string; + disabled?: boolean; + onClear: () => void; +} + +export function SelectedFileLayout({ + title, + helperText, + disabled, + children, + onClear, +}: PropsWithChildren): React.JSX.Element { + return ( + + + {title} + {!disabled && ( + + + + )} + + {children} + {helperText && {helperText}} + + ); +} + +const Title = styled.span` + flex: 1; +`; + +const Wrapper = styled.div` + border: 1px solid ${p => p.theme.colors.bg2}; + border-radius: ${p => p.theme.radius}; + width: min(100%, 20rem); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const PreviewWrapper = styled.div` + aspect-ratio: 1 / 1; + width: 100%; + display: grid; + overflow: hidden; + border-radius: ${p => p.theme.radius}; +`; + +const Helper = styled.p` + color: ${p => p.theme.colors.textLight}; + margin: 0; +`; diff --git a/browser/data-browser/src/components/forms/InputResourceArray.tsx b/browser/data-browser/src/components/forms/InputResourceArray.tsx index 1124d17ef..f03b754d1 100644 --- a/browser/data-browser/src/components/forms/InputResourceArray.tsx +++ b/browser/data-browser/src/components/forms/InputResourceArray.tsx @@ -98,32 +98,32 @@ export default function InputResourceArray({ ))} )} - - - - - {array.length > 1 && ( + {!props.disabled && ( + - - Clear - + - )} - + {array.length > 1 && ( + + + Clear + + + )} + + )} {!!err && {err?.message}} ); diff --git a/browser/data-browser/src/components/forms/InputStyles.tsx b/browser/data-browser/src/components/forms/InputStyles.tsx index 57942ef78..ca38404e5 100644 --- a/browser/data-browser/src/components/forms/InputStyles.tsx +++ b/browser/data-browser/src/components/forms/InputStyles.tsx @@ -22,6 +22,7 @@ export const LabelHelper = styled.label` export interface InputWrapperProps { $invalid?: boolean; + hasPrefix?: boolean; } /** A wrapper for inputs, for example when you want to add a button to some field */ @@ -33,6 +34,12 @@ export const InputWrapper = styled.div` border: solid 1px var(--border-color); border-radius: ${props => props.theme.radius}; overflow: hidden; + align-items: center; + padding-inline-start: ${p => + p.hasPrefix ? `${p.theme.margin / 2}rem` : '0'}; + & svg { + color: ${p => p.theme.colors.textLight}; + } &:hover:has(input:not(:disabled)) { border-color: ${props => props.theme.colors.main}; diff --git a/browser/data-browser/src/components/forms/InputSwitcher.tsx b/browser/data-browser/src/components/forms/InputSwitcher.tsx index 4f72b2286..0ab600f55 100644 --- a/browser/data-browser/src/components/forms/InputSwitcher.tsx +++ b/browser/data-browser/src/components/forms/InputSwitcher.tsx @@ -1,4 +1,4 @@ -import { Datatype } from '@tomic/react'; +import { Datatype, server } from '@tomic/react'; import { InputProps } from './ResourceField'; import InputString from './InputString'; @@ -10,6 +10,7 @@ import InputBoolean from './InputBoolean'; import InputSlug from './InputSlug'; import { InputTimestamp } from './InputTimestamp'; import { InputDate } from './InputDate'; +import { FilePicker } from './FilePicker/FilePicker'; /** Renders a fitting HTML input depending on the Datatype */ export default function InputSwitcher(props: InputProps): JSX.Element { @@ -35,6 +36,10 @@ export default function InputSwitcher(props: InputProps): JSX.Element { } case Datatype.ATOMIC_URL: { + if (props.property.classType === server.classes.file) { + return ; + } + return ; } diff --git a/browser/data-browser/src/components/forms/ResourceField.tsx b/browser/data-browser/src/components/forms/ResourceField.tsx index c6144860c..dd553441c 100644 --- a/browser/data-browser/src/components/forms/ResourceField.tsx +++ b/browser/data-browser/src/components/forms/ResourceField.tsx @@ -5,8 +5,9 @@ import Markdown from '../datatypes/Markdown'; import { InputWrapper, InputStyled } from './InputStyles'; import InputSwitcher from './InputSwitcher'; import { AtomicLink } from '../AtomicLink'; -import { useId, useState } from 'react'; -import { Button } from '../Button'; +import { useId } from 'react'; +import { Row } from '../Row'; +import { FaServer } from 'react-icons/fa6'; function generateErrorPropName(prop: Property): string { if (prop.error) { @@ -28,7 +29,6 @@ function ResourceField({ }: IFieldProps): JSX.Element { const id = useId(); const property = useProperty(propertyURL); - const [collapsedDynamic, setCollapsedDynamic] = useState(true); if (property === null) { return ( @@ -49,19 +49,29 @@ function ResourceField({ ? generateErrorPropName(property) : property.shortname; - if (property.isDynamic && collapsedDynamic) { + if (property.isDynamic) { return ( } label={label} - disabled={disabled} + disabled + fieldId={id} > - {'This field is calculated server-side, edits will not be saved. '} - + + + + This field is calculated server-side. + ); } @@ -101,6 +111,11 @@ const HelperTextWraper = styled.div` margin-bottom: 0rem; `; +const Extra = styled(Row)` + color: ${props => props.theme.colors.textLight}; + margin-top: 0.5rem; +`; + function HelperText({ text, link }: HelperTextProps) { return ( diff --git a/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx b/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx index c6357887d..3b87d39bc 100644 --- a/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx +++ b/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx @@ -78,7 +78,7 @@ export const ResourceSelector = memo(function ResourceSelector({ disabled={disabled} onCreateItem={handleCreateItem} > - {handleRemove && ( + {handleRemove && !disabled && ( diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx index c73d8768f..db4977ead 100644 --- a/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx @@ -177,13 +177,15 @@ export function SearchBox({ > - onChange(undefined)} - type='button' - > - - + {!disabled && ( + onChange(undefined)} + type='button' + > + + + )} )} {children} diff --git a/browser/data-browser/src/routes/EditRoute.tsx b/browser/data-browser/src/routes/EditRoute.tsx index b32c79517..4302f1209 100644 --- a/browser/data-browser/src/routes/EditRoute.tsx +++ b/browser/data-browser/src/routes/EditRoute.tsx @@ -48,6 +48,7 @@ export function Edit(): JSX.Element { diff --git a/browser/data-browser/src/views/File/FilePreview.tsx b/browser/data-browser/src/views/File/FilePreview.tsx index de2a69575..6741af824 100644 --- a/browser/data-browser/src/views/File/FilePreview.tsx +++ b/browser/data-browser/src/views/File/FilePreview.tsx @@ -6,7 +6,7 @@ import { styled } from 'styled-components'; import { TextPreview } from './TextPreview'; import { displayFileSize } from './displayFileSize'; import { Button } from '../../components/Button'; -import { isTextFile } from './isTextFile'; +import { isTextFile } from './fileTypeUtils'; import { useFilePreviewSizeLimit } from '../../hooks/useFilePreviewSizeLimit'; const PDFViewer = lazy(() => import('../../chunks/PDFViewer')); diff --git a/browser/data-browser/src/views/File/FilePreviewThumbnail.tsx b/browser/data-browser/src/views/File/FilePreviewThumbnail.tsx new file mode 100644 index 000000000..ba1d73fbe --- /dev/null +++ b/browser/data-browser/src/views/File/FilePreviewThumbnail.tsx @@ -0,0 +1,73 @@ +import { Resource } from '@tomic/react'; +import { FaTriangleExclamation } from 'react-icons/fa6'; +import { useFilePreviewSizeLimit } from '../../hooks/useFilePreviewSizeLimit'; +import { useFileImageTransitionStyles } from './useFileImageTransitionStyles'; +import { useFileInfo } from '../../hooks/useFile'; +import { Thumbnail } from '../../components/Thumbnail'; +import { isImageFile, isTextFile } from './fileTypeUtils'; +import { styled } from 'styled-components'; +import { InnerWrapper } from '../FolderPage/GridItem/components'; +import { TextPreview } from './TextPreview'; +import { ErrorBoundary } from '../ErrorPage'; +import { Row } from '../../components/Row'; + +interface FilePreviewThumbnailProps { + resource: Resource; +} + +export function FilePreviewThumbnail( + props: FilePreviewThumbnailProps, +): JSX.Element { + return ( + ( + + + + Could not display file preview. + + + )} + > + + + ); +} + +function FilePreviewThumbnailInner({ + resource, +}: FilePreviewThumbnailProps): JSX.Element { + const { downloadUrl, mimeType, bytes } = useFileInfo(resource); + const previewSizeLimit = useFilePreviewSizeLimit(); + const transitionStyles = useFileImageTransitionStyles(resource.getSubject()); + + if (bytes >= previewSizeLimit) { + return To large for preview; + } + + if (isImageFile(mimeType)) { + return ; + } + + if (isTextFile(mimeType)) { + return ; + } + + return No preview available; +} + +const TextWrapper = styled(InnerWrapper)<{ error?: boolean }>` + display: grid; + place-items: center; + color: ${p => (p.error ? p.theme.colors.alert : p.theme.colors.textLight)}; +`; + +const StyledTextPreview = styled(TextPreview)` + padding: ${p => p.theme.margin}rem; + color: ${p => p.theme.colors.textLight}; + + &:is(pre) { + padding: 0; + padding-inline: ${p => p.theme.margin}rem; + } +`; diff --git a/browser/data-browser/src/views/File/isTextFile.ts b/browser/data-browser/src/views/File/fileTypeUtils.ts similarity index 60% rename from browser/data-browser/src/views/File/isTextFile.ts rename to browser/data-browser/src/views/File/fileTypeUtils.ts index b05f9679c..9b5088ef2 100644 --- a/browser/data-browser/src/views/File/isTextFile.ts +++ b/browser/data-browser/src/views/File/fileTypeUtils.ts @@ -8,5 +8,17 @@ const supportedApplicationFormats = new Set([ 'application/x-sh', ]); +const supportedImageTypes = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'image/avif', +]); + export const isTextFile = (mimeType: string): boolean => mimeType?.startsWith('text/') || supportedApplicationFormats.has(mimeType); + +export const isImageFile = (mimeType: string): boolean => + supportedImageTypes.has(mimeType); diff --git a/browser/data-browser/src/views/FolderPage/GridItem/ArticleGridItem.tsx b/browser/data-browser/src/views/FolderPage/GridItem/ArticleGridItem.tsx index 86f6edac3..51d5bfa77 100644 --- a/browser/data-browser/src/views/FolderPage/GridItem/ArticleGridItem.tsx +++ b/browser/data-browser/src/views/FolderPage/GridItem/ArticleGridItem.tsx @@ -2,7 +2,7 @@ import { Server, useResource, useSubject } from '@tomic/react'; import { atomicArgu } from '../../../ontologies/atomic-argu'; import { GridItemViewProps } from './GridItemViewProps'; -import { GridItemWithImage } from './GridItemWithImage'; +import { Thumbnail } from '../../../components/Thumbnail'; export function ArticleGridItem({ resource }: GridItemViewProps): JSX.Element { const [coverImgSubject] = useSubject( @@ -12,5 +12,5 @@ export function ArticleGridItem({ resource }: GridItemViewProps): JSX.Element { const coverImg = useResource(coverImgSubject); - return ; + return ; } diff --git a/browser/data-browser/src/views/FolderPage/GridItem/FileGridItem.tsx b/browser/data-browser/src/views/FolderPage/GridItem/FileGridItem.tsx deleted file mode 100644 index 881d00fa5..000000000 --- a/browser/data-browser/src/views/FolderPage/GridItem/FileGridItem.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { styled } from 'styled-components'; -import { useFileInfo } from '../../../hooks/useFile'; -import { useFilePreviewSizeLimit } from '../../../hooks/useFilePreviewSizeLimit'; -import { isTextFile } from '../../File/isTextFile'; -import { TextPreview } from '../../File/TextPreview'; -import { InnerWrapper } from './components'; -import { GridItemViewProps } from './GridItemViewProps'; -import { useFileImageTransitionStyles } from '../../File/useFileImageTransitionStyles'; -import { GridItemWithImage } from './GridItemWithImage'; - -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 { downloadUrl, mimeType, bytes } = useFileInfo(resource); - const previewSizeLimit = useFilePreviewSizeLimit(); - const transitionStyles = useFileImageTransitionStyles(resource.getSubject()); - - if (bytes >= previewSizeLimit) { - return To large for preview; - } - - if (imageMimeTypes.has(mimeType)) { - return ; - } - - if (isTextFile(mimeType)) { - return ; - } - - return No preview available; -} - -const TextWrapper = styled(InnerWrapper)` - display: grid; - place-items: center; - color: ${p => p.theme.colors.textLight}; -`; - -const StyledTextPreview = styled(TextPreview)` - padding: ${p => p.theme.margin}rem; - color: ${p => p.theme.colors.textLight}; - - &:is(pre) { - padding: 0; - padding-inline: ${p => p.theme.margin}rem; - } -`; diff --git a/browser/data-browser/src/views/FolderPage/GridItem/ResourceGridItem.tsx b/browser/data-browser/src/views/FolderPage/GridItem/ResourceGridItem.tsx index 38afe87fa..09d3cb256 100644 --- a/browser/data-browser/src/views/FolderPage/GridItem/ResourceGridItem.tsx +++ b/browser/data-browser/src/views/FolderPage/GridItem/ResourceGridItem.tsx @@ -18,11 +18,11 @@ import { GridItemViewProps } from './GridItemViewProps'; import { FaFolder } from 'react-icons/fa'; import { ChatRoomGridItem } from './ChatRoomGridItem'; import { DocumentGridItem } from './DocumentGridItem'; -import { FileGridItem } from './FileGridItem'; import { ErrorBoundary } from '../../ErrorPage'; import { useNavigateWithTransition } from '../../../hooks/useNavigateWithTransition'; import { LoaderBlock } from '../../../components/Loader'; import { ArticleGridItem } from './ArticleGridItem'; +import { FilePreviewThumbnail } from '../../File/FilePreviewThumbnail'; export interface ResourceGridItemProps { subject: string; @@ -34,7 +34,7 @@ const gridItemMap = new Map>([ [core.classes.property, BasicGridItem], [dataBrowser.classes.chatroom, ChatRoomGridItem], [dataBrowser.classes.document, DocumentGridItem], - [server.classes.file, FileGridItem], + [server.classes.file, FilePreviewThumbnail], [dataBrowser.classes.article, ArticleGridItem], ]); diff --git a/browser/e2e/tests/e2e.spec.ts b/browser/e2e/tests/e2e.spec.ts index a6796685e..a862b1335 100644 --- a/browser/e2e/tests/e2e.spec.ts +++ b/browser/e2e/tests/e2e.spec.ts @@ -343,7 +343,7 @@ test.describe('data-browser', async () => { // Cleanup drives for signed in user await openAgentPage(page); await page.click('text=Edit profile'); - await page.click('[data-test="input-drives-clear"]'); + await page.getByTestId('input-drives-clear').click(); await page.click('[data-test="save"]'); }); diff --git a/browser/e2e/tests/filePicker.spec.ts b/browser/e2e/tests/filePicker.spec.ts new file mode 100644 index 000000000..5b70793df --- /dev/null +++ b/browser/e2e/tests/filePicker.spec.ts @@ -0,0 +1,169 @@ +import { test, expect, Page } from '@playwright/test'; + +import { + FRONTEND_URL, + REBUILD_INDEX_TIME, + before, + currentDialog, + fillSearchBox, + newDrive, + newResource, + sideBarNewResource, + signIn, + testFilePath, + waitForCommit, +} from './test-utils'; + +const ONTOLOGY_NAME = 'filepicker-test'; + +const uploadFile = async (page: Page, fileName: string) => { + await page.locator(sideBarNewResource).click(); + await expect(page).toHaveURL(`${FRONTEND_URL}/app/new`); + + const fileChooserPromise = page.waitForEvent('filechooser'); + + await page + .getByRole('button', { name: 'Drop files or click here to upload.' }) + .click(); + + const fileChooser = await fileChooserPromise; + + fileChooser.setFiles(testFilePath(fileName)); + + await expect(page.getByText(fileName)).toHaveCount(2); +}; + +// Creates an ontology with a class we can use to test the file picker. +const createModel = async (page: Page) => { + await newResource('ontology', page); + + await page.getByPlaceholder('my-ontology').fill(ONTOLOGY_NAME); + await currentDialog(page).getByRole('button', { name: 'Create' }).click(); + + await expect(page.locator(`h1:has-text("${ONTOLOGY_NAME}")`)).toBeVisible(); + + page.getByRole('button', { name: 'Edit', exact: true }).click(); + + await page.getByRole('button', { name: 'Add class', exact: true }).click(); + await page.getByPlaceholder('shortname').fill('robot'); + await page.getByRole('button', { name: 'Save' }).click(); + + await expect(page.locator('input[value="robot"]')).toBeVisible(); + + await page.getByRole('button', { name: 'add required property' }).click(); + await page + .getByPlaceholder('Search for a property or enter a URL') + .type('programming'); + + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + + await page.getByRole('button', { name: 'Configure programming' }).click(); + + await currentDialog(page) + .getByLabel('Datatype') + .selectOption('https://atomicdata.dev/datatypes/atomicURL'); + + await expect(currentDialog(page).getByLabel('Classtype')).not.toBeDisabled(); + + await fillSearchBox( + currentDialog(page), + 'Search for a class', + 'https://atomicdata.dev/classes/File', + { label: 'Classtype' }, + ); + + const commitPromise = waitForCommit(page); + await page.keyboard.press('Enter'); + await commitPromise; + await expect(currentDialog(page).getByLabel('Classtype')).toHaveText('file'); + + await currentDialog(page).getByRole('button', { name: 'close' }).click(); +}; + +test.describe('File Picker', () => { + test.beforeEach(before); + + test('select file and upload using the filepicker', async ({ page }) => { + await signIn(page); + await newDrive(page); + + await uploadFile(page, 'testFile1.txt'); + await uploadFile(page, 'testFile2.md'); + + await createModel(page); + + // The new resource page relies on the search API to show ontology class buttons. If the prossess of creating the ontology took less than 5 seconds it will not appear on the new resource page. + await page.waitForTimeout(REBUILD_INDEX_TIME); + + { + // Test selecting an existing file. + await newResource('robot', page); + + await expect( + page.getByRole('heading', { name: 'new robot' }), + ).toBeVisible(); + + await expect( + page.getByRole('button', { name: 'Select File' }), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Select File' }).click(); + + const filepicker = currentDialog(page); + await expect(filepicker.getByPlaceholder('Search...')).toBeVisible(); + await expect( + filepicker.getByText('Contents of test file 1'), + ).toBeVisible(); + await expect(filepicker.getByText('testFile2.md')).toBeVisible(); + + await filepicker.getByPlaceholder('Search...').fill('.md'); + + await expect( + filepicker.getByText('Contents of test file 1'), + ).not.toBeVisible(); + + await filepicker.getByRole('button', { name: 'testFile2.md' }).click(); + + await expect(filepicker).not.toBeVisible(); + await expect( + page.getByText('first step in understanding recursion?'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Resource Saved')).toBeVisible(); + } + + { + // Test uploading a new file. + await newResource('robot', page); + + await expect( + page.getByRole('heading', { name: 'new robot' }), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Select File' }).click(); + + const filepicker = currentDialog(page); + await expect(filepicker.getByPlaceholder('Search...')).toBeVisible(); + + await filepicker + .getByLabel('Upload') + .setInputFiles(testFilePath('testFile3.txt')); + + await expect(filepicker).not.toBeVisible(); + await expect( + page.getByText('File preview not available at this time'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Save' }).click(); + + await expect(page.getByText('Resource Saved')).toBeVisible(); + await expect(page.getByText('testFile3.txt').nth(1)).toBeVisible(); + await page.getByText('testFile3.txt').nth(1).click(); + + // For some reason playwright will only find text with quotes in them when using a regex instead of string. + await expect(page.getByText(/It's a secret to everybody/)).toBeVisible(); + } + }); +}); diff --git a/browser/e2e/tests/test-utils.ts b/browser/e2e/tests/test-utils.ts index bbb17b965..f0502fa4a 100644 --- a/browser/e2e/tests/test-utils.ts +++ b/browser/e2e/tests/test-utils.ts @@ -10,17 +10,19 @@ export const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; export const INITIAL_TEST = false; export const DEMO_INVITE_NAME = 'document demo'; -export const demoFile = () => { +export const testFilePath = (filename: string) => { const processPath = process.cwd(); // In the CI, the tests dir is missing for some reason? if (processPath.endsWith('tests')) { - return `${processPath}/${DEMO_FILENAME}`; + return `${processPath}/${filename}`; } else { - return `${processPath}/tests/${DEMO_FILENAME}`; + return `${processPath}/tests/${filename}`; } }; +export const demoFile = () => testFilePath(DEMO_FILENAME); + export const timestamp = () => new Date().toLocaleTimeString(); export const editableTitle = '[data-test="editable-title"]'; export const sideBarDriveSwitcher = '[title="Open Drive Settings"]'; @@ -203,21 +205,25 @@ export async function editProfileAndCommit(page: Page) { } export async function fillSearchBox( - page: Page, + page: Page | Locator, placeholder: string, fillText: string, options: { nth?: number; container?: Locator; + label?: string; } = {}, ) { - const { nth, container } = options; + const { nth, container, label } = options; const selector = container ?? page; if (nth !== undefined) { - await selector.getByRole('button', { name: placeholder }).nth(nth).click(); + await selector + .getByRole('button', { name: label ?? placeholder }) + .nth(nth) + .click(); } else { - await selector.getByRole('button', { name: placeholder }).click(); + await selector.getByRole('button', { name: label ?? placeholder }).click(); } await selector.getByPlaceholder(placeholder).type(fillText); @@ -295,7 +301,7 @@ export async function clickSidebarItem(text: string, page: Page) { export async function fillInput( propertyShortname: string, - page: Page, + page: Page | Locator, value?: string, ) { let locator = `[data-test="input-${propertyShortname}"]`; @@ -305,7 +311,7 @@ export async function fillInput( } await page.click(locator); - await page.fill(locator, value || `test-${propertyShortname}`); + await page.locator(locator).fill(value || `test-${propertyShortname}`); } /** Click an item from the main, visible context menu */ diff --git a/browser/e2e/tests/testFile1.txt b/browser/e2e/tests/testFile1.txt new file mode 100644 index 000000000..632d79d24 --- /dev/null +++ b/browser/e2e/tests/testFile1.txt @@ -0,0 +1 @@ +Contents of test file 1 diff --git a/browser/e2e/tests/testFile2.md b/browser/e2e/tests/testFile2.md new file mode 100644 index 000000000..5b446eb4f --- /dev/null +++ b/browser/e2e/tests/testFile2.md @@ -0,0 +1,3 @@ +# What’s the first step in understanding recursion? + +To understand recursion, you must first understand recursion. diff --git a/browser/e2e/tests/testFile3.txt b/browser/e2e/tests/testFile3.txt new file mode 100644 index 000000000..92743fb95 --- /dev/null +++ b/browser/e2e/tests/testFile3.txt @@ -0,0 +1 @@ +It's a secret to everybody.