[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 `<slot />` 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>
This commit is contained in:
Lynx 2024-06-18 05:52:19 -05:00 committed by GitHub
parent 18235c6f09
commit 0688406adc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 487 additions and 73 deletions

View file

@ -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 (
<div
className="group absolute inset-x-0 bottom-0 z-50 flex items-center border-t border-t-app-line bg-app/90 px-3.5 text-[11px] text-ink-dull backdrop-blur-lg"
style={{ height: PATH_BAR_HEIGHT }}
className={clsx(
'group flex items-center border-t border-t-app-line bg-app/90 px-3.5 text-[11px] text-ink-dull backdrop-blur-lg',
`h-[${PATH_BAR_HEIGHT}px]`
)}
>
{paths.map((path) => (
{paths.map((path, idx) => (
<Path
key={path.pathname}
path={path}
isLast={idx === paths.length - 1}
locationPath={location?.path ?? ''}
onClick={() => 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) => {
<button
ref={setDroppableRef}
className={clsx(
'group flex items-center gap-1 rounded px-1 py-0.5',
'flex items-center gap-1 rounded p-1',
(isDroppable || contextMenuOpen) && [
isDark ? 'bg-app-button/70' : 'bg-app-darkerBox'
],
@ -205,11 +210,7 @@ const Path = ({ path, onClick, disabled, locationPath }: PathProps) => {
>
<Icon name="Folder" size={16} alt="Folder" />
<span className="max-w-xs truncate text-ink-dull">{path.name}</span>
<CaretRight
weight="bold"
className="text-ink-dull group-last:hidden"
size={10}
/>
{!isLast && <CaretRight weight="bold" className="text-ink-dull" size={10} />}
</button>
}
>

View file

@ -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<HTMLUListElement | null>(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<number | undefined>();
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 (
<div
className={clsx(
'flex flex-row flex-wrap-reverse items-center justify-between gap-1 border-t border-t-app-line bg-app/90 px-3.5 py-2 text-ink-dull backdrop-blur-lg',
)}
>
<em className="text-sm tracking-wide">{t('tags_bulk_instructions')}</em>
<ul
ref={tagsRef}
// TODO: I want to replace this `overlay-scroll` style with a better system for non-horizontral-scroll mouse users, but
// for now this style looks the least disgusting. Probably will end up going for a left/right arrows button that dynamically
// shows/hides depending on scroll position.
className={clsx(
'flex-0 explorer-scroll my-1 flex max-w-full list-none flex-row gap-2 overflow-x-auto',
isTagsOverflowing ? 'pb-2' : 'pb-0'
)}
>
{/* 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) => (
<li key={tag.id}>
<TagItem
tag={tag}
assignKey={
tagBulkAssignHotkeys.find(({ tagId }) => 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);
}}
/>
</li>
))}
</ul>
</div>
);
};
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<HTMLButtonElement>(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 (
<button
className={clsx('group flex items-center gap-1 rounded-lg border px-2.5 py-0.5', {
['border border-app-line bg-app-box']: !isAwaitingKeyPress && isDark,
['border-accent bg-app-box']: isAwaitingKeyPress && isDark,
['border-accent bg-app-lightBox']: isAwaitingKeyPress && !isDark
})}
ref={buttonRef}
onClick={onClick}
aria-live={isAwaitingKeyPress ? 'assertive' : 'off'}
aria-label={
isAwaitingKeyPress
? `Type a number to map it to the "${tag.name}" tag. Press escape to cancel.`
: undefined
}
>
<Circle
fill={tag.color ?? 'grey'}
weight="fill"
alt=""
aria-hidden
className="size-2.5"
/>
<span className="max-w-xs truncate py-0.5 text-sm font-semibold text-ink-dull">
{tag.name}
</span>
{assignKey && <Shortcut chars={keybind([], [assignKey])} />}
</button>
);
};

View file

@ -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<ExplorerLayout, Icon> = {
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 className={TOP_BAR_ICON_STYLE} />,
icon: <Icon className={TOP_BAR_ICON_CLASSLIST} />,
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: <SlidersHorizontal className={TOP_BAR_ICON_STYLE} />,
icon: <SlidersHorizontal className={TOP_BAR_ICON_CLASSLIST} />,
popOverComponent: <OptionsPanel />,
individual: true,
showAtResolution: 'sm:flex'
@ -87,7 +88,7 @@ export const useExplorerTopBarOptions = () => {
icon: (
<SidebarSimple
weight={showInspector ? 'fill' : 'regular'}
className={clsx(TOP_BAR_ICON_STYLE, '-scale-x-100')}
className={clsx(TOP_BAR_ICON_CLASSLIST, '-scale-x-100')}
/>
),
individual: true,
@ -118,11 +119,28 @@ export const useExplorerTopBarOptions = () => {
showAtResolution: 'xl:flex'
},
{
toolTipLabel: t('key_manager'),
icon: <Key className={TOP_BAR_ICON_STYLE} />,
toolTipLabel: 'Key Manager',
icon: <Key className={TOP_BAR_ICON_CLASSLIST} />,
popOverComponent: <KeyManager />,
individual: true,
showAtResolution: 'xl:flex'
},
{
toolTipLabel: 'Assign tags',
icon: (
<Tag
weight={tagAssignMode ? 'fill' : 'regular'}
className={TOP_BAR_ICON_CLASSLIST}
/>
),
// 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',

View file

@ -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`;

View file

@ -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;

View file

@ -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<Props>) {
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<Props>) {
</div>
</ExplorerContextMenu>
{showPathBar && <ExplorerPath />}
{/* TODO: wrap path bar and tag bar in nice wrapper, ideally animate tag bar in/out directly above path bar */}
<div className="absolute inset-x-0 bottom-0 z-50 flex flex-col">
{showTagBar && <ExplorerTagBar />}
{showPathBar && <ExplorerPathBar />}
</div>
{showInspector && (
<Inspector
className="no-scrollbar absolute right-1.5 top-0 pb-3 pl-3 pr-1.5"
className={clsx(
'no-scrollbar absolute right-1.5 top-0 pb-3 pl-3 pr-1.5',
showPathBar && `b-[${PATH_BAR_HEIGHT}px]`
)}
style={{
paddingTop: topBar.topBarHeight + 12,
bottom: showPathBar ? PATH_BAR_HEIGHT : 0
paddingTop: topBar.topBarHeight + 12
}}
/>
)}

View file

@ -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<string>,
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) {

View file

@ -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 (
<div ref={ref} className={dndState === 'active' && !isPanelOpen ? 'animate-bounce' : ''}>
<Planet className={TOP_BAR_ICON_STYLE} />
<Planet className={TOP_BAR_ICON_CLASSLIST} />
</div>
);
}

View file

@ -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={
<TopBarButton>
<DotsThreeCircle className={TOP_BAR_ICON_STYLE} />
<DotsThreeCircle className={TOP_BAR_ICON_CLASSLIST} />
</TopBarButton>
}
>

View file

@ -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()}
>
<Minus weight="regular" className={clsx(TOP_BAR_ICON_STYLE)} />
<Minus weight="regular" className={clsx(TOP_BAR_ICON_CLASSLIST)} />
</TopBarButton>
<TopBarButton
rounding="both"
@ -204,9 +204,9 @@ export function WindowsControls({ windowSize }: { windowSize: number }) {
}}
>
{maximized ? (
<Cards weight="regular" className={clsx(TOP_BAR_ICON_STYLE)} />
<Cards weight="regular" className={clsx(TOP_BAR_ICON_CLASSLIST)} />
) : (
<Square weight="regular" className={clsx(TOP_BAR_ICON_STYLE)} />
<Square weight="regular" className={clsx(TOP_BAR_ICON_CLASSLIST)} />
)}
</TopBarButton>
<TopBarButton
@ -215,7 +215,7 @@ export function WindowsControls({ windowSize }: { windowSize: number }) {
active={false}
onClick={() => appWindow.close()}
>
<X weight="regular" className={clsx(TOP_BAR_ICON_STYLE, 'hover:text-white')} />
<X weight="regular" className={clsx(TOP_BAR_ICON_CLASSLIST, 'hover:text-white')} />
</TopBarButton>
</div>
);

View file

@ -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: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />,
icon: <ArrowClockwise className={TOP_BAR_ICON_CLASSLIST} />,
individual: true,
showAtResolution: 'xl:flex'
}

View file

@ -28,6 +28,7 @@ export const useIsLocationIndexing = (locationId: number): boolean => {
) {
return job.completed_task_count === 0;
}
return false;
})
) || false;

View file

@ -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';

View file

@ -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', '[']

View file

@ -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": "فلاش",

View file

@ -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": "Успышка",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "閃光",

View file

@ -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",

View file

@ -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": "Вспышка",

View file

@ -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ş",

View file

@ -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": "闪光",

View file

@ -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": "閃光",

View file

@ -16,5 +16,5 @@
"cache": false
}
},
"globalEnv": ["PORT", "NODE_ENV", "GENERATE_SOURCEMAP"]
"globalEnv": ["PORT", "NODE_ENV", "GENERATE_SOURCEMAP", "DEV"]
}