frontend: add biome checks and pre-commit integration (excluding preview files)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
.*
|
||||
*.lock
|
||||
!.gitignore
|
||||
!.pre-commit-config.yaml
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
/cista/_version.py
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
name: ruff check
|
||||
entry: uv run ruff check .
|
||||
language: system
|
||||
pass_filenames: false
|
||||
- id: ruff-format-check
|
||||
name: ruff format check
|
||||
entry: uv run ruff format --check .
|
||||
language: system
|
||||
pass_filenames: false
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: uv run pytest
|
||||
language: system
|
||||
pass_filenames: false
|
||||
- id: frontend-type-check
|
||||
name: frontend type-check
|
||||
entry: npm --prefix frontend run type-check
|
||||
language: system
|
||||
pass_filenames: false
|
||||
- id: frontend-biome-check
|
||||
name: frontend biome check
|
||||
entry: npm --prefix frontend run check
|
||||
language: system
|
||||
pass_filenames: false
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"files": {
|
||||
"ignore": ["node_modules", "dist", "coverage", "components.d.ts"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 88
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noInferrableTypes": "off",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off",
|
||||
"noUselessElse": "off",
|
||||
"useExponentiationOperator": "off",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useTemplate": "off",
|
||||
"useConst": "off",
|
||||
"useImportType": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAssignInExpressions": "off",
|
||||
"noDoubleEquals": "off",
|
||||
"noExplicitAny": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"noMisleadingCharacterClass": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noBannedTypes": "off",
|
||||
"useOptionalChain": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noSwitchDeclarations": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"useGenericFontNames": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"include": ["**/*.d.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noBannedTypes": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "none",
|
||||
"arrowParentheses": "asNeeded"
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-17
@@ -9,8 +9,10 @@
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
"lint": "biome lint .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format --check .",
|
||||
"check": "biome check ."
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -30,33 +32,19 @@
|
||||
"vue-router": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.15.0",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tsconfig/node18": "^18.2.6",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^25.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 88
|
||||
}
|
||||
}
|
||||
|
||||
+73
-33
@@ -24,21 +24,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import Router from '@/router/index'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
import { computed } from 'vue'
|
||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||
import UserManagementModal from './components/UserManagementModal.vue'
|
||||
import UserTokensModal from './components/UserTokensModal.vue'
|
||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
|
||||
interface Path {
|
||||
path: string
|
||||
@@ -57,7 +57,10 @@ const path: ComputedRef<Path> = computed(() => {
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
|
||||
document.title =
|
||||
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||
store.server.name ||
|
||||
'Cista Storage'
|
||||
})
|
||||
onMounted(loadSession)
|
||||
onMounted(watchConnect)
|
||||
@@ -106,11 +109,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||
const searchInput = inHeader && input
|
||||
const searchHasText = searchInput && (event.target as HTMLInputElement).value
|
||||
if (event.key.startsWith("Arrow")) {
|
||||
if (event.key.startsWith('Arrow')) {
|
||||
const dir = event.key.slice(5).toLowerCase()
|
||||
// In search with text: left/right move cursor, up/down navigate
|
||||
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||
return // Let browser handle cursor movement
|
||||
return // Let browser handle cursor movement
|
||||
}
|
||||
arrow = dir
|
||||
}
|
||||
@@ -135,8 +138,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
store.clearToast()
|
||||
headerMain.value!.clearSearch(event)
|
||||
store.focusBreadcrumb()
|
||||
}
|
||||
else if (!input && keyup && event.key === 'Backspace') {
|
||||
} else if (!input && keyup && event.key === 'Backspace') {
|
||||
Router.back()
|
||||
}
|
||||
// Select all (toggle); keydown to precede and prevent builtin
|
||||
@@ -151,20 +153,27 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
else if (
|
||||
!input &&
|
||||
keyup &&
|
||||
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
|
||||
(event.code === 'Backquote' ||
|
||||
event.key === '1' ||
|
||||
event.key === '2' ||
|
||||
event.key === '3')
|
||||
) {
|
||||
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
||||
}
|
||||
// Rename
|
||||
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
||||
else if (
|
||||
!input &&
|
||||
c &&
|
||||
keyup &&
|
||||
!event.ctrlKey &&
|
||||
(event.key === 'F2' || event.key === 'r')
|
||||
) {
|
||||
fileExplorer.cursorRename()
|
||||
}
|
||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||
else if (!input && c && event.code === 'Space') {
|
||||
if (keyup && !event.altKey && !event.ctrlKey)
|
||||
fileExplorer.cursorSelect()
|
||||
}
|
||||
else return
|
||||
if (keyup && !event.altKey && !event.ctrlKey) fileExplorer.cursorSelect()
|
||||
} else return
|
||||
/// We are handling this!
|
||||
event.preventDefault()
|
||||
if (timer) {
|
||||
@@ -174,36 +183,67 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
let f: any
|
||||
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||
if (arrow && !keyup) {
|
||||
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
|
||||
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||
const focusSearch = () =>
|
||||
(
|
||||
document.querySelector('.headermain input[type="search"]') as HTMLElement
|
||||
)?.focus()
|
||||
const focusBreadcrumb = () =>
|
||||
(document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||
|
||||
if (inBreadcrumb) {
|
||||
// Breadcrumb: up→header (no repeat), down→files (with repeat)
|
||||
if (arrow === 'up') { focusSearch(); f = null }
|
||||
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
|
||||
if (arrow === 'up') {
|
||||
focusSearch()
|
||||
f = null
|
||||
} else if (arrow === 'down') {
|
||||
fileExplorer.focusFirst?.()
|
||||
f = null
|
||||
}
|
||||
} else if (inHeader) {
|
||||
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
|
||||
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
|
||||
const items = Array.from(
|
||||
document.querySelectorAll(
|
||||
'.headermain button:not([tabindex="-1"]), .headermain input[type="search"], .headermain [tabindex="0"]'
|
||||
)
|
||||
) as HTMLElement[]
|
||||
const idx = items.indexOf(document.activeElement as HTMLElement)
|
||||
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
|
||||
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
|
||||
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
|
||||
else if (arrow === 'down') { focusBreadcrumb(); f = null }
|
||||
if (arrow === 'left' && idx > 0) {
|
||||
items[idx - 1]?.focus()
|
||||
f = null
|
||||
} else if (arrow === 'right' && idx < items.length - 1) {
|
||||
items[idx + 1]?.focus()
|
||||
f = null
|
||||
} else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
|
||||
else if (arrow === 'down') {
|
||||
focusBreadcrumb()
|
||||
f = null
|
||||
}
|
||||
} else {
|
||||
// File explorer: normal navigation with repeat
|
||||
switch (arrow) {
|
||||
case 'up': f = () => fileExplorer.up(event); break
|
||||
case 'down': f = () => fileExplorer.down(event); break
|
||||
case 'left': f = () => fileExplorer.left(event); break
|
||||
case 'right': f = () => fileExplorer.right(event); break
|
||||
case 'up':
|
||||
f = () => fileExplorer.up(event)
|
||||
break
|
||||
case 'down':
|
||||
f = () => fileExplorer.down(event)
|
||||
break
|
||||
case 'left':
|
||||
f = () => fileExplorer.left(event)
|
||||
break
|
||||
case 'right':
|
||||
f = () => fileExplorer.right(event)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
||||
const t0 = 200,
|
||||
tr = event.altKey ? 20 : 100
|
||||
f()
|
||||
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
|
||||
timer = setTimeout(() => {
|
||||
timer = setInterval(f, tr)
|
||||
}, t0 - tr)
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
--header-color: #ccc;
|
||||
--input-background: var(--soft-color);
|
||||
--input-color: #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
.size,
|
||||
@@ -50,8 +50,12 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
header .headermain { order: 1; }
|
||||
header .breadcrumb { align-self: stretch; }
|
||||
header .headermain {
|
||||
order: 1;
|
||||
}
|
||||
header .breadcrumb {
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
:root {
|
||||
@@ -74,7 +78,7 @@
|
||||
max-width: none !important;
|
||||
}
|
||||
.breadcrumb > a::after {
|
||||
content: '/';
|
||||
content: "/";
|
||||
}
|
||||
.breadcrumb svg {
|
||||
fill: black !important;
|
||||
@@ -101,7 +105,8 @@
|
||||
video::-webkit-media-controls {
|
||||
display: none;
|
||||
}
|
||||
tr, figure {
|
||||
tr,
|
||||
figure {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.selection {
|
||||
@@ -134,7 +139,7 @@ main {
|
||||
body {
|
||||
background-color: var(--primary-background);
|
||||
font-size: 1rem;
|
||||
font-family: 'Roboto';
|
||||
font-family: "Roboto";
|
||||
color: var(--primary-color);
|
||||
margin: 0;
|
||||
/* Prevent any scrolling on body */
|
||||
@@ -145,7 +150,7 @@ body {
|
||||
}
|
||||
tbody .size,
|
||||
tbody .modified {
|
||||
font-family: 'Roboto Mono';
|
||||
font-family: "Roboto Mono";
|
||||
}
|
||||
header {
|
||||
flex: 0 0 auto;
|
||||
@@ -209,9 +214,13 @@ header nav.headermain {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
.spacer { flex-grow: 1 }
|
||||
.smallgap { flex-shrink: 1; width: 2em }
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.smallgap {
|
||||
flex-shrink: 1;
|
||||
width: 2em;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: .5em;
|
||||
|
||||
@@ -60,78 +60,126 @@ import Zoomout from './zoomout.svg'
|
||||
|
||||
// Named exports for direct imports
|
||||
export {
|
||||
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV,
|
||||
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross,
|
||||
Disk, Download, Exclamation, Eye, Find, Fullscreen,
|
||||
Github, Home, Info, Link, Logo, Loop, Menu,
|
||||
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous,
|
||||
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip,
|
||||
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow,
|
||||
UploadCloud, UserCog, User, VolumeHigh, VolumeLow,
|
||||
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap,
|
||||
Zoomin, Zoomout
|
||||
AddFile,
|
||||
AddFolder,
|
||||
Arrow,
|
||||
ArrowsH,
|
||||
ArrowsV,
|
||||
Check,
|
||||
Code,
|
||||
Cog,
|
||||
Copy,
|
||||
CreateFile,
|
||||
CreateFolder,
|
||||
Cross,
|
||||
Disk,
|
||||
Download,
|
||||
Exclamation,
|
||||
Eye,
|
||||
Find,
|
||||
Fullscreen,
|
||||
Github,
|
||||
Home,
|
||||
Info,
|
||||
Link,
|
||||
Logo,
|
||||
Loop,
|
||||
Menu,
|
||||
Next,
|
||||
Open,
|
||||
Paste,
|
||||
Pause,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Previous,
|
||||
Reload,
|
||||
Rename,
|
||||
Scissors,
|
||||
Shuffle,
|
||||
Signin,
|
||||
Signout,
|
||||
Skip,
|
||||
Spinner,
|
||||
Stop,
|
||||
Trash,
|
||||
Triangle,
|
||||
Unfullscreen,
|
||||
UpArrow,
|
||||
UploadCloud,
|
||||
UserCog,
|
||||
User,
|
||||
VolumeHigh,
|
||||
VolumeLow,
|
||||
VolumeMedium,
|
||||
VolumeMute,
|
||||
WindowCross,
|
||||
Window,
|
||||
Wordwrap,
|
||||
Zoomin,
|
||||
Zoomout
|
||||
}
|
||||
|
||||
// Icon lookup by kebab-case name (for SvgButton compatibility)
|
||||
export const icons = {
|
||||
'add-file': AddFile,
|
||||
'add-folder': AddFolder,
|
||||
'arrow': Arrow,
|
||||
arrow: Arrow,
|
||||
'arrows-h': ArrowsH,
|
||||
'arrows-v': ArrowsV,
|
||||
'check': Check,
|
||||
'code': Code,
|
||||
'cog': Cog,
|
||||
'copy': Copy,
|
||||
check: Check,
|
||||
code: Code,
|
||||
cog: Cog,
|
||||
copy: Copy,
|
||||
'create-file': CreateFile,
|
||||
'create-folder': CreateFolder,
|
||||
'cross': Cross,
|
||||
'disk': Disk,
|
||||
'download': Download,
|
||||
'exclamation': Exclamation,
|
||||
'eye': Eye,
|
||||
'find': Find,
|
||||
'fullscreen': Fullscreen,
|
||||
'github': Github,
|
||||
'home': Home,
|
||||
'info': Info,
|
||||
'link': Link,
|
||||
'logo': Logo,
|
||||
'loop': Loop,
|
||||
'menu': Menu,
|
||||
'next': Next,
|
||||
'open': Open,
|
||||
'paste': Paste,
|
||||
'pause': Pause,
|
||||
'pencil': Pencil,
|
||||
'play': Play,
|
||||
'plus': Plus,
|
||||
'previous': Previous,
|
||||
'reload': Reload,
|
||||
'rename': Rename,
|
||||
'scissors': Scissors,
|
||||
'shuffle': Shuffle,
|
||||
'signin': Signin,
|
||||
'signout': Signout,
|
||||
'skip': Skip,
|
||||
'spinner': Spinner,
|
||||
'stop': Stop,
|
||||
'trash': Trash,
|
||||
'triangle': Triangle,
|
||||
'unfullscreen': Unfullscreen,
|
||||
cross: Cross,
|
||||
disk: Disk,
|
||||
download: Download,
|
||||
exclamation: Exclamation,
|
||||
eye: Eye,
|
||||
find: Find,
|
||||
fullscreen: Fullscreen,
|
||||
github: Github,
|
||||
home: Home,
|
||||
info: Info,
|
||||
link: Link,
|
||||
logo: Logo,
|
||||
loop: Loop,
|
||||
menu: Menu,
|
||||
next: Next,
|
||||
open: Open,
|
||||
paste: Paste,
|
||||
pause: Pause,
|
||||
pencil: Pencil,
|
||||
play: Play,
|
||||
plus: Plus,
|
||||
previous: Previous,
|
||||
reload: Reload,
|
||||
rename: Rename,
|
||||
scissors: Scissors,
|
||||
shuffle: Shuffle,
|
||||
signin: Signin,
|
||||
signout: Signout,
|
||||
skip: Skip,
|
||||
spinner: Spinner,
|
||||
stop: Stop,
|
||||
trash: Trash,
|
||||
triangle: Triangle,
|
||||
unfullscreen: Unfullscreen,
|
||||
'up-arrow': UpArrow,
|
||||
'upload-cloud': UploadCloud,
|
||||
'user-cog': UserCog,
|
||||
'user': User,
|
||||
user: User,
|
||||
'volume-high': VolumeHigh,
|
||||
'volume-low': VolumeLow,
|
||||
'volume-medium': VolumeMedium,
|
||||
'volume-mute': VolumeMute,
|
||||
'window-cross': WindowCross,
|
||||
'window': Window,
|
||||
'wordwrap': Wordwrap,
|
||||
'zoomin': Zoomin,
|
||||
'zoomout': Zoomout,
|
||||
window: Window,
|
||||
wordwrap: Wordwrap,
|
||||
zoomin: Zoomin,
|
||||
zoomout: Zoomout
|
||||
} as const
|
||||
|
||||
export type IconName = keyof typeof icons
|
||||
|
||||
@@ -37,17 +37,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Home } from '@/assets/svg'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const home = Home
|
||||
const router = useRouter()
|
||||
|
||||
const links = [] as Array<HTMLElement>
|
||||
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
|
||||
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
|
||||
const setLinkRef = (index: number, el: any) => {
|
||||
if (el) links[index] = el
|
||||
}
|
||||
onBeforeUpdate(() => {
|
||||
links.length = 1
|
||||
}) // 1 to keep home
|
||||
|
||||
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
|
||||
@@ -63,7 +67,8 @@ const props = defineProps<{
|
||||
|
||||
const longest = ref<Array<string>>([])
|
||||
|
||||
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
|
||||
const isCurrent = (index: number) =>
|
||||
index == props.path.length ? 'location' : undefined
|
||||
|
||||
const focusCurrent = () => {
|
||||
nextTick(() => {
|
||||
@@ -80,7 +85,10 @@ const navigate = (index: number) => {
|
||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Clicking on current link clears the rest of the path and adds new history
|
||||
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
|
||||
if (isCurrent(index)) {
|
||||
longest.value.splice(index)
|
||||
router.push(u)
|
||||
}
|
||||
// Moving along breadcrumbs doesn't create new history
|
||||
else if (long.startsWith(browser)) router.replace(u)
|
||||
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
|
||||
@@ -100,8 +108,7 @@ watchEffect(() => {
|
||||
if (!same) longest.value = props.path
|
||||
else if (props.path.length > longcut.length) {
|
||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// Prune deleted folders from longest
|
||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||
if (!exists(longest.value.slice(0, i + 1))) {
|
||||
@@ -111,10 +118,11 @@ watchEffect(() => {
|
||||
}
|
||||
}
|
||||
// If needed, focus primary navigation to new location
|
||||
if (props.primary) nextTick(() => {
|
||||
const act = document.activeElement as HTMLElement
|
||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||
})
|
||||
if (props.primary)
|
||||
nextTick(() => {
|
||||
const act = document.activeElement as HTMLElement
|
||||
if (!act || [...links, document.body].includes(act)) focusCurrent()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -62,8 +62,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
@@ -88,7 +88,7 @@ const formatGB = (bytes: number) => {
|
||||
const fmtSize = (bytes: number, angle: number) => {
|
||||
const s = formatGB(bytes)
|
||||
const a = Math.abs(angle % 180)
|
||||
return (Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s)) ? `${s}.` : s
|
||||
return Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s) ? `${s}.` : s
|
||||
}
|
||||
|
||||
const truncateLabel = (name: string, maxLen = 10): string => {
|
||||
@@ -157,7 +157,7 @@ const freeColor = computed(() => {
|
||||
if (!s.disk) return '#6c6'
|
||||
const freePct = s.free / s.disk
|
||||
if (freePct > 0.25) return '#5b5'
|
||||
if (freePct > 0.10) return '#ff0'
|
||||
if (freePct > 0.1) return '#ff0'
|
||||
return '#f00'
|
||||
})
|
||||
|
||||
@@ -165,23 +165,24 @@ const PIE_RADIUS = 55
|
||||
const LABEL_RADIUS = 62
|
||||
|
||||
const getPoint = (angle: number, radius: number) => {
|
||||
const rad = TAU * (angle - 90) / 360
|
||||
const rad = (TAU * (angle - 90)) / 360
|
||||
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
|
||||
}
|
||||
|
||||
const sectorInfo = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return {
|
||||
storage: { angle: 45, pct: 0.25 },
|
||||
free: { angle: 180, pct: 0.5 },
|
||||
other: { angle: 270, pct: 0.25 }
|
||||
}
|
||||
if (!s.disk)
|
||||
return {
|
||||
storage: { angle: 45, pct: 0.25 },
|
||||
free: { angle: 180, pct: 0.5 },
|
||||
other: { angle: 270, pct: 0.25 }
|
||||
}
|
||||
|
||||
const storagePct = s.allocated / s.disk
|
||||
const freePct = s.free / s.disk
|
||||
const otherPct = (s.used - s.allocated) / s.disk
|
||||
|
||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||
const freeStart = storagePct * 360
|
||||
const freeAngle = freeStart + freePct * 180
|
||||
const otherStart = (storagePct + freePct) * 360
|
||||
@@ -200,13 +201,19 @@ const rawAngles = computed(() => ({
|
||||
other: sectorInfo.value.other.angle
|
||||
}))
|
||||
|
||||
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90
|
||||
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start'
|
||||
const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
|
||||
const getSizeAnchor = (angle: number) => (angle < 180 ? 'end' : 'start')
|
||||
|
||||
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
|
||||
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS))
|
||||
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS))
|
||||
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS))
|
||||
const storageInnerPos = computed(() =>
|
||||
getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS)
|
||||
)
|
||||
const freeInnerPos = computed(() =>
|
||||
getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS)
|
||||
)
|
||||
const otherInnerPos = computed(() =>
|
||||
getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS)
|
||||
)
|
||||
|
||||
// Collision avoidance for curved name labels
|
||||
const labelLengths = computed(() => ({
|
||||
@@ -269,11 +276,17 @@ const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length))
|
||||
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4))
|
||||
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5))
|
||||
const storageLabelPath = computed(() =>
|
||||
createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length)
|
||||
)
|
||||
const freeLabelPath = computed(() =>
|
||||
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
||||
)
|
||||
const otherLabelPath = computed(() =>
|
||||
createArcPath(adjustedLabelAngles.value.other!, 'other', 5)
|
||||
)
|
||||
|
||||
const handleClick = () => isExpanded.value ? collapse() : expand()
|
||||
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
||||
|
||||
const applyAnimState = (t: number, opacity: number) => {
|
||||
const widget = widgetRef.value
|
||||
@@ -296,9 +309,9 @@ const animate = (duration: number, expanding: boolean, onComplete?: () => void)
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
|
||||
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
|
||||
const t = expanding ? eased : 1 - eased
|
||||
applyAnimState(t, t) // opacity follows position
|
||||
applyAnimState(t, t) // opacity follows position
|
||||
if (progress < 1) {
|
||||
animationFrame = requestAnimationFrame(tick)
|
||||
} else {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { zipName } from '@/utils/fileutil'
|
||||
|
||||
const store = useMainStore()
|
||||
@@ -24,9 +24,9 @@ const status_init = {
|
||||
filename: '',
|
||||
filesize: 0,
|
||||
filepos: 0,
|
||||
status: 'idle',
|
||||
status: 'idle'
|
||||
}
|
||||
store.dprogress = {...status_init}
|
||||
store.dprogress = { ...status_init }
|
||||
setInterval(() => {
|
||||
if (Date.now() - store.dprogress.tlast > 3000) {
|
||||
// Reset
|
||||
@@ -34,8 +34,8 @@ setInterval(() => {
|
||||
store.dprogress.statdur = 1
|
||||
} else {
|
||||
// Running average by decay
|
||||
store.dprogress.statbytes *= .9
|
||||
store.dprogress.statdur *= .9
|
||||
store.dprogress.statbytes *= 0.9
|
||||
store.dprogress.statdur *= 0.9
|
||||
}
|
||||
}, 100)
|
||||
const statReset = () => {
|
||||
@@ -44,10 +44,9 @@ const statReset = () => {
|
||||
store.dprogress.tlast = store.dprogress.t0 + 1
|
||||
}
|
||||
const cancelDownloads = () => {
|
||||
location.reload() // FIXME
|
||||
location.reload() // FIXME
|
||||
}
|
||||
|
||||
|
||||
const linkdl = (href: string) => {
|
||||
const a = document.createElement('a')
|
||||
a.href = href
|
||||
@@ -156,7 +155,10 @@ const download = async (e: MouseEvent) => {
|
||||
if (e.altKey && 'showDirectoryPicker' in window) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
|
||||
const handle = await window.showDirectoryPicker({
|
||||
startIn: 'downloads',
|
||||
mode: 'readwrite'
|
||||
})
|
||||
await filesystemdl(sel, handle)
|
||||
store.selected.clear()
|
||||
} catch (e) {
|
||||
@@ -168,7 +170,6 @@ const download = async (e: MouseEvent) => {
|
||||
// Default: ZIP download
|
||||
zipdl(sel)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Cog } from '@/assets/svg'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
|
||||
const cog = Cog
|
||||
const store = useMainStore()
|
||||
const props = defineProps<{
|
||||
path: string[],
|
||||
documents: Document[],
|
||||
path: string[]
|
||||
documents: Document[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
@@ -72,14 +72,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
@@ -89,7 +97,11 @@ const store = useMainStore()
|
||||
const router = useRouter()
|
||||
|
||||
const filesUrl = (path: string) =>
|
||||
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
'/files/' +
|
||||
path
|
||||
.split('/')
|
||||
.map(part => encodeURIComponent(part))
|
||||
.join('/')
|
||||
|
||||
const parseErrorMessage = async (res: Response) => {
|
||||
try {
|
||||
@@ -120,7 +132,7 @@ const rename = async (doc: Doc, newName: string) => {
|
||||
}
|
||||
defineExpose({
|
||||
newFolder() {
|
||||
console.log("New folder")
|
||||
console.log('New folder')
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
editing.value = new Doc({
|
||||
loc: loc.value,
|
||||
@@ -129,7 +141,7 @@ defineExpose({
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
allocated: 0,
|
||||
allocated: 0
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
@@ -146,7 +158,9 @@ defineExpose({
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} .name a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
@@ -164,8 +178,12 @@ defineExpose({
|
||||
}
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||
up(ev: KeyboardEvent) {
|
||||
this.cursorMove(-1, ev)
|
||||
},
|
||||
down(ev: KeyboardEvent) {
|
||||
this.cursorMove(1, ev)
|
||||
},
|
||||
left(ev: KeyboardEvent) {
|
||||
// Only go back if we're in a subfolder (not at root)
|
||||
if (props.path.length > 0) {
|
||||
@@ -173,7 +191,9 @@ defineExpose({
|
||||
}
|
||||
},
|
||||
right(ev: KeyboardEvent) {
|
||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.click()
|
||||
},
|
||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||
@@ -187,8 +207,9 @@ defineExpose({
|
||||
const N = docs.length
|
||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||
const index =
|
||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
|
||||
const index = store.cursor
|
||||
? docs.findIndex(doc => doc.key === store.cursor)
|
||||
: docs.length
|
||||
const moveto = increment(index, d)
|
||||
store.cursor = docs[moveto]?.key ?? ''
|
||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||
@@ -206,8 +227,7 @@ defineExpose({
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr)
|
||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
@@ -219,7 +239,9 @@ defineExpose({
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
const el = document.querySelector(
|
||||
'.headermain input[type="search"]'
|
||||
) as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
@@ -250,14 +272,17 @@ const updateModified = () => {
|
||||
nowkey.value = Math.floor(Date.now() / 1000)
|
||||
}
|
||||
onMounted(() => {
|
||||
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
|
||||
updateModified()
|
||||
modifiedTimer = setInterval(updateModified, 1000)
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
}
|
||||
})
|
||||
onUnmounted(() => { clearInterval(modifiedTimer) })
|
||||
onUnmounted(() => {
|
||||
clearInterval(modifiedTimer)
|
||||
})
|
||||
const mkdir = async (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
@@ -349,12 +374,14 @@ const copyImage = async (doc: Doc) => {
|
||||
if (blob.type !== 'image/png') {
|
||||
const img = new Image()
|
||||
img.src = URL.createObjectURL(blob)
|
||||
await new Promise(r => img.onload = r)
|
||||
await new Promise(r => (img.onload = r))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
canvas.getContext('2d')!.drawImage(img, 0, 0)
|
||||
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
|
||||
const pngBlob = await new Promise<Blob>(r =>
|
||||
canvas.toBlob(b => r(b!), 'image/png')
|
||||
)
|
||||
URL.revokeObjectURL(img.src)
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
} else {
|
||||
@@ -385,12 +412,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
store.cursor = doc.key
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }
|
||||
]
|
||||
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
|
||||
items.push(
|
||||
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
|
||||
{
|
||||
label: '✏️ Rename',
|
||||
onClick: () => {
|
||||
editing.value = doc
|
||||
}
|
||||
},
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
|
||||
)
|
||||
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@ import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
now: number
|
||||
doc: Doc
|
||||
now: number
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
// Reference props.now to trigger reactivity when time updates
|
||||
const modified = computed(() => {
|
||||
props.now // trigger reactivity
|
||||
props.now // trigger reactivity
|
||||
return formatUnixDate(props.doc.mtime)
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const name = ref('')
|
||||
|
||||
@@ -13,20 +13,20 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatSize } from '@/utils'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
doc: Doc
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||
return +unit ? "bytes" : unit
|
||||
return +unit ? 'bytes' : unit
|
||||
})
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
|
||||
@@ -9,13 +9,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import type { SortOrder } from '@/utils/docsort'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
@@ -25,7 +33,11 @@ const store = useMainStore()
|
||||
const router = useRouter()
|
||||
|
||||
const filesUrl = (path: string) =>
|
||||
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
'/files/' +
|
||||
path
|
||||
.split('/')
|
||||
.map(part => encodeURIComponent(part))
|
||||
.join('/')
|
||||
|
||||
const parseErrorMessage = async (res: Response) => {
|
||||
try {
|
||||
@@ -38,7 +50,9 @@ const parseErrorMessage = async (res: Response) => {
|
||||
|
||||
// File rename
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const exit = () => { editing.value = null }
|
||||
const exit = () => {
|
||||
editing.value = null
|
||||
}
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
@@ -59,7 +73,9 @@ const gallery = ref<HTMLElement>()
|
||||
const columnCount = ref(1)
|
||||
const updateColumns = () => {
|
||||
if (!gallery.value) return
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
|
||||
' '
|
||||
).length
|
||||
}
|
||||
const columns = computed(() => columnCount.value)
|
||||
defineExpose({
|
||||
@@ -72,7 +88,7 @@ defineExpose({
|
||||
dir: true,
|
||||
mtime: now,
|
||||
size: 0,
|
||||
allocated: 0,
|
||||
allocated: 0
|
||||
})
|
||||
store.cursor = editing.value.key
|
||||
},
|
||||
@@ -93,7 +109,9 @@ defineExpose({
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
})
|
||||
}
|
||||
@@ -111,10 +129,18 @@ defineExpose({
|
||||
}
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) },
|
||||
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) },
|
||||
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||
right(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||
up(ev: KeyboardEvent) {
|
||||
this.cursorMove(-columns.value, ev)
|
||||
},
|
||||
down(ev: KeyboardEvent) {
|
||||
this.cursorMove(columns.value, ev)
|
||||
},
|
||||
left(ev: KeyboardEvent) {
|
||||
this.cursorMove(-1, ev)
|
||||
},
|
||||
right(ev: KeyboardEvent) {
|
||||
this.cursorMove(1, ev)
|
||||
},
|
||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||
const select = !!ev?.shiftKey
|
||||
// Move cursor up or down (keyboard navigation)
|
||||
@@ -126,11 +152,10 @@ defineExpose({
|
||||
const N = docs.length
|
||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||
const index =
|
||||
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
||||
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
||||
// Stop navigation sideways away from the grid (only with up/down)
|
||||
if (ev && index === 0 && ev.key === "ArrowLeft") return
|
||||
if (ev && index === N - 1 && ev.key === "ArrowRight") return
|
||||
if (ev && index === 0 && ev.key === 'ArrowLeft') return
|
||||
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
|
||||
// Calculate new position
|
||||
let moveto
|
||||
if (index === N) moveto = d > 0 ? 0 : N - 1
|
||||
@@ -155,8 +180,7 @@ defineExpose({
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr)
|
||||
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
@@ -168,7 +192,9 @@ defineExpose({
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||
const el = document.querySelector(
|
||||
'.headermain input[type="search"]'
|
||||
) as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
const focusBreadcrumb = () => {
|
||||
@@ -181,8 +207,13 @@ watchEffect(() => {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value.key
|
||||
if (store.cursor) {
|
||||
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
||||
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
a.focus()
|
||||
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
@@ -288,12 +319,14 @@ const copyImage = async (doc: Doc) => {
|
||||
if (blob.type !== 'image/png') {
|
||||
const img = new Image()
|
||||
img.src = URL.createObjectURL(blob)
|
||||
await new Promise(r => img.onload = r)
|
||||
await new Promise(r => (img.onload = r))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
canvas.getContext('2d')!.drawImage(img, 0, 0)
|
||||
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
|
||||
const pngBlob = await new Promise<Blob>(r =>
|
||||
canvas.toBlob(b => r(b!), 'image/png')
|
||||
)
|
||||
URL.revokeObjectURL(img.src)
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
} else {
|
||||
@@ -324,12 +357,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
store.cursor = doc.key
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) },
|
||||
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) }
|
||||
]
|
||||
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
|
||||
items.push(
|
||||
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
|
||||
{
|
||||
label: '✏️ Rename',
|
||||
onClick: () => {
|
||||
editing.value = doc
|
||||
}
|
||||
},
|
||||
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) }
|
||||
)
|
||||
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
|
||||
}
|
||||
|
||||
@@ -31,24 +31,24 @@
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { formatSize } from '@/utils'
|
||||
<script setup lang="ts">
|
||||
import MediaPreview from '@/components/MediaPreview.vue'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
type EditingProp = {
|
||||
rename: (name: string) => void;
|
||||
exit: () => void;
|
||||
rename: (name: string) => void
|
||||
exit: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc,
|
||||
editing?: EditingProp,
|
||||
doc: Doc
|
||||
editing?: EditingProp
|
||||
}>()
|
||||
const m = ref<typeof MediaPreview | null>(null)
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
@@ -26,13 +26,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { resumeWatching } from '@/repositories/WS'
|
||||
import router from '@/router'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||
import { ref } from 'vue'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import { showAuthIframe } from 'paskia'
|
||||
import { resumeWatching } from '@/repositories/WS'
|
||||
import router from '@/router';
|
||||
import { ref } from 'vue'
|
||||
import DiskSpace from './DiskSpace.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
@@ -78,7 +78,7 @@ const updateSearch = (ev: Event) => {
|
||||
pendingRouteUpdate = null
|
||||
let p = loc
|
||||
p = p ? `/${p}` : ''
|
||||
const url = q ? `${p}//${q}` : (p || '/')
|
||||
const url = q ? `${p}//${q}` : p || '/'
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Use replace to avoid building up history for each keystroke
|
||||
router.replace(u)
|
||||
@@ -96,45 +96,66 @@ const settingsMenu = (e: Event) => {
|
||||
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||
items.push({
|
||||
label: '👤 ' + (store.user.username || 'User Account'),
|
||||
onClick: () => { window.location.href = '/auth/' }
|
||||
onClick: () => {
|
||||
window.location.href = '/auth/'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only show password change for non-SSO users
|
||||
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||||
items.push({ label: '🔑 Change Password', onClick: () => { store.dialog = 'settings' }})
|
||||
items.push({
|
||||
label: '🔑 Change Password',
|
||||
onClick: () => {
|
||||
store.dialog = 'settings'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: '🔑 API Tokens', onClick: () => { store.dialog = 'tokens' }})
|
||||
items.push({
|
||||
label: '🔑 API Tokens',
|
||||
onClick: () => {
|
||||
store.dialog = 'tokens'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (store.user.privileged) {
|
||||
items.push({ label: '⚙️ Admin Settings', onClick: () => { store.dialog = 'usermgmt' }})
|
||||
items.push({
|
||||
label: '⚙️ Admin Settings',
|
||||
onClick: () => {
|
||||
store.dialog = 'usermgmt'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||||
} else if (store.server.public) {
|
||||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||
items.push({ label: '🔐 Login', onClick: async () => {
|
||||
try {
|
||||
await showAuthIframe('/auth/restricted/#theme=light')
|
||||
resumeWatching()
|
||||
} catch (e) {
|
||||
console.log('Login cancelled')
|
||||
items.push({
|
||||
label: '🔐 Login',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await showAuthIframe('/auth/restricted/#theme=light')
|
||||
resumeWatching()
|
||||
} catch (e) {
|
||||
console.log('Login cancelled')
|
||||
}
|
||||
}
|
||||
}})
|
||||
})
|
||||
}
|
||||
ContextMenu.showContextMenu({
|
||||
// @ts-ignore
|
||||
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom,
|
||||
items,
|
||||
x: e.target.getBoundingClientRect().right,
|
||||
y: e.target.getBoundingClientRect().bottom,
|
||||
items
|
||||
})
|
||||
}
|
||||
defineExpose({
|
||||
toggleSearchInput,
|
||||
clearSearch,
|
||||
clearSearch
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect, nextTick } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import { nextTick, ref, watchEffect } from 'vue'
|
||||
|
||||
const overlay = ref<HTMLDivElement | null>(null)
|
||||
const dialog = ref<HTMLDivElement | null>(null)
|
||||
@@ -27,9 +27,9 @@ const close = () => {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string,
|
||||
name: typeof store.dialog,
|
||||
}>()
|
||||
title: string
|
||||
name: typeof store.dialog
|
||||
}>()
|
||||
|
||||
const show = () => {
|
||||
store.dialog = props.name
|
||||
|
||||
@@ -10,13 +10,12 @@
|
||||
>
|
||||
</template>
|
||||
|
||||
<script setup lang=ts>
|
||||
import { useMainStore } from '@/stores/main'
|
||||
<script setup lang="ts">
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: Doc
|
||||
}>()
|
||||
const store = useMainStore()
|
||||
|
||||
</script>
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatSize } from '@/utils'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import router from '@/router'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
@@ -50,7 +50,11 @@ const navigateTo = (path: string) => {
|
||||
}
|
||||
|
||||
const filesUrl = (path: string) =>
|
||||
'/files/' + path.split('/').map(part => encodeURIComponent(part)).join('/')
|
||||
'/files/' +
|
||||
path
|
||||
.split('/')
|
||||
.map(part => encodeURIComponent(part))
|
||||
.join('/')
|
||||
|
||||
const parseErrorMessage = async (res: Response) => {
|
||||
try {
|
||||
@@ -110,7 +114,7 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
|
||||
if (count === 1) {
|
||||
displayName = truncateName(names[0]!)
|
||||
} else {
|
||||
const folderName = loc ? loc.split('/').pop()! : (store.server.name || 'Root')
|
||||
const folderName = loc ? loc.split('/').pop()! : store.server.name || 'Root'
|
||||
displayName = `${truncateName(folderName)} (${count})`
|
||||
}
|
||||
return {
|
||||
@@ -166,7 +170,6 @@ const op = async (opName: string, dst?: string) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -44,10 +44,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { changePassword } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { changePassword } from '@/repositories/User'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const confirmLoading = ref<boolean>(false)
|
||||
const store = useMainStore()
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { icons, type IconName } from '@/assets/svg'
|
||||
import { type IconName, icons } from '@/assets/svg'
|
||||
import { ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineEmits(['cancel'])
|
||||
|
||||
@@ -38,16 +38,17 @@ const props = defineProps<{
|
||||
}
|
||||
}>()
|
||||
|
||||
const percent = computed(() => props.status.xfer / props.status.total * 100)
|
||||
const percent = computed(() => (props.status.xfer / props.status.total) * 100)
|
||||
const speed = computed(() => {
|
||||
let s = props.status.statbytes / props.status.statdur / 1e3
|
||||
const tsince = (Date.now() - props.status.tlast) / 1e3
|
||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
||||
return s // "Current speed"
|
||||
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
|
||||
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
|
||||
return s // "Current speed"
|
||||
})
|
||||
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
|
||||
|
||||
const speeddisp = computed(() =>
|
||||
speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s' : 'stalled'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { collator } from '@/utils';
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { collator } from '@/utils'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -43,7 +43,7 @@ type InflightBlock = {
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||
function pasteHandler(event: ClipboardEvent) {
|
||||
const items = Array.from(event.clipboardData?.items ?? [])
|
||||
const infiles = [] as File[]
|
||||
@@ -62,7 +62,8 @@ function pasteHandler(event: ClipboardEvent) {
|
||||
event.preventDefault()
|
||||
uploadFiles(infiles)
|
||||
const base = props.path!.join('/')
|
||||
for (const entry of dirs) pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
|
||||
for (const entry of dirs)
|
||||
pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
|
||||
}
|
||||
}
|
||||
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
||||
@@ -72,8 +73,8 @@ const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
||||
for (const entry of entries) {
|
||||
const cloudName = `${loc}/${entry.name}`
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise(resolve => entry.file(resolve)) as File
|
||||
cloudfiles.push({file, cloudName, cloudPos: 0})
|
||||
const file = (await new Promise(resolve => entry.file(resolve))) as File
|
||||
cloudfiles.push({ file, cloudName, cloudPos: 0 })
|
||||
} else if (entry.isDirectory) {
|
||||
await pasteDirectory(entry, cloudName)
|
||||
}
|
||||
@@ -84,7 +85,9 @@ function uploadHandler(event: Event) {
|
||||
event.preventDefault()
|
||||
// @ts-ignore
|
||||
const input = event.target as HTMLInputElement | null
|
||||
const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[]
|
||||
const infiles = Array.from(
|
||||
(input ?? (event as DragEvent).dataTransfer)?.files ?? []
|
||||
) as File[]
|
||||
if (input) input.value = ''
|
||||
if (infiles.length) uploadFiles(infiles)
|
||||
}
|
||||
@@ -99,7 +102,7 @@ const uploadFiles = (infiles: File[]) => {
|
||||
files.push({
|
||||
file,
|
||||
cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
|
||||
cloudPos: 0,
|
||||
cloudPos: 0
|
||||
})
|
||||
}
|
||||
uploadCloudFiles(files)
|
||||
@@ -131,13 +134,34 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const folderPath = parts.slice(0, i + 1).join('/')
|
||||
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
||||
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, allocated: 0, mtime: now, dir: true }))
|
||||
store.addGhost(
|
||||
new Doc({
|
||||
loc: parts.slice(0, i).join('/'),
|
||||
name: parts[i],
|
||||
key: crypto.randomUUID(),
|
||||
size: 0,
|
||||
allocated: 0,
|
||||
mtime: now,
|
||||
dir: true
|
||||
})
|
||||
)
|
||||
added.add(folderPath)
|
||||
}
|
||||
}
|
||||
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||
const existing = byPath.get(f.cloudName)
|
||||
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
|
||||
if (!existing)
|
||||
store.addGhost(
|
||||
new Doc({
|
||||
loc,
|
||||
name,
|
||||
key: crypto.randomUUID(),
|
||||
size: f.file.size,
|
||||
allocated: 0,
|
||||
mtime: now,
|
||||
dir: false
|
||||
})
|
||||
)
|
||||
}
|
||||
// @ts-ignore
|
||||
upqueue = [...upqueue, ...files]
|
||||
@@ -169,9 +193,9 @@ const uprogress_init = {
|
||||
filename: '',
|
||||
filesize: 0,
|
||||
filepos: 0,
|
||||
status: 'idle',
|
||||
status: 'idle'
|
||||
}
|
||||
store.uprogress = {...uprogress_init}
|
||||
store.uprogress = { ...uprogress_init }
|
||||
// Track uploaded bytes for each file to handle out-of-order uploads
|
||||
const uploadedBytes = new Map<string, Set<number>>()
|
||||
const inflightBlocks = new Map<string, InflightBlock>()
|
||||
@@ -240,13 +264,13 @@ setInterval(() => {
|
||||
store.uprogress.statbytes = 0
|
||||
store.uprogress.statdur = 1
|
||||
} else {
|
||||
store.uprogress.statbytes *= .95
|
||||
store.uprogress.statdur *= .95
|
||||
store.uprogress.statbytes *= 0.95
|
||||
store.uprogress.statdur *= 0.95
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const statUpdate = ({name, size, start, end}: UploadRange) => {
|
||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||
const statUpdate = ({ name, size, start, end }: UploadRange) => {
|
||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||
|
||||
// Track which bytes have been uploaded (using start to end range)
|
||||
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
|
||||
@@ -263,9 +287,12 @@ const statUpdate = ({name, size, start, end}: UploadRange) => {
|
||||
const currentUpload = blockQueue[0]
|
||||
if (!currentUpload) return
|
||||
|
||||
if (currentUpload.file.cloudName === name && currentUpload.completed >= currentUpload.blocks.length) {
|
||||
if (
|
||||
currentUpload.file.cloudName === name &&
|
||||
currentUpload.completed >= currentUpload.blocks.length
|
||||
) {
|
||||
// All blocks for this file have been uploaded
|
||||
uploadedBytes.delete(name) // Clean up tracking
|
||||
uploadedBytes.delete(name) // Clean up tracking
|
||||
store.uprogress.filestart += size
|
||||
statNextFile()
|
||||
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
||||
@@ -299,35 +326,35 @@ const MAX_PARALLEL_REQUESTS = 4
|
||||
const RETRY_DELAY_MS = 400
|
||||
|
||||
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
|
||||
const getUploadBlocks = (file: CloudFile): {start: number, end: number}[] => {
|
||||
const getUploadBlocks = (file: CloudFile): { start: number; end: number }[] => {
|
||||
const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
|
||||
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
||||
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
||||
const FINAL_BLOCKS_COUNT = 2
|
||||
|
||||
const fileSize = file.file.size
|
||||
const blocks: {start: number, end: number}[] = []
|
||||
const blocks: { start: number; end: number }[] = []
|
||||
|
||||
if (fileSize >= MIN_SIZE_FOR_REORDER) {
|
||||
// File is large enough, prioritize final blocks
|
||||
const finalBlocksStart = fileSize - (FINAL_BLOCKS_COUNT * BLOCK_SIZE)
|
||||
const finalBlocksStart = fileSize - FINAL_BLOCKS_COUNT * BLOCK_SIZE
|
||||
|
||||
// Add final blocks first
|
||||
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
|
||||
const start = finalBlocksStart + (i * BLOCK_SIZE)
|
||||
const start = finalBlocksStart + i * BLOCK_SIZE
|
||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||
blocks.push({start, end})
|
||||
blocks.push({ start, end })
|
||||
}
|
||||
|
||||
// Add remaining blocks from beginning
|
||||
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
|
||||
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
|
||||
blocks.push({start, end})
|
||||
blocks.push({ start, end })
|
||||
}
|
||||
} else {
|
||||
// File is smaller, use sequential upload
|
||||
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
|
||||
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||
blocks.push({start, end})
|
||||
blocks.push({ start, end })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +363,7 @@ const getUploadBlocks = (file: CloudFile): {start: number, end: number}[] => {
|
||||
|
||||
type BlockUpload = {
|
||||
file: CloudFile
|
||||
blocks: {start: number, end: number}[]
|
||||
blocks: { start: number; end: number }[]
|
||||
nextIndex: number
|
||||
completed: number
|
||||
runId: number
|
||||
@@ -360,14 +387,17 @@ const uploadUrlForFile = (cloudName: string) => {
|
||||
return `/files/${encoded}`
|
||||
}
|
||||
|
||||
const uploadBlock = async (upload: BlockUpload, block: {start: number, end: number}) => {
|
||||
const uploadBlock = async (
|
||||
upload: BlockUpload,
|
||||
block: { start: number; end: number }
|
||||
) => {
|
||||
const body = upload.file.file.slice(block.start, block.end)
|
||||
const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}`
|
||||
const fallbackReq = {
|
||||
name: upload.file.cloudName,
|
||||
size: upload.file.file.size,
|
||||
start: block.start,
|
||||
end: block.end,
|
||||
end: block.end
|
||||
}
|
||||
let attempt = 0
|
||||
|
||||
@@ -379,9 +409,9 @@ const uploadBlock = async (upload: BlockUpload, block: {start: number, end: numb
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Range': range,
|
||||
'Content-Range': range
|
||||
},
|
||||
body,
|
||||
body
|
||||
})
|
||||
if (!res.ok) {
|
||||
const message = await res.text().catch(() => '')
|
||||
@@ -404,16 +434,16 @@ const uploadBlock = async (upload: BlockUpload, block: {start: number, end: numb
|
||||
}
|
||||
}
|
||||
|
||||
const startInflightBlock = (name: string, block: {start: number, end: number}) => {
|
||||
const startInflightBlock = (name: string, block: { start: number; end: number }) => {
|
||||
inflightBlocks.set(inflightKey(name, block.start), {
|
||||
name,
|
||||
start: block.start,
|
||||
end: block.end,
|
||||
startedAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
const finishInflightBlock = (name: string, block: {start: number, end: number}) => {
|
||||
const finishInflightBlock = (name: string, block: { start: number; end: number }) => {
|
||||
const key = inflightKey(name, block.start)
|
||||
const info = inflightBlocks.get(key)
|
||||
if (!info) return
|
||||
@@ -433,9 +463,9 @@ const worker = async (runId: number) => {
|
||||
|
||||
while (runId === uploadRunId && upload.completed < upload.blocks.length) {
|
||||
while (
|
||||
runId === uploadRunId
|
||||
&& upload.nextIndex < upload.blocks.length
|
||||
&& inflight.size < MAX_PARALLEL_REQUESTS
|
||||
runId === uploadRunId &&
|
||||
upload.nextIndex < upload.blocks.length &&
|
||||
inflight.size < MAX_PARALLEL_REQUESTS
|
||||
) {
|
||||
const block = upload.blocks[upload.nextIndex++]!
|
||||
store.uprogress.status = 'uploading'
|
||||
|
||||
@@ -74,10 +74,18 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getServerConfig,
|
||||
listUsers,
|
||||
updatePublic,
|
||||
updateServerName,
|
||||
updateUser
|
||||
} from '@/repositories/User'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
interface User {
|
||||
username: string
|
||||
@@ -92,7 +100,7 @@ const success = ref('')
|
||||
const copyButtonText = ref('📋')
|
||||
const serverSettings = reactive({
|
||||
public: false,
|
||||
name: '',
|
||||
name: ''
|
||||
})
|
||||
|
||||
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -163,10 +171,13 @@ const renameUser = async (user: User) => {
|
||||
}
|
||||
|
||||
const resetPassword = async (user: User) => {
|
||||
if (!confirm(`Reset password for ${user.username}? A new password will be generated.`)) return
|
||||
if (
|
||||
!confirm(`Reset password for ${user.username}? A new password will be generated.`)
|
||||
)
|
||||
return
|
||||
try {
|
||||
success.value = ''
|
||||
const result = await updateUser(user.username, { password: "" })
|
||||
const result = await updateUser(user.username, { password: '' })
|
||||
if (result.password) {
|
||||
success.value = `Password reset for ${user.username}. New password: ${result.password}`
|
||||
}
|
||||
@@ -195,7 +206,10 @@ const copySuccess = async (isButtonClick: boolean = false) => {
|
||||
// Show "Copied!" indication on button
|
||||
copyButtonText.value = '✅ Copied!'
|
||||
// Hide password/key and button immediately after copying
|
||||
const baseMessage = success.value.replace(/(?:Password|New password|Key): .+/, 'Copied to clipboard!')
|
||||
const baseMessage = success.value.replace(
|
||||
/(?:Password|New password|Key): .+/,
|
||||
'Copied to clipboard!'
|
||||
)
|
||||
success.value = baseMessage
|
||||
// Hide the entire message after 3 seconds
|
||||
setTimeout(() => {
|
||||
@@ -258,18 +272,24 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
// Load users and config when dialog opens
|
||||
watch(() => store.dialog, (newVal) => {
|
||||
if (newVal === 'usermgmt') {
|
||||
loadServerConfig()
|
||||
if (!store.server.paskia) {
|
||||
loadUsers()
|
||||
watch(
|
||||
() => store.dialog,
|
||||
newVal => {
|
||||
if (newVal === 'usermgmt') {
|
||||
loadServerConfig()
|
||||
if (!store.server.paskia) {
|
||||
loadUsers()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
watch(() => store.server.public, (newVal) => {
|
||||
serverSettings.public = newVal || false
|
||||
})
|
||||
watch(
|
||||
() => store.server.public,
|
||||
newVal => {
|
||||
serverSettings.public = newVal || false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -67,10 +67,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { listTokens, createToken, deleteToken } from '@/repositories/User'
|
||||
import type { ISimpleError } from '@/repositories/Client'
|
||||
import { createToken, deleteToken, listTokens } from '@/repositories/User'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
|
||||
interface Token {
|
||||
id: string
|
||||
@@ -145,7 +145,7 @@ const submitCreate = async () => {
|
||||
if (result.url) {
|
||||
createdToken.value = {
|
||||
...(result as CreatedToken),
|
||||
url: ensureFilesBaseUrl((result as CreatedToken).url),
|
||||
url: ensureFilesBaseUrl((result as CreatedToken).url)
|
||||
}
|
||||
mode.value = 'created'
|
||||
}
|
||||
@@ -188,12 +188,15 @@ const formatDate = (ts: number) => {
|
||||
}
|
||||
|
||||
// Load tokens when dialog opens
|
||||
watch(() => store.dialog, (newVal) => {
|
||||
if (newVal === 'tokens') {
|
||||
resetCreate()
|
||||
loadTokens()
|
||||
watch(
|
||||
() => store.dialog,
|
||||
newVal => {
|
||||
if (newVal === 'tokens') {
|
||||
resetCreate()
|
||||
loadTokens()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
|
||||
import { AuthCancelledError, apiFetch, apiJson } from 'paskia'
|
||||
|
||||
// Type for API error responses
|
||||
interface ApiError {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatSize, formatUnixDate } from "@/utils"
|
||||
import { formatSize, formatUnixDate } from '@/utils'
|
||||
|
||||
export type FUID = string
|
||||
|
||||
@@ -11,45 +11,53 @@ export type DocProps = {
|
||||
mtime: number
|
||||
dir: boolean
|
||||
ghost?: boolean
|
||||
expires?: number // Unix timestamp for ghost expiry
|
||||
expires?: number // Unix timestamp for ghost expiry
|
||||
}
|
||||
|
||||
export class Doc {
|
||||
public loc: string = ""
|
||||
public key: FUID = ""
|
||||
public loc: string = ''
|
||||
public key: FUID = ''
|
||||
public size: number = 0
|
||||
public allocated: number = 0
|
||||
public mtime: number = 0
|
||||
public dir: boolean = false
|
||||
public ghost: boolean = false
|
||||
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||
/** @internal Use the name getter/setter instead */
|
||||
public _name: string = ""
|
||||
public _name: string = ''
|
||||
|
||||
constructor(props: Partial<DocProps> = {}) {
|
||||
const { name, ...rest } = props
|
||||
Object.assign(this, rest)
|
||||
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||
}
|
||||
get name() {
|
||||
return this._name
|
||||
}
|
||||
get name() { return this._name }
|
||||
set name(name: string) {
|
||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||
this._name = name
|
||||
}
|
||||
get sizedisp(): string { return formatSize(this.size) }
|
||||
get sizedisp(): string {
|
||||
return formatSize(this.size)
|
||||
}
|
||||
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
|
||||
get sparseIndicator(): string {
|
||||
if (this.dir || this.size <= this.allocated) return ''
|
||||
if (this.allocated === 0) return '⭕' // exactly zero
|
||||
if (this.allocated === 0) return '⭕' // exactly zero
|
||||
const ratio = this.allocated / this.size
|
||||
// Round to nearest 25%: ◔◑◕⬤
|
||||
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
|
||||
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
|
||||
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
|
||||
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
|
||||
}
|
||||
get modified(): string {
|
||||
return formatUnixDate(this.mtime)
|
||||
}
|
||||
get modified(): string { return formatUnixDate(this.mtime) }
|
||||
get url(): string {
|
||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||
return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
return this.dir
|
||||
? '/#/' + `${p}/`.replaceAll('#', '%23')
|
||||
: `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
}
|
||||
get urlrouter(): string {
|
||||
return this.url.replace(/^\/#/, '')
|
||||
@@ -57,7 +65,17 @@ export class Doc {
|
||||
get img(): boolean {
|
||||
// Folders cannot be images
|
||||
if (this.dir) return false
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
|
||||
return [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'avif',
|
||||
'heic',
|
||||
'heif',
|
||||
'svg'
|
||||
].includes(this.ext)
|
||||
}
|
||||
get complete(): boolean {
|
||||
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||
@@ -90,13 +108,13 @@ export type errorEvent = {
|
||||
// Raw types the backend /api/watch sends us
|
||||
|
||||
export type FileEntry = [
|
||||
number, // level
|
||||
string, // name
|
||||
number, // level
|
||||
string, // name
|
||||
FUID,
|
||||
number, // mtime
|
||||
number, // size
|
||||
number, // allocated (actual disk usage)
|
||||
number, // isfile
|
||||
number, // mtime
|
||||
number, // size
|
||||
number, // allocated (actual disk usage)
|
||||
number // isfile
|
||||
]
|
||||
|
||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||
|
||||
@@ -16,7 +16,11 @@ export async function logoutUser() {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function changePassword(username: string, passwordChange: string, password: string) {
|
||||
export async function changePassword(
|
||||
username: string,
|
||||
passwordChange: string,
|
||||
password: string
|
||||
) {
|
||||
const data = await Client.post(url_password, {
|
||||
username,
|
||||
passwordChange,
|
||||
@@ -32,7 +36,11 @@ export async function listUsers() {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createUser(username: string, password?: string, privileged?: boolean) {
|
||||
export async function createUser(
|
||||
username: string,
|
||||
password?: string,
|
||||
privileged?: boolean
|
||||
) {
|
||||
const data = await Client.post(url_users, {
|
||||
username,
|
||||
password,
|
||||
@@ -41,7 +49,10 @@ export async function createUser(username: string, password?: string, privileged
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUser(username: string, changes: { password?: string, privileged?: boolean }) {
|
||||
export async function updateUser(
|
||||
username: string,
|
||||
changes: { password?: string; privileged?: boolean }
|
||||
) {
|
||||
const data = await Client.put(`${url_users}/${username}`, changes)
|
||||
return data
|
||||
}
|
||||
@@ -63,7 +74,7 @@ export async function updateServerName(name: string) {
|
||||
|
||||
export async function getServerConfig() {
|
||||
const data = await Client.get('/api/config')
|
||||
return data as { name: string, public: boolean }
|
||||
return data as { name: string; public: boolean }
|
||||
}
|
||||
|
||||
export const url_tokens = '/api/tokens'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMainStore } from "@/stores/main"
|
||||
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
|
||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { AuthCancelledError, isAuthIframeOpen, showAuthIframe } from 'paskia'
|
||||
import type { FileEntry, UpdateEntry, errorEvent } from './Document'
|
||||
|
||||
export const watchUrl = '/api/watch'
|
||||
|
||||
@@ -25,18 +25,22 @@ export const loadSession = () => {
|
||||
console.log(`Loaded session with ${tree.length} items cached`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log("Loading session failed", error)
|
||||
console.log('Loading session failed', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const saveSession = () => {
|
||||
localStorage["cista-files"] = JSON.stringify(tree)
|
||||
localStorage['cista-files'] = JSON.stringify(tree)
|
||||
}
|
||||
|
||||
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
|
||||
export const connect = (
|
||||
path: string,
|
||||
handlers: Partial<Record<keyof WebSocketEventMap, any>>
|
||||
) => {
|
||||
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
|
||||
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler)
|
||||
for (const [event, handler] of Object.entries(handlers))
|
||||
webSocket.addEventListener(event, handler)
|
||||
return webSocket
|
||||
}
|
||||
|
||||
@@ -51,7 +55,7 @@ async function handleWsAuthError(msg: any) {
|
||||
// Stop reconnection attempts while showing auth dialog
|
||||
awaitingAuth = true
|
||||
store.authInProgress = true
|
||||
store.error = '' // Clear any connection message
|
||||
store.error = '' // Clear any connection message
|
||||
if (watchTimeout !== null) {
|
||||
clearTimeout(watchTimeout)
|
||||
watchTimeout = null
|
||||
@@ -89,9 +93,9 @@ export const watchConnect = () => {
|
||||
|
||||
wsWatch = connect(watchUrl, {
|
||||
message: handleWatchMessage,
|
||||
close: watchReconnect,
|
||||
close: watchReconnect
|
||||
})
|
||||
wsWatch.addEventListener("message", event => {
|
||||
wsWatch.addEventListener('message', event => {
|
||||
if (store.connected) return
|
||||
const msg = JSON.parse(event.data)
|
||||
if ('error' in msg) {
|
||||
@@ -103,7 +107,7 @@ export const watchConnect = () => {
|
||||
}
|
||||
return
|
||||
}
|
||||
if ("server" in msg) {
|
||||
if ('server' in msg) {
|
||||
console.log('Connected to backend', msg)
|
||||
store.server = msg.server
|
||||
store.connected = true
|
||||
@@ -141,7 +145,7 @@ const watchReconnect = (event: MessageEvent) => {
|
||||
return
|
||||
}
|
||||
if (store.connected) {
|
||||
console.warn("Disconnected from server", event)
|
||||
console.warn('Disconnected from server', event)
|
||||
store.connected = false
|
||||
store.error = 'Reconnecting...'
|
||||
}
|
||||
@@ -151,7 +155,6 @@ const watchReconnect = (event: MessageEvent) => {
|
||||
watchTimeout = setTimeout(watchConnect, reconnDelay)
|
||||
}
|
||||
|
||||
|
||||
const handleWatchMessage = (event: MessageEvent) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
switch (true) {
|
||||
@@ -192,13 +195,14 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
if (action === 'k') {
|
||||
newtree.push(...tree.slice(oidx, oidx + arg))
|
||||
oidx += arg
|
||||
}
|
||||
else if (action === 'd') oidx += arg
|
||||
} else if (action === 'd') oidx += arg
|
||||
else if (action === 'i') newtree.push(...arg)
|
||||
else console.log("Unknown update action", action, arg)
|
||||
else console.log('Unknown update action', action, arg)
|
||||
}
|
||||
if (oidx != tree.length)
|
||||
throw Error(`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`)
|
||||
throw Error(
|
||||
`Tree update out of sync, number of entries mismatch: got ${oidx}, expected ${tree.length}, new tree ${newtree.length}`
|
||||
)
|
||||
store.updateRoot(newtree)
|
||||
tree = newtree
|
||||
saveSession()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import ExplorerView from '@/views/ExplorerView.vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
|
||||
+53
-41
@@ -1,11 +1,11 @@
|
||||
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
|
||||
import type { FUID, FileEntry, SelectedItems } from '@/repositories/Document'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { defineStore, type StateTree } from 'pinia'
|
||||
import { resumeWatching, watchConnect } from '@/repositories/WS'
|
||||
import { collator } from '@/utils'
|
||||
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||
import { type SortOrder, sorted } from '@/utils/docsort'
|
||||
import SearchWorker from '@/workers/searchWorker?worker'
|
||||
import { getDocuments, setDocuments, documentRef } from './documentStore'
|
||||
import { type StateTree, defineStore } from 'pinia'
|
||||
import { documentRef, getDocuments, setDocuments } from './documentStore'
|
||||
|
||||
// Singleton search worker instance
|
||||
let searchWorker: Worker | null = null
|
||||
@@ -19,8 +19,8 @@ function getSearchWorker(): Worker {
|
||||
if (!searchWorker) {
|
||||
searchWorker = new SearchWorker()
|
||||
// Set up message handler once
|
||||
searchWorker.onmessage = (e) => {
|
||||
if (!searchStore || e.data.id !== searchId) return // Stale result
|
||||
searchWorker.onmessage = e => {
|
||||
if (!searchStore || e.data.id !== searchId) return // Stale result
|
||||
|
||||
// Convert plain data back to Doc instances
|
||||
const docs = e.data.docs.map((d: any) => new Doc(d))
|
||||
@@ -34,7 +34,7 @@ function getSearchWorker(): Worker {
|
||||
// Throttle rapid intermediate updates to reduce UI flicker
|
||||
const now = performance.now()
|
||||
if (!e.data.done && now - lastResultUpdate < 50) {
|
||||
return // Skip intermediate update if too recent
|
||||
return // Skip intermediate update if too recent
|
||||
}
|
||||
lastResultUpdate = now
|
||||
|
||||
@@ -73,13 +73,13 @@ export const useMainStore = defineStore('main', {
|
||||
searchLoading: false,
|
||||
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
fileExplorer: null as any,
|
||||
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
||||
toast: '' as string, // Temporary toast (auto-dismisses)
|
||||
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
||||
toast: '' as string, // Temporary toast (auto-dismisses)
|
||||
toastTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||
connected: false,
|
||||
authInProgress: false,
|
||||
cursor: '' as string,
|
||||
server: {} as Record<string, any> & { public?: boolean, paskia?: boolean },
|
||||
server: {} as Record<string, any> & { public?: boolean; paskia?: boolean },
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
||||
uprogress: {} as any,
|
||||
dprogress: {} as any,
|
||||
@@ -87,19 +87,19 @@ export const useMainStore = defineStore('main', {
|
||||
gallery: false,
|
||||
sortListing: '' as SortOrder,
|
||||
sortFiltered: '' as SortOrder,
|
||||
searchHotkey: '/', // Character shown for search hotkey (Slash key)
|
||||
searchHotkey: '/' // Character shown for search hotkey (Slash key)
|
||||
},
|
||||
user: {
|
||||
username: '' as string,
|
||||
privileged: false as boolean,
|
||||
isLoggedIn: false as boolean,
|
||||
isLoggedIn: false as boolean
|
||||
},
|
||||
space: {
|
||||
disk: 0,
|
||||
free: 0,
|
||||
used: 0,
|
||||
storage: 0,
|
||||
allocated: 0,
|
||||
allocated: 0
|
||||
}
|
||||
}),
|
||||
persist: {
|
||||
@@ -114,7 +114,7 @@ export const useMainStore = defineStore('main', {
|
||||
tree.selected = Array.from(tree.selected)
|
||||
return JSON.stringify(tree)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
@@ -122,22 +122,26 @@ export const useMainStore = defineStore('main', {
|
||||
let loc = [] as string[]
|
||||
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
||||
loc = loc.slice(0, level - 1)
|
||||
docs.push(new Doc({
|
||||
name,
|
||||
loc: level ? loc.join('/') : '/',
|
||||
key,
|
||||
size,
|
||||
allocated,
|
||||
mtime,
|
||||
dir: !isfile,
|
||||
}))
|
||||
docs.push(
|
||||
new Doc({
|
||||
name,
|
||||
loc: level ? loc.join('/') : '/',
|
||||
key,
|
||||
size,
|
||||
allocated,
|
||||
mtime,
|
||||
dir: !isfile
|
||||
})
|
||||
)
|
||||
loc.push(name)
|
||||
}
|
||||
// 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))
|
||||
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)
|
||||
@@ -224,14 +228,14 @@ export const useMainStore = defineStore('main', {
|
||||
size: doc.size,
|
||||
allocated: doc.allocated,
|
||||
mtime: doc.mtime,
|
||||
dir: doc.dir,
|
||||
dir: doc.dir
|
||||
}))
|
||||
worker.postMessage({ type: 'update', documents: docData })
|
||||
},
|
||||
search(query: string, loc: string) {
|
||||
const worker = getSearchWorker()
|
||||
const id = ++searchId
|
||||
searchStore = this // Store reference for worker callback
|
||||
searchStore = this // Store reference for worker callback
|
||||
|
||||
// Update query immediately so watchers know we're handling this
|
||||
this.query = query
|
||||
@@ -264,7 +268,8 @@ export const useMainStore = defineStore('main', {
|
||||
|
||||
// Delay showing loading indicator to avoid flicker on fast searches
|
||||
loadingTimer = setTimeout(() => {
|
||||
if (searchId === id) { // Still the current search
|
||||
if (searchId === id) {
|
||||
// Still the current search
|
||||
this.searchLoading = true
|
||||
}
|
||||
loadingTimer = null
|
||||
@@ -296,7 +301,7 @@ export const useMainStore = defineStore('main', {
|
||||
this.cursor = ''
|
||||
},
|
||||
async logout() {
|
||||
console.log("Logout")
|
||||
console.log('Logout')
|
||||
try {
|
||||
const res = await fetch('/auth/api/logout', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
@@ -326,25 +331,29 @@ export const useMainStore = defineStore('main', {
|
||||
showSortToast(order: SortOrder | '') {
|
||||
const labels: Record<string, string> = {
|
||||
'': 'Folders first',
|
||||
'name': 'Alphabetical order',
|
||||
'modified': 'Newest first',
|
||||
'size': 'Largest first',
|
||||
name: 'Alphabetical order',
|
||||
modified: 'Newest first',
|
||||
size: 'Largest first'
|
||||
}
|
||||
this.showToast(labels[order] || order, 1200)
|
||||
},
|
||||
focusBreadcrumb() {
|
||||
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||
;(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||
},
|
||||
cancelDownloads() {
|
||||
location.reload() // FIXME
|
||||
location.reload() // FIXME
|
||||
},
|
||||
cancelUploads() {
|
||||
location.reload() // FIXME
|
||||
},
|
||||
location.reload() // FIXME
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
||||
isUserLogged(): boolean { return this.user.isLoggedIn },
|
||||
sortOrder(): SortOrder {
|
||||
return this.query ? this.prefs.sortFiltered : this.prefs.sortListing
|
||||
},
|
||||
isUserLogged(): boolean {
|
||||
return this.user.isLoggedIn
|
||||
},
|
||||
/** Get documents count (triggers on docVersion change) */
|
||||
documentCount(): number {
|
||||
// Access docVersion to make this reactive
|
||||
@@ -366,7 +375,7 @@ export const useMainStore = defineStore('main', {
|
||||
missing: new Set(),
|
||||
docs: {},
|
||||
keys: [],
|
||||
recursive: [],
|
||||
recursive: []
|
||||
}
|
||||
for (const doc of docs) {
|
||||
if (selected.has(doc.key)) {
|
||||
@@ -384,7 +393,10 @@ export const useMainStore = defineStore('main', {
|
||||
const nremove = base.loc.length
|
||||
ret.recursive.push([base.name, basepath, base])
|
||||
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 rel = full.slice(nremove)
|
||||
ret.recursive.push([rel, full, doc])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clearTree } from '@/repositories/WS'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useMainStore } from './main'
|
||||
import { clearTree } from '@/repositories/WS'
|
||||
|
||||
export const useSsoAuthStore = defineStore('ssoAuth', () => {
|
||||
const isExternalAuth = computed(() => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
export const exists = (path: string[]) => {
|
||||
const store = useMainStore()
|
||||
// Access docVersion to make this reactive
|
||||
void store.docVersion
|
||||
const p = path.join('/')
|
||||
return getDocuments().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.) */
|
||||
|
||||
+52
-23
@@ -26,26 +26,39 @@ export function formatUnixDate(t: number) {
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||
if (adiff <= 5000) return 'now'
|
||||
if (adiff <= 60000) {
|
||||
return formatter.format(Math.round(diff / 1000), 'second').replace(' ago', '').replaceAll(' ', '\u202F')
|
||||
return formatter
|
||||
.format(Math.round(diff / 1000), 'second')
|
||||
.replace(' ago', '')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
if (adiff <= 3600000) {
|
||||
return formatter.format(Math.round(diff / 60000), 'minute').replace('utes', '').replace('ute', '').replaceAll(' ', '\u202F')
|
||||
return formatter
|
||||
.format(Math.round(diff / 60000), 'minute')
|
||||
.replace('utes', '')
|
||||
.replace('ute', '')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
if (adiff <= 86400000) {
|
||||
return formatter.format(Math.round(diff / 3600000), 'hour').replaceAll(' ', '\u202F')
|
||||
return formatter
|
||||
.format(Math.round(diff / 3600000), 'hour')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
if (adiff <= 604800000) {
|
||||
return formatter.format(Math.round(diff / 86400000), 'day').replaceAll(' ', '\u202F')
|
||||
return formatter
|
||||
.format(Math.round(diff / 86400000), 'day')
|
||||
.replaceAll(' ', '\u202F')
|
||||
}
|
||||
let d = date.toLocaleDateString('en-ie', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).replace("Sept", "Sep")
|
||||
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
|
||||
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
|
||||
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
|
||||
let d = date
|
||||
.toLocaleDateString('en-ie', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
.replace('Sept', 'Sep')
|
||||
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
|
||||
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
|
||||
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
|
||||
return d
|
||||
}
|
||||
|
||||
@@ -63,34 +76,50 @@ interface FileTypes {
|
||||
const filetypes: FileTypes = {
|
||||
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
|
||||
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||
pdf: ['pdf'],
|
||||
pdf: ['pdf']
|
||||
}
|
||||
|
||||
export function getFileType(name: string): string {
|
||||
const dotIndex = name.lastIndexOf('.')
|
||||
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
|
||||
const ext = name.slice(dotIndex + 1).toLowerCase()
|
||||
return Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
|
||||
return (
|
||||
Object.keys(filetypes).find(type => filetypes[type]!.includes(ext)) || 'unknown'
|
||||
)
|
||||
}
|
||||
|
||||
// Prebuilt for fast & consistent sorting
|
||||
export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' })
|
||||
export const collator = new Intl.Collator('en', {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
usage: 'search'
|
||||
})
|
||||
|
||||
// Preformat document names for faster search
|
||||
export function haystackFormat(str: string) {
|
||||
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
const based = str
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
return '^' + based + '$'
|
||||
}
|
||||
|
||||
|
||||
// Preformat search string for faster search
|
||||
export function needleFormat(query: string) {
|
||||
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
return {based, words: based.split(/\s+/)}
|
||||
const based = query
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
return { based, words: based.split(/\s+/) }
|
||||
}
|
||||
|
||||
// Test if haystack includes needle
|
||||
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) {
|
||||
const {based, words} = filter
|
||||
return haystack.includes(based) || words && words.every(word => haystack.includes(word))
|
||||
export function localeIncludes(
|
||||
haystack: string,
|
||||
filter: { based: string; words: string[] }
|
||||
) {
|
||||
const { based, words } = filter
|
||||
return (
|
||||
haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect, ref, computed, watch } from 'vue'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import FileExplorer from '@/components/FileExplorer.vue'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { collator } from '@/utils'
|
||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||
import FileExplorer from '@/components/FileExplorer.vue'
|
||||
import { computed, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const fileExplorer = ref()
|
||||
@@ -40,7 +40,7 @@ const folderPath = computed(() => props.path.join('/'))
|
||||
watch(
|
||||
() => [props.query, props.path.join('/')] as const,
|
||||
([query, loc]) => {
|
||||
if (store.query === query) return // Already searching this query
|
||||
if (store.query === query) return // Already searching this query
|
||||
store.search(query, loc)
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -55,9 +55,14 @@ const documents = computed(() => {
|
||||
// Access docVersion to make this reactive to document changes
|
||||
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))
|
||||
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))
|
||||
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))]
|
||||
@@ -66,7 +71,9 @@ const documents = computed(() => {
|
||||
|
||||
// Search results from worker (also filter hidden)
|
||||
const hidden = store.hiddenPaths
|
||||
const docs = store.searchResults.filter(doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
|
||||
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
|
||||
const order = store.prefs.sortFiltered
|
||||
@@ -81,11 +88,15 @@ watchEffect(() => {
|
||||
})
|
||||
|
||||
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||
watch([() => props.path.join('/'), () => store.documentCount], ([path, len], [oldPath, oldLen]) => {
|
||||
// React to path change or initial document load (0 → non-zero)
|
||||
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
||||
store.prefs.gallery = documents.value.some(d => d.previewable)
|
||||
}, { immediate: true })
|
||||
watch(
|
||||
[() => props.path.join('/'), () => store.documentCount],
|
||||
([path, len], [oldPath, oldLen]) => {
|
||||
// React to path change or initial document load (0 → non-zero)
|
||||
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
||||
store.prefs.gallery = documents.value.some(d => d.previewable)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -37,14 +37,14 @@ interface ResultMessage {
|
||||
}
|
||||
|
||||
// Worker state
|
||||
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
||||
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
||||
let currentSearchId = 0
|
||||
|
||||
// Search result cache - cleared when documents change
|
||||
interface CacheEntry {
|
||||
query: string // Normalized query string
|
||||
results: WorkerDoc[] // Matched results (up to limit)
|
||||
complete: boolean // True if search scanned all documents
|
||||
query: string // Normalized query string
|
||||
results: WorkerDoc[] // Matched results (up to limit)
|
||||
complete: boolean // True if search scanned all documents
|
||||
}
|
||||
const searchCache: CacheEntry[] = []
|
||||
const MAX_CACHE_SIZE = 10
|
||||
@@ -53,11 +53,21 @@ const RESULT_LIMIT = 100
|
||||
// Normalize string for search (remove diacritics, lowercase)
|
||||
// Haystack adds ^ and $ markers to allow matching start/end of name
|
||||
function normalizeHaystack(str: string): string {
|
||||
return '^' + str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + '$'
|
||||
return (
|
||||
'^' +
|
||||
str
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase() +
|
||||
'$'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeQuery(str: string): string {
|
||||
return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
return str
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
// Test if document matches search query
|
||||
@@ -143,8 +153,12 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
|
||||
|
||||
// Slow path: scan all documents
|
||||
const batchSize = 500
|
||||
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) {
|
||||
if (currentSearchId !== searchId) return // Superseded
|
||||
for (
|
||||
let i = 0;
|
||||
i < recentDocuments.length && results.length < RESULT_LIMIT;
|
||||
i += batchSize
|
||||
) {
|
||||
if (currentSearchId !== searchId) return // Superseded
|
||||
|
||||
// Process batch
|
||||
const end = Math.min(i + batchSize, recentDocuments.length)
|
||||
@@ -175,7 +189,13 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
|
||||
}
|
||||
|
||||
// Post results to main thread
|
||||
function postResults(docs: WorkerDoc[], query: string, loc: string, id: number, done: boolean) {
|
||||
function postResults(
|
||||
docs: WorkerDoc[],
|
||||
query: string,
|
||||
loc: string,
|
||||
id: number,
|
||||
done: boolean
|
||||
) {
|
||||
const sorted = sortResults(docs, query, loc)
|
||||
postMessage({
|
||||
type: 'results',
|
||||
@@ -188,20 +208,21 @@ function postResults(docs: WorkerDoc[], query: string, loc: string, id: number,
|
||||
// Sort results by relevance
|
||||
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
|
||||
const locsub = loc + '/'
|
||||
return [...docs].sort((a, b) => (
|
||||
// Current folder first
|
||||
Number(b.loc === loc) - Number(a.loc === loc) ||
|
||||
// Then subfolders
|
||||
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
|
||||
// Then by location
|
||||
collator.compare(a.loc, b.loc) ||
|
||||
// Folders before files
|
||||
Number(b.dir) - Number(a.dir) ||
|
||||
// Exact name match first
|
||||
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
|
||||
// Finally by name
|
||||
collator.compare(a.name, b.name)
|
||||
))
|
||||
return [...docs].sort(
|
||||
(a, b) =>
|
||||
// Current folder first
|
||||
Number(b.loc === loc) - Number(a.loc === loc) ||
|
||||
// Then subfolders
|
||||
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
|
||||
// Then by location
|
||||
collator.compare(a.loc, b.loc) ||
|
||||
// Folders before files
|
||||
Number(b.dir) - Number(a.dir) ||
|
||||
// Exact name match first
|
||||
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
|
||||
// Finally by name
|
||||
collator.compare(a.name, b.name)
|
||||
)
|
||||
}
|
||||
|
||||
// Handle incoming messages
|
||||
@@ -220,7 +241,12 @@ self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
|
||||
await performSearch(msg.query, msg.loc, msg.id)
|
||||
} else {
|
||||
// Empty query - no results needed
|
||||
postMessage({ type: 'results', docs: [], id: msg.id, done: true } as ResultMessage)
|
||||
postMessage({
|
||||
type: 'results',
|
||||
docs: [],
|
||||
id: msg.id,
|
||||
done: true
|
||||
} as ResultMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,27 +9,27 @@
|
||||
* FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
|
||||
*/
|
||||
|
||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:8999"
|
||||
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || 'http://localhost:8999'
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
for (const path of paths) {
|
||||
proxy[path] = {
|
||||
target: backendUrl,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: "fastapi-vite",
|
||||
name: 'fastapi-vite',
|
||||
config: () => ({
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../cista/frontend-build",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
outDir: '../cista/frontend-build',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+14
-16
@@ -1,29 +1,29 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { URL, fileURLToPath } from 'node:url'
|
||||
import fastapiVue from './vite-plugin-fastapi.js'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
// @ts-ignore
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }),
|
||||
fastapiVue({ paths: ['/api', '/auth', '/files', '/zip', '/preview'] }),
|
||||
vue(),
|
||||
svgLoader(), // import svg files
|
||||
Components(), // auto import components
|
||||
svgLoader(), // import svg files
|
||||
Components() // auto import components
|
||||
],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
modifyVars: {},
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
javascriptEnabled: true
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -35,11 +35,9 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Bundle all SVG icons into a single chunk
|
||||
icons: [
|
||||
'/src/assets/svg/index.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
icons: ['/src/assets/svg/index.ts']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+51
-1
@@ -25,6 +25,7 @@ classifiers = [
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"argon2-cffi>=25.1.0",
|
||||
"aspose-words>=26.4.0",
|
||||
"av>=15.0.0",
|
||||
"blake3>=1.0.5",
|
||||
"docopt-ng>=0.9.0",
|
||||
@@ -115,8 +116,57 @@ filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["E402"]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"COM812", # formatter compatibility
|
||||
"ISC001", # formatter compatibility
|
||||
"ANN001", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN002", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN003", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN201", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN202", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN204", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ANN205", # legacy codebase: no full runtime annotation coverage yet
|
||||
"ARG001", # framework and callback signatures commonly require unused args
|
||||
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
|
||||
"C901", # legacy complexity; keep other correctness rules enabled
|
||||
"D100", # legacy docs not yet standardized
|
||||
"D101", # legacy docs not yet standardized
|
||||
"D102", # legacy docs not yet standardized
|
||||
"D103", # legacy docs not yet standardized
|
||||
"D104", # legacy docs not yet standardized
|
||||
"D105", # legacy docs not yet standardized
|
||||
"D107", # legacy docs not yet standardized
|
||||
"D200", # legacy docs not yet standardized
|
||||
"D203", # avoid D203/D211 conflict under ALL selection
|
||||
"D212", # legacy docs not yet standardized
|
||||
"D213", # legacy docs not yet standardized
|
||||
"D400", # legacy docs not yet standardized
|
||||
"D401", # legacy docs not yet standardized
|
||||
"D413", # legacy docs not yet standardized
|
||||
"D415", # legacy docs not yet standardized
|
||||
"E501", # existing long literals/log strings
|
||||
"EM101", # exception-message style; low signal for this project
|
||||
"EM102", # exception-message style; low signal for this project
|
||||
"INP001", # scripts folder intentionally lacks package markers
|
||||
"PLC0415", # lazy imports used to avoid startup/circular import issues
|
||||
"PLR0911", # legacy complexity; keep other correctness rules enabled
|
||||
"PLR0912", # legacy complexity; keep other correctness rules enabled
|
||||
"PLR0913", # legacy complexity; keep other correctness rules enabled
|
||||
"PLR0915", # legacy complexity; keep other correctness rules enabled
|
||||
"PLR2004", # legacy comparisons use inline constants
|
||||
"PLW0603", # module-level shared state exists in server runtime code
|
||||
"SLF001", # cohesive modules occasionally need private-member access
|
||||
"TRY002", # exception-class strictness too noisy on legacy handlers
|
||||
"TRY003", # exception-message strictness too noisy on legacy handlers
|
||||
"TRY004", # type-check strictness too noisy on legacy handlers
|
||||
"TRY300", # stylistic try/else preference
|
||||
"TRY301", # stylistic raise-in-try preference
|
||||
]
|
||||
isort.known-first-party = ["cista"]
|
||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
|
||||
per-file-ignores."scripts/*" = ["T20"]
|
||||
|
||||
Reference in New Issue
Block a user