From 0688406adc1ab77925c34b36ff0c1f8fe58eeac0 Mon Sep 17 00:00:00 2001
From: Lynx <141365347+iLynxcat@users.noreply.github.com>
Date: Tue, 18 Jun 2024 05:52:19 -0500
Subject: [PATCH] [ENG-293] Tag assign mode (#2462)
* Rename top bar icon *style* to *classlist*
* Update pnpm-lock.yaml
* Add user-facing name for tag assign mode: "Assign tags"
* Refactor path bar to allow tag bar display above
* Oops, use original PATH_BAR_HEIGHT
* rename ExplorerPath to more consistent ExplorerPathBar
* remove debug tag assign mode toggle
* fix straggling old reference to ExplorerPathBar
* rename useShortcuts to useExplorerShortcuts
* add `DEV` to turbo.json env deps
* tag assign mode list display + keyboard toggle
* Update pnpm-lock.yaml after merge
* Use new query style in ExplorerTagBar
* WIP debugging list bug
* Fix to the ` ` bug
* Remove awaiting tag assign keypress state variable
* Add tag assign mode localization lines
* Add actual tag assign mode functionality
* Use localization for bulk tag assignment
* i18n: Add proper "file" plurals and nest within tag assignment msgs
* implement tag hotkey assignment
* Use i18n for unknown error in tag assignment
* use type for tag bulk assign keycodes
* remove custom ordering todo
* add awaiting-assign visual state to tag bar tags
* Make Escape cancel tag bar hotkey assignment + add a11y + remove focus state on keypress
* Remove tag assign mutation success from tag bar
* Remove tab index `-1` from tag bar tag
* Update tag bar awaiting hotkey assignment colors
* Fix tag bar styling + sort properly
* Fix some missed `TOP_BAR_ICON_CLASSLIST` references
* improve tag ui & fix location redir
* fix pathbar caret and layout adjustment experiment
* Add better tag bar layout for users with lots of tags
* set padding if tags overflow
---------
Co-authored-by: Arnab Chakraborty <11457760+Rocky43007@users.noreply.github.com>
Co-authored-by: ameer2468 <33054370+ameer2468@users.noreply.github.com>
---
.../{ExplorerPath.tsx => ExplorerPathBar.tsx} | 23 +-
.../$libraryId/Explorer/ExplorerTagBar.tsx | 336 ++++++++++++++++++
.../app/$libraryId/Explorer/TopBarOptions.tsx | 34 +-
.../Explorer/View/DragScrollable.tsx | 2 +-
.../app/$libraryId/Explorer/View/index.tsx | 13 +-
interface/app/$libraryId/Explorer/index.tsx | 24 +-
interface/app/$libraryId/Explorer/store.ts | 12 +-
interface/app/$libraryId/Spacedrop/index.tsx | 4 +-
.../app/$libraryId/TopBar/TopBarMobile.tsx | 4 +-
.../app/$libraryId/TopBar/TopBarOptions.tsx | 10 +-
interface/app/$libraryId/location/$id.tsx | 4 +-
interface/hooks/useIsLocationIndexing.ts | 1 +
interface/hooks/useRedirectToNewLocation.ts | 2 +-
interface/hooks/useShortcut.ts | 4 +
interface/locales/ar/common.json | 6 +-
interface/locales/be/common.json | 6 +-
interface/locales/de/common.json | 6 +-
interface/locales/en/common.json | 13 +-
interface/locales/es/common.json | 6 +-
interface/locales/fr/common.json | 6 +-
interface/locales/it/common.json | 6 +-
interface/locales/ja/common.json | 6 +-
interface/locales/nl/common.json | 6 +-
interface/locales/ru/common.json | 6 +-
interface/locales/tr/common.json | 6 +-
interface/locales/zh-CN/common.json | 6 +-
interface/locales/zh-TW/common.json | 6 +-
turbo.json | 2 +-
28 files changed, 487 insertions(+), 73 deletions(-)
rename interface/app/$libraryId/Explorer/{ExplorerPath.tsx => ExplorerPathBar.tsx} (92%)
create mode 100644 interface/app/$libraryId/Explorer/ExplorerTagBar.tsx
diff --git a/interface/app/$libraryId/Explorer/ExplorerPath.tsx b/interface/app/$libraryId/Explorer/ExplorerPathBar.tsx
similarity index 92%
rename from interface/app/$libraryId/Explorer/ExplorerPath.tsx
rename to interface/app/$libraryId/Explorer/ExplorerPathBar.tsx
index bb8e0913c..182279454 100644
--- a/interface/app/$libraryId/Explorer/ExplorerPath.tsx
+++ b/interface/app/$libraryId/Explorer/ExplorerPathBar.tsx
@@ -21,9 +21,10 @@ import { lookup } from './RevealInNativeExplorer';
import { useExplorerDroppable } from './useExplorerDroppable';
import { useExplorerSearchParams } from './util';
+// todo: ENTIRELY replace with computed combined pathbar+tagbar height
export const PATH_BAR_HEIGHT = 32;
-export const ExplorerPath = memo(() => {
+export const ExplorerPathBar = memo(() => {
const os = useOperatingSystem(true);
const navigate = useNavigate();
const [{ path: searchPath }] = useExplorerSearchParams();
@@ -118,13 +119,16 @@ export const ExplorerPath = memo(() => {
return (
- {paths.map((path) => (
+ {paths.map((path, idx) => (
handleOnClick(path)}
disabled={path.pathname === (searchPath ?? (location && '/'))}
@@ -148,9 +152,10 @@ interface PathProps {
onClick: () => void;
disabled: boolean;
locationPath: string;
+ isLast: boolean;
}
-const Path = ({ path, onClick, disabled, locationPath }: PathProps) => {
+const Path = ({ path, onClick, disabled, locationPath, isLast }: PathProps) => {
const isDark = useIsDark();
const { revealItems } = usePlatform();
const { library } = useLibraryContext();
@@ -192,7 +197,7 @@ const Path = ({ path, onClick, disabled, locationPath }: PathProps) => {
{
>
{path.name}
-
+ {!isLast && }
}
>
diff --git a/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx b/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx
new file mode 100644
index 000000000..cb571d953
--- /dev/null
+++ b/interface/app/$libraryId/Explorer/ExplorerTagBar.tsx
@@ -0,0 +1,336 @@
+import { Circle } from '@phosphor-icons/react';
+import clsx from 'clsx';
+import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
+import {
+ ExplorerItem,
+ Tag,
+ Target,
+ useLibraryMutation,
+ useLibraryQuery,
+ useRspcContext,
+ useSelector
+} from '@sd/client';
+import { Shortcut, toast } from '@sd/ui';
+import { useIsDark, useKeybind, useLocale, useOperatingSystem } from '~/hooks';
+import { keybindForOs } from '~/util/keybinds';
+
+import { useExplorerContext } from './Context';
+import { explorerStore } from './store';
+
+export const TAG_BAR_HEIGHT = 64;
+const NUMBER_KEYCODES: string[][] = [
+ ['Key1'],
+ ['Key2'],
+ ['Key3'],
+ ['Key4'],
+ ['Key5'],
+ ['Key6'],
+ ['Key7'],
+ ['Key8'],
+ ['Key9']
+];
+
+// TODO: hoist this to somewhere higher as a utility function
+const toTarget = (data: ExplorerItem): Target => {
+ if (!data || !('id' in data.item))
+ throw new Error('Tried to convert an invalid object to Target.');
+
+ return (
+ data.type === 'Object'
+ ? {
+ Object: data.item.id
+ }
+ : {
+ FilePath: data.item.id
+ }
+ ) satisfies Target;
+};
+
+type TagBulkAssignHotkeys = typeof explorerStore.tagBulkAssignHotkeys;
+function getHotkeysWithNewAssignment(
+ hotkeys: TagBulkAssignHotkeys,
+ options:
+ | {
+ unassign?: false;
+ tagId: number;
+ hotkey: string;
+ }
+ | {
+ unassign: true;
+ tagId: number;
+ hotkey?: string;
+ }
+): TagBulkAssignHotkeys {
+ const hotkeysWithoutCurrentTag = hotkeys.filter(
+ ({ hotkey, tagId }) => !(tagId === options.tagId || hotkey === options.hotkey)
+ );
+
+ if (options.unassign) {
+ return hotkeysWithoutCurrentTag;
+ }
+
+ return hotkeysWithoutCurrentTag.concat({
+ hotkey: options.hotkey,
+ tagId: options.tagId
+ });
+}
+
+// million-ignore
+export const ExplorerTagBar = () => {
+ const [tagBulkAssignHotkeys] = useSelector(explorerStore, (s) => [s.tagBulkAssignHotkeys]);
+ const explorer = useExplorerContext();
+ const rspc = useRspcContext();
+ const tagsRef = useRef(null);
+ const [isTagsOverflowing, setIsTagsOverflowing] = useState(false);
+
+ const updateOverflowState = () => {
+ const element = tagsRef.current;
+ if (element) {
+ setIsTagsOverflowing(
+ element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth
+ );
+ }
+ }
+
+ useEffect(() => {
+ const element = tagsRef.current;
+ if (!element) return;
+ //handles initial render when not resizing
+ setIsTagsOverflowing(element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth)
+ //make sure state updates when window resizing
+ window.addEventListener('resize', () => {
+ updateOverflowState();
+ })
+ //remove listeners on unmount
+ return () => {
+ window.removeEventListener('resize', () => {
+ updateOverflowState();
+ })
+ }
+ }, [tagsRef])
+
+ const [tagListeningForKeyPress, setTagListeningForKeyPress] = useState();
+
+ const { data: allTags = [] } = useLibraryQuery(['tags.list']);
+ const mutation = useLibraryMutation(['tags.assign'], {
+ onSuccess: () => rspc.queryClient.invalidateQueries(['search.paths'])
+ });
+
+ const { t } = useLocale();
+
+ // This will automagically listen for any keypress 1-9 while the tag bar is visible.
+ // These listeners will unmount when ExplorerTagBar is unmounted.
+ useKeybind(
+ NUMBER_KEYCODES,
+ async (e) => {
+ const targets = Array.from(explorer.selectedItems.entries()).map((item) =>
+ toTarget(item[0])
+ );
+
+ // Silent fail if no files are selected
+ if (targets.length < 1) return;
+
+ const keyPressed = e.key;
+
+ let tag: Tag | undefined;
+
+ findTag: {
+ const tagId = tagBulkAssignHotkeys.find(
+ ({ hotkey }) => hotkey === keyPressed
+ )?.tagId;
+ const foundTag = allTags.find((t) => t.id === tagId);
+
+ if (!foundTag) break findTag;
+
+ tag = foundTag;
+ }
+
+ if (!tag) return;
+
+ try {
+ await mutation.mutateAsync({
+ targets,
+ tag_id: tag.id,
+ unassign: false
+ });
+
+ toast(
+ t('tags_bulk_assigned', {
+ tag_name: tag.name,
+ file_count: targets.length
+ }),
+ {
+ type: 'success'
+ }
+ );
+ } catch (err) {
+ let msg: string = t('error_unknown');
+
+ if (err instanceof Error || (typeof err === 'object' && err && 'message' in err)) {
+ msg = err.message as string;
+ } else if (typeof err === 'string') {
+ msg = err;
+ }
+
+ console.error('Tag assignment failed with error', err);
+
+ let failedToastMessage: string = t('tags_bulk_failed_without_tag', {
+ file_count: targets.length,
+ error_message: msg
+ });
+
+ if (tag)
+ failedToastMessage = t('tags_bulk_failed_with_tag', {
+ tag_name: tag.name,
+ file_count: targets.length,
+ error_message: msg
+ });
+
+ toast(failedToastMessage, {
+ type: 'error'
+ });
+ }
+ },
+ {
+ enabled: typeof tagListeningForKeyPress !== 'number'
+ }
+ );
+
+ return (
+
+
{t('tags_bulk_instructions')}
+
+
+ {/* Did not want to write a .toSorted() predicate for this so lazy spreading things with hotkeys first then the rest after */}
+ {allTags
+ .toSorted((tagA, tagB) => {
+ // Sort this array by hotkeys 1-9 first, then unasssigned tags. I know, it's terrible.
+ // This 998/999 bit is likely terrible for sorting. I'm bad at writing sort predicates.
+ // Improvements (probably much simpler than this anyway) are much welcome <3
+ // -- iLynxcat 3/jun/2024
+
+ const hotkeyA = +(
+ tagBulkAssignHotkeys.find((k) => k.tagId === tagA.id)?.hotkey ?? 998
+ );
+ const hotkeyB = +(
+ tagBulkAssignHotkeys.find((k) => k.tagId === tagB.id)?.hotkey ?? 999
+ );
+
+ return hotkeyA - hotkeyB;
+ })
+ .map((tag) => (
+
+ tagId === tag.id)
+ ?.hotkey
+ }
+ isAwaitingKeyPress={tagListeningForKeyPress === tag.id}
+ onClick={() => {
+ setTagListeningForKeyPress(tag.id);
+ }}
+ onKeyPress={(e) => {
+ if (e.key === 'Escape') {
+ explorerStore.tagBulkAssignHotkeys =
+ getHotkeysWithNewAssignment(tagBulkAssignHotkeys, {
+ unassign: true,
+ tagId: tag.id
+ });
+
+ setTagListeningForKeyPress(undefined);
+
+ return;
+ }
+
+ explorerStore.tagBulkAssignHotkeys =
+ getHotkeysWithNewAssignment(tagBulkAssignHotkeys, {
+ tagId: tag.id,
+ hotkey: e.key
+ });
+ setTagListeningForKeyPress(undefined);
+ }}
+ />
+
+ ))}
+
+
+ );
+};
+
+interface TagItemProps {
+ tag: Tag;
+ assignKey?: string;
+ isAwaitingKeyPress: boolean;
+ onKeyPress: (e: KeyboardEvent) => void;
+ onClick: () => void;
+}
+
+const TagItem = ({
+ tag,
+ assignKey,
+ isAwaitingKeyPress = false,
+ onKeyPress,
+ onClick
+}: TagItemProps) => {
+ const buttonRef = useRef(null);
+ const isDark = useIsDark();
+
+ const os = useOperatingSystem(true);
+ const keybind = keybindForOs(os);
+
+ useKeybind(
+ [...NUMBER_KEYCODES, ['Escape']],
+ (e) => {
+ buttonRef.current?.blur(); // Hides the focus ring after Escape is pressed to cancel assignment
+ return onKeyPress(e);
+ },
+ {
+ enabled: isAwaitingKeyPress
+ }
+ );
+
+ return (
+
+
+
+ {tag.name}
+
+
+ {assignKey && }
+
+ );
+};
diff --git a/interface/app/$libraryId/Explorer/TopBarOptions.tsx b/interface/app/$libraryId/Explorer/TopBarOptions.tsx
index 799be0022..b79dd7ae4 100644
--- a/interface/app/$libraryId/Explorer/TopBarOptions.tsx
+++ b/interface/app/$libraryId/Explorer/TopBarOptions.tsx
@@ -5,7 +5,8 @@ import {
Rows,
SidebarSimple,
SlidersHorizontal,
- SquaresFour
+ SquaresFour,
+ Tag
} from '@phosphor-icons/react';
import clsx from 'clsx';
import { useMemo } from 'react';
@@ -15,7 +16,7 @@ import { useKeyMatcher, useLocale } from '~/hooks';
import { KeyManager } from '../KeyManager';
import { Spacedrop, SpacedropButton } from '../Spacedrop';
-import TopBarOptions, { ToolOption, TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
+import TopBarOptions, { ToolOption, TOP_BAR_ICON_CLASSLIST } from '../TopBar/TopBarOptions';
import { useExplorerContext } from './Context';
import OptionsPanel from './OptionsPanel';
import { explorerStore } from './store';
@@ -29,7 +30,7 @@ const layoutIcons: Record = {
export const useExplorerTopBarOptions = () => {
const [showInspector, tagAssignMode] = useSelector(explorerStore, (s) => [
s.showInspector,
- s.tagAssignMode
+ s.isTagAssignModeActive
]);
const explorer = useExplorerContext();
const controlIcon = useKeyMatcher('Meta').icon;
@@ -48,7 +49,7 @@ export const useExplorerTopBarOptions = () => {
const option = {
layout,
toolTipLabel: t(`${layout}_view`),
- icon: ,
+ icon: ,
keybinds: [controlIcon, (i + 1).toString()],
topBarActive:
!explorer.isLoadingPreferences && settings.layoutMode === layout,
@@ -73,7 +74,7 @@ export const useExplorerTopBarOptions = () => {
const controlOptions: ToolOption[] = [
{
toolTipLabel: t('explorer_settings'),
- icon: ,
+ icon: ,
popOverComponent: ,
individual: true,
showAtResolution: 'sm:flex'
@@ -87,7 +88,7 @@ export const useExplorerTopBarOptions = () => {
icon: (
),
individual: true,
@@ -118,11 +119,28 @@ export const useExplorerTopBarOptions = () => {
showAtResolution: 'xl:flex'
},
{
- toolTipLabel: t('key_manager'),
- icon: ,
+ toolTipLabel: 'Key Manager',
+ icon: ,
popOverComponent: ,
individual: true,
showAtResolution: 'xl:flex'
+ },
+ {
+ toolTipLabel: 'Assign tags',
+ icon: (
+
+ ),
+ // TODO: Assign tag mode is not yet implemented!
+ onClick: () =>
+ (explorerStore.isTagAssignModeActive = !explorerStore.isTagAssignModeActive),
+ // TODO: remove once tag-assign-mode impl complete
+ // onClick: () => toast.info('Coming soon!'),
+ topBarActive: tagAssignMode,
+ individual: true,
+ showAtResolution: 'xl:flex'
}
// {
// toolTipLabel: 'Tag Assign Mode',
diff --git a/interface/app/$libraryId/Explorer/View/DragScrollable.tsx b/interface/app/$libraryId/Explorer/View/DragScrollable.tsx
index 4d4d789ac..7b7a701b8 100644
--- a/interface/app/$libraryId/Explorer/View/DragScrollable.tsx
+++ b/interface/app/$libraryId/Explorer/View/DragScrollable.tsx
@@ -3,7 +3,7 @@ import { tw } from '@sd/ui';
import { useTopBarContext } from '../../TopBar/Context';
import { useExplorerContext } from '../Context';
-import { PATH_BAR_HEIGHT } from '../ExplorerPath';
+import { PATH_BAR_HEIGHT } from '../ExplorerPathBar';
import { useDragScrollable } from './useDragScrollable';
const Trigger = tw.div`absolute inset-x-0 h-10 pointer-events-none`;
diff --git a/interface/app/$libraryId/Explorer/View/index.tsx b/interface/app/$libraryId/Explorer/View/index.tsx
index 6a7807909..77fe48bc7 100644
--- a/interface/app/$libraryId/Explorer/View/index.tsx
+++ b/interface/app/$libraryId/Explorer/View/index.tsx
@@ -94,7 +94,7 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
const activeItem = useActiveItem();
- useShortcuts();
+ useExplorerShortcuts();
useShortcut('explorerEscape', () => explorer.resetSelectedItems([]), {
disabled: !selectable || explorer.selectedItems.size === 0
@@ -192,9 +192,12 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
);
};
-const useShortcuts = () => {
+const useExplorerShortcuts = () => {
const explorer = useExplorerContext();
- const isRenaming = useSelector(explorerStore, (s) => s.isRenaming);
+ const [isRenaming, tagAssignMode] = useSelector(explorerStore, (s) => [
+ s.isRenaming,
+ s.isTagAssignModeActive
+ ]);
const quickPreviewStore = useQuickPreviewStore();
const meta = useKeyMatcher('Meta');
@@ -207,6 +210,10 @@ const useShortcuts = () => {
useShortcut('duplicateObject', duplicate);
useShortcut('pasteObject', paste);
+ useShortcut('toggleTagAssignMode', (e) => {
+ explorerStore.isTagAssignModeActive = !tagAssignMode;
+ });
+
useShortcut('toggleQuickPreview', (e) => {
if (isRenaming || dialogManager.isAnyDialogOpen()) return;
if (explorerStore.isCMDPOpen) return;
diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx
index f11151f69..a4a98b1e1 100644
--- a/interface/app/$libraryId/Explorer/index.tsx
+++ b/interface/app/$libraryId/Explorer/index.tsx
@@ -13,7 +13,7 @@ import { useTopBarContext } from '../TopBar/Context';
import { useExplorerContext } from './Context';
import ContextMenu from './ContextMenu';
import DismissibleNotice from './DismissibleNotice';
-import { ExplorerPath, PATH_BAR_HEIGHT } from './ExplorerPath';
+import { ExplorerPathBar, PATH_BAR_HEIGHT } from './ExplorerPathBar';
import { Inspector, INSPECTOR_WIDTH } from './Inspector';
import ExplorerContextMenu from './ParentContextMenu';
import { getQuickPreviewStore } from './QuickPreview/store';
@@ -24,6 +24,9 @@ import { EmptyNotice } from './View/EmptyNotice';
import 'react-slidedown/lib/slidedown.css';
+import clsx from 'clsx';
+
+import { ExplorerTagBar } from './ExplorerTagBar';
import { useExplorerDnd } from './useExplorerDnd';
interface Props {
@@ -38,7 +41,10 @@ interface Props {
export default function Explorer(props: PropsWithChildren) {
const explorer = useExplorerContext();
const layoutStore = useExplorerLayoutStore();
- const showInspector = useSelector(explorerStore, (s) => s.showInspector);
+ const [showInspector, showTagBar] = useSelector(explorerStore, (s) => [
+ s.showInspector,
+ s.isTagAssignModeActive
+ ]);
const showPathBar = explorer.showPathBar && layoutStore.showPathBar;
const rspc = useRspcLibraryContext();
@@ -117,14 +123,20 @@ export default function Explorer(props: PropsWithChildren) {
- {showPathBar && }
+ {/* TODO: wrap path bar and tag bar in nice wrapper, ideally animate tag bar in/out directly above path bar */}
+
+ {showTagBar && }
+ {showPathBar && }
+
{showInspector && (
)}
diff --git a/interface/app/$libraryId/Explorer/store.ts b/interface/app/$libraryId/Explorer/store.ts
index 5ba8bfb96..da6e6a545 100644
--- a/interface/app/$libraryId/Explorer/store.ts
+++ b/interface/app/$libraryId/Explorer/store.ts
@@ -1,3 +1,6 @@
+import { proxy } from 'valtio';
+import { proxySet } from 'valtio/utils';
+import { z } from 'zod';
import {
ThumbKey,
resetStore,
@@ -7,9 +10,6 @@ import {
type ExplorerSettings,
type Ordering
} from '@sd/client';
-import { proxy } from 'valtio';
-import { proxySet } from 'valtio/utils';
-import { z } from 'zod';
import i18n from '~/app/I18n';
import {
@@ -98,7 +98,6 @@ type DragState =
};
const state = {
- tagAssignMode: false,
showInspector: false,
showMoreInfo: false,
newLocationToRedirect: null as null | number,
@@ -106,12 +105,15 @@ const state = {
newThumbnails: proxySet() as Set,
cutCopyState: { type: 'Idle' } as CutCopyState,
drag: null as null | DragState,
+ isTagAssignModeActive: false,
isDragSelecting: false,
isRenaming: false,
// Used for disabling certain keyboard shortcuts when command palette is open
isCMDPOpen: false,
isContextMenuOpen: false,
- quickRescanLastRun: Date.now() - 200
+ quickRescanLastRun: Date.now() - 200,
+ // Map = { hotkey: '0'...'9', tagId: 1234 }
+ tagBulkAssignHotkeys: [] as Array<{ hotkey: string; tagId: number }>
};
export function flattenThumbnailKey(thumbKey: ThumbKey) {
diff --git a/interface/app/$libraryId/Spacedrop/index.tsx b/interface/app/$libraryId/Spacedrop/index.tsx
index 6fe6d556b..c06563d31 100644
--- a/interface/app/$libraryId/Spacedrop/index.tsx
+++ b/interface/app/$libraryId/Spacedrop/index.tsx
@@ -15,7 +15,7 @@ import { useDropzone, useLocale, useOnDndLeave } from '~/hooks';
import { hardwareModelToIcon } from '~/util/hardware';
import { usePlatform } from '~/util/Platform';
-import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
+import { TOP_BAR_ICON_CLASSLIST } from '../TopBar/TopBarOptions';
import { useIncomingSpacedropToast, useSpacedropProgressToast } from './toast';
// TODO: This is super hacky so should probs be rewritten but for now it works.
@@ -57,7 +57,7 @@ export function SpacedropButton({ triggerOpen }: { triggerOpen: () => void }) {
return (
);
}
diff --git a/interface/app/$libraryId/TopBar/TopBarMobile.tsx b/interface/app/$libraryId/TopBar/TopBarMobile.tsx
index 2ca74c83a..3d6ae447a 100644
--- a/interface/app/$libraryId/TopBar/TopBarMobile.tsx
+++ b/interface/app/$libraryId/TopBar/TopBarMobile.tsx
@@ -3,7 +3,7 @@ import React, { forwardRef, HTMLAttributes } from 'react';
import { Popover, usePopover } from '@sd/ui';
import TopBarButton, { TopBarButtonProps } from './TopBarButton';
-import { ToolOption, TOP_BAR_ICON_STYLE } from './TopBarOptions';
+import { ToolOption, TOP_BAR_ICON_CLASSLIST } from './TopBarOptions';
const GroupTool = forwardRef<
HTMLButtonElement,
@@ -40,7 +40,7 @@ export default ({ toolOptions, className }: Props) => {
popover={popover}
trigger={
-
+
}
>
diff --git a/interface/app/$libraryId/TopBar/TopBarOptions.tsx b/interface/app/$libraryId/TopBar/TopBarOptions.tsx
index 399137492..cc4c676a3 100644
--- a/interface/app/$libraryId/TopBar/TopBarOptions.tsx
+++ b/interface/app/$libraryId/TopBar/TopBarOptions.tsx
@@ -26,7 +26,7 @@ interface TopBarChildrenProps {
options?: ToolOption[][];
}
-export const TOP_BAR_ICON_STYLE = 'm-0.5 w-[18px] h-[18px] text-ink-dull';
+export const TOP_BAR_ICON_CLASSLIST = 'm-0.5 w-[18px] h-[18px] text-ink-dull';
export default ({ options }: TopBarChildrenProps) => {
const [windowSize, setWindowSize] = useState(0);
@@ -193,7 +193,7 @@ export function WindowsControls({ windowSize }: { windowSize: number }) {
active={false}
onClick={() => appWindow.minimize()}
>
-
+
{maximized ? (
-
+
) : (
-
+
)}
appWindow.close()}
>
-
+
);
diff --git a/interface/app/$libraryId/location/$id.tsx b/interface/app/$libraryId/location/$id.tsx
index 576399779..76606d318 100644
--- a/interface/app/$libraryId/location/$id.tsx
+++ b/interface/app/$libraryId/location/$id.tsx
@@ -35,7 +35,7 @@ import { SearchContextProvider, SearchOptions, useSearchFromSearchParams } from
import SearchBar from '../search/SearchBar';
import { useSearchExplorerQuery } from '../search/useSearchExplorerQuery';
import { TopBarPortal } from '../TopBar/Portal';
-import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
+import { TOP_BAR_ICON_CLASSLIST } from '../TopBar/TopBarOptions';
import LocationOptions from './LocationOptions';
export const Component = () => {
@@ -151,7 +151,7 @@ const LocationExplorer = ({ location }: { location: Location; path?: string }) =
{
toolTipLabel: t('reload'),
onClick: () => rescan(location.id),
- icon: ,
+ icon: ,
individual: true,
showAtResolution: 'xl:flex'
}
diff --git a/interface/hooks/useIsLocationIndexing.ts b/interface/hooks/useIsLocationIndexing.ts
index c77604e5e..e9e4d510a 100644
--- a/interface/hooks/useIsLocationIndexing.ts
+++ b/interface/hooks/useIsLocationIndexing.ts
@@ -28,6 +28,7 @@ export const useIsLocationIndexing = (locationId: number): boolean => {
) {
return job.completed_task_count === 0;
}
+ return false;
})
) || false;
diff --git a/interface/hooks/useRedirectToNewLocation.ts b/interface/hooks/useRedirectToNewLocation.ts
index 4310753bd..d34ec3096 100644
--- a/interface/hooks/useRedirectToNewLocation.ts
+++ b/interface/hooks/useRedirectToNewLocation.ts
@@ -1,6 +1,6 @@
+import { useLibraryQuery, useSelector } from '@sd/client';
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
-import { useLibraryQuery, useSelector } from '@sd/client';
import { explorerStore } from '~/app/$libraryId/Explorer/store';
import { LibraryIdParamsSchema } from '../app/route-schemas';
diff --git a/interface/hooks/useShortcut.ts b/interface/hooks/useShortcut.ts
index a7208aed5..997fb4485 100644
--- a/interface/hooks/useShortcut.ts
+++ b/interface/hooks/useShortcut.ts
@@ -37,6 +37,10 @@ const shortcuts = {
macOS: ['Meta', 'KeyJ'],
all: ['Control', 'KeyJ']
},
+ toggleTagAssignMode: {
+ macOS: ['Meta', 'Alt', 'KeyT'],
+ all: ['Control', 'Alt', 'KeyT']
+ },
navBackwardHistory: {
macOS: ['Meta', '['],
all: ['Control', '[']
diff --git a/interface/locales/ar/common.json b/interface/locales/ar/common.json
index bdc3c88b9..79b32e7a4 100644
--- a/interface/locales/ar/common.json
+++ b/interface/locales/ar/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "تسجيل الدخول يسمح لنا بالرد على ملاحظاتك",
"feedback_placeholder": "ملاحظاتك...",
"feedback_toast_error_message": "حدث خطأ أثناء إرسال ملاحظاتك. يرجى المحاولة مرة أخرى.",
- "file": "file",
"file_already_exist_in_this_location": "الملف موجود بالفعل في هذا الموقع",
"file_directory_name": "اسم الملف/الدليل",
"file_extension_description": "امتداد الملف (على سبيل المثال، .mp4، .jpg، .txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "قواعد فهرسة الملفات",
+ "file_one": "file",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "ملفات",
+ "file_zero": "ملفات",
+ "files_many": "files",
"filter": "Filter",
"filters": "مرشحات",
"flash": "فلاش",
diff --git a/interface/locales/be/common.json b/interface/locales/be/common.json
index 2a0c79f26..a068dffad 100644
--- a/interface/locales/be/common.json
+++ b/interface/locales/be/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "Уваход у сістэму дазваляе нам адказваць на ваш фідбэк",
"feedback_placeholder": "Ваш фідбэк...",
"feedback_toast_error_message": "Пры адпраўленні вашага фідбэку адбылася абмыла. Калі ласка, паспрабуйце яшчэ раз.",
- "file": "файл",
"file_already_exist_in_this_location": "Файл ужо існуе ў гэтай лакацыі",
"file_directory_name": "Імя файла/папкі",
"file_extension_description": "Пашырэнне файла (напрыклад, .mp4, .jpg, .txt)",
"file_from": "Файл {{file}} з {{name}}",
"file_indexing_rules": "Правілы індэксацыі файлаў",
+ "file_one": "файл",
"file_picker_not_supported": "Сістэма выбару файлаў не падтрымліваецца на гэтай платформе",
- "files": "файлы",
+ "file_two": "файлы",
+ "file_zero": "файлы",
+ "files_many": "файлы",
"filter": "Фільтр",
"filters": "Фільтры",
"flash": "Успышка",
diff --git a/interface/locales/de/common.json b/interface/locales/de/common.json
index ebc769556..804ef8818 100644
--- a/interface/locales/de/common.json
+++ b/interface/locales/de/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "Die Anmeldung ermöglicht es uns, auf Ihr Feedback zu antworten",
"feedback_placeholder": "Ihr Feedback...",
"feedback_toast_error_message": "Beim Senden deines Feedbacks ist ein Fehler aufgetreten. Bitte versuche es erneut.",
- "file": "file",
"file_already_exist_in_this_location": "Die Datei existiert bereits an diesem Speicherort",
"file_directory_name": "Datei-/Verzeichnisname",
"file_extension_description": "Dateierweiterung (z. B. .mp4, .jpg, .txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "Dateiindizierungsregeln",
+ "file_one": "file",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "Dateien",
+ "file_zero": "Dateien",
+ "files_many": "files",
"filter": "Filter",
"filters": "Filter",
"flash": "Blitz",
diff --git a/interface/locales/en/common.json b/interface/locales/en/common.json
index 7167481ba..274ea90dd 100644
--- a/interface/locales/en/common.json
+++ b/interface/locales/en/common.json
@@ -216,6 +216,7 @@
"error": "Error",
"error_loading_original_file": "Error loading original file",
"error_message": "Error: {{error}}.",
+ "error_unknown": "An unknown error occurred.",
"executable": "Executable",
"expand": "Expand",
"explorer": "Explorer",
@@ -262,14 +263,17 @@
"feedback_login_description": "Logging in allows us to respond to your feedback",
"feedback_placeholder": "Your feedback...",
"feedback_toast_error_message": "There was an error submitting your feedback. Please try again.",
- "file": "file",
"file_already_exist_in_this_location": "File already exists in this location",
"file_directory_name": "File/Directory name",
"file_extension_description": "File extension (e.g., .mp4, .jpg, .txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "File indexing rules",
+ "file_many": "files",
+ "file_one": "file",
+ "file_other": "files",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "files",
+ "file_zero": "files",
"filter": "Filter",
"filters": "Filters",
"flash": "Flash",
@@ -670,6 +674,11 @@
"tag_one": "Tag",
"tag_other": "Tags",
"tags": "Tags",
+ "tags_bulk_assigned": "Assigned tag \"{{tag_name}}\" to {{file_count}} $t(file, { \"count\": {{file_count}} }).",
+ "tags_bulk_failed_with_tag": "Could not assign tag \"{{tag_name}}\" to {{file_count}} $t(file, { \"count\": {{file_count}} }): {{error_message}}",
+ "tags_bulk_failed_without_tag": "Could not tag {{file_count}} $t(file, { \"count\": {{file_count}} }): {{error_message}}",
+ "tags_bulk_instructions": "Select one or more files and press a key to assign the corresponding tag.",
+ "tags_bulk_mode_active": "Tag assign mode is enabled.",
"tags_description": "Manage your tags.",
"tags_notice_message": "No items assigned to this tag.",
"task": "task",
diff --git a/interface/locales/es/common.json b/interface/locales/es/common.json
index 2051ce55c..c1cfc7beb 100644
--- a/interface/locales/es/common.json
+++ b/interface/locales/es/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "Iniciar sesión nos permite responder a tu retroalimentación",
"feedback_placeholder": "Tu retroalimentación...",
"feedback_toast_error_message": "Hubo un error al enviar tu retroalimentación. Por favor, inténtalo de nuevo.",
- "file": "file",
"file_already_exist_in_this_location": "El archivo ya existe en esta ubicación",
"file_directory_name": "Nombre de archivo/directorio",
"file_extension_description": "Extensión de archivo (por ejemplo, .mp4, .jpg, .txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "Reglas de indexación de archivos",
+ "file_one": "file",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "archivos",
+ "file_zero": "archivos",
+ "files_many": "files",
"filter": "Filtro",
"filters": "Filtros",
"flash": "Destello",
diff --git a/interface/locales/fr/common.json b/interface/locales/fr/common.json
index 936d712f0..fcff764bd 100644
--- a/interface/locales/fr/common.json
+++ b/interface/locales/fr/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "La connexion nous permet de répondre à votre retour d'information",
"feedback_placeholder": "Votre retour d'information...",
"feedback_toast_error_message": "Une erreur s'est produite lors de l'envoi de votre retour d'information. Veuillez réessayer.",
- "file": "file",
"file_already_exist_in_this_location": "Le fichier existe déjà à cet emplacement",
"file_directory_name": "Nom du fichier/répertoire",
"file_extension_description": "Extension de fichier (par exemple, .mp4, .jpg, .txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "Règles d'indexation des fichiers",
+ "file_one": "file",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "des dossiers",
+ "file_zero": "des dossiers",
+ "files_many": "files",
"filter": "Filtre",
"filters": "Filtres",
"flash": "Éclair",
diff --git a/interface/locales/it/common.json b/interface/locales/it/common.json
index 24d1164c6..ecf7f3b21 100644
--- a/interface/locales/it/common.json
+++ b/interface/locales/it/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "Effettuando l'accesso possiamo rispondere al tuo feedback",
"feedback_placeholder": "Il tuo feedback...",
"feedback_toast_error_message": "Si è verificato un errore durante l'invio del tuo feedback. Riprova.",
- "file": "file",
"file_already_exist_in_this_location": "Il file esiste già in questa posizione",
"file_directory_name": "Nome del file/directory",
"file_extension_description": "Estensione del file (ad esempio, .mp4, .jpg, .txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "Regole di indicizzazione dei file",
+ "file_one": "file",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "File",
+ "file_zero": "File",
+ "files_many": "files",
"filter": "Filtro",
"filters": "Filtri",
"flash": "Veloce",
diff --git a/interface/locales/ja/common.json b/interface/locales/ja/common.json
index 89a6bc18a..de87c8d9c 100644
--- a/interface/locales/ja/common.json
+++ b/interface/locales/ja/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "ログインすることで、フィードバックを送ることができます。",
"feedback_placeholder": "フィードバックを入力...",
"feedback_toast_error_message": "フィードバックの送信中にエラーが発生しました。もう一度お試しください。",
- "file": "ファイル",
"file_already_exist_in_this_location": "このファイルは既にこのロケーションに存在します",
"file_directory_name": "ファイル/ディレクトリ名",
"file_extension_description": "ファイル拡張子 (例: .mp4、.jpg、.txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "ファイルのインデックス化ルール",
+ "file_one": "ファイル",
"file_picker_not_supported": "このプラットフォームではファイルピッカーはサポートされていません",
- "files": "ファイル",
+ "file_two": "ファイル",
+ "file_zero": "ファイル",
+ "files_many": "ファイル",
"filter": "フィルター",
"filters": "フィルター",
"flash": "閃光",
diff --git a/interface/locales/nl/common.json b/interface/locales/nl/common.json
index 56ec29a97..3554aac2f 100644
--- a/interface/locales/nl/common.json
+++ b/interface/locales/nl/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "Inloggen stelt ons in staat om te reageren op jouw feedback",
"feedback_placeholder": "Jouw feedback...",
"feedback_toast_error_message": "Er is een fout opgetreden bij het verzenden van je feedback. Probeer het opnieuw.",
- "file": "file",
"file_already_exist_in_this_location": "Bestand bestaat al op deze locatie",
"file_directory_name": "Bestands-/mapnaam",
"file_extension_description": "Bestandsextensie (bijvoorbeeld .mp4, .jpg, .txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "Bestand indexeringsregels",
+ "file_one": "file",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "bestanden",
+ "file_zero": "bestanden",
+ "files_many": "files",
"filter": "Filter",
"filters": "Filters",
"flash": "Flash",
diff --git a/interface/locales/ru/common.json b/interface/locales/ru/common.json
index 16cff4359..608371df7 100644
--- a/interface/locales/ru/common.json
+++ b/interface/locales/ru/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "Вход в систему позволяет нам отвечать на ваш фидбек",
"feedback_placeholder": "Ваш фидбек...",
"feedback_toast_error_message": "При отправке вашего фидбека произошла ошибка. Пожалуйста, попробуйте еще раз.",
- "file": "файл",
"file_already_exist_in_this_location": "Файл уже существует в этой локации",
"file_directory_name": "Имя файла/папки",
"file_extension_description": "Расширение файла (например, .mp4, .jpg, .txt)",
"file_from": "Файл {{file}} из {{name}}",
"file_indexing_rules": "Правила индексации файлов",
+ "file_one": "файл",
"file_picker_not_supported": "Система выбора файлов не поддерживается на этой платформе",
- "files": "файлы",
+ "file_two": "файлы",
+ "file_zero": "файлы",
+ "files_many": "файлы",
"filter": "Фильтр",
"filters": "Фильтры",
"flash": "Вспышка",
diff --git a/interface/locales/tr/common.json b/interface/locales/tr/common.json
index 35319e6f2..f15564171 100644
--- a/interface/locales/tr/common.json
+++ b/interface/locales/tr/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "Giriş yapmak, geribildiriminize yanıt vermemizi sağlar",
"feedback_placeholder": "Geribildiriminiz...",
"feedback_toast_error_message": "Geribildiriminizi gönderirken bir hata oluştu. Lütfen tekrar deneyin.",
- "file": "file",
"file_already_exist_in_this_location": "Dosya bu konumda zaten mevcut",
"file_directory_name": "Dosya/Dizin adı",
"file_extension_description": "Dosya uzantısı (ör. .mp4, .jpg, .txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "Dosya İndeksleme Kuralları",
+ "file_one": "file",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "Dosyalar",
+ "file_zero": "Dosyalar",
+ "files_many": "files",
"filter": "Filtre",
"filters": "Filtreler",
"flash": "Flaş",
diff --git a/interface/locales/zh-CN/common.json b/interface/locales/zh-CN/common.json
index 08ed5797f..78a26ca0e 100644
--- a/interface/locales/zh-CN/common.json
+++ b/interface/locales/zh-CN/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "登录使我们能够回复您的反馈",
"feedback_placeholder": "您的反馈...",
"feedback_toast_error_message": "提交反馈时出错,请重试。",
- "file": "file",
"file_already_exist_in_this_location": "文件已存在于此位置",
"file_directory_name": "文件/目录名称",
"file_extension_description": "文件扩展名(例如 .mp4、.jpg、.txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "文件索引规则",
+ "file_one": "file",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "文件",
+ "file_zero": "文件",
+ "files_many": "files",
"filter": "筛选",
"filters": "过滤器",
"flash": "闪光",
diff --git a/interface/locales/zh-TW/common.json b/interface/locales/zh-TW/common.json
index 22746df81..8abf6b03e 100644
--- a/interface/locales/zh-TW/common.json
+++ b/interface/locales/zh-TW/common.json
@@ -260,14 +260,16 @@
"feedback_login_description": "登入可讓我們回應您的回饋",
"feedback_placeholder": "您的回饋...",
"feedback_toast_error_message": "提交回饋時發生錯誤。請重試。",
- "file": "file",
"file_already_exist_in_this_location": "該位置已存在該檔案",
"file_directory_name": "檔案/目錄名稱",
"file_extension_description": "檔案副檔名(例如 .mp4、.jpg、.txt)",
"file_from": "File {{file}} from {{name}}",
"file_indexing_rules": "文件索引規則",
+ "file_one": "file",
"file_picker_not_supported": "File picker not supported on this platform",
- "files": "files",
+ "file_two": "文件",
+ "file_zero": "文件",
+ "files_many": "files",
"filter": "篩選",
"filters": "篩選器",
"flash": "閃光",
diff --git a/turbo.json b/turbo.json
index 65e1c39a0..ea9c9edd6 100644
--- a/turbo.json
+++ b/turbo.json
@@ -16,5 +16,5 @@
"cache": false
}
},
- "globalEnv": ["PORT", "NODE_ENV", "GENERATE_SOURCEMAP"]
+ "globalEnv": ["PORT", "NODE_ENV", "GENERATE_SOURCEMAP", "DEV"]
}