frontend: add biome checks and pre-commit integration (excluding preview files)
This commit is contained in:
@@ -37,17 +37,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Home } from '@/assets/svg'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const home = Home
|
||||
const router = useRouter()
|
||||
|
||||
const links = [] as Array<HTMLElement>
|
||||
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
|
||||
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
|
||||
const setLinkRef = (index: number, el: any) => {
|
||||
if (el) links[index] = el
|
||||
}
|
||||
onBeforeUpdate(() => {
|
||||
links.length = 1
|
||||
}) // 1 to keep home
|
||||
|
||||
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
|
||||
@@ -63,7 +67,8 @@ const props = defineProps<{
|
||||
|
||||
const longest = ref<Array<string>>([])
|
||||
|
||||
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
|
||||
const isCurrent = (index: number) =>
|
||||
index == props.path.length ? 'location' : undefined
|
||||
|
||||
const focusCurrent = () => {
|
||||
nextTick(() => {
|
||||
@@ -80,7 +85,10 @@ const navigate = (index: number) => {
|
||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Clicking on current link clears the rest of the path and adds new history
|
||||
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
||||
if (isCurrent(index)) {
|
||||
longest.value.splice(index)
|
||||
router.push(u)
|
||||
}
|
||||
// Moving along breadcrumbs doesn't create new history
|
||||
else if (long.startsWith(browser)) router.replace(u)
|
||||
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
||||
@@ -100,8 +108,7 @@ watchEffect(() => {
|
||||
if (!same) longest.value = props.path
|
||||
else if (props.path.length > longcut.length) {
|
||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||
}
|
||||
else {
|
||||
} 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))) {
|
||||
@@ -111,10 +118,11 @@ watchEffect(() => {
|
||||
}
|
||||
}
|
||||
// If needed, focus primary navigation to new location
|
||||
if (props.primary) nextTick(() => {
|
||||
const act = document.activeElement as HTMLElement
|
||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||
})
|
||||
if (props.primary)
|
||||
nextTick(() => {
|
||||
const act = document.activeElement as HTMLElement
|
||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -62,8 +62,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
@@ -88,7 +88,7 @@ const formatGB = (bytes: number) => {
|
||||
const fmtSize = (bytes: number, angle: number) => {
|
||||
const s = formatGB(bytes)
|
||||
const a = Math.abs(angle % 180)
|
||||
return (Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s)) ? `${s}.` : s
|
||||
return Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s) ? `${s}.` : s
|
||||
}
|
||||
|
||||
const truncateLabel = (name: string, maxLen = 10): string => {
|
||||
@@ -157,7 +157,7 @@ const freeColor = computed(() => {
|
||||
if (!s.disk) return '#6c6'
|
||||
const freePct = s.free / s.disk
|
||||
if (freePct > 0.25) return '#5b5'
|
||||
if (freePct > 0.10) return '#ff0'
|
||||
if (freePct > 0.1) return '#ff0'
|
||||
return '#f00'
|
||||
})
|
||||
|
||||
@@ -165,23 +165,24 @@ const PIE_RADIUS = 55
|
||||
const LABEL_RADIUS = 62
|
||||
|
||||
const getPoint = (angle: number, radius: number) => {
|
||||
const rad = TAU * (angle - 90) / 360
|
||||
const rad = (TAU * (angle - 90)) / 360
|
||||
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
|
||||
}
|
||||
|
||||
const sectorInfo = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return {
|
||||
storage: { angle: 45, pct: 0.25 },
|
||||
free: { angle: 180, pct: 0.5 },
|
||||
other: { angle: 270, pct: 0.25 }
|
||||
}
|
||||
if (!s.disk)
|
||||
return {
|
||||
storage: { angle: 45, pct: 0.25 },
|
||||
free: { angle: 180, pct: 0.5 },
|
||||
other: { angle: 270, pct: 0.25 }
|
||||
}
|
||||
|
||||
const storagePct = s.allocated / s.disk
|
||||
const freePct = s.free / s.disk
|
||||
const otherPct = (s.used - s.allocated) / s.disk
|
||||
|
||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||
const freeStart = storagePct * 360
|
||||
const freeAngle = freeStart + freePct * 180
|
||||
const otherStart = (storagePct + freePct) * 360
|
||||
@@ -200,13 +201,19 @@ const rawAngles = computed(() => ({
|
||||
other: sectorInfo.value.other.angle
|
||||
}))
|
||||
|
||||
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90
|
||||
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start'
|
||||
const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
|
||||
const getSizeAnchor = (angle: number) => (angle < 180 ? 'end' : 'start')
|
||||
|
||||
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
|
||||
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS))
|
||||
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS))
|
||||
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS))
|
||||
const storageInnerPos = computed(() =>
|
||||
getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS)
|
||||
)
|
||||
const freeInnerPos = computed(() =>
|
||||
getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS)
|
||||
)
|
||||
const otherInnerPos = computed(() =>
|
||||
getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS)
|
||||
)
|
||||
|
||||
// Collision avoidance for curved name labels
|
||||
const labelLengths = computed(() => ({
|
||||
@@ -269,11 +276,17 @@ const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length))
|
||||
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4))
|
||||
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5))
|
||||
const storageLabelPath = computed(() =>
|
||||
createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length)
|
||||
)
|
||||
const freeLabelPath = computed(() =>
|
||||
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
||||
)
|
||||
const otherLabelPath = computed(() =>
|
||||
createArcPath(adjustedLabelAngles.value.other!, 'other', 5)
|
||||
)
|
||||
|
||||
const handleClick = () => isExpanded.value ? collapse() : expand()
|
||||
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
||||
|
||||
const applyAnimState = (t: number, opacity: number) => {
|
||||
const widget = widgetRef.value
|
||||
@@ -296,9 +309,9 @@ const animate = (duration: number, expanding: boolean, onComplete?: () => void)
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
|
||||
const t = expanding ? eased : 1 - eased
|
||||
applyAnimState(t, t) // opacity follows position
|
||||
applyAnimState(t, t) // opacity follows position
|
||||
if (progress < 1) {
|
||||
animationFrame = requestAnimationFrame(tick)
|
||||
} else {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { zipName } from '@/utils/fileutil'
|
||||
|
||||
const store = useMainStore()
|
||||
@@ -24,9 +24,9 @@ const status_init = {
|
||||
filename: '',
|
||||
filesize: 0,
|
||||
filepos: 0,
|
||||
status: 'idle',
|
||||
status: 'idle'
|
||||
}
|
||||
store.dprogress = {...status_init}
|
||||
store.dprogress = { ...status_init }
|
||||
setInterval(() => {
|
||||
if (Date.now() - store.dprogress.tlast > 3000) {
|
||||
// Reset
|
||||
@@ -34,8 +34,8 @@ setInterval(() => {
|
||||
store.dprogress.statdur = 1
|
||||
} else {
|
||||
// Running average by decay
|
||||
store.dprogress.statbytes *= .9
|
||||
store.dprogress.statdur *= .9
|
||||
store.dprogress.statbytes *= 0.9
|
||||
store.dprogress.statdur *= 0.9
|
||||
}
|
||||
}, 100)
|
||||
const statReset = () => {
|
||||
@@ -44,10 +44,9 @@ const statReset = () => {
|
||||
store.dprogress.tlast = store.dprogress.t0 + 1
|
||||
}
|
||||
const cancelDownloads = () => {
|
||||
location.reload() // FIXME
|
||||
location.reload() // FIXME
|
||||
}
|
||||
|
||||
|
||||
const linkdl = (href: string) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
@@ -156,7 +155,10 @@ const download = async (e: MouseEvent) => {
|
||||
if (e.altKey && 'showDirectoryPicker' in window) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
|
||||
const handle = await window.showDirectoryPicker({
|
||||
startIn: 'downloads',
|
||||
mode: 'readwrite'
|
||||
})
|
||||
await filesystemdl(sel, handle)
|
||||
store.selected.clear()
|
||||
} catch (e) {
|
||||
@@ -168,7 +170,6 @@ const download = async (e: MouseEvent) => {
|
||||
// Default: ZIP download
|
||||
zipdl(sel)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Cog } from '@/assets/svg'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
|
||||
const cog = Cog
|
||||
const store = useMainStore()
|
||||
const props = defineProps<{
|
||||
path: string[],
|
||||
documents: Document[],
|
||||
path: string[]
|
||||
documents: Document[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -72,14 +72,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
@@ -89,7 +97,11 @@ const store = useMainStore()
|
||||
const router = useRouter()
|
||||
|
||||
const filesUrl = (path: string) =>
|
||||
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
'/files/' +
|
||||
path
|
||||
.split('/')
|
||||
.map(part => encodeURIComponent(part))
|
||||
.join('/')
|
||||
|
||||
const parseErrorMessage = async (res: Response) => {
|
||||
try {
|
||||
@@ -120,7 +132,7 @@ const rename = async (doc: Doc, newName: string) => {
|
||||
}
|
||||
defineExpose({
|
||||
newFolder() {
|
||||
console.log("New folder")
|
||||
console.log('New folder')
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
editing.value = new Doc({
|
||||
loc: loc.value,
|
||||
@@ -129,7 +141,7 @@ defineExpose({
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
allocated: 0,
|
||||
allocated: 0
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
@@ -146,7 +158,9 @@ defineExpose({
|
||||
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} .name a`) as HTMLAnchorElement | null
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} .name a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
@@ -164,8 +178,12 @@ defineExpose({
|
||||
}
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||
up(ev: KeyboardEvent) {
|
||||
this.cursorMove(-1, ev)
|
||||
},
|
||||
down(ev: KeyboardEvent) {
|
||||
this.cursorMove(1, ev)
|
||||
},
|
||||
left(ev: KeyboardEvent) {
|
||||
// Only go back if we're in a subfolder (not at root)
|
||||
if (props.path.length > 0) {
|
||||
@@ -173,7 +191,9 @@ defineExpose({
|
||||
}
|
||||
},
|
||||
right(ev: KeyboardEvent) {
|
||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.click()
|
||||
},
|
||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||
@@ -187,8 +207,9 @@ defineExpose({
|
||||
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) : docs.length
|
||||
const index = store.cursor
|
||||
? docs.findIndex(doc => doc.key === store.cursor)
|
||||
: docs.length
|
||||
const moveto = increment(index, d)
|
||||
store.cursor = docs[moveto]?.key ?? ''
|
||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||
@@ -206,8 +227,7 @@ defineExpose({
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr)
|
||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
@@ -219,7 +239,9 @@ defineExpose({
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
const el = document.querySelector(
|
||||
'.headermain input[type="search"]'
|
||||
) as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
@@ -250,14 +272,17 @@ const updateModified = () => {
|
||||
nowkey.value = Math.floor(Date.now() / 1000)
|
||||
}
|
||||
onMounted(() => {
|
||||
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
|
||||
updateModified()
|
||||
modifiedTimer = setInterval(updateModified, 1000)
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
||||
onUnmounted(() => {
|
||||
clearInterval(modifiedTimer)
|
||||
})
|
||||
const mkdir = async (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
@@ -349,12 +374,14 @@ const copyImage = async (doc: Doc) => {
|
||||
if (blob.type !== 'image/png') {
|
||||
const img = new Image()
|
||||
img.src = URL.createObjectURL(blob)
|
||||
await new Promise(r => img.onload = r)
|
||||
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'))
|
||||
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 {
|
||||
@@ -385,12 +412,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
store.cursor = doc.key
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(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) },
|
||||
{
|
||||
label: '✏️ Rename',
|
||||
onClick: () => {
|
||||
editing.value = doc
|
||||
}
|
||||
},
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
|
||||
)
|
||||
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@ import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
now: number
|
||||
doc: Doc
|
||||
now: number
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
// Reference props.now to trigger reactivity when time updates
|
||||
const modified = computed(() => {
|
||||
props.now // trigger reactivity
|
||||
props.now // trigger reactivity
|
||||
return formatUnixDate(props.doc.mtime)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const name = ref('')
|
||||
|
||||
@@ -13,20 +13,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatSize } from '@/utils'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
doc: Doc
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||
return +unit ? "bytes" : unit
|
||||
return +unit ? 'bytes' : unit
|
||||
})
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
|
||||
@@ -9,13 +9,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
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>
|
||||
@@ -25,7 +33,11 @@ const store = useMainStore()
|
||||
const router = useRouter()
|
||||
|
||||
const filesUrl = (path: string) =>
|
||||
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
'/files/' +
|
||||
path
|
||||
.split('/')
|
||||
.map(part => encodeURIComponent(part))
|
||||
.join('/')
|
||||
|
||||
const parseErrorMessage = async (res: Response) => {
|
||||
try {
|
||||
@@ -38,7 +50,9 @@ const parseErrorMessage = async (res: Response) => {
|
||||
|
||||
// File rename
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const exit = () => { editing.value = 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
|
||||
@@ -59,7 +73,9 @@ const gallery = ref<HTMLElement>()
|
||||
const columnCount = ref(1)
|
||||
const updateColumns = () => {
|
||||
if (!gallery.value) return
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
|
||||
' '
|
||||
).length
|
||||
}
|
||||
const columns = computed(() => columnCount.value)
|
||||
defineExpose({
|
||||
@@ -72,7 +88,7 @@ defineExpose({
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
allocated: 0,
|
||||
allocated: 0
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
@@ -93,7 +109,9 @@ defineExpose({
|
||||
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
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
@@ -111,10 +129,18 @@ defineExpose({
|
||||
}
|
||||
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) },
|
||||
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)
|
||||
@@ -126,11 +152,10 @@ defineExpose({
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -155,8 +180,7 @@ defineExpose({
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr)
|
||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
@@ -168,7 +192,9 @@ defineExpose({
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
const el = document.querySelector(
|
||||
'.headermain input[type="search"]'
|
||||
) as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
@@ -181,8 +207,13 @@ 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' }) }
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
a.focus()
|
||||
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
@@ -288,12 +319,14 @@ const copyImage = async (doc: Doc) => {
|
||||
if (blob.type !== 'image/png') {
|
||||
const img = new Image()
|
||||
img.src = URL.createObjectURL(blob)
|
||||
await new Promise(r => img.onload = r)
|
||||
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'))
|
||||
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 {
|
||||
@@ -324,12 +357,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
store.cursor = doc.key
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(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) },
|
||||
{
|
||||
label: '✏️ Rename',
|
||||
onClick: () => {
|
||||
editing.value = doc
|
||||
}
|
||||
},
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
|
||||
)
|
||||
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||
}
|
||||
|
||||
@@ -31,24 +31,24 @@
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { formatSize } from '@/utils'
|
||||
<script setup lang="ts">
|
||||
import MediaPreview from '@/components/MediaPreview.vue'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
type EditingProp = {
|
||||
rename: (name: string) => void;
|
||||
exit: () => void;
|
||||
rename: (name: string) => void
|
||||
exit: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc,
|
||||
editing?: EditingProp,
|
||||
doc: Doc
|
||||
editing?: EditingProp
|
||||
}>()
|
||||
const m = ref<typeof MediaPreview | null>(null)
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { resumeWatching } from '@/repositories/WS'
|
||||
import router from '@/router'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||
import { ref } from 'vue'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import { showAuthIframe } from 'paskia'
|
||||
import { resumeWatching } from '@/repositories/WS'
|
||||
import router from '@/router';
|
||||
import { ref } from 'vue'
|
||||
import DiskSpace from './DiskSpace.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
@@ -78,7 +78,7 @@ const updateSearch = (ev: Event) => {
|
||||
pendingRouteUpdate = null
|
||||
let p = loc
|
||||
p = p ? `/${p}` : ''
|
||||
const url = q ? `${p}//${q}` : (p || '/')
|
||||
const url = q ? `${p}//${q}` : p || '/'
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Use replace to avoid building up history for each keystroke
|
||||
router.replace(u)
|
||||
@@ -96,45 +96,66 @@ const settingsMenu = (e: Event) => {
|
||||
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||
items.push({
|
||||
label: '👤 ' + (store.user.username || 'User Account'),
|
||||
onClick: () => { window.location.href = '/auth/' }
|
||||
onClick: () => {
|
||||
window.location.href = '/auth/'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only show password change for non-SSO users
|
||||
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
|
||||
items.push({
|
||||
label: '🔑 Change Password',
|
||||
onClick: () => {
|
||||
store.dialog = 'settings'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: '🔑 API Tokens', onClick: () => { store.dialog = 'tokens' }})
|
||||
items.push({
|
||||
label: '🔑 API Tokens',
|
||||
onClick: () => {
|
||||
store.dialog = 'tokens'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (store.user.privileged) {
|
||||
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
||||
items.push({
|
||||
label: '⚙️ Admin Settings',
|
||||
onClick: () => {
|
||||
store.dialog = 'usermgmt'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||||
} else if (store.server.public) {
|
||||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||
items.push({ label: '🔐 Login', onClick: async () => {
|
||||
try {
|
||||
await showAuthIframe('/auth/restricted/#theme=light')
|
||||
resumeWatching()
|
||||
} catch (e) {
|
||||
console.log('Login cancelled')
|
||||
items.push({
|
||||
label: '🔐 Login',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await showAuthIframe('/auth/restricted/#theme=light')
|
||||
resumeWatching()
|
||||
} catch (e) {
|
||||
console.log('Login cancelled')
|
||||
}
|
||||
}
|
||||
}})
|
||||
})
|
||||
}
|
||||
ContextMenu.showContextMenu({
|
||||
// @ts-ignore
|
||||
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom,
|
||||
items,
|
||||
x: e.target.getBoundingClientRect().right,
|
||||
y: e.target.getBoundingClientRect().bottom,
|
||||
items
|
||||
})
|
||||
}
|
||||
defineExpose({
|
||||
toggleSearchInput,
|
||||
clearSearch,
|
||||
clearSearch
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import { nextTick, ref, watchEffect } from 'vue'
|
||||
|
||||
const overlay = ref<HTMLDivElement | null>(null)
|
||||
const dialog = ref<HTMLDivElement | null>(null)
|
||||
@@ -27,9 +27,9 @@ const close = () => {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
name: typeof store.dialog,
|
||||
}>()
|
||||
title: string
|
||||
name: typeof store.dialog
|
||||
}>()
|
||||
|
||||
const show = () => {
|
||||
store.dialog = props.name
|
||||
|
||||
@@ -10,13 +10,12 @@
|
||||
>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { useMainStore } from '@/stores/main'
|
||||
<script setup lang="ts">
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
|
||||
</script>
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatSize } from '@/utils'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import router from '@/router'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
@@ -50,7 +50,11 @@ const navigateTo = (path: string) => {
|
||||
}
|
||||
|
||||
const filesUrl = (path: string) =>
|
||||
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
'/files/' +
|
||||
path
|
||||
.split('/')
|
||||
.map(part => encodeURIComponent(part))
|
||||
.join('/')
|
||||
|
||||
const parseErrorMessage = async (res: Response) => {
|
||||
try {
|
||||
@@ -110,7 +114,7 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
|
||||
if (count === 1) {
|
||||
displayName = truncateName(names[0]!)
|
||||
} else {
|
||||
const folderName = loc ? loc.split('/').pop()! : (store.server.name || 'Root')
|
||||
const folderName = loc ? loc.split('/').pop()! : store.server.name || 'Root'
|
||||
displayName = `${truncateName(folderName)} (${count})`
|
||||
}
|
||||
return {
|
||||
@@ -166,7 +170,6 @@ const op = async (opName: string, dst?: string) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -44,10 +44,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { changePassword } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { changePassword } from '@/repositories/User'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const confirmLoading = ref<boolean>(false)
|
||||
const store = useMainStore()
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { icons, type IconName } from '@/assets/svg'
|
||||
import { type IconName, icons } from '@/assets/svg'
|
||||
import { ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineEmits(['cancel'])
|
||||
|
||||
@@ -38,16 +38,17 @@ const props = defineProps<{
|
||||
}
|
||||
}>()
|
||||
|
||||
const percent = computed(() => props.status.xfer / props.status.total * 100)
|
||||
const percent = computed(() => (props.status.xfer / props.status.total) * 100)
|
||||
const speed = computed(() => {
|
||||
let s = props.status.statbytes / props.status.statdur / 1e3
|
||||
const tsince = (Date.now() - props.status.tlast) / 1e3
|
||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
||||
return s // "Current speed"
|
||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
||||
return s // "Current speed"
|
||||
})
|
||||
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
||||
|
||||
const speeddisp = computed(() =>
|
||||
speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s' : 'stalled'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { collator } from '@/utils';
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { collator } from '@/utils'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -43,7 +43,7 @@ type InflightBlock = {
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||
function pasteHandler(event: ClipboardEvent) {
|
||||
const items = Array.from(event.clipboardData?.items ?? [])
|
||||
const infiles = [] as File[]
|
||||
@@ -62,7 +62,8 @@ function pasteHandler(event: ClipboardEvent) {
|
||||
event.preventDefault()
|
||||
uploadFiles(infiles)
|
||||
const base = props.path!.join('/')
|
||||
for (const entry of dirs) pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
|
||||
for (const entry of dirs)
|
||||
pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
|
||||
}
|
||||
}
|
||||
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
||||
@@ -72,8 +73,8 @@ const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
||||
for (const entry of entries) {
|
||||
const cloudName = `${loc}/${entry.name}`
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise(resolve => entry.file(resolve)) as File
|
||||
cloudfiles.push({file, cloudName, cloudPos: 0})
|
||||
const file = (await new Promise(resolve => entry.file(resolve))) as File
|
||||
cloudfiles.push({ file, cloudName, cloudPos: 0 })
|
||||
} else if (entry.isDirectory) {
|
||||
await pasteDirectory(entry, cloudName)
|
||||
}
|
||||
@@ -84,7 +85,9 @@ function uploadHandler(event: Event) {
|
||||
event.preventDefault()
|
||||
// @ts-ignore
|
||||
const input = event.target as HTMLInputElement | null
|
||||
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[]
|
||||
const infiles = Array.from(
|
||||
(input ?? (event as DragEvent).dataTransfer)?.files ?? []
|
||||
) as File[]
|
||||
if (input) input.value = ''
|
||||
if (infiles.length) uploadFiles(infiles)
|
||||
}
|
||||
@@ -99,7 +102,7 @@ const uploadFiles = (infiles: File[]) => {
|
||||
files.push({
|
||||
file,
|
||||
cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
|
||||
cloudPos: 0,
|
||||
cloudPos: 0
|
||||
})
|
||||
}
|
||||
uploadCloudFiles(files)
|
||||
@@ -131,13 +134,34 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const folderPath = parts.slice(0, i + 1).join('/')
|
||||
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
||||
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, allocated: 0, mtime: now, dir: true }))
|
||||
store.addGhost(
|
||||
new Doc({
|
||||
loc: parts.slice(0, i).join('/'),
|
||||
name: parts[i],
|
||||
key: crypto.randomUUID(),
|
||||
size: 0,
|
||||
allocated: 0,
|
||||
mtime: now,
|
||||
dir: true
|
||||
})
|
||||
)
|
||||
added.add(folderPath)
|
||||
}
|
||||
}
|
||||
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||
const existing = byPath.get(f.cloudName)
|
||||
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
|
||||
if (!existing)
|
||||
store.addGhost(
|
||||
new Doc({
|
||||
loc,
|
||||
name,
|
||||
key: crypto.randomUUID(),
|
||||
size: f.file.size,
|
||||
allocated: 0,
|
||||
mtime: now,
|
||||
dir: false
|
||||
})
|
||||
)
|
||||
}
|
||||
// @ts-ignore
|
||||
upqueue = [...upqueue, ...files]
|
||||
@@ -169,9 +193,9 @@ const uprogress_init = {
|
||||
filename: '',
|
||||
filesize: 0,
|
||||
filepos: 0,
|
||||
status: 'idle',
|
||||
status: 'idle'
|
||||
}
|
||||
store.uprogress = {...uprogress_init}
|
||||
store.uprogress = { ...uprogress_init }
|
||||
// Track uploaded bytes for each file to handle out-of-order uploads
|
||||
const uploadedBytes = new Map<string, Set<number>>()
|
||||
const inflightBlocks = new Map<string, InflightBlock>()
|
||||
@@ -240,13 +264,13 @@ setInterval(() => {
|
||||
store.uprogress.statbytes = 0
|
||||
store.uprogress.statdur = 1
|
||||
} else {
|
||||
store.uprogress.statbytes *= .95
|
||||
store.uprogress.statdur *= .95
|
||||
store.uprogress.statbytes *= 0.95
|
||||
store.uprogress.statdur *= 0.95
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const statUpdate = ({name, size, start, end}: UploadRange) => {
|
||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||
const statUpdate = ({ name, size, start, end }: UploadRange) => {
|
||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||
|
||||
// Track which bytes have been uploaded (using start to end range)
|
||||
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
|
||||
@@ -263,9 +287,12 @@ const statUpdate = ({name, size, start, end}: UploadRange) => {
|
||||
const currentUpload = blockQueue[0]
|
||||
if (!currentUpload) return
|
||||
|
||||
if (currentUpload.file.cloudName === name && currentUpload.completed >= currentUpload.blocks.length) {
|
||||
if (
|
||||
currentUpload.file.cloudName === name &&
|
||||
currentUpload.completed >= currentUpload.blocks.length
|
||||
) {
|
||||
// All blocks for this file have been uploaded
|
||||
uploadedBytes.delete(name) // Clean up tracking
|
||||
uploadedBytes.delete(name) // Clean up tracking
|
||||
store.uprogress.filestart += size
|
||||
statNextFile()
|
||||
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
||||
@@ -299,35 +326,35 @@ const MAX_PARALLEL_REQUESTS = 4
|
||||
const RETRY_DELAY_MS = 400
|
||||
|
||||
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
|
||||
const getUploadBlocks = (file: CloudFile): {start: number, end: number}[] => {
|
||||
const getUploadBlocks = (file: CloudFile): { start: number; end: number }[] => {
|
||||
const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
|
||||
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
||||
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
||||
const FINAL_BLOCKS_COUNT = 2
|
||||
|
||||
const fileSize = file.file.size
|
||||
const blocks: {start: number, end: number}[] = []
|
||||
const blocks: { start: number; end: number }[] = []
|
||||
|
||||
if (fileSize >= MIN_SIZE_FOR_REORDER) {
|
||||
// File is large enough, prioritize final blocks
|
||||
const finalBlocksStart = fileSize - (FINAL_BLOCKS_COUNT * BLOCK_SIZE)
|
||||
const finalBlocksStart = fileSize - FINAL_BLOCKS_COUNT * BLOCK_SIZE
|
||||
|
||||
// Add final blocks first
|
||||
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
|
||||
const start = finalBlocksStart + (i * BLOCK_SIZE)
|
||||
const start = finalBlocksStart + i * BLOCK_SIZE
|
||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||
blocks.push({start, end})
|
||||
blocks.push({ start, end })
|
||||
}
|
||||
|
||||
// Add remaining blocks from beginning
|
||||
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
|
||||
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
|
||||
blocks.push({start, end})
|
||||
blocks.push({ start, end })
|
||||
}
|
||||
} else {
|
||||
// File is smaller, use sequential upload
|
||||
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
|
||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||
blocks.push({start, end})
|
||||
blocks.push({ start, end })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +363,7 @@ const getUploadBlocks = (file: CloudFile): {start: number, end: number}[] => {
|
||||
|
||||
type BlockUpload = {
|
||||
file: CloudFile
|
||||
blocks: {start: number, end: number}[]
|
||||
blocks: { start: number; end: number }[]
|
||||
nextIndex: number
|
||||
completed: number
|
||||
runId: number
|
||||
@@ -360,14 +387,17 @@ const uploadUrlForFile = (cloudName: string) => {
|
||||
return `/files/${encoded}`
|
||||
}
|
||||
|
||||
const uploadBlock = async (upload: BlockUpload, block: {start: number, end: number}) => {
|
||||
const uploadBlock = async (
|
||||
upload: BlockUpload,
|
||||
block: { start: number; end: number }
|
||||
) => {
|
||||
const body = upload.file.file.slice(block.start, block.end)
|
||||
const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}`
|
||||
const fallbackReq = {
|
||||
name: upload.file.cloudName,
|
||||
size: upload.file.file.size,
|
||||
start: block.start,
|
||||
end: block.end,
|
||||
end: block.end
|
||||
}
|
||||
let attempt = 0
|
||||
|
||||
@@ -379,9 +409,9 @@ const uploadBlock = async (upload: BlockUpload, block: {start: number, end: numb
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Range': range,
|
||||
'Content-Range': range
|
||||
},
|
||||
body,
|
||||
body
|
||||
})
|
||||
if (!res.ok) {
|
||||
const message = await res.text().catch(() => '')
|
||||
@@ -404,16 +434,16 @@ const uploadBlock = async (upload: BlockUpload, block: {start: number, end: numb
|
||||
}
|
||||
}
|
||||
|
||||
const startInflightBlock = (name: string, block: {start: number, end: number}) => {
|
||||
const startInflightBlock = (name: string, block: { start: number; end: number }) => {
|
||||
inflightBlocks.set(inflightKey(name, block.start), {
|
||||
name,
|
||||
start: block.start,
|
||||
end: block.end,
|
||||
startedAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
const finishInflightBlock = (name: string, block: {start: number, end: number}) => {
|
||||
const finishInflightBlock = (name: string, block: { start: number; end: number }) => {
|
||||
const key = inflightKey(name, block.start)
|
||||
const info = inflightBlocks.get(key)
|
||||
if (!info) return
|
||||
@@ -433,9 +463,9 @@ const worker = async (runId: number) => {
|
||||
|
||||
while (runId === uploadRunId && upload.completed < upload.blocks.length) {
|
||||
while (
|
||||
runId === uploadRunId
|
||||
&& upload.nextIndex < upload.blocks.length
|
||||
&& inflight.size < MAX_PARALLEL_REQUESTS
|
||||
runId === uploadRunId &&
|
||||
upload.nextIndex < upload.blocks.length &&
|
||||
inflight.size < MAX_PARALLEL_REQUESTS
|
||||
) {
|
||||
const block = upload.blocks[upload.nextIndex++]!
|
||||
store.uprogress.status = 'uploading'
|
||||
|
||||
@@ -74,10 +74,18 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getServerConfig,
|
||||
listUsers,
|
||||
updatePublic,
|
||||
updateServerName,
|
||||
updateUser
|
||||
} from '@/repositories/User'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
@@ -92,7 +100,7 @@ const success = ref('')
|
||||
const copyButtonText = ref('📋')
|
||||
const serverSettings = reactive({
|
||||
public: false,
|
||||
name: '',
|
||||
name: ''
|
||||
})
|
||||
|
||||
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -163,10 +171,13 @@ const renameUser = async (user: User) => {
|
||||
}
|
||||
|
||||
const resetPassword = async (user: User) => {
|
||||
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
||||
if (
|
||||
!confirm(`Reset password for ${user.username}? A new password will be generated.`)
|
||||
)
|
||||
return
|
||||
try {
|
||||
success.value = ''
|
||||
const result = await updateUser(user.username, { password: "" })
|
||||
const result = await updateUser(user.username, { password: '' })
|
||||
if (result.password) {
|
||||
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
||||
}
|
||||
@@ -195,7 +206,10 @@ const copySuccess = async (isButtonClick: boolean = false) => {
|
||||
// Show "Copied!" indication on button
|
||||
copyButtonText.value = '✅ Copied!'
|
||||
// Hide password/key and button immediately after copying
|
||||
const baseMessage = success.value.replace(/(?:Password|New password|Key): .+/, 'Copied to clipboard!')
|
||||
const baseMessage = success.value.replace(
|
||||
/(?:Password|New password|Key): .+/,
|
||||
'Copied to clipboard!'
|
||||
)
|
||||
success.value = baseMessage
|
||||
// Hide the entire message after 3 seconds
|
||||
setTimeout(() => {
|
||||
@@ -258,18 +272,24 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
// Load users and config when dialog opens
|
||||
watch(() => store.dialog, (newVal) => {
|
||||
if (newVal === 'usermgmt') {
|
||||
loadServerConfig()
|
||||
if (!store.server.paskia) {
|
||||
loadUsers()
|
||||
watch(
|
||||
() => store.dialog,
|
||||
newVal => {
|
||||
if (newVal === 'usermgmt') {
|
||||
loadServerConfig()
|
||||
if (!store.server.paskia) {
|
||||
loadUsers()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
watch(() => store.server.public, (newVal) => {
|
||||
serverSettings.public = newVal || false
|
||||
})
|
||||
watch(
|
||||
() => store.server.public,
|
||||
newVal => {
|
||||
serverSettings.public = newVal || false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -67,10 +67,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { listTokens, createToken, deleteToken } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { createToken, deleteToken, listTokens } from '@/repositories/User'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
interface Token {
|
||||
id: string
|
||||
@@ -145,7 +145,7 @@ const submitCreate = async () => {
|
||||
if (result.url) {
|
||||
createdToken.value = {
|
||||
...(result as CreatedToken),
|
||||
url: ensureFilesBaseUrl((result as CreatedToken).url),
|
||||
url: ensureFilesBaseUrl((result as CreatedToken).url)
|
||||
}
|
||||
mode.value = 'created'
|
||||
}
|
||||
@@ -188,12 +188,15 @@ const formatDate = (ts: number) => {
|
||||
}
|
||||
|
||||
// Load tokens when dialog opens
|
||||
watch(() => store.dialog, (newVal) => {
|
||||
if (newVal === 'tokens') {
|
||||
resetCreate()
|
||||
loadTokens()
|
||||
watch(
|
||||
() => store.dialog,
|
||||
newVal => {
|
||||
if (newVal === 'tokens') {
|
||||
resetCreate()
|
||||
loadTokens()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user