mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 12:13:27 +00:00
[ENG-443] Allow folders to be tagged (#1712)
* allow folders to be tagged * remove bruh * custom useAssignItems hook * fix types
This commit is contained in:
parent
282e35c294
commit
c91ccff37d
|
@ -1,19 +1,23 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use itertools::{Either, Itertools};
|
||||
use rspc::{alpha::AlphaRouter, ErrorCode};
|
||||
use sd_prisma::prisma_sync;
|
||||
use sd_file_ext::kind::ObjectKind;
|
||||
use sd_prisma::{prisma, prisma_sync};
|
||||
use sd_sync::OperationFactory;
|
||||
use sd_utils::uuid_to_bytes;
|
||||
use serde::Deserialize;
|
||||
use specta::Type;
|
||||
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
invalidate_query,
|
||||
library::Library,
|
||||
object::tag::TagCreateArgs,
|
||||
prisma::{object, tag, tag_on_object},
|
||||
prisma::{file_path, object, tag, tag_on_object},
|
||||
};
|
||||
|
||||
use super::{utils::library, Ctx, R};
|
||||
|
@ -97,78 +101,161 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
})
|
||||
.procedure("assign", {
|
||||
#[derive(Debug, Type, Deserialize)]
|
||||
pub struct TagAssignArgs {
|
||||
pub object_ids: Vec<i32>,
|
||||
pub tag_id: i32,
|
||||
pub unassign: bool,
|
||||
#[specta(inline)]
|
||||
enum Target {
|
||||
Object(prisma::object::id::Type),
|
||||
FilePath(prisma::file_path::id::Type),
|
||||
}
|
||||
|
||||
#[derive(Debug, Type, Deserialize)]
|
||||
#[specta(inline)]
|
||||
struct TagAssignArgs {
|
||||
targets: Vec<Target>,
|
||||
tag_id: i32,
|
||||
unassign: bool,
|
||||
}
|
||||
|
||||
R.with2(library())
|
||||
.mutation(|(_, library), args: TagAssignArgs| async move {
|
||||
let Library { db, sync, .. } = library.as_ref();
|
||||
|
||||
let (tag, objects) = db
|
||||
._batch((
|
||||
db.tag()
|
||||
.find_unique(tag::id::equals(args.tag_id))
|
||||
.select(tag::select!({ pub_id })),
|
||||
db.object()
|
||||
.find_many(vec![object::id::in_vec(args.object_ids)])
|
||||
.select(object::select!({ id pub_id })),
|
||||
))
|
||||
let tag = db
|
||||
.tag()
|
||||
.find_unique(tag::id::equals(args.tag_id))
|
||||
.select(tag::select!({ pub_id }))
|
||||
.exec()
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
rspc::Error::new(ErrorCode::NotFound, "Tag not found".to_string())
|
||||
})?;
|
||||
|
||||
let (objects, file_paths) = db
|
||||
._batch({
|
||||
let (objects, file_paths): (Vec<_>, Vec<_>) = args
|
||||
.targets
|
||||
.into_iter()
|
||||
.partition_map(|target| match target {
|
||||
Target::Object(id) => Either::Left(id),
|
||||
Target::FilePath(id) => Either::Right(id),
|
||||
});
|
||||
|
||||
(
|
||||
db.object()
|
||||
.find_many(vec![object::id::in_vec(objects)])
|
||||
.select(object::select!({
|
||||
id
|
||||
pub_id
|
||||
})),
|
||||
db.file_path()
|
||||
.find_many(vec![file_path::id::in_vec(file_paths)])
|
||||
.select(file_path::select!({
|
||||
id
|
||||
pub_id
|
||||
object: select { id pub_id }
|
||||
})),
|
||||
)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let tag = tag.ok_or_else(|| {
|
||||
rspc::Error::new(ErrorCode::NotFound, "Tag not found".to_string())
|
||||
})?;
|
||||
|
||||
macro_rules! sync_id {
|
||||
($object:ident) => {
|
||||
($pub_id:expr) => {
|
||||
prisma_sync::tag_on_object::SyncId {
|
||||
tag: prisma_sync::tag::SyncId {
|
||||
pub_id: tag.pub_id.clone(),
|
||||
},
|
||||
object: prisma_sync::object::SyncId {
|
||||
pub_id: $object.pub_id.clone(),
|
||||
},
|
||||
object: prisma_sync::object::SyncId { pub_id: $pub_id },
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if args.unassign {
|
||||
let query = db.tag_on_object().delete_many(vec![
|
||||
tag_on_object::tag_id::equals(args.tag_id),
|
||||
tag_on_object::object_id::in_vec(
|
||||
objects.iter().map(|o| o.id).collect(),
|
||||
),
|
||||
]);
|
||||
let query =
|
||||
db.tag_on_object().delete_many(vec![
|
||||
tag_on_object::tag_id::equals(args.tag_id),
|
||||
tag_on_object::object_id::in_vec(
|
||||
objects
|
||||
.iter()
|
||||
.map(|o| o.id)
|
||||
.chain(file_paths.iter().filter_map(|fp| {
|
||||
fp.object.as_ref().map(|o| o.id.clone())
|
||||
}))
|
||||
.collect(),
|
||||
),
|
||||
]);
|
||||
|
||||
sync.write_ops(
|
||||
db,
|
||||
(
|
||||
objects
|
||||
.into_iter()
|
||||
.map(|object| sync.relation_delete(sync_id!(object)))
|
||||
.map(|o| o.pub_id)
|
||||
.chain(
|
||||
file_paths
|
||||
.into_iter()
|
||||
.filter_map(|fp| fp.object.map(|o| o.pub_id)),
|
||||
)
|
||||
.map(|pub_id| sync.relation_delete(sync_id!(pub_id)))
|
||||
.collect(),
|
||||
query,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let (sync_ops, db_creates) = objects.into_iter().fold(
|
||||
(vec![], vec![]),
|
||||
|(mut sync_ops, mut db_creates), object| {
|
||||
db_creates.push(tag_on_object::CreateUnchecked {
|
||||
tag_id: args.tag_id,
|
||||
object_id: object.id,
|
||||
_params: vec![],
|
||||
});
|
||||
let (new_objects, _) = db
|
||||
._batch({
|
||||
let (left, right): (Vec<_>, Vec<_>) = file_paths
|
||||
.iter()
|
||||
.filter(|fp| fp.object.is_none())
|
||||
.map(|fp| {
|
||||
let id = uuid_to_bytes(Uuid::new_v4());
|
||||
|
||||
sync_ops.extend(sync.relation_create(sync_id!(object), []));
|
||||
(
|
||||
db.object().create(
|
||||
id.clone(),
|
||||
vec![
|
||||
object::date_created::set(None),
|
||||
object::kind::set(Some(
|
||||
ObjectKind::Folder as i32,
|
||||
)),
|
||||
],
|
||||
),
|
||||
db.file_path().update(
|
||||
file_path::id::equals(fp.id),
|
||||
vec![file_path::object::connect(
|
||||
object::pub_id::equals(id),
|
||||
)],
|
||||
),
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
|
||||
(sync_ops, db_creates)
|
||||
},
|
||||
);
|
||||
(left, right)
|
||||
})
|
||||
.await?;
|
||||
|
||||
let (sync_ops, db_creates) = objects
|
||||
.into_iter()
|
||||
.map(|o| (o.id, o.pub_id))
|
||||
.chain(
|
||||
file_paths
|
||||
.into_iter()
|
||||
.filter_map(|fp| fp.object.map(|o| (o.id, o.pub_id))),
|
||||
)
|
||||
.chain(new_objects.into_iter().map(|o| (o.id, o.pub_id)))
|
||||
.fold(
|
||||
(vec![], vec![]),
|
||||
|(mut sync_ops, mut db_creates), (id, pub_id)| {
|
||||
db_creates.push(tag_on_object::CreateUnchecked {
|
||||
tag_id: args.tag_id,
|
||||
object_id: id,
|
||||
_params: vec![],
|
||||
});
|
||||
|
||||
sync_ops.extend(sync.relation_create(sync_id!(pub_id), []));
|
||||
|
||||
(sync_ops, db_creates)
|
||||
},
|
||||
);
|
||||
|
||||
sync.write_ops(db, (sync_ops, db.tag_on_object().create_many(db_creates)))
|
||||
.await?;
|
||||
|
@ -176,6 +263,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
|
|||
|
||||
invalidate_query!(library, "tags.getForObject");
|
||||
invalidate_query!(library, "tags.getWithObjects");
|
||||
invalidate_query!(library, "search.objects");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
|
|
|
@ -210,7 +210,7 @@ async fn identifier_job_step(
|
|||
.map(|object| (*pub_id, object))
|
||||
})
|
||||
.map(|(pub_id, object)| {
|
||||
let (crdt_op, db_op) = file_path_object_connect_ops(
|
||||
let (crdt_op, db_op) = connect_file_path_to_object(
|
||||
pub_id,
|
||||
// SAFETY: This pub_id is generated by the uuid lib, but we have to store bytes in sqlite
|
||||
Uuid::from_slice(&object.pub_id).expect("uuid bytes are invalid"),
|
||||
|
@ -277,21 +277,22 @@ async fn identifier_job_step(
|
|||
.into_iter()
|
||||
.unzip();
|
||||
|
||||
let object_creation_args = (
|
||||
sync.shared_create(sync_id(), sync_params),
|
||||
object::create_unchecked(uuid_to_bytes(object_pub_id), db_params),
|
||||
);
|
||||
(
|
||||
(
|
||||
sync.shared_create(sync_id(), sync_params),
|
||||
object::create_unchecked(uuid_to_bytes(object_pub_id), db_params),
|
||||
),
|
||||
{
|
||||
let (crdt_op, db_op) = connect_file_path_to_object(
|
||||
*file_path_pub_id,
|
||||
object_pub_id,
|
||||
sync,
|
||||
db,
|
||||
);
|
||||
|
||||
(object_creation_args, {
|
||||
let (crdt_op, db_op) = file_path_object_connect_ops(
|
||||
*file_path_pub_id,
|
||||
object_pub_id,
|
||||
sync,
|
||||
db,
|
||||
);
|
||||
|
||||
(crdt_op, db_op.select(file_path::select!({ pub_id })))
|
||||
})
|
||||
(crdt_op, db_op.select(file_path::select!({ pub_id })))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
.unzip();
|
||||
|
@ -335,7 +336,7 @@ async fn identifier_job_step(
|
|||
Ok((total_created, updated_file_paths.len()))
|
||||
}
|
||||
|
||||
fn file_path_object_connect_ops<'db>(
|
||||
fn connect_file_path_to_object<'db>(
|
||||
file_path_id: Uuid,
|
||||
object_id: Uuid,
|
||||
sync: &crate::sync::Manager,
|
||||
|
|
|
@ -2,39 +2,33 @@ import { Plus } from '@phosphor-icons/react';
|
|||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import clsx from 'clsx';
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
Object,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery,
|
||||
usePlausibleEvent,
|
||||
useRspcLibraryContext
|
||||
} from '@sd/client';
|
||||
import { ExplorerItem, useLibraryQuery } from '@sd/client';
|
||||
import { dialogManager, ModifierKeys } from '@sd/ui';
|
||||
import CreateDialog from '~/app/$libraryId/settings/library/tags/CreateDialog';
|
||||
import CreateDialog, {
|
||||
AssignTagItems,
|
||||
useAssignItemsToTag
|
||||
} from '~/app/$libraryId/settings/library/tags/CreateDialog';
|
||||
import { Menu } from '~/components/Menu';
|
||||
import { useOperatingSystem } from '~/hooks';
|
||||
import { useScrolled } from '~/hooks/useScrolled';
|
||||
import { keybindForOs } from '~/util/keybinds';
|
||||
|
||||
import { Menu } from './Menu';
|
||||
|
||||
export default (props: { objects: Object[] }) => {
|
||||
export default (props: { items: Array<Extract<ExplorerItem, { type: 'Object' | 'Path' }>> }) => {
|
||||
const os = useOperatingSystem();
|
||||
const keybind = keybindForOs(os);
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
const rspc = useRspcLibraryContext();
|
||||
const tags = useLibraryQuery(['tags.list'], { suspense: true });
|
||||
|
||||
// Map<tag::id, Vec<object::id>>
|
||||
const tagsWithObjects = useLibraryQuery([
|
||||
'tags.getWithObjects',
|
||||
props.objects.map(({ id }) => id)
|
||||
props.items
|
||||
.map((item) => {
|
||||
if (item.type === 'Path') return item.item.object?.id;
|
||||
else if (item.type === 'Object') return item.item.id;
|
||||
})
|
||||
.filter((item): item is number => item !== undefined)
|
||||
]);
|
||||
|
||||
const assignTag = useLibraryMutation('tags.assign', {
|
||||
onSuccess: () => {
|
||||
submitPlausibleEvent({ event: { type: 'tagAssign' } });
|
||||
}
|
||||
});
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: tags.data?.length || 0,
|
||||
|
@ -45,6 +39,8 @@ export default (props: { objects: Object[] }) => {
|
|||
|
||||
const { isScrolled } = useScrolled(parentRef, 10);
|
||||
|
||||
const assignItemsToTag = useAssignItemsToTag();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Item
|
||||
|
@ -54,71 +50,87 @@ export default (props: { objects: Object[] }) => {
|
|||
iconProps={{ size: 15 }}
|
||||
keybind={keybind([ModifierKeys.Control], ['N'])}
|
||||
onClick={() => {
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} objects={props.objects} />);
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} items={props.items} />);
|
||||
}}
|
||||
/>
|
||||
<Menu.Separator className={clsx('mx-0 mb-0 transition', isScrolled && 'shadow')} />
|
||||
{tags.data && tags.data.length > 0 ? (
|
||||
<div
|
||||
ref={parentRef}
|
||||
style={{
|
||||
maxHeight: `400px`,
|
||||
height: `100%`,
|
||||
width: `100%`,
|
||||
overflow: 'auto'
|
||||
}}
|
||||
className="h-full w-full overflow-auto"
|
||||
style={{ maxHeight: `400px` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
className="relative w-full"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const tag = tags.data[virtualRow.index];
|
||||
if (!tag) return null;
|
||||
|
||||
const objectsWithTag = tagsWithObjects.data?.[tag?.id];
|
||||
const objectsWithTag = new Set(tagsWithObjects.data?.[tag?.id]);
|
||||
|
||||
// only unassign if all objects have tag
|
||||
// this is the same functionality as finder
|
||||
const unassign = objectsWithTag?.length === props.objects.length;
|
||||
const unassign = props.items.every((item) => {
|
||||
if (item.type === 'Object') {
|
||||
return objectsWithTag.has(item.item.id);
|
||||
} else {
|
||||
const { object } = item.item;
|
||||
|
||||
if (!object) return false;
|
||||
return objectsWithTag.has(object.id);
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: UI to differentiate tag assigning when some objects have tag when no objects have tag - ENG-965
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key={virtualRow.index}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
await assignTag.mutateAsync({
|
||||
unassign,
|
||||
tag_id: tag.id,
|
||||
object_ids: unassign
|
||||
await assignItemsToTag(
|
||||
tag.id,
|
||||
unassign
|
||||
? // use objects that already have tag
|
||||
objectsWithTag
|
||||
props.items.flatMap((item) => {
|
||||
if (
|
||||
item.type === 'Object' ||
|
||||
item.type === 'Path'
|
||||
) {
|
||||
return [item];
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
: // use objects that don't have tag
|
||||
props.objects
|
||||
.filter(
|
||||
(o) =>
|
||||
!objectsWithTag?.some(
|
||||
(ot) => ot === o.id
|
||||
props.items.flatMap<AssignTagItems[number]>(
|
||||
(item) => {
|
||||
if (item.type === 'Object') {
|
||||
if (
|
||||
!objectsWithTag.has(
|
||||
item.item.id
|
||||
)
|
||||
)
|
||||
)
|
||||
.map((o) => o.id)
|
||||
});
|
||||
if (unassign)
|
||||
rspc.queryClient.invalidateQueries(['search.objects']);
|
||||
return [item];
|
||||
} else if (item.type === 'Path') {
|
||||
return [item];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
),
|
||||
unassign
|
||||
);
|
||||
|
||||
tagsWithObjects.refetch();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
@ -126,7 +138,7 @@ export default (props: { objects: Object[] }) => {
|
|||
style={{
|
||||
backgroundColor:
|
||||
objectsWithTag &&
|
||||
objectsWithTag.length > 0 &&
|
||||
objectsWithTag.size > 0 &&
|
||||
tag.color
|
||||
? tag.color
|
||||
: 'transparent',
|
|
@ -1,11 +1,11 @@
|
|||
import { ArrowBendUpRight, TagSimple } from '@phosphor-icons/react';
|
||||
import { useMemo } from 'react';
|
||||
import { ObjectKind, useLibraryMutation, type ObjectKindEnum } from '@sd/client';
|
||||
import { ExplorerItem, ObjectKind, useLibraryMutation, type ObjectKindEnum } from '@sd/client';
|
||||
import { ContextMenu, toast } from '@sd/ui';
|
||||
import AssignTagMenuItems from '~/components/AssignTagMenuItems';
|
||||
import { Menu } from '~/components/Menu';
|
||||
import { isNonEmpty } from '~/util';
|
||||
|
||||
import AssignTagMenuItems from '../AssignTagMenuItems';
|
||||
import { ConditionalItem } from '../ConditionalItem';
|
||||
import { useContextMenuContext } from '../context';
|
||||
|
||||
|
@ -43,14 +43,24 @@ export const RemoveFromRecents = new ConditionalItem({
|
|||
|
||||
export const AssignTag = new ConditionalItem({
|
||||
useCondition: () => {
|
||||
const { selectedObjects } = useContextMenuContext();
|
||||
if (!isNonEmpty(selectedObjects)) return null;
|
||||
const { selectedItems } = useContextMenuContext();
|
||||
|
||||
return { selectedObjects };
|
||||
const items = selectedItems
|
||||
.map((item) => {
|
||||
if (item.type === 'Object' || item.type === 'Path') return item;
|
||||
})
|
||||
.filter(
|
||||
(item): item is Extract<ExplorerItem, { type: 'Object' | 'Path' }> =>
|
||||
item !== undefined
|
||||
);
|
||||
|
||||
if (!isNonEmpty(items)) return null;
|
||||
|
||||
return { items };
|
||||
},
|
||||
Component: ({ selectedObjects }) => (
|
||||
Component: ({ items }) => (
|
||||
<Menu.SubMenu label="Assign tag" icon={TagSimple}>
|
||||
<AssignTagMenuItems objects={selectedObjects} />
|
||||
<AssignTagMenuItems items={items} />
|
||||
</Menu.SubMenu>
|
||||
)
|
||||
});
|
||||
|
|
|
@ -164,7 +164,7 @@ export const FileThumb = memo((props: ThumbProps) => {
|
|||
}}
|
||||
className={clsx(
|
||||
'relative flex shrink-0 items-center justify-center',
|
||||
!loaded && 'invisible',
|
||||
// !loaded && 'invisible',
|
||||
!props.size && 'h-full w-full',
|
||||
props.cover && 'overflow-hidden',
|
||||
props.className
|
||||
|
|
|
@ -44,12 +44,12 @@ import {
|
|||
} from '@sd/client';
|
||||
import { Button, Divider, DropdownMenu, toast, Tooltip, tw } from '@sd/ui';
|
||||
import { LibraryIdParamsSchema } from '~/app/route-schemas';
|
||||
import AssignTagMenuItems from '~/components/AssignTagMenuItems';
|
||||
import { useIsDark, useZodRouteParams } from '~/hooks';
|
||||
import { isNonEmpty } from '~/util';
|
||||
|
||||
import { Folder } from '../../../../components';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import AssignTagMenuItems from '../ContextMenu/AssignTagMenuItems';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { useQuickPreviewStore } from '../QuickPreview/store';
|
||||
import { getExplorerStore, useExplorerStore } from '../store';
|
||||
|
@ -192,7 +192,7 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
|||
break;
|
||||
}
|
||||
case 'SpacedropPeer': {
|
||||
objectData = item.item as unknown as Object;
|
||||
// objectData = item.item as unknown as Object;
|
||||
// filePathData = item.item.file_paths[0] ?? null;
|
||||
break;
|
||||
}
|
||||
|
@ -347,16 +347,17 @@ export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
|||
</NavLink>
|
||||
))}
|
||||
|
||||
{objectData && (
|
||||
<DropdownMenu.Root
|
||||
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
alignOffset={-10}
|
||||
>
|
||||
<AssignTagMenuItems objects={[objectData]} />
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
{item.type === 'Object' ||
|
||||
(item.type === 'Path' && (
|
||||
<DropdownMenu.Root
|
||||
trigger={<PlaceholderPill>Add Tag</PlaceholderPill>}
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
alignOffset={-10}
|
||||
>
|
||||
<AssignTagMenuItems items={[item]} />
|
||||
</DropdownMenu.Root>
|
||||
))}
|
||||
</MetaContainer>
|
||||
|
||||
{!isDir && objectData && (
|
||||
|
@ -520,7 +521,12 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
|
|||
sideOffset={5}
|
||||
alignOffset={-10}
|
||||
>
|
||||
<AssignTagMenuItems objects={selectedObjects} />
|
||||
<AssignTagMenuItems
|
||||
items={items.flatMap((item) => {
|
||||
if (item.type === 'Object' || item.type === 'Path') return [item];
|
||||
else return [];
|
||||
})}
|
||||
/>
|
||||
</DropdownMenu.Root>
|
||||
)}
|
||||
</MetaContainer>
|
||||
|
|
|
@ -226,7 +226,9 @@ const useKeyDownHandlers = ({ disabled }: { disabled: boolean }) => {
|
|||
)
|
||||
return;
|
||||
|
||||
dialogManager.create((dp) => <CreateDialog {...dp} objects={objects} />);
|
||||
dialogManager.create((dp) => (
|
||||
<CreateDialog {...dp} items={objects.map((item) => ({ type: 'Object', item }))} />
|
||||
));
|
||||
},
|
||||
[os, explorer.selectedItems]
|
||||
);
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
import { Object, useLibraryMutation, usePlausibleEvent, useZodForm } from '@sd/client';
|
||||
import {
|
||||
FilePath,
|
||||
libraryClient,
|
||||
Object,
|
||||
Target,
|
||||
useLibraryMutation,
|
||||
usePlausibleEvent,
|
||||
useZodForm
|
||||
} from '@sd/client';
|
||||
import { Dialog, InputField, useDialog, UseDialogProps, z } from '@sd/ui';
|
||||
import { ColorPicker } from '~/components';
|
||||
|
||||
|
@ -7,7 +15,41 @@ const schema = z.object({
|
|||
color: z.string()
|
||||
});
|
||||
|
||||
export default (props: UseDialogProps & { objects?: Object[] }) => {
|
||||
export type AssignTagItems = Array<
|
||||
{ type: 'Object'; item: Object } | { type: 'Path'; item: FilePath }
|
||||
>;
|
||||
|
||||
export function useAssignItemsToTag() {
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const mutation = useLibraryMutation(['tags.assign'], {
|
||||
onSuccess: () => {
|
||||
submitPlausibleEvent({ event: { type: 'tagAssign' } });
|
||||
}
|
||||
});
|
||||
|
||||
return (tagId: number, items: AssignTagItems, unassign: boolean) => {
|
||||
const targets = items.map<Target>((item) => {
|
||||
if (item.type === 'Object') {
|
||||
return { Object: item.item.id };
|
||||
} else {
|
||||
return { FilePath: item.item.id };
|
||||
}
|
||||
});
|
||||
|
||||
return mutation.mutateAsync({
|
||||
tag_id: tagId,
|
||||
targets,
|
||||
unassign
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default (
|
||||
props: UseDialogProps & {
|
||||
items?: AssignTagItems;
|
||||
}
|
||||
) => {
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const form = useZodForm({
|
||||
|
@ -17,7 +59,8 @@ export default (props: UseDialogProps & { objects?: Object[] }) => {
|
|||
});
|
||||
|
||||
const createTag = useLibraryMutation('tags.create');
|
||||
const assignTag = useLibraryMutation('tags.assign');
|
||||
|
||||
const assignItemsToTag = useAssignItemsToTag();
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
|
@ -25,13 +68,7 @@ export default (props: UseDialogProps & { objects?: Object[] }) => {
|
|||
|
||||
submitPlausibleEvent({ event: { type: 'tagCreate' } });
|
||||
|
||||
if (props.objects !== undefined) {
|
||||
await assignTag.mutateAsync({
|
||||
tag_id: tag.id,
|
||||
object_ids: props.objects.map((o) => o.id),
|
||||
unassign: false
|
||||
});
|
||||
}
|
||||
if (props.items !== undefined) await assignItemsToTag(tag.id, props.items, false);
|
||||
} catch (e) {
|
||||
console.error('error', e);
|
||||
}
|
||||
|
|
|
@ -91,7 +91,7 @@ export type Procedures = {
|
|||
{ key: "p2p.pairingResponse", input: [number, PairingDecision], result: null } |
|
||||
{ key: "p2p.spacedrop", input: SpacedropArgs, result: string } |
|
||||
{ key: "preferences.update", input: LibraryArgs<LibraryPreferences>, result: null } |
|
||||
{ key: "tags.assign", input: LibraryArgs<TagAssignArgs>, result: null } |
|
||||
{ key: "tags.assign", input: LibraryArgs<{ targets: Target[]; tag_id: number; unassign: boolean }>, result: null } |
|
||||
{ key: "tags.create", input: LibraryArgs<TagCreateArgs>, result: Tag } |
|
||||
{ key: "tags.delete", input: LibraryArgs<number>, result: null } |
|
||||
{ key: "tags.update", input: LibraryArgs<TagUpdateArgs>, result: null } |
|
||||
|
@ -417,12 +417,12 @@ export type SystemLocations = { desktop: string | null; documents: string | null
|
|||
|
||||
export type Tag = { id: number; pub_id: number[]; name: string | null; color: string | null; redundancy_goal: number | null; date_created: string | null; date_modified: string | null }
|
||||
|
||||
export type TagAssignArgs = { object_ids: number[]; tag_id: number; unassign: boolean }
|
||||
|
||||
export type TagCreateArgs = { name: string; color: string }
|
||||
|
||||
export type TagUpdateArgs = { id: number; name: string | null; color: string | null }
|
||||
|
||||
export type Target = { Object: number } | { FilePath: number }
|
||||
|
||||
export type VideoMetadata = { duration: number | null; video_codec: string | null; audio_codec: string | null }
|
||||
|
||||
export type Volume = { name: string; mount_points: string[]; total_capacity: string; available_capacity: string; disk_type: DiskType; file_system: string | null; is_root_filesystem: boolean }
|
||||
|
|
|
@ -130,9 +130,9 @@ const Item = ({
|
|||
}: ContextMenuItemProps) => {
|
||||
return (
|
||||
<RadixCM.Item
|
||||
className={contextMenuItemClassNames}
|
||||
onClick={(e) => !props.disabled && onClick?.(e)}
|
||||
{...props}
|
||||
className={clsx(contextMenuItemClassNames, props.className)}
|
||||
onClick={(e) => !props.disabled && onClick?.(e)}
|
||||
>
|
||||
<ContextMenuDivItem {...{ icon, iconProps, label, keybind, variant, children }} />
|
||||
</RadixCM.Item>
|
||||
|
|
Loading…
Reference in a new issue