Files
cista-storage/frontend/src/components/Gallery.vue
T

392 lines
11 KiB
Vue

<template>
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key>
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
</template>
</div>
</template>
<script setup lang="ts">
import { apiFetch } from '@/repositories/Client'
import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import type { SortOrder } from '@/utils/docsort'
import ContextMenu from '@imengyu/vue3-context-menu'
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
watchEffect
} from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{
path: Array<string>
documents: Doc[]
}>()
const store = useMainStore()
const router = useRouter()
const filesUrl = (path: string) =>
'/files/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const parseErrorMessage = async (res: Response) => {
try {
const data = await res.json()
return data.message || data.detail || `${res.status} ${res.statusText}`
} catch {
return `${res.status} ${res.statusText}`
}
}
// File rename
const editing = shallowRef<Doc | null>(null)
const exit = () => {
editing.value = null
}
const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker
try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const res = await apiFetch(
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
{ method: 'POST' }
)
if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) {
console.error('Rename failed', err)
doc.name = oldName
store.showToast(err instanceof Error ? err.message : 'Rename failed')
}
}
const gallery = ref<HTMLElement>()
const columnCount = ref(1)
const updateColumns = () => {
if (!gallery.value) return
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
' '
).length
}
const columns = computed(() => columnCount.value)
defineExpose({
newFolder() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New Folder',
dir: true,
mtime: now,
size: 0,
allocated: 0
})
store.cursor = editing.value.key
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const order = ['', 'name', 'modified', 'size', ''][column]
if (order) store.toggleSort(order as SortOrder)
},
isCursor() {
return store.cursor && editing.value === null
},
focusFirst() {
const docs = props.documents
if (docs.length > 0) {
store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(
`#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) a.focus()
})
}
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
cursorSelect() {
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
} else {
store.selected.add(key)
}
this.cursorMove(1, null)
},
up(ev: KeyboardEvent) {
this.cursorMove(-columns.value, ev)
},
down(ev: KeyboardEvent) {
this.cursorMove(columns.value, ev)
},
left(ev: KeyboardEvent) {
this.cursorMove(-1, ev)
},
right(ev: KeyboardEvent) {
this.cursorMove(1, ev)
},
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
// Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === 'ArrowLeft') return
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
// Calculate new position
let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1
else {
moveto = increment(index, d)
// Wrapping either end, just land outside the list
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
}
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
// When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
}
})
const focusHeader = () => {
const el = document.querySelector(
'.headermain input[type="search"]'
) as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus()
}
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key
if (store.cursor) {
const a = document.querySelector(
`#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) {
a.focus()
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor && !store.query) {
store.cursor = ''
focusBreadcrumb()
}
})
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null
if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus()
}
updateColumns()
if (gallery.value) {
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
const mkdir = async (doc: Doc, name: string) => {
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
const path = doc.loc ? `${doc.loc}/${name}` : name
try {
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
router.push(doc.urlrouter)
} catch (err) {
console.error('Mkdir failed', err)
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
}
}
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
const docloc = docs[i]!.loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
}
const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Doc) => store.selected.has(doc.key))
)
},
set: (value: boolean) => {
console.log('Setting allSelected', value)
for (const doc of props.documents) {
if (value) {
store.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
}
}
}
})
const loc = computed(() => props.path.join('/'))
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => (img.onload = r))
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r =>
canvas.toBlob(b => r(b!), 'image/png')
)
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
store.hideDoc(path)
try {
const res = await apiFetch(filesUrl(path), { method: 'DELETE' })
if (!res.ok) throw new Error(await parseErrorMessage(res))
store.showToast(`🗑️ Deleted ${doc.name}`)
} catch (err) {
console.error('Delete failed', err)
store.unhideDoc(path)
store.showToast(err instanceof Error ? err.message : 'Delete failed')
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }
]
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{
label: '✏️ Rename',
onClick: () => {
editing.value = doc
}
},
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
}
</script>
<style scoped>
.gallery {
padding: 1em;
width: 100%;
display: grid;
gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
align-items: end;
}
.folder-indicator {
grid-column: 1 / -1;
}
.folder-start {
grid-column-start: 1;
}
</style>