Compare commits

...

4 Commits

11 changed files with 106 additions and 45 deletions

View File

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

View File

@ -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()
} }

View File

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

View 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>

View File

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

View File

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

View File

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

View File

@ -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()"

View File

@ -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')
} }

View 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)
}

View File

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