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) => { } > 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 ( + + ); +}; 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"] }