mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-10-24 09:58:35 -04:00
[ENG-1086, ENG-1087] Add error and loading indicator to thumbnail (#1448)
* Add onLoad and onError listeners to FileThumb - Show toast when Original file fails loading in QuickPreview - Formatting * Fix close toast not working when QuickPreview was open * Fix panic when serving unsupported files * Formatting & lint
This commit is contained in:
1 parent
01dbc2caf7
commit
9ca22733ad
12 files changed
+218
-128
No files matched your search
+10
-12
@@ -1,16 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="vanilla-theme">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spacedrive</title>
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spacedrive</title>
|
||||
</head>
|
||||
|
||||
<body style="overflow: hidden">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
|
||||
<body style="overflow: hidden">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -475,8 +475,8 @@ async fn infer_the_mime_type(
|
||||
"txt" => "text/plain",
|
||||
_ => {
|
||||
if charset.is_empty() {
|
||||
todo!("file ext='{ext}' mime_type='{mime_type}' is not supported");
|
||||
// "TODO: This filetype is not supported because of the missing mime type!",
|
||||
return Err(not_implemented(()));
|
||||
};
|
||||
mime_type
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(crate) fn not_found(err: impl Debug) -> http::Response<BoxBody> {
|
||||
#[track_caller]
|
||||
pub(crate) fn internal_server_error(err: impl Debug) -> http::Response<BoxBody> {
|
||||
debug!(
|
||||
"500 - Internal Server Error at {}: {err:?}",
|
||||
"500: Internal Server Error at {}: {err:?}",
|
||||
Location::caller()
|
||||
);
|
||||
|
||||
@@ -40,6 +40,15 @@ pub(crate) fn internal_server_error(err: impl Debug) -> http::Response<BoxBody>
|
||||
.body(body::boxed(Full::from("")))
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub(crate) fn not_implemented(err: impl Debug) -> http::Response<BoxBody> {
|
||||
debug!("501: Not Implemented at {}: {err:?}", Location::caller());
|
||||
|
||||
InfallibleResponse::builder()
|
||||
.status(StatusCode::NOT_IMPLEMENTED)
|
||||
.body(body::boxed(Full::from("")))
|
||||
}
|
||||
|
||||
pub(crate) async fn cors_middleware<B>(req: Request<B>, next: Next<B>) -> Response<BoxBody> {
|
||||
if req.method() == Method::OPTIONS {
|
||||
return Response::builder()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getIcon, getIconByName, iconNames } from '@sd/assets/util';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
memo,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
@@ -37,6 +38,8 @@ export interface ThumbProps {
|
||||
size?: number;
|
||||
cover?: boolean;
|
||||
frame?: boolean;
|
||||
onLoad?: (state: ThumbType) => void;
|
||||
onError?: (state: ThumbType, error: Error) => void;
|
||||
blackBars?: boolean;
|
||||
blackBarsSize?: number;
|
||||
extension?: boolean;
|
||||
@@ -69,16 +72,31 @@ export const FileThumb = memo((props: ThumbProps) => {
|
||||
isDark ? classes.checkers : classes.checkersLight
|
||||
);
|
||||
|
||||
const onLoad = useCallback(() => setLoaded(true), []);
|
||||
const _onLoad = props.onLoad;
|
||||
const onLoad = useCallback(() => {
|
||||
setLoaded(true);
|
||||
_onLoad?.(thumbType);
|
||||
}, [_onLoad, thumbType]);
|
||||
|
||||
const onError = useCallback(() => {
|
||||
setLoaded(false);
|
||||
setThumbType((prevThumbType) =>
|
||||
prevThumbType === ThumbType.Original && itemData.hasLocalThumbnail
|
||||
? ThumbType.Thumbnail
|
||||
: ThumbType.Icon
|
||||
);
|
||||
}, [itemData.hasLocalThumbnail]);
|
||||
const _onError = props.onError;
|
||||
const onError = useCallback(
|
||||
(event: ErrorEvent | SyntheticEvent<Element, Event>) => {
|
||||
setLoaded(false);
|
||||
setThumbType((prevThumbType) =>
|
||||
prevThumbType === ThumbType.Original && itemData.hasLocalThumbnail
|
||||
? ThumbType.Thumbnail
|
||||
: ThumbType.Icon
|
||||
);
|
||||
|
||||
const rawError =
|
||||
('error' in event && event.error) ||
|
||||
('message' in event && event.message) ||
|
||||
'Filetype is not supported yet';
|
||||
|
||||
_onError?.(thumbType, rawError instanceof Error ? rawError : new Error(rawError));
|
||||
},
|
||||
[_onError, thumbType, itemData.hasLocalThumbnail]
|
||||
);
|
||||
|
||||
// useLayoutEffect is required to ensure the thumbType is always updated before the onError listener can execute,
|
||||
// thus avoiding improper thumb types changes
|
||||
@@ -251,6 +269,11 @@ export const FileThumb = memo((props: ThumbProps) => {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
setThumbType(ThumbType.Thumbnail);
|
||||
return null;
|
||||
case 'Image':
|
||||
// Fallthrough as image and thumbnail use the same component
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -177,13 +177,13 @@ export default () => {
|
||||
<Select
|
||||
className="w-full"
|
||||
value={
|
||||
explorer.settingsStore.mediaViewWithDescendants ?
|
||||
'withDescendants'
|
||||
: 'withoutDescendants'
|
||||
explorer.settingsStore.mediaViewWithDescendants
|
||||
? 'withDescendants'
|
||||
: 'withoutDescendants'
|
||||
}
|
||||
onChange={(value) => {
|
||||
|
||||
explorer.settingsStore.mediaViewWithDescendants = value === 'withDescendants';
|
||||
explorer.settingsStore.mediaViewWithDescendants =
|
||||
value === 'withDescendants';
|
||||
}}
|
||||
>
|
||||
{mediaViewContextActions.options.map((option) => (
|
||||
|
||||
@@ -4,6 +4,7 @@ import clsx from 'clsx';
|
||||
import {
|
||||
ButtonHTMLAttributes,
|
||||
createContext,
|
||||
createRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
@@ -20,7 +21,16 @@ import {
|
||||
useRspcLibraryContext,
|
||||
useZodForm
|
||||
} from '@sd/client';
|
||||
import { dialogManager, DropdownMenu, Form, ModifierKeys, toast, Tooltip, z } from '@sd/ui';
|
||||
import {
|
||||
dialogManager,
|
||||
DropdownMenu,
|
||||
Form,
|
||||
ModifierKeys,
|
||||
toast,
|
||||
ToastMessage,
|
||||
Tooltip,
|
||||
z
|
||||
} from '@sd/ui';
|
||||
import { useIsDark, useKeybind, useOperatingSystem } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
@@ -61,6 +71,8 @@ export const QuickPreview = () => {
|
||||
const explorer = useExplorerContext();
|
||||
const { open, itemIndex } = useQuickPreviewStore();
|
||||
|
||||
const thumb = createRef<HTMLDivElement>();
|
||||
const [thumbErrorToast, setThumbErrorToast] = useState<ToastMessage>();
|
||||
const [showMetadata, setShowMetadata] = useState<boolean>(false);
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState<boolean>(false);
|
||||
const [isRenaming, setIsRenaming] = useState<boolean>(false);
|
||||
@@ -82,9 +94,33 @@ export const QuickPreview = () => {
|
||||
if (items[index]) getQuickPreviewStore().itemIndex = index;
|
||||
};
|
||||
|
||||
// Error toast
|
||||
useEffect(() => {
|
||||
if (!thumbErrorToast) return;
|
||||
|
||||
let id: string | number | undefined;
|
||||
toast.error(
|
||||
(_id) => {
|
||||
id = _id;
|
||||
return thumbErrorToast;
|
||||
},
|
||||
{
|
||||
ref: thumb,
|
||||
duration: Infinity,
|
||||
onClose() {
|
||||
id = undefined;
|
||||
setThumbErrorToast(undefined);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => void toast.dismiss(id);
|
||||
}, [thumb, thumbErrorToast]);
|
||||
|
||||
// Reset state
|
||||
useEffect(() => {
|
||||
setNewName(null);
|
||||
setThumbErrorToast(undefined);
|
||||
|
||||
if (open || item) return;
|
||||
|
||||
@@ -202,6 +238,14 @@ export const QuickPreview = () => {
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => isRenaming && e.preventDefault()}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
if (
|
||||
e.target &&
|
||||
e.target instanceof Node &&
|
||||
thumb.current?.contains(e.target)
|
||||
)
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -220,7 +264,6 @@ export const QuickPreview = () => {
|
||||
<div className="absolute inset-0 bg-black/25 backdrop-blur-3xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'z-50 flex items-center p-2',
|
||||
@@ -396,6 +439,16 @@ export const QuickPreview = () => {
|
||||
|
||||
<FileThumb
|
||||
data={item}
|
||||
onLoad={(type) =>
|
||||
type === 'ORIGINAL' && setThumbErrorToast(undefined)
|
||||
}
|
||||
onError={(type, error) =>
|
||||
type === 'ORIGINAL' &&
|
||||
setThumbErrorToast({
|
||||
title: 'Error loading original file',
|
||||
body: error.message
|
||||
})
|
||||
}
|
||||
loadOriginal
|
||||
mediaControls
|
||||
className={clsx(
|
||||
|
||||
@@ -40,7 +40,7 @@ const GridViewItem = memo(({ data, selected, cut, isRenaming }: GridViewItemProp
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewItem data={data} className="w-full h-full">
|
||||
<ViewItem data={data} className="h-full w-full">
|
||||
<div
|
||||
className={clsx('mb-1 aspect-square rounded-lg', selected && 'bg-app-selectedItem')}
|
||||
>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function useExplorerSettings<TOrder extends Ordering>({
|
||||
|
||||
useEffect(() => {
|
||||
setStore(proxy(settings));
|
||||
}, [location])
|
||||
}, [location]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function FeedbackDialog(props: UseDialogProps) {
|
||||
ctaLabel="Submit"
|
||||
closeLabel="Cancel"
|
||||
buttonsSideContent={
|
||||
<div className="flex items-center justify-center w-full gap-1">
|
||||
<div className="flex w-full items-center justify-center gap-1">
|
||||
{EMOJIS.map((emoji, i) => (
|
||||
<div
|
||||
onClick={() => emojiSelectHandler(i)}
|
||||
|
||||
@@ -206,7 +206,7 @@ export const AddLocationDialog = ({
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<ErrorMessage name={REMOTE_ERROR_FORM_FIELD} variant="large" className="mt-2 mb-4" />
|
||||
<ErrorMessage name={REMOTE_ERROR_FORM_FIELD} variant="large" className="mb-4 mt-2" />
|
||||
|
||||
<LocationPathInputField {...form.register('path')} />
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function getExplorerItemData(data?: null | ExplorerItem) {
|
||||
itemData.fullName = `${filePath.name}${filePath.extension ? `.${filePath.extension}` : ''}`;
|
||||
itemData.size = byteSize(filePath.size_in_bytes_bytes);
|
||||
itemData.isDir = filePath.is_dir ?? false;
|
||||
itemData.extension = filePath.extension;
|
||||
itemData.extension = filePath.extension?.toLocaleLowerCase() ?? null;
|
||||
if ('kind' in filePath) itemData.kind = ObjectKind[filePath.kind] ?? 'Unknown';
|
||||
if ('cas_id' in filePath) itemData.casId = filePath.cas_id;
|
||||
if ('location_id' in filePath) itemData.locationId = filePath.location_id;
|
||||
|
||||
+100
-93
@@ -1,6 +1,6 @@
|
||||
import { CheckCircle, Icon, Info, Warning, WarningCircle, X } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import { CSSProperties, ReactNode, useEffect, useState } from 'react';
|
||||
import { CSSProperties, ForwardedRef, forwardRef, ReactNode, useEffect, useState } from 'react';
|
||||
import { toast as SonnerToast } from 'sonner';
|
||||
|
||||
import { Button } from './Button';
|
||||
@@ -20,7 +20,7 @@ const toastClassName = clsx(
|
||||
|
||||
export type ToastId<T = string | number> = T;
|
||||
type ToastType = 'info' | 'success' | 'error' | 'warning';
|
||||
type ToastMessage = ReactNode | { title: ReactNode; body?: ReactNode };
|
||||
export type ToastMessage = ReactNode | { title: ReactNode; body?: ReactNode };
|
||||
type ToastPromiseData = unknown;
|
||||
type ToastPromise<T = ToastPromiseData> = Promise<T> | (() => Promise<T>);
|
||||
type ToastAction = { label: string; onClick: () => void; className?: string };
|
||||
@@ -28,6 +28,7 @@ type ToastCloseEvent = 'on-dismiss' | 'on-auto-close' | 'on-action' | 'on-cancel
|
||||
|
||||
interface ToastOptions {
|
||||
id?: ToastId;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
type?: ToastType;
|
||||
icon?: ReactNode;
|
||||
duration?: number;
|
||||
@@ -42,7 +43,7 @@ interface ToastOptions {
|
||||
}
|
||||
|
||||
interface PromiseToastOptions<T = ToastPromiseData>
|
||||
extends Omit<ToastOptions, 'icon' | 'type' | 'action' | 'cancel' | 'onClose'> {
|
||||
extends Omit<ToastOptions, 'ref' | 'icon' | 'type' | 'action' | 'cancel' | 'onClose'> {
|
||||
loading: ToastMessage;
|
||||
success: ToastMessage | ((data: T) => ToastMessage);
|
||||
error: ToastMessage | ((error: unknown) => ToastMessage);
|
||||
@@ -73,107 +74,111 @@ const icons: Record<ToastType, Icon> = {
|
||||
warning: Warning
|
||||
};
|
||||
|
||||
const Toast = ({ closable = true, action, cancel, ...props }: ToastProps) => {
|
||||
const message = typeof props.message === 'function' ? props.message(props.id) : props.message;
|
||||
const Toast = forwardRef<HTMLDivElement, ToastProps>(
|
||||
({ closable = true, action, cancel, ...props }, ref) => {
|
||||
const message =
|
||||
typeof props.message === 'function' ? props.message(props.id) : props.message;
|
||||
|
||||
const title =
|
||||
message && typeof message === 'object' && 'title' in message ? message.title : message;
|
||||
const title =
|
||||
message && typeof message === 'object' && 'title' in message ? message.title : message;
|
||||
|
||||
const body =
|
||||
message && typeof message === 'object' && 'body' in message ? message.body : undefined;
|
||||
const body =
|
||||
message && typeof message === 'object' && 'body' in message ? message.body : undefined;
|
||||
|
||||
const typeIcon = (type: ToastType) => {
|
||||
const Icon = icons[type];
|
||||
return (
|
||||
<Icon
|
||||
size={16}
|
||||
weight="fill"
|
||||
className={clsx(
|
||||
type === 'success' && 'text-green-500',
|
||||
type === 'error' && 'text-red-500',
|
||||
type === 'warning' && 'text-yellow-500'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const typeIcon = (type: ToastType) => {
|
||||
const Icon = icons[type];
|
||||
return (
|
||||
<Icon
|
||||
size={16}
|
||||
weight="fill"
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
type === 'success' && 'text-green-500',
|
||||
type === 'error' && 'text-red-500',
|
||||
type === 'warning' && 'text-yellow-500'
|
||||
'pointer-events-auto flex gap-2',
|
||||
body || action || cancel ? 'items-start' : 'items-center'
|
||||
)}
|
||||
>
|
||||
{(props.icon || props.type) && (
|
||||
<div className={clsx((body || action || cancel) && 'mt-px')}>
|
||||
{props.icon || (props.type && typeIcon(props.type))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex gap-2',
|
||||
body || action || cancel ? 'items-start' : 'items-center'
|
||||
)}
|
||||
>
|
||||
{(props.icon || props.type) && (
|
||||
<div className={clsx((body || action || cancel) && 'mt-px')}>
|
||||
{props.icon || (props.type && typeIcon(props.type))}
|
||||
<div className="flex grow flex-col">
|
||||
{title && (
|
||||
<span className="font-medium text-ink" style={{ wordBreak: 'break-word' }}>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{body && (
|
||||
<div className="mt-0.5" style={{ wordBreak: 'break-word' }}>
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(action || cancel) && (
|
||||
<div className="mt-2.5 flex gap-2">
|
||||
{action && (
|
||||
<Button
|
||||
variant="accent"
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
props.onClose?.({ id: props.id, event: 'on-action' });
|
||||
toast.dismiss(props.id);
|
||||
}}
|
||||
className={clsx(actionButtonClassName, action.className)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{cancel && (
|
||||
<Button
|
||||
variant="gray"
|
||||
onClick={() => {
|
||||
typeof cancel === 'object' && cancel.onClick?.();
|
||||
props.onClose?.({ id: props.id, event: 'on-cancel' });
|
||||
toast.dismiss(props.id);
|
||||
}}
|
||||
className={clsx(
|
||||
actionButtonClassName,
|
||||
typeof cancel === 'object' ? cancel.className : null
|
||||
)}
|
||||
>
|
||||
{typeof cancel === 'object' ? cancel.label : cancel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex grow flex-col">
|
||||
{title && (
|
||||
<span className="font-medium text-ink" style={{ wordBreak: 'break-word' }}>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{body && (
|
||||
<div className="mt-0.5" style={{ wordBreak: 'break-word' }}>
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(action || cancel) && (
|
||||
<div className="mt-2.5 flex gap-2">
|
||||
{action && (
|
||||
<Button
|
||||
variant="accent"
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
props.onClose?.({ id: props.id, event: 'on-action' });
|
||||
toast.dismiss(props.id);
|
||||
}}
|
||||
className={clsx(actionButtonClassName, action.className)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{cancel && (
|
||||
<Button
|
||||
variant="gray"
|
||||
onClick={() => {
|
||||
typeof cancel === 'object' && cancel.onClick?.();
|
||||
props.onClose?.({ id: props.id, event: 'on-cancel' });
|
||||
toast.dismiss(props.id);
|
||||
}}
|
||||
className={clsx(
|
||||
actionButtonClassName,
|
||||
typeof cancel === 'object' ? cancel.className : null
|
||||
)}
|
||||
>
|
||||
{typeof cancel === 'object' ? cancel.label : cancel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{closable && (
|
||||
<button
|
||||
className="relative transition-colors before:absolute before:-inset-2 before:content-[''] hover:text-ink"
|
||||
onClick={() => {
|
||||
props.onDismiss?.(props.id);
|
||||
props.onClose?.({ id: props.id, event: 'on-dismiss' });
|
||||
toast.dismiss(props.id);
|
||||
}}
|
||||
>
|
||||
<X weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{closable && (
|
||||
<button
|
||||
className="relative transition-colors before:absolute before:-inset-2 before:content-[''] hover:text-ink"
|
||||
onClick={() => {
|
||||
props.onDismiss?.(props.id);
|
||||
props.onClose?.({ id: props.id, event: 'on-dismiss' });
|
||||
toast.dismiss(props.id);
|
||||
}}
|
||||
>
|
||||
<X weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface PromiseToastProps<T = ToastPromiseData>
|
||||
extends Pick<
|
||||
@@ -244,6 +249,7 @@ const PromiseToast = <T extends ToastPromiseData>({
|
||||
const renderToast = (
|
||||
message: ToastMessage | ((id: ToastId) => ToastMessage),
|
||||
{
|
||||
ref,
|
||||
type,
|
||||
icon,
|
||||
action,
|
||||
@@ -259,6 +265,7 @@ const renderToast = (
|
||||
(id) => (
|
||||
<Toast
|
||||
id={id}
|
||||
ref={ref}
|
||||
type={type}
|
||||
icon={icon}
|
||||
message={message}
|
||||
|
||||
Reference in new issue
Block a user