Compare commits
4 Commits
a49dd2f111
...
b2a24fca57
Author | SHA1 | Date | |
---|---|---|---|
b2a24fca57 | |||
7cc7e32c33 | |||
fa98cb9177 | |||
b3ab09a614 |
|
@ -153,13 +153,14 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||||
isfile=entry.isfile,
|
isfile=entry.isfile,
|
||||||
)
|
)
|
||||||
ret.extend(sub)
|
ret.extend(sub)
|
||||||
ret[0] = entry
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass # Things may be rapidly in motion
|
pass # Things may be rapidly in motion
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == 13: # Permission denied
|
if e.errno == 13: # Permission denied
|
||||||
pass
|
pass
|
||||||
logger.error(f"Watching {path=}: {e!r}")
|
logger.error(f"Watching {path=}: {e!r}")
|
||||||
|
if ret:
|
||||||
|
ret[0] = entry
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -78,13 +78,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||||
//console.log("key pressed", event)
|
//console.log("key pressed", event)
|
||||||
/// Long if-else machina for all keys we handle here
|
/// Long if-else machina for all keys we handle here
|
||||||
let arrow = ''
|
let arrow = ''
|
||||||
if (event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
||||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Search also on / (UNIX style)
|
// Search also on / (UNIX style)
|
||||||
else if (keyup && !input && event.key === '/') {
|
else if (!input && keyup && event.key === '/') {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Globally close search, clear errors on Escape
|
// Globally close search, clear errors on Escape
|
||||||
|
@ -97,7 +97,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||||
Router.back()
|
Router.back()
|
||||||
}
|
}
|
||||||
// Select all (toggle); keydown to precede and prevent builtin
|
// Select all (toggle); keydown to precede and prevent builtin
|
||||||
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
else if (!input && !keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||||
fileExplorer.toggleSelectAll()
|
fileExplorer.toggleSelectAll()
|
||||||
}
|
}
|
||||||
// G toggles Gallery
|
// G toggles Gallery
|
||||||
|
@ -113,11 +113,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||||
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
||||||
}
|
}
|
||||||
// Rename
|
// Rename
|
||||||
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
||||||
fileExplorer.cursorRename()
|
fileExplorer.cursorRename()
|
||||||
}
|
}
|
||||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||||
else if (c && event.code === 'Space') {
|
else if (!input && c && event.code === 'Space') {
|
||||||
if (keyup && !event.altKey && !event.ctrlKey)
|
if (keyup && !event.altKey && !event.ctrlKey)
|
||||||
fileExplorer.cursorSelect()
|
fileExplorer.cursorSelect()
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@
|
||||||
import home from '@/assets/svg/home.svg'
|
import home from '@/assets/svg/home.svg'
|
||||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { exists } from '@/utils/fileutil'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
@ -86,6 +87,15 @@ watchEffect(() => {
|
||||||
else if (props.path.length > longcut.length) {
|
else if (props.path.length > longcut.length) {
|
||||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
// Prune deleted folders from longest
|
||||||
|
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||||
|
if (!exists(longest.value.slice(0, i + 1))) {
|
||||||
|
longest.value = longest.value.slice(0, i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// If needed, focus primary navigation to new location
|
// If needed, focus primary navigation to new location
|
||||||
if (props.primary) nextTick(() => {
|
if (props.primary) nextTick(() => {
|
||||||
const act = document.activeElement as HTMLElement
|
const act = document.activeElement as HTMLElement
|
||||||
|
@ -112,6 +122,7 @@ watchEffect(() => {
|
||||||
min-height: 2em;
|
min-height: 2em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1em 0 0;
|
padding: 0 1em 0 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.breadcrumb > a {
|
.breadcrumb > a {
|
||||||
flex: 0 4 auto;
|
flex: 0 4 auto;
|
||||||
|
|
38
frontend/src/components/EmptyFolder.vue
Normal file
38
frontend/src/components/EmptyFolder.vue
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
||||||
|
<component :is="cog" class="cog"/>
|
||||||
|
<p v-if="!store.connected">No Connection</p>
|
||||||
|
<p v-else-if="store.document.length === 0">Waiting for File List</p>
|
||||||
|
<p v-else-if="store.query">No matches!</p>
|
||||||
|
<p v-else-if="!exists(props.path)">Folder not found</p>
|
||||||
|
<p v-else>Empty folder</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps } from 'vue'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import cog from '@/assets/svg/cog.svg'
|
||||||
|
import { exists } from '@/utils/fileutil'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const props = defineProps<{
|
||||||
|
path: string[],
|
||||||
|
documents: Document[],
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes rotate {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
svg.cog {
|
||||||
|
width: 10rem;
|
||||||
|
height: 10rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: rotate 10s linear infinite;
|
||||||
|
filter: drop-shadow(0 0 1rem black);
|
||||||
|
fill: #888;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -56,4 +56,10 @@ input#FileRenameInput {
|
||||||
outline: none;
|
outline: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
.gallery input#FileRenameInput {
|
||||||
|
padding: .75em;
|
||||||
|
font-weight: 600;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,18 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
||||||
<GalleryFigure v-for="(doc, index) in documents" :key="doc.key" :doc="doc" :index="index">
|
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
||||||
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" v-if="showFolderBreadcrumb(index)" class="folder-change"/>
|
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||||
|
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null">
|
||||||
|
<BreadCrumb v-if=showFolderBreadcrumb(index) :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
|
||||||
</GalleryFigure>
|
</GalleryFigure>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
import { connect, controlUrl } from '@/repositories/WS'
|
||||||
import { formatSize } from '@/utils'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import type { SortOrder } from '@/utils/docsort'
|
import type { SortOrder } from '@/utils/docsort'
|
||||||
|
@ -25,6 +26,7 @@ const store = useMainStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
|
const exit = () => { editing.value = null }
|
||||||
const rename = (doc: Doc, newName: string) => {
|
const rename = (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
|
@ -252,7 +254,6 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
position: absolute;
|
border-radius: .5em;
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -10,9 +10,14 @@
|
||||||
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
|
||||||
<div class="titlespacer"></div>
|
<div class="titlespacer"></div>
|
||||||
<figcaption>
|
<figcaption>
|
||||||
|
<template v-if="editing">
|
||||||
|
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<SelectBox :doc=doc />
|
<SelectBox :doc=doc />
|
||||||
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
|
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
|
</template>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
</a>
|
</a>
|
||||||
|
@ -25,9 +30,14 @@ import { Doc } from '@/repositories/Document'
|
||||||
import MediaPreview from '@/components/MediaPreview.vue'
|
import MediaPreview from '@/components/MediaPreview.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
type EditingProp = {
|
||||||
|
rename: (name: string) => void;
|
||||||
|
exit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
doc: Doc,
|
||||||
index: number
|
editing?: EditingProp,
|
||||||
}>()
|
}>()
|
||||||
const m = ref<typeof MediaPreview | null>(null)
|
const m = ref<typeof MediaPreview | null>(null)
|
||||||
|
|
||||||
|
@ -70,7 +80,7 @@ figcaption {
|
||||||
figcaption input[type='checkbox'] {
|
figcaption input[type='checkbox'] {
|
||||||
width: 1.5em;
|
width: 1.5em;
|
||||||
height: 1.5em;
|
height: 1.5em;
|
||||||
margin: .25em;
|
margin: .25em 0 .25em .25em;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
</template>
|
</template>
|
||||||
<UploadButton :path="props.path" />
|
<UploadButton :path="props.path" />
|
||||||
<SvgButton
|
<SvgButton
|
||||||
v-if="!store.prefs.gallery"
|
|
||||||
name="create-folder"
|
name="create-folder"
|
||||||
data-tooltip="New folder"
|
data-tooltip="New folder"
|
||||||
@click="() => store.fileExplorer!.newFolder()"
|
@click="() => store.fileExplorer!.newFolder()"
|
||||||
|
|
|
@ -40,6 +40,12 @@ export class Doc {
|
||||||
const ext = this.name.split('.').pop()?.toLowerCase()
|
const ext = this.name.split('.').pop()?.toLowerCase()
|
||||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '')
|
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '')
|
||||||
}
|
}
|
||||||
|
get previewable(): boolean {
|
||||||
|
if (this.img) return true
|
||||||
|
const ext = this.name.split('.').pop()?.toLowerCase()
|
||||||
|
// Not a comprehensive list, but good enough for now
|
||||||
|
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(ext || '')
|
||||||
|
}
|
||||||
get previewurl(): string {
|
get previewurl(): string {
|
||||||
return this.url.replace(/^\/files/, '/preview')
|
return this.url.replace(/^\/files/, '/preview')
|
||||||
}
|
}
|
||||||
|
|
8
frontend/src/utils/fileutil.ts
Normal file
8
frontend/src/utils/fileutil.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
|
|
||||||
|
export const exists = (path: string[]) => {
|
||||||
|
const store = useMainStore()
|
||||||
|
const p = path.join('/')
|
||||||
|
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
||||||
|
}
|
|
@ -1,14 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
|
||||||
<component :is="cog" class="cog"/>
|
|
||||||
<p v-if="!store.connected">No Connection</p>
|
|
||||||
<p v-else-if="store.document.length === 0">Waiting for File List</p>
|
|
||||||
<p v-else-if="store.query">No matches!</p>
|
|
||||||
<p v-else-if="!store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === props.path.join('/'))">Folder not found</p>
|
|
||||||
<p v-else>Empty folder</p>
|
|
||||||
</div>
|
|
||||||
<Gallery
|
<Gallery
|
||||||
v-else-if="store.prefs.gallery"
|
v-if="store.prefs.gallery"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`gallery-${Router.currentRoute.value.path}`"
|
:key="`gallery-${Router.currentRoute.value.path}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
|
@ -21,10 +13,11 @@
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
<div v-if="!store.prefs.gallery && documents.some(doc => doc.img)" class="suggest-gallery">
|
<div v-if="!store.prefs.gallery && documents.some(doc => doc.previewable)" class="suggest-gallery">
|
||||||
<p>Media files found. Would you like a gallery view?</p>
|
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }"></SvgButton>
|
||||||
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }">Gallery</SvgButton>
|
Gallery View
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyFolder :documents=documents :path=props.path />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
@ -97,10 +90,6 @@ watchEffect(() => {
|
||||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
@keyframes rotate {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(359deg); }
|
|
||||||
}
|
|
||||||
.suggest-gallery p {
|
.suggest-gallery p {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
|
@ -112,12 +101,4 @@ watchEffect(() => {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg.cog {
|
|
||||||
width: 10rem;
|
|
||||||
height: 10rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
animation: rotate 10s linear infinite;
|
|
||||||
filter: drop-shadow(0 0 1rem black);
|
|
||||||
fill: #888;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user