@ -101,29 +101,68 @@
Narrower while it overlays , so there is a comfortable width of scrim left to tap on .
Narrower while it overlays , so there is a comfortable width of scrim left to tap on .
-- >
-- >
< w -drawer class = "fileman-left" v-model ="treeDrawerOpen" :width="isTreeOverlay ? 300 : 350" >
< w -drawer class = "fileman-left" v-model ="treeDrawerOpen" :width="isTreeOverlay ? 300 : 350" >
< w -scroll -area :thumb-style ="thumbStyle" :bar-style ="barStyle" style = "height: 100%" >
< div class = "flex h-full flex-col" >
< w -scroll -area
class = "min-h-0 flex-1"
: thumb - style = "thumbStyle"
: bar - style = "barStyle"
style = "height: 100%" >
<!--
<!--
- > No side padding : the tree ' s rows run the full width of the drawer , so a hovered or
- > No side padding : the tree ' s rows run the full width of the drawer , so a hovered or
selected row reads as a band across it rather than a floating pill . ` pt-2 ` is the gap
selected row reads as a band across it rather than a floating pill . ` pt-2 ` is the gap
above the root entry that the padding used to imply .
above the root entry that the padding used to imply .
-- >
-- >
<!--
- > The tree is told the bin is selected by being handed an id no folder has , which leaves
every row -- the root included -- unhighlighted while the bin is open
-- >
< div class = "pt-2 pb-2" >
< div class = "pt-2 pb-2" >
< tree
< tree
ref = "treeComp"
ref = "treeComp"
: nodes = "state.treeNodes"
: nodes = "state.treeNodes"
: roots = "state.treeRoots"
: roots = "state.treeRoots"
v - model : selected = "state.currentFolderId"
: selected = "state.isRecycleBin ? RECYCLE_BIN_SELECTION : state.currentFolderId"
@ update : selected = "openFolder"
@ lazy - load = "treeLazyLoad"
@ lazy - load = "treeLazyLoad"
: use - lazy - load = "true"
: use - lazy - load = "true"
@ context - action = "treeContextAction"
@ context - action = "treeContextAction"
: display - mode = "state.displayMode" / >
: display - mode = "state.displayMode" / >
< / div >
< / div >
< / w - s c r o l l - a r e a >
< / w - s c r o l l - a r e a >
<!--
Pinned under the tree rather than a node in it : it is not a place in the wiki , and it has to
stay reachable however far the tree has been scrolled or expanded . Signed in only -- the
permissions that open it cannot be granted to the guests group , so a visitor would only
ever find it empty .
-- >
< button
v - if = "userStore.authenticated"
type = "button"
class = "fileman-bin w-unstyled"
: class = "{ 'is-active': state.isRecycleBin }"
: aria - pressed = "state.isRecycleBin"
@ click = "openRecycleBin" >
< w -icon name = "la:recycle" size = "sm" / >
< span class = "fileman-bin-label" > { { t ( 'fileman.recycleBin' ) } } < / span >
< w -icon v -if = " state.isRecycleBin " name = "la:angle-right" / >
< / button >
< / div >
< / w - d r a w e r >
< / w - d r a w e r >
< w -drawer class = "fileman-right" :model-value ="detailsPaneShown" :width ="350" side = "right" >
< w -drawer class = "fileman-right" :model-value ="detailsPaneShown" :width ="350" side = "right" >
< w -scroll -area :thumb-style ="thumbStyle" :bar-style ="barStyle" style = "height: 100%" >
< w -scroll -area :thumb-style ="thumbStyle" :bar-style ="barStyle" style = "height: 100%" >
< div class = "p-4" >
< div class = "p-4" >
< template v-if ="currentFileDetails" >
< template v-if ="state.isRecycleBin" >
< template v-if ="selectedDeletedPage" >
< img
class = "mb-4 w-full rounded object-cover"
src = "/_assets/illustrations/fileman-page.svg" / >
< div class = "fileman-details-row" v-for ="item of deletedPageDetails" :key="item.label" >
< label > { { item . label } } < / label >
< span > { { item . value } } < / span >
< / div >
< / template >
< / template >
< template v -else -if = " currentFileDetails " >
<!--
<!--
A button around the thumbnail , and only for an image : it opens the full view , and the
A button around the thumbnail , and only for an image : it opens the full view , and the
illustration standing in for a page has nothing behind it to open .
illustration standing in for a page has nothing behind it to open .
@ -213,6 +252,10 @@
t ( ` common.sidebar.browse ` )
t ( ` common.sidebar.browse ` )
} } < / w - t o o l t i p >
} } < / w - t o o l t i p >
< / w - b t n >
< / w - b t n >
< div v-if ="state.isRecycleBin" class="fileman-toolbar-title" >
< w -icon name = "la:recycle" class = "mr-2" size = "sm" / >
{ { t ( 'fileman.recycleBin' ) } }
< / div >
< w -space / >
< w -space / >
< w -btn
< w -btn
class = "mr-2"
class = "mr-2"
@ -240,7 +283,7 @@
< w -item -section side >
< w -item -section side >
< w -icon name = "la:list" color = "grey" size = "xs" / >
< w -icon name = "la:list" color = "grey" size = "xs" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > Browse using ... < / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > { { t ( ` fileman.browseUsing ` ) } } < / w - i t e m - s e c t i o n >
< w -item -section side >
< w -item -section side >
< w -icon name = "la:angle-right" color = "grey" size = "xs" / >
< w -icon name = "la:angle-right" color = "grey" size = "xs" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
@ -255,7 +298,9 @@
: color = "state.displayMode === `path` ? `positive` : `grey`"
: color = "state.displayMode === `path` ? `positive` : `grey`"
size = "xs" / >
size = "xs" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > Browse Using Paths < / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > { {
t ( ` fileman.browseUsingPaths ` )
} } < / w - i t e m - s e c t i o n >
< / w - i t e m >
< / w - i t e m >
< w -item clickable @ click = "state.displayMode = `title`" >
< w -item clickable @ click = "state.displayMode = `title`" >
< w -item -section side >
< w -item -section side >
@ -266,7 +311,9 @@
: color = "state.displayMode === `title` ? `positive` : `grey`"
: color = "state.displayMode === `title` ? `positive` : `grey`"
size = "xs" / >
size = "xs" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > Browse Using Titles < / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > { {
t ( ` fileman.browseUsingTitles ` )
} } < / w - i t e m - s e c t i o n >
< / w - i t e m >
< / w - i t e m >
< / w - l i s t >
< / w - l i s t >
< / w - m e n u >
< / w - m e n u >
@ -278,7 +325,7 @@
: color = "state.isCompact ? `positive` : `grey`"
: color = "state.isCompact ? `positive` : `grey`"
size = "xs" / >
size = "xs" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > Compact List < / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > { { t ( ` fileman.compactList ` ) } } < / w - i t e m - s e c t i o n >
< / w - i t e m >
< / w - i t e m >
< w -item clickable @ click = "state.shouldShowFolders = !state.shouldShowFolders" >
< w -item clickable @ click = "state.shouldShowFolders = !state.shouldShowFolders" >
< w -item -section side >
< w -item -section side >
@ -287,7 +334,21 @@
: color = "state.shouldShowFolders ? `positive` : `grey`"
: color = "state.shouldShowFolders ? `positive` : `grey`"
size = "xs" / >
size = "xs" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > Show Folders < / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > { { t ( ` fileman.showFolders ` ) } } < / w - i t e m - s e c t i o n >
< / w - i t e m >
< w -item
clickable
: disabled = "!state.shouldShowFolders"
@ click = "state.shouldListFoldersFirst = !state.shouldListFoldersFirst" >
< w -item -section side >
< w -icon
: name = "state.shouldListFoldersFirst ? `la:check-square` : `la:stop`"
: color = "state.shouldListFoldersFirst ? `positive` : `grey`"
size = "xs" / >
< / w - i t e m - s e c t i o n >
< w -item -section class = "pr-2" > { {
t ( ` fileman.listFoldersFirst ` )
} } < / w - i t e m - s e c t i o n >
< / w - i t e m >
< / w - i t e m >
< / w - l i s t >
< / w - l i s t >
< / w - c a r d >
< / w - c a r d >
@ -301,11 +362,13 @@
color = "grey"
color = "grey"
: aria - label = "t(`common.actions.refresh`)"
: aria - label = "t(`common.actions.refresh`)"
icon = "la:redo-alt"
icon = "la:redo-alt"
@ click = "re loadFolder(state.currentFolderId) ">
@ click = "re freshListing ">
< w -tooltip anchor = "bottom middle" self = "top middle" > { {
< w -tooltip anchor = "bottom middle" self = "top middle" > { {
t ( ` common.actions.refresh ` )
t ( ` common.actions.refresh ` )
} } < / w - t o o l t i p >
} } < / w - t o o l t i p >
< / w - b t n >
< / w - b t n >
<!-- - > New , Upload and Insert all act on the folder being listed , and the bin is not one -- >
< template v-if ="!state.isRecycleBin" >
< w -separator class = "mr-2" inset vertical / >
< w -separator class = "mr-2" inset vertical / >
< w -btn
< w -btn
class = "mr-2"
class = "mr-2"
@ -351,10 +414,81 @@
icon = "la:plus-circle"
icon = "la:plus-circle"
@ click = "insertItem()" / >
@ click = "insertItem()" / >
< / template >
< / template >
< / template >
< / w - t o o l b a r >
< / w - t o o l b a r >
< div class = "flex flex-wrap" style = "flex: 1 1 100%" >
< div class = "flex flex-wrap" style = "flex: 1 1 100%" >
< div class = "min-w-0 flex-1" >
< div class = "min-w-0 flex-1" >
< w -scroll -area :thumb-style ="thumbStyle" :bar-style ="barStyle" style = "height: 100%" >
< w -scroll -area :thumb-style ="thumbStyle" :bar-style ="barStyle" style = "height: 100%" >
<!-- RECYCLE BIN -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- >
< template v-if ="state.isRecycleBin" >
< div class = "fileman-loadinglist" v-if ="state.binLoading" >
< w -spinner class = "mr-2" color = "primary" size = "64px" / >
< span class = "text-primary" > { { t ( 'fileman.recycleBinLoading' ) } } < / span >
< / div >
< div class = "fileman-emptylist" v -else -if = " deletedPages.length < 1 " >
< img src = "/_assets/icons/carbon-copy-empty-box.svg" / >
< span > { { t ( 'fileman.recycleBinEmpty' ) } } < / span >
< / div >
< w -list class = "fileman-filelist" v -else : class = "state.isCompact && `is-compact`" >
< w -item
v - for = "item of deletedPages"
: key = "item.versionId"
clickable
active - class = "active"
: active = "item.versionId === state.binSelectedId"
@ click = "state.binSelectedId = item.versionId"
@ dblclick = "viewDeletedPage(item)" >
< w -item -section class = "fileman-filelist-icon" avatar >
<!-- - > The page 's own icon where it chose one, and its kind' s otherwise . No
colour of its own : it inherits the row ' s ink , which is what keeps it
visible on a selected row ' s primary fill -- >
< w -icon
: name = "item.icon || deletedPageTypeIcon(item)"
: size = "state.isCompact ? `md` : `xl`" / >
< / w - i t e m - s e c t i o n >
< w -item -section class = "fileman-filelist-label" >
< w -item -label > { { item . title } } < / w - i t e m - l a b e l >
< w -item -label caption v-if ="!state.isCompact" > / {{ item.path }} < / w -item -label >
< / w - i t e m - s e c t i o n >
< w -item -section class = "fileman-filelist-side" side >
< div class = "text-caption" > { { formatDateTime ( item . deletedAt ) } } < / div >
< / w - i t e m - s e c t i o n >
<!-- RIGHT - CLICK MENU -- >
< w -menu
class = "translucent-menu"
touch - position
context - menu
auto - close
transition - show = "jump-down"
transition - hide = "jump-up" >
< w -card class = "p-2" >
< w -list dense style = "min-width: 150px" >
< w -item clickable @click ="viewDeletedPage(item)" >
< w -item -section side >
< w -icon name = "la:eye" color = "primary" / >
< / w - i t e m - s e c t i o n >
< w -item -section > { { t ( ` common.actions.view ` ) } } < / w - i t e m - s e c t i o n >
< / w - i t e m >
< w -item clickable @click ="restoreDeletedItem(item)" >
< w -item -section side >
< w -icon name = "la:undo" color = "deep-orange-9" / >
< / w - i t e m - s e c t i o n >
< w -item -section > { { t ( ` history.restore ` ) } } ... < / w - i t e m - s e c t i o n >
< / w - i t e m >
< w -item clickable @click ="downloadDeletedPage(item)" >
< w -item -section side >
< w -icon name = "la:download" color = "primary" / >
< / w - i t e m - s e c t i o n >
< w -item -section > { { t ( ` common.actions.download ` ) } } < / w - i t e m - s e c t i o n >
< / w - i t e m >
< / w - l i s t >
< / w - c a r d >
< / w - m e n u >
< / w - i t e m >
< / w - l i s t >
< / template >
<!-- FOLDER LISTING -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - -- >
< template v-else >
< div class = "fileman-loadinglist" v-if ="state.fileListLoading" >
< div class = "fileman-loadinglist" v-if ="state.fileListLoading" >
< w -spinner class = "mr-2" color = "primary" size = "64px" / >
< w -spinner class = "mr-2" color = "primary" size = "64px" / >
< span class = "text-primary" > Fetching folder contents ... < / span >
< span class = "text-primary" > Fetching folder contents ... < / span >
@ -380,7 +514,9 @@
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
< w -item -section class = "fileman-filelist-label" >
< w -item -section class = "fileman-filelist-label" >
< w -item -label > { { usePathTitle ? item . fileName : item . title } } < / w - i t e m - l a b e l >
< w -item -label > { { usePathTitle ? item . fileName : item . title } } < / w - i t e m - l a b e l >
< w -item -label caption v-if ="!state.isCompact" > {{ item.caption }} < / w -item -label >
< w -item -label caption v-if ="!state.isCompact" > {{
item . caption
} } < / w - i t e m - l a b e l >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
< w -item -section class = "fileman-filelist-side" side v-if ="item.side" >
< w -item -section class = "fileman-filelist-side" side v-if ="item.side" >
< div class = "text-caption" > { { item . side } } < / div >
< div class = "text-caption" > { { item . side } } < / div >
@ -433,7 +569,9 @@
<!-- - > Behind the experimental flag : neither has a handler behind it yet ,
<!-- - > Behind the experimental flag : neither has a handler behind it yet ,
so on an ordinary instance they are two rows that do nothing -- >
so on an ordinary instance they are two rows that do nothing -- >
< template
< template
v - if = "flagsStore.experimental && item.type === `asset` && item.imageEdit" >
v - if = "
flagsStore . experimental && item . type === ` asset ` && item . imageEdit
" >
< w -item clickable >
< w -item clickable >
< w -item -section side >
< w -item -section side >
< w -icon name = "la:edit" color = "orange" / >
< w -icon name = "la:edit" color = "orange" / >
@ -447,13 +585,19 @@
< w -item -section > Resize Image ... < / w - i t e m - s e c t i o n >
< w -item -section > Resize Image ... < / w - i t e m - s e c t i o n >
< / w - i t e m >
< / w - i t e m >
< / template >
< / template >
< w -item clickable v-if ="item.type !== `folder`" @click="copyItemURL(item)" >
< w -item
clickable
v - if = "item.type !== `folder`"
@ click = "copyItemURL(item)" >
< w -item -section side >
< w -item -section side >
< w -icon name = "la:clipboard" color = "primary" / >
< w -icon name = "la:clipboard" color = "primary" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
< w -item -section > { { t ( ` common.actions.copyURL ` ) } } < / w - i t e m - s e c t i o n >
< w -item -section > { { t ( ` common.actions.copyURL ` ) } } < / w - i t e m - s e c t i o n >
< / w - i t e m >
< / w - i t e m >
< w -item clickable v-if ="item.type === `asset`" @click="downloadItem(item)" >
< w -item
clickable
v - if = "item.type === `asset`"
@ click = "downloadItem(item)" >
< w -item -section side >
< w -item -section side >
< w -icon name = "la:download" color = "primary" / >
< w -icon name = "la:download" color = "primary" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
@ -473,7 +617,10 @@
upload has none to copy -- a second name over the same bytes is all it could
upload has none to copy -- a second name over the same bytes is all it could
mean , which is not what anyone is asking a wiki for .
mean , which is not what anyone is asking a wiki for .
-- >
-- >
< w -item clickable v-if ="item.type !== `asset`" @click="duplicateItem(item)" >
< w -item
clickable
v - if = "item.type !== `asset`"
@ click = "duplicateItem(item)" >
< w -item -section side >
< w -item -section side >
< w -icon name = "la:copy" color = "teal" / >
< w -icon name = "la:copy" color = "teal" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
@ -484,7 +631,10 @@
the page view ' s own action rail opens , so offering them as two actions
the page view ' s own action rail opens , so offering them as two actions
would be offering two ways into one form .
would be offering two ways into one form .
-- >
-- >
< w -item clickable v-if ="item.type === `page`" @click="renameMovePage(item)" >
< w -item
clickable
v - if = "item.type === `page`"
@ click = "renameMovePage(item)" >
< w -item -section side >
< w -item -section side >
< w -icon name = "la:share" color = "teal" / >
< w -icon name = "la:share" color = "teal" / >
< / w - i t e m - s e c t i o n >
< / w - i t e m - s e c t i o n >
@ -533,6 +683,7 @@
< / w - m e n u >
< / w - m e n u >
< / w - i t e m >
< / w - i t e m >
< / w - l i s t >
< / w - l i s t >
< / template >
< / w - s c r o l l - a r e a >
< / w - s c r o l l - a r e a >
< / div >
< / div >
< / div >
< / div >
@ -540,7 +691,9 @@
< / w - p a g e - c o n t a i n e r >
< / w - p a g e - c o n t a i n e r >
< w -footer >
< w -footer >
< w -bar class = "fileman-path" >
< w -bar class = "fileman-path" >
< small class = "text-caption text-grey-7" > { { folderPath } } < / small >
< small class = "text-caption text-grey-7" > { {
state . isRecycleBin ? t ( 'fileman.recycleBin' ) : folderPath
} } < / small >
< / w - b a r >
< / w - b a r >
< / w - f o o t e r >
< / w - f o o t e r >
< input type = "file" ref = "fileIpt" multiple @change ="uploadNewFiles" style = "display: none" / >
< input type = "file" ref = "fileIpt" multiple @change ="uploadNewFiles" style = "display: none" / >
@ -567,11 +720,13 @@ import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify'
import { notify } from '@/composables/notify'
import { useMinWidth , useScreen } from '@/composables/screen'
import { useMinWidth , useScreen } from '@/composables/screen'
import { useDark } from '@/composables/dark'
import { useDark } from '@/composables/dark'
import { useDeletedPages } from '@/composables/deletedPages'
import { useCommonStore } from '@/stores/common'
import { useCommonStore } from '@/stores/common'
import { useFlagsStore } from '@/stores/flags'
import { useFlagsStore } from '@/stores/flags'
import { usePageStore } from '@/stores/page'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
import { filesize } from 'filesize'
import { filesize } from 'filesize'
import Fuse from 'fuse.js/basic'
import Fuse from 'fuse.js/basic'
@ -581,6 +736,7 @@ import { apiErrorMessage } from '@/helpers/apiError'
import { assetUrl } from '@/helpers/assets'
import { assetUrl } from '@/helpers/assets'
import fileTypes from '@/helpers/fileTypes'
import fileTypes from '@/helpers/fileTypes'
import { folderIconStyle } from '@/helpers/folderColors'
import { folderIconStyle } from '@/helpers/folderColors'
import { saveVersionSource } from '@/helpers/pageVersions'
import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue'
import FolderDeleteDialog from '@/components/FolderDeleteDialog.vue'
import FolderRenameDialog from '@/components/FolderRenameDialog.vue'
import FolderRenameDialog from '@/components/FolderRenameDialog.vue'
@ -590,6 +746,7 @@ import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
const dark = useDark ( )
const dark = useDark ( )
const screen = useScreen ( )
const screen = useScreen ( )
const { restoreDeletedPage } = useDeletedPages ( )
/ / S T O R E S
/ / S T O R E S
@ -597,6 +754,7 @@ const commonStore = useCommonStore()
const flagsStore = useFlagsStore ( )
const flagsStore = useFlagsStore ( )
const pageStore = usePageStore ( )
const pageStore = usePageStore ( )
const siteStore = useSiteStore ( )
const siteStore = useSiteStore ( )
const userStore = useUserStore ( )
/ / R O U T E R
/ / R O U T E R
@ -615,6 +773,13 @@ const { t } = useI18n()
* /
* /
const VIEW _OPTIONS _KEY = 'wiki.fileman.viewOptions'
const VIEW _OPTIONS _KEY = 'wiki.fileman.viewOptions'
/ * *
* What the folder tree is told is selected while the Recycle Bin is open : an id no folder can have , so
* that no row in it -- the root included , which is what a null would highlight -- claims to be the
* place being listed .
* /
const RECYCLE _BIN _SELECTION = 'recycle-bin'
/ * *
/ * *
* The remembered view options , each taken only if it is still a value this component understands .
* The remembered view options , each taken only if it is still a value this component understands .
*
*
@ -637,6 +802,9 @@ function storedViewOptions() {
... ( typeof stored . isCompact === 'boolean' ? { isCompact : stored . isCompact } : { } ) ,
... ( typeof stored . isCompact === 'boolean' ? { isCompact : stored . isCompact } : { } ) ,
... ( typeof stored . shouldShowFolders === 'boolean'
... ( typeof stored . shouldShowFolders === 'boolean'
? { shouldShowFolders : stored . shouldShowFolders }
? { shouldShowFolders : stored . shouldShowFolders }
: { } ) ,
... ( typeof stored . shouldListFoldersFirst === 'boolean'
? { shouldListFoldersFirst : stored . shouldListFoldersFirst }
: { } )
: { } )
}
}
}
}
@ -666,11 +834,23 @@ const state = reactive({
displayMode : 'title' ,
displayMode : 'title' ,
isCompact : false ,
isCompact : false ,
shouldShowFolders : true ,
shouldShowFolders : true ,
/** Only consulted while `shouldShowFolders` is on: with no folders, there is nothing to put first. */
shouldListFoldersFirst : false ,
isUploading : false ,
isUploading : false ,
shouldCancelUpload : false ,
shouldCancelUpload : false ,
uploadPercentage : 0 ,
uploadPercentage : 0 ,
fileList : [ ] ,
fileList : [ ] ,
fileListLoading : false
fileListLoading : false ,
/ * *
* Whether the Recycle Bin is what the centre pane lists , in place of the folder . The folder stays
* selected underneath , listing and all , so leaving the bin for it again costs no fetch .
* /
isRecycleBin : false ,
binLoading : false ,
/** The deleted pages this reader may recover, as `GET …/pages/deleted` answered, newest first. */
binItems : [ ] ,
/** Keyed by the deletion's version id, which is what every action on a bin entry works from. */
binSelectedId : null
} )
} )
/ / - > O v e r t h e d e f a u l t s j u s t a b o v e , w h i c h i s w h a t t h e v i e w f a l l s b a c k t o o n a f i r s t v i s i t
/ / - > O v e r t h e d e f a u l t s j u s t a b o v e , w h i c h i s w h a t t h e v i e w f a l l s b a c k t o o n a f i r s t v i s i t
@ -681,12 +861,12 @@ Object.assign(state, storedViewOptions())
the editor ' s insert flow , which can be dismissed in ways that never reach a teardown here .
the editor ' s insert flow , which can be dismissed in ways that never reach a teardown here .
* /
* /
watch (
watch (
( ) => [ state . displayMode , state . isCompact , state . shouldShowFolders ],
( ) => [ state . displayMode , state . isCompact , state . shouldShowFolders , state . shouldListFoldersFirst ],
( [ displayMode , isCompact , shouldShowFolders ]) => {
( [ displayMode , isCompact , shouldShowFolders , shouldListFoldersFirst ]) => {
try {
try {
globalThis . localStorage ? . setItem (
globalThis . localStorage ? . setItem (
VIEW _OPTIONS _KEY ,
VIEW _OPTIONS _KEY ,
JSON . stringify ( { displayMode , isCompact , shouldShowFolders } )
JSON . stringify ( { displayMode , isCompact , shouldShowFolders , shouldListFoldersFirst } )
)
)
} catch {
} catch {
/ / - > F u l l , o r s t o r a g e d e n i e d . N o t w o r t h a w o r d t o t h e r e a d e r : t h e o p t i o n s s t i l l w o r k , t h e y
/ / - > F u l l , o r s t o r a g e d e n i e d . N o t w o r t h a w o r d t o t h e r e a d e r : t h e o p t i o n s s t i l l w o r k , t h e y
@ -772,6 +952,18 @@ const filteredFiles = computed(() => {
}
}
} )
} )
/ * *
* The order the listing is drawn in : folders ahead of everything else when List Folders First is on ,
* and otherwise exactly as it came . The sort is stable , so the listing 's own order -- or a search' s
* ranking -- still holds within each of the two groups .
* /
function listingOrder ( a , b ) {
if ( ! state . shouldShowFolders || ! state . shouldListFoldersFirst ) {
return 0
}
return Number ( b . type === 'folder' ) - Number ( a . type === 'folder' )
}
const files = computed ( ( ) => {
const files = computed ( ( ) => {
return filteredFiles . value
return filteredFiles . value
. filter ( ( f ) => {
. filter ( ( f ) => {
@ -781,6 +973,7 @@ const files = computed(() => {
}
}
return true
return true
} )
} )
. toSorted ( listingOrder )
. map ( ( f ) => {
. map ( ( f ) => {
switch ( f . type ) {
switch ( f . type ) {
case 'folder' : {
case 'folder' : {
@ -816,6 +1009,37 @@ const files = computed(() => {
} )
} )
} )
} )
/** The Recycle Bin, narrowed by the same search field the folder listing uses. */
const deletedPages = computed ( ( ) => {
if ( ! state . search ) {
return state . binItems
}
const fuse = new Fuse ( state . binItems , { keys : [ 'title' , 'path' ] } )
return fuse . search ( state . search ) . map ( ( n ) => n . item )
} )
const selectedDeletedPage = computed (
( ) => state . binItems . find ( ( item ) => item . versionId === state . binSelectedId ) ? ? null
)
/** The details pane for a bin entry, in the same rows a live page gets. */
const deletedPageDetails = computed ( ( ) => {
const item = selectedDeletedPage . value
if ( ! item ) {
return [ ]
}
return [
{ label : t ( 'fileman.detailsTitle' ) , value : item . title } ,
{ label : t ( 'fileman.detailsPath' ) , value : ` / ${ item . path } ` } ,
{ label : t ( 'fileman.detailsPageType' ) , value : t ( ` fileman. ${ item . editor } PageType ` ) } ,
{ label : t ( 'fileman.detailsDeletedAt' ) , value : formatDateTime ( item . deletedAt ) } ,
/ / - > L e f t o u t o n c e t h e a c c o u n t i s g o n e , r a t h e r t h a n s h o w n a s a n e m p t y r o w
... ( item . deletedBy ? . name
? [ { label : t ( 'fileman.detailsDeletedBy' ) , value : item . deletedBy . name } ]
: [ ] )
]
} )
/ * *
/ * *
* Whether this listing entry is an image , which is what decides both the thumbnail beside it and
* Whether this listing entry is an image , which is what decides both the thumbnail beside it and
* whether View opens it in place rather than handing it to the browser .
* whether View opens it in place rather than handing it to the browser .
@ -931,6 +1155,58 @@ function close() {
siteStore . overlay = null
siteStore . overlay = null
}
}
/ * *
* List a folder picked in the tree -- which is also how the Recycle Bin is left .
*
* Picking the folder that was already selected under the bin changes nothing the watcher would see ,
* and needs nothing fetched either : its listing was left as it was when the bin opened .
* /
function openFolder ( folderId ) {
state . isRecycleBin = false
state . currentFolderId = folderId
}
function openRecycleBin ( ) {
/ / - > T h e s a m e a s p i c k i n g a f o l d e r , w h i l e t h e t r e e i s a p a n e l : w h a t w a s a s k e d f o r i s u n d e r n e a t h i t
state . treeOpen = false
if ( state . isRecycleBin ) {
return
}
state . isRecycleBin = true
state . binSelectedId = null
loadRecycleBin ( )
}
async function loadRecycleBin ( ) {
state . binLoading = true
try {
state . binItems = await API _CLIENT . get ( ` sites/ ${ siteStore . id } /pages/deleted ` , {
searchParams : { locale : state . locale }
} ) . json ( )
if ( ! state . binItems . some ( ( item ) => item . versionId === state . binSelectedId ) ) {
state . binSelectedId = null
}
} catch ( err ) {
state . binItems = [ ]
notify ( {
type : 'negative' ,
message : t ( 'fileman.recycleBinLoadFailed' ) ,
caption : apiErrorMessage ( err , 'An unexpected error occured.' )
} )
} finally {
state . binLoading = false
}
}
/** The toolbar's Refresh, which reloads whichever of the two the centre pane is showing. */
function refreshListing ( ) {
if ( state . isRecycleBin ) {
loadRecycleBin ( )
} else {
reloadFolder ( state . currentFolderId )
}
}
function formatDateTime ( value ) {
function formatDateTime ( value ) {
if ( ! value ) {
if ( ! value ) {
return ''
return ''
@ -1104,6 +1380,11 @@ async function switchLocale(locale) {
/ / - > T h e t r e e m a r k s w h i c h f o l d e r s i t h a s f e t c h e d c h i l d r e n f o r , b y i d . N o n e o f t h e m a r e i n t h e t r e e
/ / - > T h e t r e e m a r k s w h i c h f o l d e r s i t h a s f e t c h e d c h i l d r e n f o r , b y i d . N o n e o f t h e m a r e i n t h e t r e e
/ / b e i n g e n t e r e d , s o t h e m a r k s w o u l d o n l y e v e r b e w r o n g a b o u t i t
/ / b e i n g e n t e r e d , s o t h e m a r k s w o u l d o n l y e v e r b e w r o n g a b o u t i t
treeComp . value ? . resetLoaded ( )
treeComp . value ? . resetLoaded ( )
/ / - > T h e b i n i s l i s t e d p e r l o c a l e t o o , s o i t f o l l o w s t h e p i c k e r l i k e t h e f o l d e r l i s t i n g d o e s
if ( state . isRecycleBin ) {
state . binSelectedId = null
loadRecycleBin ( )
}
await loadTree ( { initLoad : true } )
await loadTree ( { initLoad : true } )
}
}
@ -1283,6 +1564,8 @@ function findFolderIdByPath(path) {
* /
* /
async function goToFolder ( { id , folderPath , fileName , locale } ) {
async function goToFolder ( { id , folderPath , fileName , locale } ) {
const path = folderPath ? ` ${ folderPath } / ${ fileName } ` : fileName
const path = folderPath ? ` ${ folderPath } / ${ fileName } ` : fileName
/ / - > W h e r e t h e r e a d e r i s b e i n g t a k e n i s a f o l d e r , s o t h e b i n , i f i t w a s o p e n , i s l e f t f o r i t
state . isRecycleBin = false
/ / - > S e t b e f o r e a n y t h i n g i s f e t c h e d : e v e r y r e q u e s t b e l o w a s k s f o r o n e l o c a l e ' s t r e e
/ / - > S e t b e f o r e a n y t h i n g i s f e t c h e d : e v e r y r e q u e s t b e l o w a s k s f o r o n e l o c a l e ' s t r e e
state . locale = locale
state . locale = locale
state . treeNodes = { }
state . treeNodes = { }
@ -1461,6 +1744,72 @@ function delPage(pageId, pageName) {
} )
} )
}
}
/ / - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/ / R E C Y C L E B I N M E T H O D S
/ / - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/** The kind of page a bin entry was, for a page with no icon of its own. */
function deletedPageTypeIcon ( item ) {
return fileTypes [ item . editor ] ? . icon ? ? fileTypes . page . icon
}
/ * *
* The deletion 's version in full -- source included, and where the page was -- which the bin' s list
* does not carry : a screenful of deleted pages has no business carrying a screenful of their content .
* /
function fetchDeletedVersion ( item ) {
return API _CLIENT . get ( ` sites/ ${ siteStore . id } /versions/ ${ item . versionId } ` ) . json ( )
}
/ * *
* Read the page as it was when it went , in the version view -- the one screen that draws a page out
* of a version , and where it can also be restored or branched off from .
* /
function viewDeletedPage ( item ) {
router . push ( ` /_version/ ${ item . versionId } ` )
close ( )
}
async function downloadDeletedPage ( item ) {
try {
await saveVersionSource ( await fetchDeletedVersion ( item ) )
} catch ( err ) {
notify ( {
type : 'negative' ,
message : t ( 'history.downloadFailed' ) ,
caption : apiErrorMessage ( err , 'An unexpected error occured.' )
} )
}
}
async function restoreDeletedItem ( item ) {
let version
try {
version = await fetchDeletedVersion ( item )
} catch ( err ) {
notify ( {
type : 'negative' ,
message : t ( 'fileman.restoreFailed' ) ,
caption : apiErrorMessage ( err , 'An unexpected error occured.' )
} )
return
}
const restored = await restoreDeletedPage ( version )
if ( ! restored ) {
return
}
await loadRecycleBin ( )
if ( restored !== 'stale' ) {
/ *
The page is back in the tree , possibly with folders that were not there a moment ago -- its own
folder was deleted with it , or the reader put it somewhere new . The folders loaded so far are
marked stale and the branch under the bin is fetched again , ancestors and roots included .
* /
treeComp . value ? . resetLoaded ( )
await loadTree ( { parentId : state . currentFolderId , initLoad : true } )
}
}
/ / - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/ / - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/ / A S S E T M E T H O D S
/ / A S S E T M E T H O D S
/ / - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
/ / - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@ -2070,6 +2419,56 @@ $fileman-hdr-wrap-max: 899.98px;
}
}
}
}
/ *
The Recycle Bin , pinned under the tree . Drawn as one of the tree ' s own rows -- the same padding ,
the same full - width band on hover and while it is open -- with a rule above it , since it is not a
place IN the tree and should not read as its last folder .
* /
& - bin {
display : flex ;
align - items : center ;
gap : 8 px ;
width : 100 % ;
padding : 10 px 12 px ;
font : inherit ;
text - align : left ;
color : inherit ;
cursor : pointer ;
transition : background - color 0.4 s ease ;
@ at - root . body -- light & {
border - top : 1 px solid rgba ( 0 , 0 , 0 , 0.08 ) ;
}
@ at - root . body -- dark & {
border - top : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ) ;
}
& : hover ,
& : focus - visible ,
& . is - active {
@ at - root . body -- light & {
background - color : rgba ( 0 , 0 , 0 , 0.05 ) ;
}
@ at - root . body -- dark & {
background - color : rgba ( 255 , 255 , 255 , 0.1 ) ;
}
}
& - label {
flex : 1 1 auto ;
}
}
& - toolbar - title {
display : flex ;
align - items : center ;
font - weight : 500 ;
min - width : 0 ;
white - space : nowrap ;
overflow : hidden ;
text - overflow : ellipsis ;
}
& - path {
& - path {
@ at - root . body -- light & {
@ at - root . body -- light & {
background - color : $blue - grey - 1 ! important ;
background - color : $blue - grey - 1 ! important ;