Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acd38c2235 | ||
|
|
2f38f15afa | ||
|
|
0fc28e56bb | ||
|
|
22b0e503e0 | ||
|
|
e3a4ecdcc2 | ||
|
|
9f363e3f66 | ||
|
|
8270dd0cc2 | ||
|
|
1af6cd82fe | ||
|
|
0bc2a12cfa |
@@ -13,8 +13,8 @@
|
|||||||
--transition-time: 0.2s;
|
--transition-time: 0.2s;
|
||||||
/* The following are overridden by responsive layouts */
|
/* The following are overridden by responsive layouts */
|
||||||
--root-font-size: 1rem;
|
--root-font-size: 1rem;
|
||||||
--header-font-size: 1rem;
|
--header-font-size: clamp(0.7rem, 2.5vw, 1rem);
|
||||||
--header-height: 4rem;
|
--header-height: clamp(2.8rem, 10vw, 4rem);
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
@media screen and (min-width: 1000px) {
|
@media screen and (min-width: 1000px) {
|
||||||
:root {
|
:root {
|
||||||
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
||||||
|
--header-font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (min-width: 2000px) {
|
@media screen and (min-width: 2000px) {
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
<template>
|
||||||
|
<div class="disk-space-container" ref="containerRef">
|
||||||
|
<div
|
||||||
|
ref="widgetRef"
|
||||||
|
class="disk-space-widget"
|
||||||
|
:class="{ expanded: isExpanded }"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 150 150" class="pie-svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<defs>
|
||||||
|
<filter id="pieShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="rgba(0,0,0,0.4)" />
|
||||||
|
</filter>
|
||||||
|
<radialGradient id="storageGradient" cx="30%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#93e" />
|
||||||
|
<stop offset="100%" stop-color="#82d" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="otherGradient" cx="30%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#d9f" />
|
||||||
|
<stop offset="100%" stop-color="#c8e" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="highlightOverlay" cx="35%" cy="35%" r="65%">
|
||||||
|
<stop offset="0%" stop-color="rgba(255,255,255,0.15)" />
|
||||||
|
<stop offset="60%" stop-color="rgba(255,255,255,0)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(0,0,0,0.08)" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" />
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
|
||||||
|
<circle :r="holeRadius" :cx="pieCx" :cy="pieCy" fill="rgba(0,0,0,0.5)" />
|
||||||
|
<text ref="centerLabelRef" :x="pieCx" :y="pieCy" dy="0.35em" class="pie-center-label" text-anchor="middle">GB</text>
|
||||||
|
<circle :r="pieRadius" :cx="pieCx" :cy="pieCy" fill="transparent" class="pie-hitarea" @click="handleClick" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g ref="labelsRef" class="pie-labels">
|
||||||
|
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.storage, sectorInfo.storage.angle) }}</text>
|
||||||
|
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
||||||
|
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.usage - store.space.storage, sectorInfo.other.angle) }}</text>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
||||||
|
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
|
||||||
|
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<text class="pie-label-sub" fill="#93e">
|
||||||
|
<textPath :href="'#' + storageLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">{{ storageName }}</textPath>
|
||||||
|
</text>
|
||||||
|
<text class="pie-label-sub" :fill="freeColor">
|
||||||
|
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
|
||||||
|
</text>
|
||||||
|
<text class="pie-label-sub" fill="#d9f">
|
||||||
|
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const widgetRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const labelsRef = ref<SVGGElement | null>(null)
|
||||||
|
const centerLabelRef = ref<SVGTextElement | null>(null)
|
||||||
|
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
let animationFrame: number | null = null
|
||||||
|
|
||||||
|
const BASE_SIZE = 48
|
||||||
|
const EXPANDED_SCALE = 320 / 48
|
||||||
|
const ANIM_DURATION = 200
|
||||||
|
const containerPos = ref({ top: 0, left: 0, width: 0 })
|
||||||
|
|
||||||
|
const formatGB = (bytes: number) => {
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024)
|
||||||
|
return gb < 10 ? gb.toFixed(1) : `${Math.round(gb)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dot suffix for ambiguous angles (within 15° of horizontal) on numbers that look same upside down
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncateLabel = (name: string, maxLen = 10): string => {
|
||||||
|
if (name.length <= maxLen) return name
|
||||||
|
const parts = name.split(/[\s\-_.,;:!?()\[\]{}]+/)
|
||||||
|
if (parts[0] && parts[0].length <= maxLen) return parts[0]
|
||||||
|
return name.slice(0, maxLen - 1) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageName = computed(() => truncateLabel(store.server.name || 'stored'))
|
||||||
|
|
||||||
|
const TAU = 2 * Math.PI
|
||||||
|
|
||||||
|
const pieCx = 75
|
||||||
|
const pieCy = 75
|
||||||
|
const pieRadius = 55
|
||||||
|
const holeRadius = pieRadius * 0.38
|
||||||
|
const ringWidth = pieRadius - holeRadius
|
||||||
|
const midRadius = (pieRadius + holeRadius) / 2
|
||||||
|
const CIRC = TAU * midRadius
|
||||||
|
|
||||||
|
const pieStorageDash = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return `0 ${CIRC}`
|
||||||
|
return `${(s.storage / s.disk) * CIRC} ${CIRC}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieFreeDash = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return `0 ${CIRC}`
|
||||||
|
return `${(s.free / s.disk) * CIRC} ${CIRC}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieFreeOffsetVal = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return 0
|
||||||
|
return -(s.storage / s.disk) * CIRC
|
||||||
|
})
|
||||||
|
|
||||||
|
const freeColor = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return '#6c6'
|
||||||
|
const freePct = s.free / s.disk
|
||||||
|
if (freePct > 0.25) return '#5b5'
|
||||||
|
if (freePct > 0.10) return '#db3'
|
||||||
|
return '#d44'
|
||||||
|
})
|
||||||
|
|
||||||
|
const PIE_RADIUS = 55
|
||||||
|
const LABEL_RADIUS = 62
|
||||||
|
|
||||||
|
const getPoint = (angle: number, radius: number) => {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const storagePct = s.storage / s.disk
|
||||||
|
const freePct = s.free / s.disk
|
||||||
|
const otherPct = (s.usage - s.storage) / s.disk
|
||||||
|
|
||||||
|
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||||
|
const freeStart = storagePct * 360
|
||||||
|
const freeAngle = freeStart + freePct * 180
|
||||||
|
const otherStart = (storagePct + freePct) * 360
|
||||||
|
const otherAngle = otherStart + otherPct * 180
|
||||||
|
|
||||||
|
return {
|
||||||
|
storage: { angle: storageAngle, pct: storagePct },
|
||||||
|
free: { angle: freeAngle, pct: freePct },
|
||||||
|
other: { angle: otherAngle, pct: otherPct }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rawAngles = computed(() => ({
|
||||||
|
storage: sectorInfo.value.storage.angle,
|
||||||
|
free: sectorInfo.value.free.angle,
|
||||||
|
other: sectorInfo.value.other.angle
|
||||||
|
}))
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
// Collision avoidance for curved name labels
|
||||||
|
const labelLengths = computed(() => ({
|
||||||
|
storage: storageName.value.length,
|
||||||
|
free: 4,
|
||||||
|
other: 5
|
||||||
|
}))
|
||||||
|
|
||||||
|
const getGapForPair = (len1: number, len2: number) => {
|
||||||
|
return 35 + Math.max(0, len1 + len2 - 8) * 2.5
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedLabelAngles = computed(() => {
|
||||||
|
const angles = rawAngles.value
|
||||||
|
const lens = labelLengths.value
|
||||||
|
const labels = [
|
||||||
|
{ id: 'storage', angle: angles.storage, len: lens.storage },
|
||||||
|
{ id: 'free', angle: angles.free, len: lens.free },
|
||||||
|
{ id: 'other', angle: angles.other, len: lens.other }
|
||||||
|
]
|
||||||
|
labels.sort((a, b) => a.angle - b.angle)
|
||||||
|
|
||||||
|
for (let iterations = 0; iterations < 15; iterations++) {
|
||||||
|
let moved = false
|
||||||
|
for (let i = 0; i < labels.length; i++) {
|
||||||
|
const current = labels[i]!
|
||||||
|
const next = labels[(i + 1) % labels.length]!
|
||||||
|
let angleDiff = next.angle - current.angle
|
||||||
|
if (angleDiff < 0) angleDiff += 360
|
||||||
|
const requiredGap = getGapForPair(current.len, next.len)
|
||||||
|
if (angleDiff < requiredGap) {
|
||||||
|
const push = (requiredGap - angleDiff) / 2
|
||||||
|
current.angle = (current.angle - push + 360) % 360
|
||||||
|
next.angle = (next.angle + push) % 360
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!moved) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, number> = {}
|
||||||
|
for (const l of labels) result[l.id] = l.angle
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Arc path for curved text labels (CW for top half, CCW for bottom half)
|
||||||
|
const createArcPath = (centerAngle: number, id: string) => {
|
||||||
|
const radius = LABEL_RADIUS
|
||||||
|
const arcSpan = 60
|
||||||
|
const isBottom = centerAngle > 90 && centerAngle <= 270
|
||||||
|
const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2
|
||||||
|
const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2
|
||||||
|
const start = getPoint(startAngle, radius)
|
||||||
|
const end = getPoint(endAngle, radius)
|
||||||
|
const sweep = isBottom ? 0 : 1
|
||||||
|
return {
|
||||||
|
id: `label-path-${id}`,
|
||||||
|
d: `M ${start.x} ${start.y} A ${radius} ${radius} 0 0 ${sweep} ${end.x} ${end.y}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage'))
|
||||||
|
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free'))
|
||||||
|
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other'))
|
||||||
|
|
||||||
|
const handleClick = () => isExpanded.value ? collapse() : expand()
|
||||||
|
|
||||||
|
const applyAnimState = (t: number, opacity: number) => {
|
||||||
|
const widget = widgetRef.value
|
||||||
|
const labels = labelsRef.value
|
||||||
|
const centerLabel = centerLabelRef.value
|
||||||
|
if (!widget) return
|
||||||
|
|
||||||
|
const scale = 1 + (EXPANDED_SCALE - 1) * t
|
||||||
|
// Move top-right corner of widget to top-right corner of viewport
|
||||||
|
const targetX = window.innerWidth - containerPos.value.left - containerPos.value.width
|
||||||
|
const targetY = -containerPos.value.top
|
||||||
|
|
||||||
|
widget.style.transform = `translate(${targetX * t}px, ${targetY * t}px) scale(${scale})`
|
||||||
|
if (labels) labels.style.opacity = String(opacity)
|
||||||
|
if (centerLabel) centerLabel.style.opacity = String(opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = (duration: number, expanding: boolean, onComplete?: () => void) => {
|
||||||
|
const startTime = performance.now()
|
||||||
|
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 t = expanding ? eased : 1 - eased
|
||||||
|
applyAnimState(t, t) // opacity follows position
|
||||||
|
if (progress < 1) {
|
||||||
|
animationFrame = requestAnimationFrame(tick)
|
||||||
|
} else {
|
||||||
|
animationFrame = null
|
||||||
|
onComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animationFrame = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expand = () => {
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
if (containerRef.value) {
|
||||||
|
const rect = containerRef.value.getBoundingClientRect()
|
||||||
|
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
|
||||||
|
}
|
||||||
|
isExpanded.value = true
|
||||||
|
animate(ANIM_DURATION, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapse = () => {
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
if (containerRef.value) {
|
||||||
|
const rect = containerRef.value.getBoundingClientRect()
|
||||||
|
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
|
||||||
|
}
|
||||||
|
animate(ANIM_DURATION, false, () => {
|
||||||
|
isExpanded.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isExpanded.value) collapse()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
// Initialize labels as hidden
|
||||||
|
if (labelsRef.value) labelsRef.value.style.opacity = '0'
|
||||||
|
if (centerLabelRef.value) centerLabelRef.value.style.opacity = '0'
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.disk-space-container {
|
||||||
|
position: relative;
|
||||||
|
width: 3em;
|
||||||
|
height: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
will-change: transform;
|
||||||
|
filter: brightness(0.85);
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget:hover,
|
||||||
|
.disk-space-widget:focus {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget.expanded {
|
||||||
|
pointer-events: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget.expanded:hover,
|
||||||
|
.disk-space-widget.expanded:focus {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-hitarea {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-label-inner {
|
||||||
|
fill: #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 0.5px;
|
||||||
|
paint-order: stroke fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-center-label {
|
||||||
|
fill: #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-label-sub {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant: small-caps;
|
||||||
|
text-transform: lowercase;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 1px;
|
||||||
|
paint-order: stroke fill;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||||
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||||
<p v-else-if="!store.connected">No Connection</p>
|
<p v-else-if="!store.connected">No Connection</p>
|
||||||
<p v-else-if="store.document.length === 0">Waiting for File List</p>
|
<p v-else-if="store.documentCount === 0">Waiting for File List</p>
|
||||||
<p v-else-if="store.query">No matches!</p>
|
<p v-else-if="store.query">No matches!</p>
|
||||||
<p v-else-if="!exists(props.path)">Folder not found</p>
|
<p v-else-if="!exists(props.path)">Folder not found</p>
|
||||||
<p v-else>Empty folder</p>
|
<p v-else>Empty folder</p>
|
||||||
|
|||||||
@@ -251,8 +251,7 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
})
|
})
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
doc.ghost = true
|
store.addGhost(doc)
|
||||||
store.document.push(doc)
|
|
||||||
editing.value = null
|
editing.value = null
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -351,13 +350,13 @@ const copyImage = async (doc: Doc) => {
|
|||||||
|
|
||||||
const deleteFile = (doc: Doc) => {
|
const deleteFile = (doc: Doc) => {
|
||||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
doc.ghost = true
|
store.hideDoc(path)
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
message(ev: MessageEvent) {
|
message(ev: MessageEvent) {
|
||||||
const res = JSON.parse(ev.data)
|
const res = JSON.parse(ev.data)
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
console.error('Delete failed', res.error)
|
console.error('Delete failed', res.error)
|
||||||
doc.ghost = false
|
store.unhideDoc(path)
|
||||||
store.showToast(res.error.message || 'Delete failed')
|
store.showToast(res.error.message || 'Delete failed')
|
||||||
} else if (res.status === 'ack') {
|
} else if (res.status === 'ack') {
|
||||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
|
|||||||
@@ -205,8 +205,7 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
})
|
})
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
doc.ghost = true
|
store.addGhost(doc)
|
||||||
store.document.push(doc)
|
|
||||||
editing.value = null
|
editing.value = null
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -295,13 +294,13 @@ const copyImage = async (doc: Doc) => {
|
|||||||
|
|
||||||
const deleteFile = (doc: Doc) => {
|
const deleteFile = (doc: Doc) => {
|
||||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
doc.ghost = true
|
store.hideDoc(path)
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
message(ev: MessageEvent) {
|
message(ev: MessageEvent) {
|
||||||
const res = JSON.parse(ev.data)
|
const res = JSON.parse(ev.data)
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
console.error('Delete failed', res.error)
|
console.error('Delete failed', res.error)
|
||||||
doc.ghost = false
|
store.unhideDoc(path)
|
||||||
store.showToast(res.error.message || 'Delete failed')
|
store.showToast(res.error.message || 'Delete failed')
|
||||||
} else if (res.status === 'ack') {
|
} else if (res.status === 'ack') {
|
||||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
|
|||||||
@@ -20,40 +20,7 @@
|
|||||||
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
|
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="spacer smallgap"></div>
|
<div class="spacer smallgap"></div>
|
||||||
<div v-if="store.space.disk" class="disk-space"
|
<DiskSpace v-if="store.space.disk" />
|
||||||
@mouseenter="diskTooltip?.startHover"
|
|
||||||
@mousemove="diskTooltip?.updatePosition"
|
|
||||||
@mouseleave="diskTooltip?.endHover"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 32 32" class="pie-mini">
|
|
||||||
<!-- Base: 'other' usage (light purple - appears on left, before 12 o'clock) -->
|
|
||||||
<circle r="16" cx="16" cy="16" fill="#c8e" />
|
|
||||||
<!-- Middle ring: free space (dynamic color - appears at bottom) -->
|
|
||||||
<circle r="8" cx="16" cy="16" fill="transparent" :stroke="freeColor" stroke-width="16" :stroke-dasharray="pieFree" :stroke-dashoffset="pieFreeOffset" transform="rotate(-90 16 16)" />
|
|
||||||
<!-- Top ring: storage (deep purple - appears on right after 12 o'clock) -->
|
|
||||||
<circle r="8" cx="16" cy="16" fill="transparent" stroke="#82d" stroke-width="16" :stroke-dasharray="pieStorage" transform="rotate(-90 16 16)" />
|
|
||||||
<!-- Subtle inner circle for depth -->
|
|
||||||
<circle r="2" cx="16" cy="16" fill="rgba(255,255,255,0.2)" />
|
|
||||||
</svg>
|
|
||||||
<CursorTooltip ref="diskTooltip" text="Disk space">
|
|
||||||
<div class="disk-tooltip">
|
|
||||||
<svg viewBox="0 0 160 80" width="160" height="80" class="pie-tooltip">
|
|
||||||
<!-- Pie chart centered at 40,40 -->
|
|
||||||
<circle r="32" cx="40" cy="40" fill="#c8e" />
|
|
||||||
<circle r="16" cx="40" cy="40" fill="transparent" :stroke="freeColor" stroke-width="32" :stroke-dasharray="pieFreeLg" :stroke-dashoffset="pieFreeOffsetLg" transform="rotate(-90 40 40)" />
|
|
||||||
<circle r="16" cx="40" cy="40" fill="transparent" stroke="#82d" stroke-width="32" :stroke-dasharray="pieStorageLg" transform="rotate(-90 40 40)" />
|
|
||||||
<circle r="4" cx="40" cy="40" fill="rgba(255,255,255,0.25)" />
|
|
||||||
<!-- Labels on the right -->
|
|
||||||
<rect x="78" y="10" width="10" height="10" fill="#82d" rx="2"/>
|
|
||||||
<text x="92" y="19" class="pie-label">{{ formatSize(store.space.storage) }} stored</text>
|
|
||||||
<rect x="78" y="30" width="10" height="10" fill="#c8e" rx="2"/>
|
|
||||||
<text x="92" y="39" class="pie-label">{{ formatSize(store.space.usage - store.space.storage) }} other</text>
|
|
||||||
<rect x="78" y="50" width="10" height="10" :fill="freeColor" rx="2"/>
|
|
||||||
<text x="92" y="59" class="pie-label">{{ formatSize(store.space.free) }} free</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CursorTooltip>
|
|
||||||
</div>
|
|
||||||
<SvgButton name="cog" @click="settingsMenu" />
|
<SvgButton name="cog" @click="settingsMenu" />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,74 +28,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||||
import { ref, nextTick, watchEffect, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import { showAuthIframe } from 'paskia'
|
import { showAuthIframe } from 'paskia'
|
||||||
import { resumeWatching } from '@/repositories/WS'
|
import { resumeWatching } from '@/repositories/WS'
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import { formatSize } from '@/utils'
|
import DiskSpace from './DiskSpace.vue'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const ssoStore = useSsoAuthStore()
|
const ssoStore = useSsoAuthStore()
|
||||||
const search = ref<HTMLInputElement | null>()
|
const search = ref<HTMLInputElement | null>()
|
||||||
const diskTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|
||||||
|
|
||||||
const CIRC = 50.27 // 2π×8
|
const props = defineProps<{
|
||||||
|
|
||||||
// Storage segment (starts at top, -90°)
|
|
||||||
const pieStorage = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC}`
|
|
||||||
const pct = s.storage / s.disk
|
|
||||||
return `${pct * CIRC} ${CIRC}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Free segment (starts after storage, goes clockwise to bottom area)
|
|
||||||
const pieFree = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC}`
|
|
||||||
const pct = s.free / s.disk
|
|
||||||
return `${pct * CIRC} ${CIRC}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieFreeOffset = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return 0
|
|
||||||
// Start after storage segment
|
|
||||||
const storagePct = s.storage / s.disk
|
|
||||||
return -storagePct * CIRC
|
|
||||||
})
|
|
||||||
|
|
||||||
// Free space color: green when plenty, yellow when moderate, red when low
|
|
||||||
const freeColor = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return '#6c6'
|
|
||||||
const freePct = s.free / s.disk
|
|
||||||
if (freePct > 0.25) return '#5b5' // Green: > 25% free
|
|
||||||
if (freePct > 0.10) return '#db3' // Yellow: 10-25% free
|
|
||||||
return '#d44' // Red: < 10% free
|
|
||||||
})
|
|
||||||
|
|
||||||
// Large pie for tooltip (circumference = 2π×16 ≈ 100.53)
|
|
||||||
const CIRC_LG = 100.53
|
|
||||||
const pieStorageLg = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC_LG}`
|
|
||||||
return `${(s.storage / s.disk) * CIRC_LG} ${CIRC_LG}`
|
|
||||||
})
|
|
||||||
const pieFreeLg = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC_LG}`
|
|
||||||
return `${(s.free / s.disk) * CIRC_LG} ${CIRC_LG}`
|
|
||||||
})
|
|
||||||
const pieFreeOffsetLg = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return 0
|
|
||||||
return -(s.storage / s.disk) * CIRC_LG
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
}>()
|
}>()
|
||||||
@@ -240,12 +151,21 @@ defineExpose({
|
|||||||
padding: 0 0.3em;
|
padding: 0 0.3em;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 5.5em;
|
min-width: 2.5em;
|
||||||
max-width: 20em;
|
max-width: 20em;
|
||||||
}
|
}
|
||||||
|
.search-group:hover,
|
||||||
.search-group:focus-within {
|
.search-group:focus-within {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
.search-group:hover :deep(button.action-button),
|
||||||
|
.search-group:focus-within :deep(button.action-button) {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.search-group:hover :deep(button.action-button svg),
|
||||||
|
.search-group:focus-within :deep(button.action-button svg) {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
.search-group:focus-within .search-hint {
|
.search-group:focus-within .search-hint {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -253,6 +173,8 @@ defineExpose({
|
|||||||
.search-group :deep(.action-button) {
|
.search-group :deep(.action-button) {
|
||||||
width: 2.2em;
|
width: 2.2em;
|
||||||
height: 2.2em;
|
height: 2.2em;
|
||||||
|
min-width: 1.5em;
|
||||||
|
min-height: 1.5em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.search-group input[type='search'] {
|
.search-group input[type='search'] {
|
||||||
@@ -261,9 +183,10 @@ defineExpose({
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0.2em 0.5em 0.2em 0;
|
padding: 0.2em 0.5em 0.2em 0;
|
||||||
font-size: var(--header-font-size);
|
font-size: inherit;
|
||||||
flex: 1 1 3em;
|
flex: 1 1 3em;
|
||||||
min-width: 3em;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.search-hint {
|
.search-hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -279,24 +202,11 @@ defineExpose({
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.disk-space {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
display: flex;
|
.search-hint {
|
||||||
align-items: center;
|
display: block;
|
||||||
cursor: default;
|
}
|
||||||
}
|
|
||||||
.pie-mini {
|
|
||||||
width: 1.4em;
|
|
||||||
height: 1.4em;
|
|
||||||
}
|
|
||||||
.disk-tooltip {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.pie-tooltip {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.pie-tooltip .pie-label {
|
|
||||||
fill: #fff;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -31,23 +31,30 @@ const props = defineProps({
|
|||||||
const dst = computed(() => props.path!.join('/'))
|
const dst = computed(() => props.path!.join('/'))
|
||||||
const op = (opName: string, dst?: string) => {
|
const op = (opName: string, dst?: string) => {
|
||||||
const sel = store.selectedFiles
|
const sel = store.selectedFiles
|
||||||
|
const paths = sel.keys.map(key => {
|
||||||
|
const doc = sel.docs[key]!
|
||||||
|
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
})
|
||||||
const msg = {
|
const msg = {
|
||||||
op: opName,
|
op: opName,
|
||||||
sel: sel.keys.map(key => {
|
sel: paths
|
||||||
const doc = sel.docs[key]!
|
|
||||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
if (dst !== undefined) msg.dst = dst
|
if (dst !== undefined) msg.dst = dst
|
||||||
if (opName === 'rm' || opName === 'mv')
|
// Hide items being deleted or moved (optimistic update)
|
||||||
for (const key of sel.keys) sel.docs[key]!.ghost = true
|
if (opName === 'rm' || opName === 'mv') {
|
||||||
|
for (const path of paths) store.hideDoc(path)
|
||||||
|
}
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
message(ev: MessageEvent) {
|
message(ev: MessageEvent) {
|
||||||
const res = JSON.parse(ev.data)
|
const res = JSON.parse(ev.data)
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
console.error('Control socket error', msg, res.error)
|
console.error('Control socket error', msg, res.error)
|
||||||
store.error = res.error.message
|
store.error = res.error.message
|
||||||
|
// Restore hidden items on error
|
||||||
|
if (opName === 'rm' || opName === 'mv') {
|
||||||
|
for (const path of paths) store.unhideDoc(path)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
} else if (res.status === 'ack') {
|
} else if (res.status === 'ack') {
|
||||||
console.log('Control ack OK', res)
|
console.log('Control ack OK', res)
|
||||||
|
|||||||
@@ -32,18 +32,29 @@ const tooltipText = props.tooltip ?? ''
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
padding: 0.2em;
|
margin: 0 0.2em;
|
||||||
width: 3em;
|
padding: 0;
|
||||||
height: 3em;
|
width: 2.7em;
|
||||||
|
height: 2.7em;
|
||||||
|
min-width: 1.9em;
|
||||||
|
min-height: 1.9em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.action-button:hover,
|
.action-button:hover,
|
||||||
.action-button:focus {
|
.action-button:focus {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
svg {
|
.action-button svg {
|
||||||
fill: #ccc;
|
fill: #ccc;
|
||||||
transform: fill 0.2s ease;
|
transition: fill 0.2s ease;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
.action-button:hover svg,
|
.action-button:hover svg,
|
||||||
.action-button:focus svg {
|
.action-button:focus svg {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { connect, uploadUrl } from '@/repositories/WS';
|
import { connect, uploadUrl } from '@/repositories/WS';
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { collator } from '@/utils';
|
import { collator } from '@/utils';
|
||||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
@@ -98,7 +99,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
||||||
// Optimistic update: ghost folders and files
|
// Optimistic update: ghost folders and files
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const byPath = new Map(store.document.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
|
const docs = getDocuments()
|
||||||
|
const byPath = new Map(docs.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
|
||||||
|
// Also check existing ghosts
|
||||||
|
for (const g of store.ghosts) {
|
||||||
|
byPath.set(g.loc ? `${g.loc}/${g.name}` : g.name, g)
|
||||||
|
}
|
||||||
const added = new Set<string>()
|
const added = new Set<string>()
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const lastSlash = f.cloudName.lastIndexOf('/')
|
const lastSlash = f.cloudName.lastIndexOf('/')
|
||||||
@@ -109,14 +115,13 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const folderPath = parts.slice(0, i + 1).join('/')
|
const folderPath = parts.slice(0, i + 1).join('/')
|
||||||
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
||||||
store.document.push(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true, ghost: true }))
|
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true }))
|
||||||
added.add(folderPath)
|
added.add(folderPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ghost file or update existing
|
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||||
const existing = byPath.get(f.cloudName)
|
const existing = byPath.get(f.cloudName)
|
||||||
if (existing) { existing.size = f.file.size; existing.mtime = now; existing.ghost = true }
|
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false }))
|
||||||
else store.document.push(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false, ghost: true }))
|
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
upqueue = [...upqueue, ...files]
|
upqueue = [...upqueue, ...files]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type DocProps = {
|
|||||||
mtime: number
|
mtime: number
|
||||||
dir: boolean
|
dir: boolean
|
||||||
ghost?: boolean
|
ghost?: boolean
|
||||||
|
expires?: number // Unix timestamp for ghost expiry
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Doc {
|
export class Doc {
|
||||||
@@ -19,6 +20,7 @@ export class Doc {
|
|||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public dir: boolean = false
|
public dir: boolean = false
|
||||||
public ghost: boolean = false
|
public ghost: boolean = false
|
||||||
|
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||||
/** @internal Use the name getter/setter instead */
|
/** @internal Use the name getter/setter instead */
|
||||||
public _name: string = ""
|
public _name: string = ""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Non-reactive document storage for the full file list
|
||||||
|
// This avoids Vue reactivity overhead on large arrays
|
||||||
|
|
||||||
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
import { shallowRef, triggerRef } from 'vue'
|
||||||
|
|
||||||
|
// The main document list - shallowRef means only the reference is reactive, not the contents
|
||||||
|
const documents = shallowRef<Doc[]>([])
|
||||||
|
|
||||||
|
// Version counter for manual reactivity triggering
|
||||||
|
let version = 0
|
||||||
|
|
||||||
|
export function getDocuments(): Doc[] {
|
||||||
|
return documents.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDocuments(docs: Doc[]): void {
|
||||||
|
documents.value = docs
|
||||||
|
version++
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVersion(): number {
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger reactivity manually (e.g., after modifications)
|
||||||
|
export function triggerUpdate(): void {
|
||||||
|
version++
|
||||||
|
triggerRef(documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For computed dependencies that need to react to document changes
|
||||||
|
export const documentRef = documents
|
||||||
@@ -5,6 +5,7 @@ import { collator } from '@/utils'
|
|||||||
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
||||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||||
import SearchWorker from '@/workers/searchWorker?worker'
|
import SearchWorker from '@/workers/searchWorker?worker'
|
||||||
|
import { getDocuments, setDocuments, documentRef } from './documentStore'
|
||||||
|
|
||||||
// Singleton search worker instance
|
// Singleton search worker instance
|
||||||
let searchWorker: Worker | null = null
|
let searchWorker: Worker | null = null
|
||||||
@@ -52,9 +53,20 @@ function getSearchWorker(): Worker {
|
|||||||
return searchWorker
|
return searchWorker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ghost expiry time in seconds
|
||||||
|
const GHOST_TTL = 30
|
||||||
|
|
||||||
|
// Periodic cleanup interval
|
||||||
|
let cleanupInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
export const useMainStore = defineStore('main', {
|
export const useMainStore = defineStore('main', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
document: [] as Doc[],
|
// Ghosts are temporary optimistic-update files/folders shown until server confirms
|
||||||
|
ghosts: [] as Doc[],
|
||||||
|
// Hidden paths for optimistic delete (path -> expiry timestamp)
|
||||||
|
hiddenPaths: new Map<string, number>(),
|
||||||
|
// Version counter to trigger reactivity when external document list changes
|
||||||
|
docVersion: 0,
|
||||||
selected: new Set<FUID>([]),
|
selected: new Set<FUID>([]),
|
||||||
query: '' as string,
|
query: '' as string,
|
||||||
searchResults: [] as Doc[],
|
searchResults: [] as Doc[],
|
||||||
@@ -118,10 +130,66 @@ export const useMainStore = defineStore('main', {
|
|||||||
}))
|
}))
|
||||||
loc.push(name)
|
loc.push(name)
|
||||||
}
|
}
|
||||||
this.document = docs
|
// Store in non-reactive external storage
|
||||||
|
setDocuments(docs)
|
||||||
|
// Clear ghosts that now exist in the real list
|
||||||
|
const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.name))
|
||||||
|
this.ghosts = this.ghosts.filter(g => !realPaths.has(g.loc ? `${g.loc}/${g.name}` : g.name))
|
||||||
|
// Clear hidden paths that no longer exist (deletion confirmed)
|
||||||
|
for (const path of this.hiddenPaths.keys()) {
|
||||||
|
if (!realPaths.has(path)) this.hiddenPaths.delete(path)
|
||||||
|
}
|
||||||
|
// Start cleanup timer if not running
|
||||||
|
this.startCleanupTimer()
|
||||||
|
// Bump version to trigger reactive updates
|
||||||
|
this.docVersion++
|
||||||
// Sync documents to search worker
|
// Sync documents to search worker
|
||||||
this.syncSearchWorker()
|
this.syncSearchWorker()
|
||||||
},
|
},
|
||||||
|
/** Add a ghost file/folder for optimistic UI updates */
|
||||||
|
addGhost(doc: Doc) {
|
||||||
|
doc.ghost = true
|
||||||
|
doc.expires = Math.floor(Date.now() / 1000) + GHOST_TTL
|
||||||
|
this.ghosts.push(doc)
|
||||||
|
},
|
||||||
|
/** Clear all ghosts (e.g., on navigation or refresh) */
|
||||||
|
clearGhosts() {
|
||||||
|
this.ghosts = []
|
||||||
|
},
|
||||||
|
/** Hide a document path (optimistic delete) */
|
||||||
|
hideDoc(path: string) {
|
||||||
|
this.hiddenPaths.set(path, Math.floor(Date.now() / 1000) + GHOST_TTL)
|
||||||
|
},
|
||||||
|
/** Unhide a document path (delete failed, restore visibility) */
|
||||||
|
unhideDoc(path: string) {
|
||||||
|
this.hiddenPaths.delete(path)
|
||||||
|
},
|
||||||
|
/** Start the periodic cleanup timer */
|
||||||
|
startCleanupTimer() {
|
||||||
|
if (cleanupInterval) return
|
||||||
|
cleanupInterval = setInterval(() => this.cleanupExpired(), 5000)
|
||||||
|
},
|
||||||
|
/** Stop the cleanup timer */
|
||||||
|
stopCleanupTimer() {
|
||||||
|
if (cleanupInterval) {
|
||||||
|
clearInterval(cleanupInterval)
|
||||||
|
cleanupInterval = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Remove expired ghosts and hidden paths */
|
||||||
|
cleanupExpired() {
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const ghostsBefore = this.ghosts.length
|
||||||
|
const hiddenBefore = this.hiddenPaths.size
|
||||||
|
this.ghosts = this.ghosts.filter(g => g.expires > now)
|
||||||
|
for (const [path, expires] of this.hiddenPaths) {
|
||||||
|
if (expires <= now) this.hiddenPaths.delete(path)
|
||||||
|
}
|
||||||
|
// Stop timer if nothing to clean up
|
||||||
|
if (this.ghosts.length === 0 && this.hiddenPaths.size === 0) {
|
||||||
|
this.stopCleanupTimer()
|
||||||
|
}
|
||||||
|
},
|
||||||
/** Show a temporary toast message that auto-dismisses */
|
/** Show a temporary toast message that auto-dismisses */
|
||||||
showToast(message: string, duration = 3000) {
|
showToast(message: string, duration = 3000) {
|
||||||
if (this.toastTimeout) {
|
if (this.toastTimeout) {
|
||||||
@@ -145,7 +213,8 @@ export const useMainStore = defineStore('main', {
|
|||||||
syncSearchWorker() {
|
syncSearchWorker() {
|
||||||
const worker = getSearchWorker()
|
const worker = getSearchWorker()
|
||||||
// Send plain data to worker (no class instances)
|
// Send plain data to worker (no class instances)
|
||||||
const docData = this.document.map(doc => ({
|
const docs = getDocuments()
|
||||||
|
const docData = docs.map(doc => ({
|
||||||
loc: doc.loc,
|
loc: doc.loc,
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
key: doc.key,
|
key: doc.key,
|
||||||
@@ -209,7 +278,11 @@ export const useMainStore = defineStore('main', {
|
|||||||
clearSensitiveData() {
|
clearSensitiveData() {
|
||||||
// Clear all sensitive state on logout or auth failure
|
// Clear all sensitive state on logout or auth failure
|
||||||
localStorage.removeItem('cista-files')
|
localStorage.removeItem('cista-files')
|
||||||
this.document = []
|
setDocuments([])
|
||||||
|
this.ghosts = []
|
||||||
|
this.hiddenPaths.clear()
|
||||||
|
this.stopCleanupTimer()
|
||||||
|
this.docVersion++
|
||||||
this.selected.clear()
|
this.selected.clear()
|
||||||
this.user.username = ''
|
this.user.username = ''
|
||||||
this.user.privileged = false
|
this.user.privileged = false
|
||||||
@@ -268,8 +341,21 @@ export const useMainStore = defineStore('main', {
|
|||||||
getters: {
|
getters: {
|
||||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
||||||
isUserLogged(): boolean { return this.user.isLoggedIn },
|
isUserLogged(): boolean { return this.user.isLoggedIn },
|
||||||
recentDocuments(): Doc[] { return sorted(this.document, 'modified') },
|
/** Get documents count (triggers on docVersion change) */
|
||||||
|
documentCount(): number {
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void this.docVersion
|
||||||
|
return getDocuments().length
|
||||||
|
},
|
||||||
|
recentDocuments(): Doc[] {
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void this.docVersion
|
||||||
|
return sorted(getDocuments(), 'modified')
|
||||||
|
},
|
||||||
selectedFiles(): SelectedItems {
|
selectedFiles(): SelectedItems {
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void this.docVersion
|
||||||
|
const docs = getDocuments()
|
||||||
const selected = this.selected
|
const selected = this.selected
|
||||||
const found = new Set<FUID>()
|
const found = new Set<FUID>()
|
||||||
const ret: SelectedItems = {
|
const ret: SelectedItems = {
|
||||||
@@ -278,7 +364,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
keys: [],
|
keys: [],
|
||||||
recursive: [],
|
recursive: [],
|
||||||
}
|
}
|
||||||
for (const doc of this.document) {
|
for (const doc of docs) {
|
||||||
if (selected.has(doc.key)) {
|
if (selected.has(doc.key)) {
|
||||||
found.add(doc.key)
|
found.add(doc.key)
|
||||||
ret.keys.push(doc.key)
|
ret.keys.push(doc.key)
|
||||||
@@ -299,7 +385,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||||
const nremove = base.loc.length
|
const nremove = base.loc.length
|
||||||
add(base.name, basepath, base)
|
add(base.name, basepath, base)
|
||||||
for (const doc of this.document) {
|
for (const doc of docs) {
|
||||||
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
||||||
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
const rel = full.slice(nremove)
|
const rel = full.slice(nremove)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
|
|
||||||
|
|
||||||
export const exists = (path: string[]) => {
|
export const exists = (path: string[]) => {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void store.docVersion
|
||||||
const p = path.join('/')
|
const p = path.join('/')
|
||||||
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
return getDocuments().some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watchEffect, ref, computed, watch } from 'vue'
|
import { watchEffect, ref, computed, watch } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
@@ -50,13 +51,22 @@ const documents = computed(() => {
|
|||||||
const query = props.query
|
const query = props.query
|
||||||
|
|
||||||
// List the current location (no search)
|
// List the current location (no search)
|
||||||
if (!query) return sorted(
|
if (!query) {
|
||||||
store.document.filter(doc => doc.loc === loc),
|
// Access docVersion to make this reactive to document changes
|
||||||
store.prefs.sortListing,
|
void store.docVersion
|
||||||
)
|
const hidden = store.hiddenPaths
|
||||||
|
const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
|
||||||
|
// Overlay ghosts for this location (excluding hidden ones)
|
||||||
|
const ghosts = store.ghosts.filter(g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name))
|
||||||
|
// Merge: ghosts that don't conflict with real docs
|
||||||
|
const realNames = new Set(docs.map(d => d.name))
|
||||||
|
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
|
||||||
|
return sorted(merged, store.prefs.sortListing)
|
||||||
|
}
|
||||||
|
|
||||||
// Search results from worker
|
// Search results from worker (also filter hidden)
|
||||||
const docs = store.searchResults
|
const hidden = store.hiddenPaths
|
||||||
|
const docs = store.searchResults.filter(doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
|
||||||
|
|
||||||
// Custom sort override in effect? Use grouped sorting to keep folders together
|
// Custom sort override in effect? Use grouped sorting to keep folders together
|
||||||
const order = store.prefs.sortFiltered
|
const order = store.prefs.sortFiltered
|
||||||
@@ -71,7 +81,7 @@ watchEffect(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||||
watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => {
|
watch([() => props.path.join('/'), () => store.documentCount], ([path, len], [oldPath, oldLen]) => {
|
||||||
// React to path change or initial document load (0 → non-zero)
|
// React to path change or initial document load (0 → non-zero)
|
||||||
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
||||||
store.prefs.gallery = documents.value.some(d => d.previewable)
|
store.prefs.gallery = documents.value.some(d => d.previewable)
|
||||||
|
|||||||
+11
-7
@@ -2,12 +2,14 @@
|
|||||||
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/devserver.py [frontend] [--backend backend]
|
uv run scripts/devserver.py [frontend] [--backend backend] [cista_args...]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
frontend Vite frontend endpoint (default: localhost:8989)
|
frontend Vite frontend endpoint (default: localhost:8989)
|
||||||
--backend Cista backend endpoint (default: from config, or :8999)
|
--backend Cista backend endpoint (default: from config, or :8999)
|
||||||
|
|
||||||
|
Any additional arguments are passed to the cista command.
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||||
"""
|
"""
|
||||||
@@ -29,7 +31,7 @@ from cista.serve import parse_listen
|
|||||||
DEFAULT_BACKEND_PORT = 8999
|
DEFAULT_BACKEND_PORT = 8999
|
||||||
|
|
||||||
|
|
||||||
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
|
def setup_sanic_backend(listen: str | None, extra_args: list[str]) -> tuple[str, list[str]]:
|
||||||
"""Parse backend listen address and build cista dev command.
|
"""Parse backend listen address and build cista dev command.
|
||||||
|
|
||||||
Returns (url, cmd).
|
Returns (url, cmd).
|
||||||
@@ -40,11 +42,11 @@ def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
|
|||||||
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
||||||
host = opts.get("host", "localhost") or "localhost"
|
host = opts.get("host", "localhost") or "localhost"
|
||||||
|
|
||||||
cmd = ["cista", "--dev", "-l", listen]
|
cmd = ["cista", "--dev", "-l", listen] + extra_args
|
||||||
return f"http://{host}:{port}", cmd
|
return f"http://{host}:{port}", cmd
|
||||||
|
|
||||||
|
|
||||||
async def run_devserver(frontend: str | None, backend: str | None) -> None:
|
async def run_devserver(frontend: str | None, backend: str | None, extra_args: list[str]) -> None:
|
||||||
reporoot = Path(__file__).parent.parent
|
reporoot = Path(__file__).parent.parent
|
||||||
front = reporoot / "frontend"
|
front = reporoot / "frontend"
|
||||||
if not (front / "package.json").exists():
|
if not (front / "package.json").exists():
|
||||||
@@ -52,7 +54,7 @@ async def run_devserver(frontend: str | None, backend: str | None) -> None:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
||||||
backend_url, sanic_cmd = setup_sanic_backend(backend)
|
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
|
||||||
|
|
||||||
# Tell vite where to proxy API requests
|
# Tell vite where to proxy API requests
|
||||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
||||||
@@ -89,9 +91,9 @@ def main():
|
|||||||
metavar="host:port",
|
metavar="host:port",
|
||||||
help="Cista backend endpoint (default: from config, or :8999)",
|
help="Cista backend endpoint (default: from config, or :8999)",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args, unknown = parser.parse_known_args()
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
asyncio.run(run_devserver(args.frontend, args.backend))
|
asyncio.run(run_devserver(args.frontend, args.backend, unknown))
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
@@ -99,6 +101,8 @@ HELP_EPILOG = """
|
|||||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
scripts/devserver.py 3000 # Vite on localhost:3000
|
||||||
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
||||||
|
|
||||||
|
Additional arguments are passed to the cista backend command.
|
||||||
|
|
||||||
JS_RUNTIME environment variable can be used to select the JS runtime
|
JS_RUNTIME environment variable can be used to select the JS runtime
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user