frontend: add biome checks and pre-commit integration (excluding preview files)

This commit is contained in:
Leo Vasanko
2026-04-26 06:43:06 +00:00
parent 3767fb0cec
commit c49e66323f
42 changed files with 1016 additions and 504 deletions
+19 -11
View File
@@ -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>
+34 -21
View File
@@ -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 {
+10 -9
View File
@@ -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>
+3 -3
View File
@@ -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>
+56 -24
View File
@@ -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 })
}
+3 -3
View File
@@ -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)
})
+1 -1
View File
@@ -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('')
+4 -4
View File
@@ -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(() => {
+66 -28
View File
@@ -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 })
}
+9 -9
View File
@@ -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)
+39 -18
View File
@@ -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>
+4 -4
View File
@@ -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
+2 -3
View File
@@ -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>
+10 -7
View File
@@ -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>
+2 -2
View File
@@ -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()
+1 -1
View File
@@ -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'
+8 -7
View File
@@ -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>
+68 -38
View File
@@ -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'
+35 -15
View File
@@ -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>
+11 -8
View File
@@ -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>