mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-02-20 07:37:26 -05:00
[ENG-934] EXIF UI (#1305)
* Media data UI * Make `MediaTime` adjacently tagged * cleanup ts * don't destructure accordion props * Large bruh * round location coords --------- Co-authored-by: Oscar Beaumont <oscar@otbeaumont.me> Co-authored-by: Brendan Allan <brendonovich@outlook.com>
This commit is contained in:
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
@@ -174,8 +174,6 @@ pub fn router(node: Arc<Node>) -> Router<()> {
|
||||
lru_entry
|
||||
};
|
||||
|
||||
println!("Serving from: {:?}", serve_from); // TODO
|
||||
|
||||
match serve_from {
|
||||
ServeFrom::Local => {
|
||||
let metadata = file_path_full_path.metadata().map_err(internal_server_error)?;
|
||||
|
||||
58
interface/app/$libraryId/Explorer/Inspector/MediaData.tsx
Normal file
58
interface/app/$libraryId/Explorer/Inspector/MediaData.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { MediaLocation, MediaMetadata, MediaTime } from '@sd/client';
|
||||
import Accordion from '~/components/Accordion';
|
||||
import { MetaData } from './index';
|
||||
|
||||
interface Props {
|
||||
data: MediaMetadata;
|
||||
}
|
||||
|
||||
function formatMediaTime(loc: MediaTime): string | null {
|
||||
if (loc === 'Undefined') return null;
|
||||
if ('Utc' in loc) return loc.Utc;
|
||||
if ('Naive' in loc) return loc.Naive;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatLocation(loc: MediaLocation): string {
|
||||
// Stackoverflow says the `+` strips the trailing zeros or something so it's important, I think
|
||||
return `${+loc.latitude.toFixed(2)}, ${+loc.longitude.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function MediaData({ data }: Props) {
|
||||
return data.type === 'Image' ? (
|
||||
<div className="flex flex-col gap-0 py-2">
|
||||
<Accordion
|
||||
containerClassName="flex flex-col gap-1 px-4 rounded-b-none"
|
||||
titleClassName="px-4 pt-0 pb-1 !justify-end gap-2 flex-row-reverse text-ink-dull"
|
||||
className="rounded-none border-0 bg-transparent py-0"
|
||||
title="More info"
|
||||
>
|
||||
<MetaData label="Date" value={formatMediaTime(data.date_taken)} />
|
||||
<MetaData label="Type" value={data.type} />
|
||||
<MetaData
|
||||
label="Location"
|
||||
value={data.location ? formatLocation(data.location) : null}
|
||||
/>
|
||||
<MetaData
|
||||
label="Dimensions"
|
||||
value={
|
||||
<>
|
||||
{data.dimensions.width} x {data.dimensions.height}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<MetaData label="Device" value={data.camera_data.device_make} />
|
||||
<MetaData label="Model" value={data.camera_data.device_model} />
|
||||
<MetaData label="Orientation" value={data.camera_data.orientation} />
|
||||
<MetaData label="Color profile" value={data.camera_data.color_profile} />
|
||||
<MetaData label="Color space" value={data.camera_data.color_space} />
|
||||
<MetaData label="Flash" value={data.camera_data.flash?.mode} />
|
||||
<MetaData label="Zoom" value={data.camera_data.zoom} />
|
||||
<MetaData label="Iso" value={data.camera_data.iso} />
|
||||
<MetaData label="Software" value={data.camera_data.software} />
|
||||
</Accordion>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export default MediaData;
|
||||
@@ -26,10 +26,12 @@ import {
|
||||
} from 'react';
|
||||
import {
|
||||
type ExplorerItem,
|
||||
ObjectKindEnum,
|
||||
byteSize,
|
||||
getExplorerItemData,
|
||||
getItemFilePath,
|
||||
getItemObject,
|
||||
useBridgeQuery,
|
||||
useItemsAsObjects,
|
||||
useLibraryQuery
|
||||
} from '@sd/client';
|
||||
@@ -42,6 +44,7 @@ import { FileThumb } from '../FilePath/Thumb';
|
||||
import { useExplorerStore } from '../store';
|
||||
import { uniqueId, useExplorerItemData } from '../util';
|
||||
import FavoriteButton from './FavoriteButton';
|
||||
import MediaData from './MediaData';
|
||||
import Note from './Note';
|
||||
|
||||
export const InfoPill = tw.span`inline border border-transparent px-1 text-[11px] font-medium shadow shadow-app-shade/5 bg-app-selected rounded-md text-ink-dull`;
|
||||
@@ -156,16 +159,26 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
||||
enabled: !!objectData && readyToFetch
|
||||
});
|
||||
|
||||
let { data: fileFullPath } = useLibraryQuery(['files.getPath', objectData?.id ?? -1], {
|
||||
const filePath = useLibraryQuery(['files.getPath', objectData?.id ?? -1], {
|
||||
enabled: !!objectData && readyToFetch
|
||||
});
|
||||
|
||||
if (fileFullPath == null) {
|
||||
switch (item.type) {
|
||||
case 'Location':
|
||||
case 'NonIndexedPath':
|
||||
fileFullPath = item.item.path;
|
||||
//Images are only supported currently - kind = 5
|
||||
const filesMediaData = useLibraryQuery(['files.getMediaData', objectData?.id ?? -1], {
|
||||
enabled: objectData?.kind === ObjectKindEnum.Image && !isNonIndexed && readyToFetch
|
||||
});
|
||||
|
||||
const ephemeralLocationMediaData = useBridgeQuery(
|
||||
['files.getEphemeralMediaData', isNonIndexed ? item.item.path : ''],
|
||||
{
|
||||
enabled: isNonIndexed && item.item.kind === 5 && readyToFetch
|
||||
}
|
||||
);
|
||||
|
||||
const mediaData = filesMediaData ?? ephemeralLocationMediaData ?? null;
|
||||
|
||||
if (filePath.data == null && item.type === 'NonIndexedPath') {
|
||||
filePath.data = item.item.path;
|
||||
}
|
||||
|
||||
const { name, isDir, kind, size, casId, dateCreated, dateAccessed, dateModified, dateIndexed } =
|
||||
@@ -173,10 +186,11 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
||||
|
||||
const pubId = object?.data ? uniqueId(object?.data) : null;
|
||||
|
||||
let extension, integrityChecksum;
|
||||
const filePathItem = getItemFilePath(item);
|
||||
let extension, integrityChecksum;
|
||||
|
||||
if (filePathItem) {
|
||||
extension = 'extension' in filePathItem ? filePathItem.extension : null;
|
||||
extension = filePathItem.extension;
|
||||
integrityChecksum =
|
||||
'integrity_checksum' in filePathItem ? filePathItem.integrity_checksum : null;
|
||||
}
|
||||
@@ -227,20 +241,15 @@ const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
||||
<MetaData
|
||||
icon={Path}
|
||||
label="Path"
|
||||
value={fileFullPath}
|
||||
value={filePath.data}
|
||||
onClick={() => {
|
||||
// TODO: Add toast notification
|
||||
fileFullPath && navigator.clipboard.writeText(fileFullPath);
|
||||
filePath.data && navigator.clipboard.writeText(filePath.data);
|
||||
}}
|
||||
/>
|
||||
</MetaContainer>
|
||||
|
||||
<Divider />
|
||||
|
||||
{
|
||||
// TODO: Call `files.getMediaData` for indexed locations when we have media data UI
|
||||
// TODO: Call `files.getEphemeralMediaData` for ephemeral locations when we have media data UI
|
||||
}
|
||||
{mediaData.data && <MediaData data={mediaData.data} />}
|
||||
|
||||
<MetaContainer className="flex !flex-row flex-wrap gap-1 overflow-hidden">
|
||||
<InfoPill>{isDir ? 'Folder' : kind}</InfoPill>
|
||||
@@ -436,19 +445,19 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
|
||||
};
|
||||
|
||||
interface MetaDataProps {
|
||||
icon: Icon;
|
||||
icon?: Icon;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const MetaData = ({ icon: Icon, label, value, onClick }: MetaDataProps) => {
|
||||
export const MetaData = ({ icon: Icon, label, value, onClick }: MetaDataProps) => {
|
||||
return (
|
||||
<div className="flex items-center text-xs text-ink-dull" onClick={onClick}>
|
||||
<Icon weight="bold" className="mr-2 shrink-0" />
|
||||
{Icon && <Icon weight="bold" className="mr-2 shrink-0" />}
|
||||
<span className="mr-2 flex-1 whitespace-nowrap">{label}</span>
|
||||
<Tooltip label={value} asChild>
|
||||
<span className="truncate break-all text-ink">{value || '--'}</span>
|
||||
<span className="truncate break-all text-ink">{value ?? '--'}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
import clsx from 'clsx';
|
||||
import { CaretDown } from 'phosphor-react';
|
||||
import { useState } from 'react';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title: string;
|
||||
titleClassName?: string;
|
||||
containerClassName?: string;
|
||||
caretSize?: number;
|
||||
}
|
||||
|
||||
const Accordion = ({ title, className, children }: Props) => {
|
||||
const Accordion = (props: PropsWithChildren<Props>) => {
|
||||
const [toggle, setToggle] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={clsx(className, 'rounded-md border border-app-line bg-app-darkBox')}>
|
||||
<div className={clsx(props.className, 'rounded-md border border-app-line bg-app-darkBox')}>
|
||||
<div
|
||||
onClick={() => setToggle((t) => !t)}
|
||||
className="flex items-center justify-between px-3 py-2"
|
||||
className={clsx(
|
||||
'flex flex-row items-center justify-between px-3 py-2',
|
||||
props.titleClassName
|
||||
)}
|
||||
>
|
||||
<p className="text-xs">{title}</p>
|
||||
<p className="text-xs">{props.title}</p>
|
||||
<CaretDown
|
||||
size={props.caretSize || 12}
|
||||
className={clsx(toggle && 'rotate-180', 'transition-all duration-200')}
|
||||
/>
|
||||
</div>
|
||||
{toggle && (
|
||||
<div className="rounded-b-md border-t border-app-line bg-app-box p-3 pt-2">
|
||||
{children}
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-b-md border-t border-app-line bg-app-box p-3 py-2',
|
||||
props.containerClassName
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user