Skip to content

Commit 57e629e

Browse files
Pollepsjoepio
authored andcommitted
#842 Add filepicker button
1 parent 2f159e3 commit 57e629e

29 files changed

+859
-123
lines changed

browser/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This changelog covers all three packages, as they are (for now) updated as a who
77
### Atomic Browser
88

99
- [#841](https://github.com/atomicdata-dev/atomic-server/issues/841) Add better inputs for `Timestamp` and `Date` datatypes.
10+
- [#842](https://github.com/atomicdata-dev/atomic-server/issues/842) Add media picker for properties with classtype file.
1011

1112
## v0.37.0
1213

browser/data-browser/src/components/Button.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface ButtonProps
2020
gutter?: boolean;
2121
onClick?: (e: React.MouseEvent) => unknown;
2222
className?: string;
23+
as?: keyof HTMLElementTagNameMap;
2324
}
2425

2526
interface ButtonPropsStyled {

browser/data-browser/src/components/Dialog/index.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createPortal } from 'react-dom';
99
import { useHotkeys } from 'react-hotkeys-hook';
1010
import { FaTimes } from 'react-icons/fa';
1111
import { styled, keyframes } from 'styled-components';
12+
import * as CSS from 'csstype';
1213
import { effectTimeout } from '../../helpers/effectTimeout';
1314
import { Button } from '../Button';
1415
import { DropdownContainer } from '../Dropdown/DropdownContainer';
@@ -26,16 +27,17 @@ export interface InternalDialogProps {
2627
show: boolean;
2728
onClose: (success: boolean) => void;
2829
onClosed: () => void;
30+
width?: CSS.Property.Width;
2931
}
3032

31-
export type WrappedDialogType = React.FC<React.PropsWithChildren<unknown>>;
32-
3333
export enum DialogSlot {
3434
Title = 'title',
3535
Content = 'content',
3636
Actions = 'actions',
3737
}
3838

39+
export const DIALOG_MEDIA_BREAK_POINT = '640px';
40+
3941
const ANIM_MS = 80;
4042
const ANIM_SPEED = `${ANIM_MS}ms`;
4143

@@ -84,6 +86,7 @@ export function Dialog(props: React.PropsWithChildren<InternalDialogProps>) {
8486
const InnerDialog: React.FC<React.PropsWithChildren<InternalDialogProps>> = ({
8587
children,
8688
show,
89+
width,
8790
onClose,
8891
onClosed,
8992
}) => {
@@ -150,7 +153,11 @@ const InnerDialog: React.FC<React.PropsWithChildren<InternalDialogProps>> = ({
150153
}, [show, onClosed]);
151154

152155
return (
153-
<StyledDialog ref={dialogRef} onMouseDown={handleOutSideClick}>
156+
<StyledDialog
157+
ref={dialogRef}
158+
onMouseDown={handleOutSideClick}
159+
$width={width}
160+
>
154161
<StyledInnerDialog ref={innerDialogRef}>
155162
<PopoverContainer>
156163
<DropdownContainer>
@@ -252,7 +259,7 @@ const fadeInBackground = keyframes`
252259
}
253260
`;
254261

255-
const StyledDialog = styled.dialog`
262+
const StyledDialog = styled.dialog<{ $width?: CSS.Property.Width }>`
256263
--animation-speed: 500ms;
257264
box-sizing: border-box;
258265
inset: 0px;
@@ -263,8 +270,8 @@ const StyledDialog = styled.dialog`
263270
background-color: ${props => props.theme.colors.bg};
264271
border-radius: ${props => props.theme.radius};
265272
border: solid 1px ${props => props.theme.colors.bg2};
266-
max-inline-size: min(90vw, 100ch);
267-
min-inline-size: min(90vw, 60ch);
273+
max-inline-size: min(90vw, ${p => p.$width ?? '100ch'});
274+
min-inline-size: min(90vw, ${p => p.$width ?? '60ch'});
268275
max-block-size: 100vh;
269276
height: fit-content;
270277
max-height: 90vh;
@@ -310,7 +317,7 @@ const StyledDialog = styled.dialog`
310317
backdrop-filter: blur(0px);
311318
}
312319
313-
@media (max-width: ${props => props.theme.containerWidth}rem) {
320+
@media (max-width: ${DIALOG_MEDIA_BREAK_POINT}) {
314321
max-inline-size: 100%;
315322
max-block-size: 100vh;
316323
}

browser/data-browser/src/components/IconButton/IconButton.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type BaseProps = {
2424
color?: ColorProp;
2525
size?: string;
2626
title: string;
27+
edgeAlign?: 'start' | 'end';
2728
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2829
as?: string | ComponentType<any>;
2930
};
@@ -78,6 +79,7 @@ IconButtonLink.defaultProps = defaultProps as IconButtonLinkProps;
7879

7980
interface ButtonBaseProps {
8081
size?: string;
82+
edgeAlign?: 'start' | 'end';
8183
}
8284

8385
const IconButtonBase = styled.button<ButtonBaseProps>`
@@ -94,6 +96,11 @@ const IconButtonBase = styled.button<ButtonBaseProps>`
9496
width: calc(${p => p.size} + var(--button-padding) * 2);
9597
height: calc(${p => p.size} + var(--button-padding) * 2);
9698
99+
margin-inline-start: ${p =>
100+
p.edgeAlign === 'start' ? 'calc(var(--button-padding) * -1)' : '0'};
101+
102+
margin-inline-end: ${p =>
103+
p.edgeAlign === 'end' ? 'calc(var(--button-padding) * -1)' : '0'};
97104
&[disabled] {
98105
opacity: 0.5;
99106
cursor: not-allowed;

browser/data-browser/src/components/SideBar/About.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { styled } from 'styled-components';
22

33
import { FaGithub, FaDiscord, FaBook } from 'react-icons/fa';
44
import { IconButtonLink, IconButtonVariant } from '../IconButton/IconButton';
5+
import { FaRadiation } from 'react-icons/fa6';
6+
import { isDev } from '../../config';
57

68
interface AboutItem {
79
icon: React.ReactNode;
@@ -30,9 +32,6 @@ const aboutMenuItems: AboutItem[] = [
3032
export function About() {
3133
return (
3234
<>
33-
{/* <SideBarHeader>
34-
<Logo style={{ height: '1.1rem', maxWidth: '100%' }} />
35-
</SideBarHeader> */}
3635
<AboutWrapper>
3736
{aboutMenuItems.map(({ href, icon, helper }) => (
3837
<IconButtonLink
@@ -48,6 +47,17 @@ export function About() {
4847
{icon}
4948
</IconButtonLink>
5049
))}
50+
{isDev() && (
51+
<IconButtonLink
52+
href='/sandbox'
53+
title='Sandbox, test components in isolation'
54+
size='1.2em'
55+
color='textLight'
56+
variant={IconButtonVariant.Square}
57+
>
58+
<FaRadiation />
59+
</IconButtonLink>
60+
)}
5161
</AboutWrapper>
5262
</>
5363
);

browser/data-browser/src/views/FolderPage/GridItem/GridItemWithImage.tsx renamed to browser/data-browser/src/components/Thumbnail.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import { styled } from 'styled-components';
2-
import { InnerWrapper } from './components';
2+
import { InnerWrapper } from '../views/FolderPage/GridItem/components';
33

4-
interface GridItemWithImageProps {
4+
interface ThumbnailProps {
55
src: string | undefined;
66
style?: React.CSSProperties | undefined;
77
}
88

9-
export function GridItemWithImage({
10-
src,
11-
style,
12-
}: GridItemWithImageProps): JSX.Element {
9+
export function Thumbnail({ src, style }: ThumbnailProps): JSX.Element {
1310
if (src === undefined) {
1411
return <TextWrapper>No preview available</TextWrapper>;
1512
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { useResource } from '@tomic/react';
2+
import { styled } from 'styled-components';
3+
import { ErrorBoundary } from '../../../views/ErrorPage';
4+
import { FilePreviewThumbnail } from '../../../views/File/FilePreviewThumbnail';
5+
6+
interface FilePickerItemProps {
7+
subject: string;
8+
onClick?: () => void;
9+
}
10+
11+
export function FilePickerItem({
12+
subject,
13+
onClick,
14+
}: FilePickerItemProps): React.JSX.Element {
15+
const resource = useResource(subject);
16+
17+
if (resource.loading) {
18+
return <div>loading</div>;
19+
}
20+
21+
return (
22+
<ErrorBoundary FallBackComponent={ItemError}>
23+
<ItemWrapper onClick={onClick}>
24+
<ItemCard>
25+
<FilePreviewThumbnail resource={resource} />
26+
</ItemCard>
27+
<span>{resource.title}</span>
28+
</ItemWrapper>
29+
</ErrorBoundary>
30+
);
31+
}
32+
33+
const ItemCard = styled.div`
34+
background-color: ${p => p.theme.colors.bg1};
35+
border-radius: ${p => p.theme.radius};
36+
overflow: hidden;
37+
box-shadow: var(--shadow), var(--interaction-shadow);
38+
border: 1px solid ${p => p.theme.colors.bg2};
39+
height: 100%;
40+
width: 100%;
41+
touch-action: none;
42+
pointer-events: none;
43+
user-select: none;
44+
transition: border 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
45+
`;
46+
47+
const ItemWrapper = styled.button`
48+
appearance: none;
49+
text-align: start;
50+
border: none;
51+
padding: 0;
52+
background-color: transparent;
53+
--shadow: 0px 0.7px 1.3px rgba(0, 0, 0, 0.06),
54+
0px 1.8px 3.2px rgba(0, 0, 0, 0.043), 0px 3.4px 6px rgba(0, 0, 0, 0.036),
55+
0px 6px 10.7px rgba(0, 0, 0, 0.03), 0px 11.3px 20.1px rgba(0, 0, 0, 0.024),
56+
0px 27px 48px rgba(0, 0, 0, 0.017);
57+
--interaction-shadow: 0px 0px 0px 0px ${p => p.theme.colors.main};
58+
--card-banner-height: 0px;
59+
display: flex;
60+
gap: 0.5rem;
61+
flex-direction: column;
62+
align-items: center;
63+
outline: none;
64+
text-decoration: none;
65+
color: ${p => p.theme.colors.text1};
66+
width: 100%;
67+
aspect-ratio: 1 / 1;
68+
cursor: pointer;
69+
70+
&:hover ${ItemCard}, &:focus ${ItemCard} {
71+
--interaction-shadow: 0px 0px 0px 1px ${p => p.theme.colors.main};
72+
border: 1px solid ${p => p.theme.colors.main};
73+
}
74+
75+
&:hover,
76+
&:focus {
77+
color: ${p => p.theme.colors.main};
78+
}
79+
`;
80+
81+
interface ItemErrorProps {
82+
error: Error;
83+
}
84+
85+
const ItemError: React.FC<ItemErrorProps> = ({ error }) => {
86+
return <ItemErrorWrapper>{error.message}</ItemErrorWrapper>;
87+
};
88+
89+
const ItemErrorWrapper = styled.div`
90+
color: ${p => p.theme.colors.alert};
91+
text-align: center;
92+
`;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { useEffect, useState } from 'react';
2+
import { Button } from '../../Button';
3+
import { FilePickerDialog } from './FilePickerDialog';
4+
import { SelectedFileBlob, SelectedFileResource } from './SelectedFile';
5+
import { InputProps } from '../ResourceField';
6+
import { FaFileCirclePlus } from 'react-icons/fa6';
7+
import { StoreEvents, useStore, useSubject } from '@tomic/react';
8+
import { useUpload } from '../../../hooks/useUpload';
9+
import { VisuallyHidden } from '../../VisuallyHidden';
10+
import { styled } from 'styled-components';
11+
12+
/**
13+
* Button that opens a dialog that lists all files in the drive and allows the user to upload a new file.
14+
* Handles uploads and makes sure files are uploaded even when the parent resource is not saved yet.
15+
*/
16+
export function FilePicker({
17+
resource,
18+
property,
19+
disabled,
20+
required,
21+
commit,
22+
}: InputProps): React.JSX.Element {
23+
const store = useStore();
24+
const { upload } = useUpload(resource);
25+
const [value, setValue] = useSubject(resource, property.subject, {
26+
validate: false,
27+
commit: commit,
28+
});
29+
const [show, setShow] = useState(false);
30+
const [selectedSubject, setSelectedSubject] = useState<string | undefined>(
31+
value,
32+
);
33+
const [selectedFile, setSelectedFile] = useState<File | undefined>();
34+
35+
const [unsubScheduledUpload, setUnsubScheduledUpload] =
36+
useState<() => void | undefined>();
37+
38+
useEffect(() => {
39+
if (selectedSubject) {
40+
setValue(selectedSubject);
41+
} else if (selectedFile) {
42+
if (resource.new) {
43+
// 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.
44+
setValue('https://placeholder');
45+
setUnsubScheduledUpload(prevUnsub => {
46+
prevUnsub?.();
47+
48+
const thisUnsub = store.on(
49+
StoreEvents.ResourceSaved,
50+
async savedResource => {
51+
if (savedResource.getSubject() === resource.getSubject()) {
52+
thisUnsub();
53+
const [subject] = await upload([selectedFile]);
54+
await setValue(subject);
55+
resource.save(store);
56+
}
57+
},
58+
);
59+
60+
return thisUnsub;
61+
});
62+
} else {
63+
upload([selectedFile]).then(([subject]) => {
64+
setValue(subject);
65+
});
66+
}
67+
} else {
68+
setValue(undefined);
69+
}
70+
}, [selectedSubject, selectedFile]);
71+
72+
return (
73+
<Wrapper>
74+
<VisuallyHidden>
75+
{value}
76+
<input
77+
aria-hidden
78+
type='text'
79+
value={value ?? ''}
80+
required={required}
81+
disabled={disabled}
82+
/>
83+
</VisuallyHidden>
84+
{!selectedFile && !selectedSubject && (
85+
<Button subtle onClick={() => setShow(true)} disabled={disabled}>
86+
<FaFileCirclePlus />
87+
Select File
88+
</Button>
89+
)}
90+
{selectedSubject && (
91+
<SelectedFileResource
92+
disabled={disabled}
93+
subject={selectedSubject}
94+
onClear={() => setSelectedSubject(undefined)}
95+
/>
96+
)}
97+
{selectedFile && (
98+
<SelectedFileBlob
99+
file={selectedFile}
100+
disabled={disabled}
101+
onClear={() => {
102+
setSelectedFile(undefined);
103+
unsubScheduledUpload?.();
104+
}}
105+
/>
106+
)}
107+
<FilePickerDialog
108+
show={show}
109+
onShowChange={setShow}
110+
onResourcePicked={setSelectedSubject}
111+
onNewFilePicked={setSelectedFile}
112+
/>
113+
</Wrapper>
114+
);
115+
}
116+
117+
const Wrapper = styled.div`
118+
position: relative;
119+
`;

0 commit comments

Comments
 (0)