Skip to content

Commit

Permalink
#842 Add filepicker button
Browse files Browse the repository at this point in the history
  • Loading branch information
Polleps authored and joepio committed Feb 27, 2024
1 parent 2f159e3 commit 57e629e
Show file tree
Hide file tree
Showing 29 changed files with 859 additions and 123 deletions.
1 change: 1 addition & 0 deletions browser/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions browser/data-browser/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ButtonProps
gutter?: boolean;
onClick?: (e: React.MouseEvent) => unknown;
className?: string;
as?: keyof HTMLElementTagNameMap;
}

interface ButtonPropsStyled {
Expand Down
21 changes: 14 additions & 7 deletions browser/data-browser/src/components/Dialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,16 +27,17 @@ export interface InternalDialogProps {
show: boolean;
onClose: (success: boolean) => void;
onClosed: () => void;
width?: CSS.Property.Width;
}

export type WrappedDialogType = React.FC<React.PropsWithChildren<unknown>>;

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`;

Expand Down Expand Up @@ -84,6 +86,7 @@ export function Dialog(props: React.PropsWithChildren<InternalDialogProps>) {
const InnerDialog: React.FC<React.PropsWithChildren<InternalDialogProps>> = ({
children,
show,
width,
onClose,
onClosed,
}) => {
Expand Down Expand Up @@ -150,7 +153,11 @@ const InnerDialog: React.FC<React.PropsWithChildren<InternalDialogProps>> = ({
}, [show, onClosed]);

return (
<StyledDialog ref={dialogRef} onMouseDown={handleOutSideClick}>
<StyledDialog
ref={dialogRef}
onMouseDown={handleOutSideClick}
$width={width}
>
<StyledInnerDialog ref={innerDialogRef}>
<PopoverContainer>
<DropdownContainer>
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions browser/data-browser/src/components/IconButton/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
};
Expand Down Expand Up @@ -78,6 +79,7 @@ IconButtonLink.defaultProps = defaultProps as IconButtonLinkProps;

interface ButtonBaseProps {
size?: string;
edgeAlign?: 'start' | 'end';
}

const IconButtonBase = styled.button<ButtonBaseProps>`
Expand All @@ -94,6 +96,11 @@ const IconButtonBase = styled.button<ButtonBaseProps>`
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;
Expand Down
16 changes: 13 additions & 3 deletions browser/data-browser/src/components/SideBar/About.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -30,9 +32,6 @@ const aboutMenuItems: AboutItem[] = [
export function About() {
return (
<>
{/* <SideBarHeader>
<Logo style={{ height: '1.1rem', maxWidth: '100%' }} />
</SideBarHeader> */}
<AboutWrapper>
{aboutMenuItems.map(({ href, icon, helper }) => (
<IconButtonLink
Expand All @@ -48,6 +47,17 @@ export function About() {
{icon}
</IconButtonLink>
))}
{isDev() && (
<IconButtonLink
href='/sandbox'
title='Sandbox, test components in isolation'
size='1.2em'
color='textLight'
variant={IconButtonVariant.Square}
>
<FaRadiation />
</IconButtonLink>
)}
</AboutWrapper>
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <TextWrapper>No preview available</TextWrapper>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <div>loading</div>;
}

return (
<ErrorBoundary FallBackComponent={ItemError}>
<ItemWrapper onClick={onClick}>
<ItemCard>
<FilePreviewThumbnail resource={resource} />
</ItemCard>
<span>{resource.title}</span>
</ItemWrapper>
</ErrorBoundary>
);
}

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<ItemErrorProps> = ({ error }) => {
return <ItemErrorWrapper>{error.message}</ItemErrorWrapper>;
};

const ItemErrorWrapper = styled.div`
color: ${p => p.theme.colors.alert};
text-align: center;
`;
119 changes: 119 additions & 0 deletions browser/data-browser/src/components/forms/FilePicker/FilePicker.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(
value,
);
const [selectedFile, setSelectedFile] = useState<File | undefined>();

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 (
<Wrapper>
<VisuallyHidden>
{value}
<input
aria-hidden
type='text'
value={value ?? ''}
required={required}
disabled={disabled}
/>
</VisuallyHidden>
{!selectedFile && !selectedSubject && (
<Button subtle onClick={() => setShow(true)} disabled={disabled}>
<FaFileCirclePlus />
Select File
</Button>
)}
{selectedSubject && (
<SelectedFileResource
disabled={disabled}
subject={selectedSubject}
onClear={() => setSelectedSubject(undefined)}
/>
)}
{selectedFile && (
<SelectedFileBlob
file={selectedFile}
disabled={disabled}
onClear={() => {
setSelectedFile(undefined);
unsubScheduledUpload?.();
}}
/>
)}
<FilePickerDialog
show={show}
onShowChange={setShow}
onResourcePicked={setSelectedSubject}
onNewFilePicked={setSelectedFile}
/>
</Wrapper>
);
}

const Wrapper = styled.div`
position: relative;
`;
Loading

0 comments on commit 57e629e

Please sign in to comment.