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

This commit is contained in:
2026-04-26 06:43:06 +00:00
parent 942b54d795
commit eb5ff82de6
42 changed files with 1016 additions and 504 deletions
+1
View File
@@ -1,6 +1,7 @@
.* .*
*.lock *.lock
!.gitignore !.gitignore
!.pre-commit-config.yaml
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
/cista/_version.py /cista/_version.py
+28
View File
@@ -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
+69
View File
@@ -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
View File
@@ -9,8 +9,10 @@
"test:unit": "vitest", "test:unit": "vitest",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "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", "lint": "biome lint .",
"format": "prettier --write src/" "format": "biome format --write .",
"format:check": "biome format --check .",
"check": "biome check ."
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
@@ -30,33 +32,19 @@
"vue-router": "^5.0.1" "vue-router": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.15.0", "@biomejs/biome": "^1.9.4",
"@tsconfig/node18": "^18.2.6", "@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^27.0.0", "@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^25.1.0", "@types/node": "^25.1.0",
"@vitejs/plugin-vue": "^6.0.3", "@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/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"babel-eslint": "^10.1.0",
"eslint": "^9.39.2",
"eslint-plugin-vue": "^10.7.0",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"prettier": "^3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.18", "vitest": "^4.0.18",
"vue-tsc": "^3.2.4" "vue-tsc": "^3.2.4"
},
"prettier": {
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 88
} }
} }
+72 -32
View File
@@ -24,21 +24,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router'
import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue' import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { useMainStore } from '@/stores/main' 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 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 type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue' import UserManagementModal from './components/UserManagementModal.vue'
import UserTokensModal from './components/UserTokensModal.vue' import UserTokensModal from './components/UserTokensModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue' import type { SortOrder } from './utils/docsort'
import SelectionToolbar from './components/SelectionToolbar.vue'
interface Path { interface Path {
path: string path: string
@@ -57,7 +57,10 @@ const path: ComputedRef<Path> = computed(() => {
} }
}) })
watchEffect(() => { 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(loadSession)
onMounted(watchConnect) onMounted(watchConnect)
@@ -106,7 +109,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
// Handle arrows: in search input with text, only up/down; otherwise all arrows // Handle arrows: in search input with text, only up/down; otherwise all arrows
const searchInput = inHeader && input const searchInput = inHeader && input
const searchHasText = searchInput && (event.target as HTMLInputElement).value 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() const dir = event.key.slice(5).toLowerCase()
// In search with text: left/right move cursor, up/down navigate // In search with text: left/right move cursor, up/down navigate
if (searchHasText && (dir === 'left' || dir === 'right')) { if (searchHasText && (dir === 'left' || dir === 'right')) {
@@ -135,8 +138,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
store.clearToast() store.clearToast()
headerMain.value!.clearSearch(event) headerMain.value!.clearSearch(event)
store.focusBreadcrumb() store.focusBreadcrumb()
} } else if (!input && keyup && event.key === 'Backspace') {
else if (!input && keyup && event.key === 'Backspace') {
Router.back() Router.back()
} }
// Select all (toggle); keydown to precede and prevent builtin // Select all (toggle); keydown to precede and prevent builtin
@@ -151,20 +153,27 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if ( else if (
!input && !input &&
keyup && 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) store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
} }
// Rename // 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() fileExplorer.cursorRename()
} }
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey // Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
else if (!input && c && event.code === 'Space') { else if (!input && c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey) if (keyup && !event.altKey && !event.ctrlKey) fileExplorer.cursorSelect()
fileExplorer.cursorSelect() } else return
}
else return
/// We are handling this! /// We are handling this!
event.preventDefault() event.preventDefault()
if (timer) { if (timer) {
@@ -174,36 +183,67 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
let f: any let f: any
// Arrow navigation - always use fileExplorer for repeatable movement // Arrow navigation - always use fileExplorer for repeatable movement
if (arrow && !keyup) { if (arrow && !keyup) {
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus() const focusSearch = () =>
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus() (
document.querySelector('.headermain input[type="search"]') as HTMLElement
)?.focus()
const focusBreadcrumb = () =>
(document.querySelector('.breadcrumb') as HTMLElement)?.focus()
if (inBreadcrumb) { if (inBreadcrumb) {
// Breadcrumb: up→header (no repeat), down→files (with repeat) // Breadcrumb: up→header (no repeat), down→files (with repeat)
if (arrow === 'up') { focusSearch(); f = null } if (arrow === 'up') {
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null } focusSearch()
f = null
} else if (arrow === 'down') {
fileExplorer.focusFirst?.()
f = null
}
} else if (inHeader) { } else if (inHeader) {
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space) // 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) const idx = items.indexOf(document.activeElement as HTMLElement)
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null } if (arrow === 'left' && idx > 0) {
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null } items[idx - 1]?.focus()
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false }) f = null
else if (arrow === 'down') { focusBreadcrumb(); 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 { } else {
// File explorer: normal navigation with repeat // File explorer: normal navigation with repeat
switch (arrow) { switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break case 'up':
case 'down': f = () => fileExplorer.down(event); break f = () => fileExplorer.up(event)
case 'left': f = () => fileExplorer.left(event); break break
case 'right': f = () => fileExplorer.right(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) { if (f) {
// Initial move, then t0 delay until repeats at tr intervals // 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() f()
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr) timer = setTimeout(() => {
timer = setInterval(f, tr)
}, t0 - tr)
} }
} }
onMounted(() => { onMounted(() => {
+18 -9
View File
@@ -50,8 +50,12 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
header .headermain { order: 1; } header .headermain {
header .breadcrumb { align-self: stretch; } order: 1;
}
header .breadcrumb {
align-self: stretch;
}
} }
@media print { @media print {
:root { :root {
@@ -74,7 +78,7 @@
max-width: none !important; max-width: none !important;
} }
.breadcrumb > a::after { .breadcrumb > a::after {
content: '/'; content: "/";
} }
.breadcrumb svg { .breadcrumb svg {
fill: black !important; fill: black !important;
@@ -101,7 +105,8 @@
video::-webkit-media-controls { video::-webkit-media-controls {
display: none; display: none;
} }
tr, figure { tr,
figure {
page-break-inside: avoid; page-break-inside: avoid;
} }
.selection { .selection {
@@ -134,7 +139,7 @@ main {
body { body {
background-color: var(--primary-background); background-color: var(--primary-background);
font-size: 1rem; font-size: 1rem;
font-family: 'Roboto'; font-family: "Roboto";
color: var(--primary-color); color: var(--primary-color);
margin: 0; margin: 0;
/* Prevent any scrolling on body */ /* Prevent any scrolling on body */
@@ -145,7 +150,7 @@ body {
} }
tbody .size, tbody .size,
tbody .modified { tbody .modified {
font-family: 'Roboto Mono'; font-family: "Roboto Mono";
} }
header { header {
flex: 0 0 auto; flex: 0 0 auto;
@@ -209,9 +214,13 @@ header nav.headermain {
position: relative; position: relative;
z-index: 100; z-index: 100;
} }
.spacer { flex-grow: 1 } .spacer {
.smallgap { flex-shrink: 1; width: 2em } flex-grow: 1;
}
.smallgap {
flex-shrink: 1;
width: 2em;
}
.error-message { .error-message {
padding: .5em; padding: .5em;
+102 -54
View File
@@ -60,78 +60,126 @@ import Zoomout from './zoomout.svg'
// Named exports for direct imports // Named exports for direct imports
export { export {
AddFile, AddFolder, Arrow, ArrowsH, ArrowsV, AddFile,
Check, Code, Cog, Copy, CreateFile, CreateFolder, Cross, AddFolder,
Disk, Download, Exclamation, Eye, Find, Fullscreen, Arrow,
Github, Home, Info, Link, Logo, Loop, Menu, ArrowsH,
Next, Open, Paste, Pause, Pencil, Play, Plus, Previous, ArrowsV,
Reload, Rename, Scissors, Shuffle, Signin, Signout, Skip, Check,
Spinner, Stop, Trash, Triangle, Unfullscreen, UpArrow, Code,
UploadCloud, UserCog, User, VolumeHigh, VolumeLow, Cog,
VolumeMedium, VolumeMute, WindowCross, Window, Wordwrap, Copy,
Zoomin, Zoomout 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) // Icon lookup by kebab-case name (for SvgButton compatibility)
export const icons = { export const icons = {
'add-file': AddFile, 'add-file': AddFile,
'add-folder': AddFolder, 'add-folder': AddFolder,
'arrow': Arrow, arrow: Arrow,
'arrows-h': ArrowsH, 'arrows-h': ArrowsH,
'arrows-v': ArrowsV, 'arrows-v': ArrowsV,
'check': Check, check: Check,
'code': Code, code: Code,
'cog': Cog, cog: Cog,
'copy': Copy, copy: Copy,
'create-file': CreateFile, 'create-file': CreateFile,
'create-folder': CreateFolder, 'create-folder': CreateFolder,
'cross': Cross, cross: Cross,
'disk': Disk, disk: Disk,
'download': Download, download: Download,
'exclamation': Exclamation, exclamation: Exclamation,
'eye': Eye, eye: Eye,
'find': Find, find: Find,
'fullscreen': Fullscreen, fullscreen: Fullscreen,
'github': Github, github: Github,
'home': Home, home: Home,
'info': Info, info: Info,
'link': Link, link: Link,
'logo': Logo, logo: Logo,
'loop': Loop, loop: Loop,
'menu': Menu, menu: Menu,
'next': Next, next: Next,
'open': Open, open: Open,
'paste': Paste, paste: Paste,
'pause': Pause, pause: Pause,
'pencil': Pencil, pencil: Pencil,
'play': Play, play: Play,
'plus': Plus, plus: Plus,
'previous': Previous, previous: Previous,
'reload': Reload, reload: Reload,
'rename': Rename, rename: Rename,
'scissors': Scissors, scissors: Scissors,
'shuffle': Shuffle, shuffle: Shuffle,
'signin': Signin, signin: Signin,
'signout': Signout, signout: Signout,
'skip': Skip, skip: Skip,
'spinner': Spinner, spinner: Spinner,
'stop': Stop, stop: Stop,
'trash': Trash, trash: Trash,
'triangle': Triangle, triangle: Triangle,
'unfullscreen': Unfullscreen, unfullscreen: Unfullscreen,
'up-arrow': UpArrow, 'up-arrow': UpArrow,
'upload-cloud': UploadCloud, 'upload-cloud': UploadCloud,
'user-cog': UserCog, 'user-cog': UserCog,
'user': User, user: User,
'volume-high': VolumeHigh, 'volume-high': VolumeHigh,
'volume-low': VolumeLow, 'volume-low': VolumeLow,
'volume-medium': VolumeMedium, 'volume-medium': VolumeMedium,
'volume-mute': VolumeMute, 'volume-mute': VolumeMute,
'window-cross': WindowCross, 'window-cross': WindowCross,
'window': Window, window: Window,
'wordwrap': Wordwrap, wordwrap: Wordwrap,
'zoomin': Zoomin, zoomin: Zoomin,
'zoomout': Zoomout, zoomout: Zoomout
} as const } as const
export type IconName = keyof typeof icons export type IconName = keyof typeof icons
+16 -8
View File
@@ -37,17 +37,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { Home } from '@/assets/svg' import { Home } from '@/assets/svg'
import { exists } from '@/utils/fileutil'
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue' import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { exists } from '@/utils/fileutil'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
const home = Home const home = Home
const router = useRouter() const router = useRouter()
const links = [] as Array<HTMLElement> const links = [] as Array<HTMLElement>
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el } const setLinkRef = (index: number, el: any) => {
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home if (el) links[index] = el
}
onBeforeUpdate(() => {
links.length = 1
}) // 1 to keep home
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map()) const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
@@ -63,7 +67,8 @@ const props = defineProps<{
const longest = ref<Array<string>>([]) 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 = () => { const focusCurrent = () => {
nextTick(() => { nextTick(() => {
@@ -80,7 +85,10 @@ const navigate = (index: number) => {
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '') const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Clicking on current link clears the rest of the path and adds new history // 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 // Moving along breadcrumbs doesn't create new history
else if (long.startsWith(browser)) router.replace(u) else if (long.startsWith(browser)) router.replace(u)
// Nornal navigation from elsewhere (e.g. search result breadcrumbs) // Nornal navigation from elsewhere (e.g. search result breadcrumbs)
@@ -100,8 +108,7 @@ watchEffect(() => {
if (!same) longest.value = props.path if (!same) longest.value = props.path
else if (props.path.length > longcut.length) { else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length)) longest.value = longcut.concat(props.path.slice(longcut.length))
} } else {
else {
// Prune deleted folders from longest // Prune deleted folders from longest
for (let i = props.path.length; i < longest.value.length; ++i) { for (let i = props.path.length; i < longest.value.length; ++i) {
if (!exists(longest.value.slice(0, i + 1))) { if (!exists(longest.value.slice(0, i + 1))) {
@@ -111,7 +118,8 @@ watchEffect(() => {
} }
} }
// If needed, focus primary navigation to new location // If needed, focus primary navigation to new location
if (props.primary) nextTick(() => { if (props.primary)
nextTick(() => {
const act = document.activeElement as HTMLElement const act = document.activeElement as HTMLElement
if (!act || [...links, document.body].includes(act)) focusCurrent() if (!act || [...links, document.body].includes(act)) focusCurrent()
}) })
+27 -14
View File
@@ -62,8 +62,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const store = useMainStore() const store = useMainStore()
const containerRef = ref<HTMLDivElement | null>(null) const containerRef = ref<HTMLDivElement | null>(null)
@@ -88,7 +88,7 @@ const formatGB = (bytes: number) => {
const fmtSize = (bytes: number, angle: number) => { const fmtSize = (bytes: number, angle: number) => {
const s = formatGB(bytes) const s = formatGB(bytes)
const a = Math.abs(angle % 180) 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 => { const truncateLabel = (name: string, maxLen = 10): string => {
@@ -157,7 +157,7 @@ const freeColor = computed(() => {
if (!s.disk) return '#6c6' if (!s.disk) return '#6c6'
const freePct = s.free / s.disk const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5' if (freePct > 0.25) return '#5b5'
if (freePct > 0.10) return '#ff0' if (freePct > 0.1) return '#ff0'
return '#f00' return '#f00'
}) })
@@ -165,13 +165,14 @@ const PIE_RADIUS = 55
const LABEL_RADIUS = 62 const LABEL_RADIUS = 62
const getPoint = (angle: number, radius: number) => { 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) } return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
} }
const sectorInfo = computed(() => { const sectorInfo = computed(() => {
const s = store.space const s = store.space
if (!s.disk) return { if (!s.disk)
return {
storage: { angle: 45, pct: 0.25 }, storage: { angle: 45, pct: 0.25 },
free: { angle: 180, pct: 0.5 }, free: { angle: 180, pct: 0.5 },
other: { angle: 270, pct: 0.25 } other: { angle: 270, pct: 0.25 }
@@ -200,13 +201,19 @@ const rawAngles = computed(() => ({
other: sectorInfo.value.other.angle other: sectorInfo.value.other.angle
})) }))
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90 const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start' const getSizeAnchor = (angle: number) => (angle < 180 ? 'end' : 'start')
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95 const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS)) const storageInnerPos = computed(() =>
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS)) getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS)
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.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 // Collision avoidance for curved name labels
const labelLengths = computed(() => ({ 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 storageLabelPath = computed(() =>
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4)) createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length)
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5)) )
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 applyAnimState = (t: number, opacity: number) => {
const widget = widgetRef.value const widget = widgetRef.value
+9 -8
View File
@@ -3,9 +3,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client' import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document' import type { SelectedItems } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { zipName } from '@/utils/fileutil' import { zipName } from '@/utils/fileutil'
const store = useMainStore() const store = useMainStore()
@@ -24,9 +24,9 @@ const status_init = {
filename: '', filename: '',
filesize: 0, filesize: 0,
filepos: 0, filepos: 0,
status: 'idle', status: 'idle'
} }
store.dprogress = {...status_init} store.dprogress = { ...status_init }
setInterval(() => { setInterval(() => {
if (Date.now() - store.dprogress.tlast > 3000) { if (Date.now() - store.dprogress.tlast > 3000) {
// Reset // Reset
@@ -34,8 +34,8 @@ setInterval(() => {
store.dprogress.statdur = 1 store.dprogress.statdur = 1
} else { } else {
// Running average by decay // Running average by decay
store.dprogress.statbytes *= .9 store.dprogress.statbytes *= 0.9
store.dprogress.statdur *= .9 store.dprogress.statdur *= 0.9
} }
}, 100) }, 100)
const statReset = () => { const statReset = () => {
@@ -47,7 +47,6 @@ const cancelDownloads = () => {
location.reload() // FIXME location.reload() // FIXME
} }
const linkdl = (href: string) => { const linkdl = (href: string) => {
const a = document.createElement('a') const a = document.createElement('a')
a.href = href a.href = href
@@ -156,7 +155,10 @@ const download = async (e: MouseEvent) => {
if (e.altKey && 'showDirectoryPicker' in window) { if (e.altKey && 'showDirectoryPicker' in window) {
try { try {
// @ts-ignore // @ts-ignore
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' }) const handle = await window.showDirectoryPicker({
startIn: 'downloads',
mode: 'readwrite'
})
await filesystemdl(sel, handle) await filesystemdl(sel, handle)
store.selected.clear() store.selected.clear()
} catch (e) { } catch (e) {
@@ -168,7 +170,6 @@ const download = async (e: MouseEvent) => {
// Default: ZIP download // Default: ZIP download
zipdl(sel) zipdl(sel)
} }
</script> </script>
<style scoped> <style scoped>
+3 -3
View File
@@ -11,15 +11,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { Cog } from '@/assets/svg' import { Cog } from '@/assets/svg'
import { useMainStore } from '@/stores/main'
import { exists } from '@/utils/fileutil' import { exists } from '@/utils/fileutil'
const cog = Cog const cog = Cog
const store = useMainStore() const store = useMainStore()
const props = defineProps<{ const props = defineProps<{
path: string[], path: string[]
documents: Document[], documents: Document[]
}>() }>()
</script> </script>
+56 -24
View File
@@ -72,14 +72,22 @@
</template> </template>
<script setup lang="ts"> <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 { apiFetch } from '@/repositories/Client'
import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils' import { formatSize } from '@/utils'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu' 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<{ const props = defineProps<{
path: Array<string> path: Array<string>
@@ -89,7 +97,11 @@ const store = useMainStore()
const router = useRouter() const router = useRouter()
const filesUrl = (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) => { const parseErrorMessage = async (res: Response) => {
try { try {
@@ -120,7 +132,7 @@ const rename = async (doc: Doc, newName: string) => {
} }
defineExpose({ defineExpose({
newFolder() { newFolder() {
console.log("New folder") console.log('New folder')
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({ editing.value = new Doc({
loc: loc.value, loc: loc.value,
@@ -129,7 +141,7 @@ defineExpose({
dir: true, dir: true,
mtime: now, mtime: now,
size: 0, size: 0,
allocated: 0, allocated: 0
}) })
store.cursor = editing.value.key store.cursor = editing.value.key
}, },
@@ -146,7 +158,9 @@ defineExpose({
store.cursor = docs[0]!.key store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged) // Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => { 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() if (a) a.focus()
}) })
} }
@@ -164,8 +178,12 @@ defineExpose({
} }
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) }, up(ev: KeyboardEvent) {
down(ev: KeyboardEvent) { this.cursorMove(1, ev) }, this.cursorMove(-1, ev)
},
down(ev: KeyboardEvent) {
this.cursorMove(1, ev)
},
left(ev: KeyboardEvent) { left(ev: KeyboardEvent) {
// Only go back if we're in a subfolder (not at root) // Only go back if we're in a subfolder (not at root)
if (props.path.length > 0) { if (props.path.length > 0) {
@@ -173,7 +191,9 @@ defineExpose({
} }
}, },
right(ev: KeyboardEvent) { 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() if (a) a.click()
}, },
cursorMove(d: number, ev: KeyboardEvent | null) { cursorMove(d: number, ev: KeyboardEvent | null) {
@@ -187,8 +207,9 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index = store.cursor
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length ? docs.findIndex(doc => doc.key === store.cursor)
: docs.length
const moveto = increment(index, d) const moveto = increment(index, d)
store.cursor = docs[moveto]?.key ?? '' store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : '' const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
@@ -206,8 +227,7 @@ defineExpose({
scrolltr = tr scrolltr = tr
if (!scrolltimer) { if (!scrolltimer) {
scrolltimer = setTimeout(() => { scrolltimer = setTimeout(() => {
if (scrolltr) if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null scrolltimer = null
}, 300) }, 300)
} }
@@ -219,7 +239,9 @@ defineExpose({
} }
}) })
const focusHeader = () => { 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() if (el) el.focus()
} }
const focusBreadcrumb = () => { const focusBreadcrumb = () => {
@@ -250,14 +272,17 @@ const updateModified = () => {
nowkey.value = Math.floor(Date.now() / 1000) nowkey.value = Math.floor(Date.now() / 1000)
} }
onMounted(() => { onMounted(() => {
updateModified(); modifiedTimer = setInterval(updateModified, 1000) updateModified()
modifiedTimer = setInterval(updateModified, 1000)
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' }) active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus() active.focus()
} }
}) })
onUnmounted(() => { clearInterval(modifiedTimer) }) onUnmounted(() => {
clearInterval(modifiedTimer)
})
const mkdir = async (doc: Doc, name: string) => { const mkdir = async (doc: Doc, name: string) => {
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
@@ -349,12 +374,14 @@ const copyImage = async (doc: Doc) => {
if (blob.type !== 'image/png') { if (blob.type !== 'image/png') {
const img = new Image() const img = new Image()
img.src = URL.createObjectURL(blob) img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r) await new Promise(r => (img.onload = r))
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth canvas.width = img.naturalWidth
canvas.height = img.naturalHeight canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0) 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) URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]) await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else { } else {
@@ -385,12 +412,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key store.cursor = doc.key
const items = [ const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) }, { 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) }) if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push( 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 }) ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
} }
+1 -1
View File
@@ -13,7 +13,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { ref, onMounted, nextTick } from 'vue' import { nextTick, onMounted, ref } from 'vue'
const input = ref<HTMLInputElement | null>(null) const input = ref<HTMLInputElement | null>(null)
const name = ref('') const name = ref('')
+3 -3
View File
@@ -13,10 +13,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { computed, ref } from 'vue'
import { formatSize } from '@/utils' import { formatSize } from '@/utils'
import SparseIndicator from './SparseIndicator.vue' import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue'
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
@@ -26,7 +26,7 @@ const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const sizeClass = computed(() => { const sizeClass = computed(() => {
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]! const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
return +unit ? "bytes" : unit return +unit ? 'bytes' : unit
}) })
const tooltipText = computed(() => { const tooltipText = computed(() => {
+66 -28
View File
@@ -9,13 +9,21 @@
</template> </template>
<script setup lang="ts"> <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 { apiFetch } from '@/repositories/Client'
import { useRouter } from 'vue-router' import { Doc } from '@/repositories/Document'
import ContextMenu from '@imengyu/vue3-context-menu' import { useMainStore } from '@/stores/main'
import type { SortOrder } from '@/utils/docsort' 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<{ const props = defineProps<{
path: Array<string> path: Array<string>
@@ -25,7 +33,11 @@ const store = useMainStore()
const router = useRouter() const router = useRouter()
const filesUrl = (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) => { const parseErrorMessage = async (res: Response) => {
try { try {
@@ -38,7 +50,9 @@ const parseErrorMessage = async (res: Response) => {
// File rename // File rename
const editing = shallowRef<Doc | null>(null) const editing = shallowRef<Doc | null>(null)
const exit = () => { editing.value = null } const exit = () => {
editing.value = null
}
const rename = async (doc: Doc, newName: string) => { const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker 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 columnCount = ref(1)
const updateColumns = () => { const updateColumns = () => {
if (!gallery.value) return 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) const columns = computed(() => columnCount.value)
defineExpose({ defineExpose({
@@ -72,7 +88,7 @@ defineExpose({
dir: true, dir: true,
mtime: now, mtime: now,
size: 0, size: 0,
allocated: 0, allocated: 0
}) })
store.cursor = editing.value.key store.cursor = editing.value.key
}, },
@@ -93,7 +109,9 @@ defineExpose({
store.cursor = docs[0]!.key store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged) // Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => { 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() if (a) a.focus()
}) })
} }
@@ -111,10 +129,18 @@ defineExpose({
} }
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) }, up(ev: KeyboardEvent) {
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) }, this.cursorMove(-columns.value, ev)
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) }, },
right(ev: KeyboardEvent) { this.cursorMove(1, 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) { cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation) // Move cursor up or down (keyboard navigation)
@@ -126,11 +152,10 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
// Stop navigation sideways away from the grid (only with up/down) // Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === "ArrowLeft") return if (ev && index === 0 && ev.key === 'ArrowLeft') return
if (ev && index === N - 1 && ev.key === "ArrowRight") return if (ev && index === N - 1 && ev.key === 'ArrowRight') return
// Calculate new position // Calculate new position
let moveto let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1 if (index === N) moveto = d > 0 ? 0 : N - 1
@@ -155,8 +180,7 @@ defineExpose({
scrolltr = tr scrolltr = tr
if (!scrolltimer) { if (!scrolltimer) {
scrolltimer = setTimeout(() => { scrolltimer = setTimeout(() => {
if (scrolltr) if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null scrolltimer = null
}, 300) }, 300)
} }
@@ -168,7 +192,9 @@ defineExpose({
} }
}) })
const focusHeader = () => { 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() if (el) el.focus()
} }
const focusBreadcrumb = () => { const focusBreadcrumb = () => {
@@ -181,8 +207,13 @@ watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key if (editing.value) store.cursor = editing.value.key
if (store.cursor) { if (store.cursor) {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null const a = document.querySelector(
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) } `#file-${store.cursor}`
) as HTMLAnchorElement | null
if (a) {
a.focus()
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
} }
}) })
watchEffect(() => { watchEffect(() => {
@@ -288,12 +319,14 @@ const copyImage = async (doc: Doc) => {
if (blob.type !== 'image/png') { if (blob.type !== 'image/png') {
const img = new Image() const img = new Image()
img.src = URL.createObjectURL(blob) img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r) await new Promise(r => (img.onload = r))
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth canvas.width = img.naturalWidth
canvas.height = img.naturalHeight canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0) 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) URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]) await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else { } else {
@@ -324,12 +357,17 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key store.cursor = doc.key
const items = [ const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) }, { 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) }) if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push( 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 }) ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
} }
+9 -9
View File
@@ -31,24 +31,24 @@
</a> </a>
</template> </template>
<script setup lang=ts> <script setup lang="ts">
import { ref, computed } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { formatSize } from '@/utils'
import MediaPreview from '@/components/MediaPreview.vue' 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 CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue' import SparseIndicator from './SparseIndicator.vue'
const store = useMainStore() const store = useMainStore()
type EditingProp = { type EditingProp = {
rename: (name: string) => void; rename: (name: string) => void
exit: () => void; exit: () => void
} }
const props = defineProps<{ const props = defineProps<{
doc: Doc, doc: Doc
editing?: EditingProp, editing?: EditingProp
}>() }>()
const m = ref<typeof MediaPreview | null>(null) const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
+34 -13
View File
@@ -26,13 +26,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { resumeWatching } from '@/repositories/WS'
import router from '@/router'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth' import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia' import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS' import { ref } from 'vue'
import router from '@/router';
import DiskSpace from './DiskSpace.vue' import DiskSpace from './DiskSpace.vue'
const store = useMainStore() const store = useMainStore()
@@ -78,7 +78,7 @@ const updateSearch = (ev: Event) => {
pendingRouteUpdate = null pendingRouteUpdate = null
let p = loc let p = loc
p = p ? `/${p}` : '' p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/') const url = q ? `${p}//${q}` : p || '/'
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Use replace to avoid building up history for each keystroke // Use replace to avoid building up history for each keystroke
router.replace(u) router.replace(u)
@@ -96,45 +96,66 @@ const settingsMenu = (e: Event) => {
if (ssoStore.isExternalAuth && store.user.isLoggedIn) { if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
items.push({ items.push({
label: '👤 ' + (store.user.username || 'User Account'), label: '👤 ' + (store.user.username || 'User Account'),
onClick: () => { window.location.href = '/auth/' } onClick: () => {
window.location.href = '/auth/'
}
}) })
} }
// Only show password change for non-SSO users // Only show password change for non-SSO users
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) { 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) { 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) { 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) { if (store.user.isLoggedIn) {
items.push({ label: '🚪 Logout', onClick: () => store.logout() }) items.push({ label: '🚪 Logout', onClick: () => store.logout() })
} else if (store.server.public) { } else if (store.server.public) {
// Show login option only in public mode (non-public modes trigger auth automatically) // Show login option only in public mode (non-public modes trigger auth automatically)
items.push({ label: '🔐 Login', onClick: async () => { items.push({
label: '🔐 Login',
onClick: async () => {
try { try {
await showAuthIframe('/auth/restricted/#theme=light') await showAuthIframe('/auth/restricted/#theme=light')
resumeWatching() resumeWatching()
} catch (e) { } catch (e) {
console.log('Login cancelled') console.log('Login cancelled')
} }
}}) }
})
} }
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom, x: e.target.getBoundingClientRect().right,
items, y: e.target.getBoundingClientRect().bottom,
items
}) })
} }
defineExpose({ defineExpose({
toggleSearchInput, toggleSearchInput,
clearSearch, clearSearch
}) })
</script> </script>
+4 -4
View File
@@ -13,9 +13,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { nextTick, ref, watchEffect } from 'vue'
const overlay = ref<HTMLDivElement | null>(null) const overlay = ref<HTMLDivElement | null>(null)
const dialog = ref<HTMLDivElement | null>(null) const dialog = ref<HTMLDivElement | null>(null)
@@ -27,9 +27,9 @@ const close = () => {
} }
const props = defineProps<{ const props = defineProps<{
title: string, title: string
name: typeof store.dialog, name: typeof store.dialog
}>() }>()
const show = () => { const show = () => {
store.dialog = props.name store.dialog = props.name
+2 -3
View File
@@ -10,13 +10,12 @@
> >
</template> </template>
<script setup lang=ts> <script setup lang="ts">
import { useMainStore } from '@/stores/main'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main'
const props = defineProps<{ const props = defineProps<{
doc: Doc doc: Doc
}>() }>()
const store = useMainStore() const store = useMainStore()
</script> </script>
+10 -7
View File
@@ -30,11 +30,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { apiFetch } from '@/repositories/Client' 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 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) const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
@@ -50,7 +50,11 @@ const navigateTo = (path: string) => {
} }
const filesUrl = (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) => { const parseErrorMessage = async (res: Response) => {
try { try {
@@ -110,7 +114,7 @@ const selectionDisplay = computed<SelectionDisplay>(() => {
if (count === 1) { if (count === 1) {
displayName = truncateName(names[0]!) displayName = truncateName(names[0]!)
} else { } 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})` displayName = `${truncateName(folderName)} (${count})`
} }
return { return {
@@ -166,7 +170,6 @@ const op = async (opName: string, dst?: string) => {
} }
} }
} }
</script> </script>
<style> <style>
+2 -2
View File
@@ -44,10 +44,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref } from 'vue'
import { changePassword } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client' import type { ISimpleError } from '@/repositories/Client'
import { changePassword } from '@/repositories/User'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { reactive, ref } from 'vue'
const confirmLoading = ref<boolean>(false) const confirmLoading = ref<boolean>(false)
const store = useMainStore() const store = useMainStore()
+1 -1
View File
@@ -13,7 +13,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { icons, type IconName } from '@/assets/svg' import { type IconName, icons } from '@/assets/svg'
import { ref } from 'vue' import { ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
+5 -4
View File
@@ -19,7 +19,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue'
defineEmits(['cancel']) defineEmits(['cancel'])
@@ -38,7 +38,7 @@ 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(() => { const speed = computed(() => {
let s = props.status.statbytes / props.status.statdur / 1e3 let s = props.status.statbytes / props.status.statdur / 1e3
const tsince = (Date.now() - props.status.tlast) / 1e3 const tsince = (Date.now() - props.status.tlast) / 1e3
@@ -46,8 +46,9 @@ const speed = computed(() => {
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed" 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> </script>
<style scoped> <style scoped>
+64 -34
View File
@@ -8,10 +8,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { collator } from '@/utils'; import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import { collator } from '@/utils'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -62,7 +62,8 @@ function pasteHandler(event: ClipboardEvent) {
event.preventDefault() event.preventDefault()
uploadFiles(infiles) uploadFiles(infiles)
const base = props.path!.join('/') 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) => { const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
@@ -72,8 +73,8 @@ const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
for (const entry of entries) { for (const entry of entries) {
const cloudName = `${loc}/${entry.name}` const cloudName = `${loc}/${entry.name}`
if (entry.isFile) { if (entry.isFile) {
const file = await new Promise(resolve => entry.file(resolve)) as File const file = (await new Promise(resolve => entry.file(resolve))) as File
cloudfiles.push({file, cloudName, cloudPos: 0}) cloudfiles.push({ file, cloudName, cloudPos: 0 })
} else if (entry.isDirectory) { } else if (entry.isDirectory) {
await pasteDirectory(entry, cloudName) await pasteDirectory(entry, cloudName)
} }
@@ -84,7 +85,9 @@ function uploadHandler(event: Event) {
event.preventDefault() event.preventDefault()
// @ts-ignore // @ts-ignore
const input = event.target as HTMLInputElement | null 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 (input) input.value = ''
if (infiles.length) uploadFiles(infiles) if (infiles.length) uploadFiles(infiles)
} }
@@ -99,7 +102,7 @@ const uploadFiles = (infiles: File[]) => {
files.push({ files.push({
file, file,
cloudName: `${loc ? `${loc}/` : ''}${relPath}`, cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
cloudPos: 0, cloudPos: 0
}) })
} }
uploadCloudFiles(files) uploadCloudFiles(files)
@@ -131,13 +134,34 @@ const uploadCloudFiles = (files: CloudFile[]) => {
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/') const folderPath = parts.slice(0, i + 1).join('/')
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) { if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
store.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) added.add(folderPath)
} }
} }
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible) // Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
const existing = byPath.get(f.cloudName) const existing = byPath.get(f.cloudName)
if (!existing) 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 // @ts-ignore
upqueue = [...upqueue, ...files] upqueue = [...upqueue, ...files]
@@ -169,9 +193,9 @@ const uprogress_init = {
filename: '', filename: '',
filesize: 0, filesize: 0,
filepos: 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 // Track uploaded bytes for each file to handle out-of-order uploads
const uploadedBytes = new Map<string, Set<number>>() const uploadedBytes = new Map<string, Set<number>>()
const inflightBlocks = new Map<string, InflightBlock>() const inflightBlocks = new Map<string, InflightBlock>()
@@ -240,12 +264,12 @@ setInterval(() => {
store.uprogress.statbytes = 0 store.uprogress.statbytes = 0
store.uprogress.statdur = 1 store.uprogress.statdur = 1
} else { } else {
store.uprogress.statbytes *= .95 store.uprogress.statbytes *= 0.95
store.uprogress.statdur *= .95 store.uprogress.statdur *= 0.95
} }
}, 100) }, 100)
const statUpdate = ({name, size, start, end}: UploadRange) => { const statUpdate = ({ name, size, start, end }: UploadRange) => {
if (name !== store.uprogress.filename) return // If stats have been reset if (name !== store.uprogress.filename) return // If stats have been reset
// Track which bytes have been uploaded (using start to end range) // Track which bytes have been uploaded (using start to end range)
@@ -263,7 +287,10 @@ const statUpdate = ({name, size, start, end}: UploadRange) => {
const currentUpload = blockQueue[0] const currentUpload = blockQueue[0]
if (!currentUpload) return 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 // All blocks for this file have been uploaded
uploadedBytes.delete(name) // Clean up tracking uploadedBytes.delete(name) // Clean up tracking
store.uprogress.filestart += size store.uprogress.filestart += size
@@ -299,35 +326,35 @@ const MAX_PARALLEL_REQUESTS = 4
const RETRY_DELAY_MS = 400 const RETRY_DELAY_MS = 400
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB // 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 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 FINAL_BLOCKS_COUNT = 2
const fileSize = file.file.size const fileSize = file.file.size
const blocks: {start: number, end: number}[] = [] const blocks: { start: number; end: number }[] = []
if (fileSize >= MIN_SIZE_FOR_REORDER) { if (fileSize >= MIN_SIZE_FOR_REORDER) {
// File is large enough, prioritize final blocks // 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 // Add final blocks first
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) { 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) const end = Math.min(start + BLOCK_SIZE, fileSize)
blocks.push({start, end}) blocks.push({ start, end })
} }
// Add remaining blocks from beginning // Add remaining blocks from beginning
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) { for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart) const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
blocks.push({start, end}) blocks.push({ start, end })
} }
} else { } else {
// File is smaller, use sequential upload // File is smaller, use sequential upload
for (let start = 0; start < fileSize; start += BLOCK_SIZE) { for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
const end = Math.min(start + BLOCK_SIZE, fileSize) 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 = { type BlockUpload = {
file: CloudFile file: CloudFile
blocks: {start: number, end: number}[] blocks: { start: number; end: number }[]
nextIndex: number nextIndex: number
completed: number completed: number
runId: number runId: number
@@ -360,14 +387,17 @@ const uploadUrlForFile = (cloudName: string) => {
return `/files/${encoded}` 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 body = upload.file.file.slice(block.start, block.end)
const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}` const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}`
const fallbackReq = { const fallbackReq = {
name: upload.file.cloudName, name: upload.file.cloudName,
size: upload.file.file.size, size: upload.file.file.size,
start: block.start, start: block.start,
end: block.end, end: block.end
} }
let attempt = 0 let attempt = 0
@@ -379,9 +409,9 @@ const uploadBlock = async (upload: BlockUpload, block: {start: number, end: numb
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'Content-Range': range, 'Content-Range': range
}, },
body, body
}) })
if (!res.ok) { if (!res.ok) {
const message = await res.text().catch(() => '') 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), { inflightBlocks.set(inflightKey(name, block.start), {
name, name,
start: block.start, start: block.start,
end: block.end, 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 key = inflightKey(name, block.start)
const info = inflightBlocks.get(key) const info = inflightBlocks.get(key)
if (!info) return if (!info) return
@@ -433,9 +463,9 @@ const worker = async (runId: number) => {
while (runId === uploadRunId && upload.completed < upload.blocks.length) { while (runId === uploadRunId && upload.completed < upload.blocks.length) {
while ( while (
runId === uploadRunId runId === uploadRunId &&
&& upload.nextIndex < upload.blocks.length upload.nextIndex < upload.blocks.length &&
&& inflight.size < MAX_PARALLEL_REQUESTS inflight.size < MAX_PARALLEL_REQUESTS
) { ) {
const block = upload.blocks[upload.nextIndex++]! const block = upload.blocks[upload.nextIndex++]!
store.uprogress.status = 'uploading' store.uprogress.status = 'uploading'
+30 -10
View File
@@ -74,10 +74,18 @@
</template> </template>
<script lang="ts" setup> <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 type { ISimpleError } from '@/repositories/Client'
import {
createUser,
deleteUser,
getServerConfig,
listUsers,
updatePublic,
updateServerName,
updateUser
} from '@/repositories/User'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { onMounted, reactive, ref, watch } from 'vue'
interface User { interface User {
username: string username: string
@@ -92,7 +100,7 @@ const success = ref('')
const copyButtonText = ref('📋') const copyButtonText = ref('📋')
const serverSettings = reactive({ const serverSettings = reactive({
public: false, public: false,
name: '', name: ''
}) })
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -163,10 +171,13 @@ const renameUser = async (user: User) => {
} }
const resetPassword = 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 { try {
success.value = '' success.value = ''
const result = await updateUser(user.username, { password: "" }) const result = await updateUser(user.username, { password: '' })
if (result.password) { if (result.password) {
success.value = `Password reset for ${user.username}. New password: ${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 // Show "Copied!" indication on button
copyButtonText.value = '✅ Copied!' copyButtonText.value = '✅ Copied!'
// Hide password/key and button immediately after copying // 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 success.value = baseMessage
// Hide the entire message after 3 seconds // Hide the entire message after 3 seconds
setTimeout(() => { setTimeout(() => {
@@ -258,18 +272,24 @@ onMounted(() => {
}) })
// Load users and config when dialog opens // Load users and config when dialog opens
watch(() => store.dialog, (newVal) => { watch(
() => store.dialog,
newVal => {
if (newVal === 'usermgmt') { if (newVal === 'usermgmt') {
loadServerConfig() loadServerConfig()
if (!store.server.paskia) { if (!store.server.paskia) {
loadUsers() loadUsers()
} }
} }
}) }
)
watch(() => store.server.public, (newVal) => { watch(
() => store.server.public,
newVal => {
serverSettings.public = newVal || false serverSettings.public = newVal || false
}) }
)
</script> </script>
<style scoped> <style scoped>
+8 -5
View File
@@ -67,10 +67,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch, nextTick } from 'vue'
import { listTokens, createToken, deleteToken } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client' import type { ISimpleError } from '@/repositories/Client'
import { createToken, deleteToken, listTokens } from '@/repositories/User'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { nextTick, ref, watch } from 'vue'
interface Token { interface Token {
id: string id: string
@@ -145,7 +145,7 @@ const submitCreate = async () => {
if (result.url) { if (result.url) {
createdToken.value = { createdToken.value = {
...(result as CreatedToken), ...(result as CreatedToken),
url: ensureFilesBaseUrl((result as CreatedToken).url), url: ensureFilesBaseUrl((result as CreatedToken).url)
} }
mode.value = 'created' mode.value = 'created'
} }
@@ -188,12 +188,15 @@ const formatDate = (ts: number) => {
} }
// Load tokens when dialog opens // Load tokens when dialog opens
watch(() => store.dialog, (newVal) => { watch(
() => store.dialog,
newVal => {
if (newVal === 'tokens') { if (newVal === 'tokens') {
resetCreate() resetCreate()
loadTokens() loadTokens()
} }
}) }
)
</script> </script>
<style scoped> <style scoped>
+1 -1
View File
@@ -1,7 +1,7 @@
import './assets/main.css' import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
+1 -1
View File
@@ -1,4 +1,4 @@
import { apiJson, apiFetch, AuthCancelledError } from 'paskia' import { AuthCancelledError, apiFetch, apiJson } from 'paskia'
// Type for API error responses // Type for API error responses
interface ApiError { interface ApiError {
+28 -10
View File
@@ -1,4 +1,4 @@
import { formatSize, formatUnixDate } from "@/utils" import { formatSize, formatUnixDate } from '@/utils'
export type FUID = string export type FUID = string
@@ -15,8 +15,8 @@ export type DocProps = {
} }
export class Doc { export class Doc {
public loc: string = "" public loc: string = ''
public key: FUID = "" public key: FUID = ''
public size: number = 0 public size: number = 0
public allocated: number = 0 public allocated: number = 0
public mtime: number = 0 public mtime: number = 0
@@ -24,19 +24,23 @@ export class Doc {
public ghost: 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 */ /** @internal Use the name getter/setter instead */
public _name: string = "" public _name: string = ''
constructor(props: Partial<DocProps> = {}) { constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props const { name, ...rest } = props
Object.assign(this, rest) 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) { set name(name: string) {
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`) if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
this._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 */ /** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
get sparseIndicator(): string { get sparseIndicator(): string {
if (this.dir || this.size <= this.allocated) return '' if (this.dir || this.size <= this.allocated) return ''
@@ -46,10 +50,14 @@ export class Doc {
const rounded = Math.round(ratio * 4) // 0,1,2,3,4 const rounded = Math.round(ratio * 4) // 0,1,2,3,4
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above 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 { get url(): string {
const p = this.loc ? `${this.loc}/${this.name}` : this.name 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 { get urlrouter(): string {
return this.url.replace(/^\/#/, '') return this.url.replace(/^\/#/, '')
@@ -57,7 +65,17 @@ export class Doc {
get img(): boolean { get img(): boolean {
// Folders cannot be images // Folders cannot be images
if (this.dir) return false 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 { get complete(): boolean {
return !this.ghost && (this.dir || this.size <= this.allocated) return !this.ghost && (this.dir || this.size <= this.allocated)
@@ -96,7 +114,7 @@ export type FileEntry = [
number, // mtime number, // mtime
number, // size number, // size
number, // allocated (actual disk usage) number, // allocated (actual disk usage)
number, // isfile number // isfile
] ]
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>] export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
+15 -4
View File
@@ -16,7 +16,11 @@ export async function logoutUser() {
return data 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, { const data = await Client.post(url_password, {
username, username,
passwordChange, passwordChange,
@@ -32,7 +36,11 @@ export async function listUsers() {
return data 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, { const data = await Client.post(url_users, {
username, username,
password, password,
@@ -41,7 +49,10 @@ export async function createUser(username: string, password?: string, privileged
return data 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) const data = await Client.put(`${url_users}/${username}`, changes)
return data return data
} }
@@ -63,7 +74,7 @@ export async function updateServerName(name: string) {
export async function getServerConfig() { export async function getServerConfig() {
const data = await Client.get('/api/config') 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' export const url_tokens = '/api/tokens'
+20 -16
View File
@@ -1,6 +1,6 @@
import { useMainStore } from "@/stores/main" import { useMainStore } from '@/stores/main'
import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia' import { AuthCancelledError, isAuthIframeOpen, showAuthIframe } from 'paskia'
import type { FileEntry, UpdateEntry, errorEvent } from "./Document" import type { FileEntry, UpdateEntry, errorEvent } from './Document'
export const watchUrl = '/api/watch' export const watchUrl = '/api/watch'
@@ -25,18 +25,22 @@ export const loadSession = () => {
console.log(`Loaded session with ${tree.length} items cached`) console.log(`Loaded session with ${tree.length} items cached`)
return true return true
} catch (error) { } catch (error) {
console.log("Loading session failed", error) console.log('Loading session failed', error)
return false return false
} }
} }
const saveSession = () => { 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'))) 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 return webSocket
} }
@@ -89,9 +93,9 @@ export const watchConnect = () => {
wsWatch = connect(watchUrl, { wsWatch = connect(watchUrl, {
message: handleWatchMessage, message: handleWatchMessage,
close: watchReconnect, close: watchReconnect
}) })
wsWatch.addEventListener("message", event => { wsWatch.addEventListener('message', event => {
if (store.connected) return if (store.connected) return
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if ('error' in msg) { if ('error' in msg) {
@@ -103,7 +107,7 @@ export const watchConnect = () => {
} }
return return
} }
if ("server" in msg) { if ('server' in msg) {
console.log('Connected to backend', msg) console.log('Connected to backend', msg)
store.server = msg.server store.server = msg.server
store.connected = true store.connected = true
@@ -141,7 +145,7 @@ const watchReconnect = (event: MessageEvent) => {
return return
} }
if (store.connected) { if (store.connected) {
console.warn("Disconnected from server", event) console.warn('Disconnected from server', event)
store.connected = false store.connected = false
store.error = 'Reconnecting...' store.error = 'Reconnecting...'
} }
@@ -151,7 +155,6 @@ const watchReconnect = (event: MessageEvent) => {
watchTimeout = setTimeout(watchConnect, reconnDelay) watchTimeout = setTimeout(watchConnect, reconnDelay)
} }
const handleWatchMessage = (event: MessageEvent) => { const handleWatchMessage = (event: MessageEvent) => {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
switch (true) { switch (true) {
@@ -192,13 +195,14 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
if (action === 'k') { if (action === 'k') {
newtree.push(...tree.slice(oidx, oidx + arg)) newtree.push(...tree.slice(oidx, oidx + arg))
oidx += arg oidx += arg
} } else if (action === 'd') oidx += arg
else if (action === 'd') oidx += arg
else if (action === 'i') newtree.push(...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) 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) store.updateRoot(newtree)
tree = newtree tree = newtree
saveSession() saveSession()
+1 -1
View File
@@ -1,5 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import ExplorerView from '@/views/ExplorerView.vue' import ExplorerView from '@/views/ExplorerView.vue'
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
+40 -28
View File
@@ -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 { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia' import { resumeWatching, watchConnect } from '@/repositories/WS'
import { collator } from '@/utils' import { collator } from '@/utils'
import { watchConnect, resumeWatching } from '@/repositories/WS' import { type SortOrder, sorted } from '@/utils/docsort'
import { sorted, type SortOrder } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker' import SearchWorker from '@/workers/searchWorker?worker'
import { getDocuments, setDocuments, documentRef } from './documentStore' import { type StateTree, defineStore } from 'pinia'
import { documentRef, getDocuments, setDocuments } from './documentStore'
// Singleton search worker instance // Singleton search worker instance
let searchWorker: Worker | null = null let searchWorker: Worker | null = null
@@ -19,7 +19,7 @@ function getSearchWorker(): Worker {
if (!searchWorker) { if (!searchWorker) {
searchWorker = new SearchWorker() searchWorker = new SearchWorker()
// Set up message handler once // Set up message handler once
searchWorker.onmessage = (e) => { searchWorker.onmessage = e => {
if (!searchStore || e.data.id !== searchId) return // Stale result if (!searchStore || e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances // Convert plain data back to Doc instances
@@ -79,7 +79,7 @@ export const useMainStore = defineStore('main', {
connected: false, connected: false,
authInProgress: false, authInProgress: false,
cursor: '' as string, 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', dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
uprogress: {} as any, uprogress: {} as any,
dprogress: {} as any, dprogress: {} as any,
@@ -87,19 +87,19 @@ export const useMainStore = defineStore('main', {
gallery: false, gallery: false,
sortListing: '' as SortOrder, sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder, sortFiltered: '' as SortOrder,
searchHotkey: '/', // Character shown for search hotkey (Slash key) searchHotkey: '/' // Character shown for search hotkey (Slash key)
}, },
user: { user: {
username: '' as string, username: '' as string,
privileged: false as boolean, privileged: false as boolean,
isLoggedIn: false as boolean, isLoggedIn: false as boolean
}, },
space: { space: {
disk: 0, disk: 0,
free: 0, free: 0,
used: 0, used: 0,
storage: 0, storage: 0,
allocated: 0, allocated: 0
} }
}), }),
persist: { persist: {
@@ -114,7 +114,7 @@ export const useMainStore = defineStore('main', {
tree.selected = Array.from(tree.selected) tree.selected = Array.from(tree.selected)
return JSON.stringify(tree) return JSON.stringify(tree)
} }
}, }
}, },
actions: { actions: {
updateRoot(root: FileEntry[]) { updateRoot(root: FileEntry[]) {
@@ -122,22 +122,26 @@ export const useMainStore = defineStore('main', {
let loc = [] as string[] let loc = [] as string[]
for (const [level, name, key, mtime, size, allocated, isfile] of root) { for (const [level, name, key, mtime, size, allocated, isfile] of root) {
loc = loc.slice(0, level - 1) loc = loc.slice(0, level - 1)
docs.push(new Doc({ docs.push(
new Doc({
name, name,
loc: level ? loc.join('/') : '/', loc: level ? loc.join('/') : '/',
key, key,
size, size,
allocated, allocated,
mtime, mtime,
dir: !isfile, dir: !isfile
})) })
)
loc.push(name) loc.push(name)
} }
// Store in non-reactive external storage // Store in non-reactive external storage
setDocuments(docs) setDocuments(docs)
// Clear ghosts that now exist in the real list // Clear ghosts that now exist in the real list
const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.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)) 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) // Clear hidden paths that no longer exist (deletion confirmed)
for (const path of this.hiddenPaths.keys()) { for (const path of this.hiddenPaths.keys()) {
if (!realPaths.has(path)) this.hiddenPaths.delete(path) if (!realPaths.has(path)) this.hiddenPaths.delete(path)
@@ -224,7 +228,7 @@ export const useMainStore = defineStore('main', {
size: doc.size, size: doc.size,
allocated: doc.allocated, allocated: doc.allocated,
mtime: doc.mtime, mtime: doc.mtime,
dir: doc.dir, dir: doc.dir
})) }))
worker.postMessage({ type: 'update', documents: docData }) worker.postMessage({ type: 'update', documents: docData })
}, },
@@ -264,7 +268,8 @@ export const useMainStore = defineStore('main', {
// Delay showing loading indicator to avoid flicker on fast searches // Delay showing loading indicator to avoid flicker on fast searches
loadingTimer = setTimeout(() => { loadingTimer = setTimeout(() => {
if (searchId === id) { // Still the current search if (searchId === id) {
// Still the current search
this.searchLoading = true this.searchLoading = true
} }
loadingTimer = null loadingTimer = null
@@ -296,7 +301,7 @@ export const useMainStore = defineStore('main', {
this.cursor = '' this.cursor = ''
}, },
async logout() { async logout() {
console.log("Logout") console.log('Logout')
try { try {
const res = await fetch('/auth/api/logout', { method: 'POST' }) const res = await fetch('/auth/api/logout', { method: 'POST' })
if (!res.ok) { if (!res.ok) {
@@ -326,25 +331,29 @@ export const useMainStore = defineStore('main', {
showSortToast(order: SortOrder | '') { showSortToast(order: SortOrder | '') {
const labels: Record<string, string> = { const labels: Record<string, string> = {
'': 'Folders first', '': 'Folders first',
'name': 'Alphabetical order', name: 'Alphabetical order',
'modified': 'Newest first', modified: 'Newest first',
'size': 'Largest first', size: 'Largest first'
} }
this.showToast(labels[order] || order, 1200) this.showToast(labels[order] || order, 1200)
}, },
focusBreadcrumb() { focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus() ;(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
}, },
cancelDownloads() { cancelDownloads() {
location.reload() // FIXME location.reload() // FIXME
}, },
cancelUploads() { cancelUploads() {
location.reload() // FIXME location.reload() // FIXME
}, }
}, },
getters: { getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, sortOrder(): SortOrder {
isUserLogged(): boolean { return this.user.isLoggedIn }, return this.query ? this.prefs.sortFiltered : this.prefs.sortListing
},
isUserLogged(): boolean {
return this.user.isLoggedIn
},
/** Get documents count (triggers on docVersion change) */ /** Get documents count (triggers on docVersion change) */
documentCount(): number { documentCount(): number {
// Access docVersion to make this reactive // Access docVersion to make this reactive
@@ -366,7 +375,7 @@ export const useMainStore = defineStore('main', {
missing: new Set(), missing: new Set(),
docs: {}, docs: {},
keys: [], keys: [],
recursive: [], recursive: []
} }
for (const doc of docs) { for (const doc of docs) {
if (selected.has(doc.key)) { if (selected.has(doc.key)) {
@@ -384,7 +393,10 @@ export const useMainStore = defineStore('main', {
const nremove = base.loc.length const nremove = base.loc.length
ret.recursive.push([base.name, basepath, base]) ret.recursive.push([base.name, basepath, base])
for (const doc of docs) { for (const doc of docs) {
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') { if (
doc.loc === basepath ||
(doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/')
) {
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
const rel = full.slice(nremove) const rel = full.slice(nremove)
ret.recursive.push([rel, full, doc]) ret.recursive.push([rel, full, doc])
+1 -1
View File
@@ -1,7 +1,7 @@
import { clearTree } from '@/repositories/WS'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed } from 'vue' import { computed } from 'vue'
import { useMainStore } from './main' import { useMainStore } from './main'
import { clearTree } from '@/repositories/WS'
export const useSsoAuthStore = defineStore('ssoAuth', () => { export const useSsoAuthStore = defineStore('ssoAuth', () => {
const isExternalAuth = computed(() => { const isExternalAuth = computed(() => {
+4 -3
View File
@@ -1,13 +1,14 @@
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore' import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
export const exists = (path: string[]) => { export const exists = (path: string[]) => {
const store = useMainStore() const store = useMainStore()
// Access docVersion to make this reactive // Access docVersion to make this reactive
void store.docVersion void store.docVersion
const p = path.join('/') 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.) */ /** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
+45 -16
View File
@@ -26,23 +26,36 @@ export function formatUnixDate(t: number) {
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (adiff <= 5000) return 'now' if (adiff <= 5000) return 'now'
if (adiff <= 60000) { 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) { 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) { 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) { 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', { let d = date
.toLocaleDateString('en-ie', {
weekday: 'short', weekday: 'short',
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
}).replace("Sept", "Sep") })
.replace('Sept', 'Sep')
if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space) 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.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 d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
@@ -63,34 +76,50 @@ interface FileTypes {
const filetypes: FileTypes = { const filetypes: FileTypes = {
video: ['avi', 'mkv', 'mov', 'mp4', 'webm'], video: ['avi', 'mkv', 'mov', 'mp4', 'webm'],
image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'], image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
pdf: ['pdf'], pdf: ['pdf']
} }
export function getFileType(name: string): string { export function getFileType(name: string): string {
const dotIndex = name.lastIndexOf('.') const dotIndex = name.lastIndexOf('.')
if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown' if (dotIndex === -1 || dotIndex === name.length - 1) return 'unknown'
const ext = name.slice(dotIndex + 1).toLowerCase() 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 // 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 // Preformat document names for faster search
export function haystackFormat(str: string) { 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 + '$' return '^' + based + '$'
} }
// Preformat search string for faster search // Preformat search string for faster search
export function needleFormat(query: string) { export function needleFormat(query: string) {
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() const based = query
return {based, words: based.split(/\s+/)} .normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
return { based, words: based.split(/\s+/) }
} }
// Test if haystack includes needle // Test if haystack includes needle
export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) { export function localeIncludes(
const {based, words} = filter haystack: string,
return haystack.includes(based) || words && words.every(word => haystack.includes(word)) filter: { based: string; words: string[] }
) {
const { based, words } = filter
return (
haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
)
} }
+19 -8
View File
@@ -18,12 +18,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue' import FileExplorer from '@/components/FileExplorer.vue'
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore' import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main'
import { collator } from '@/utils' import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import { computed, ref, watch, watchEffect } from 'vue'
const store = useMainStore() const store = useMainStore()
const fileExplorer = ref() const fileExplorer = ref()
@@ -55,9 +55,14 @@ const documents = computed(() => {
// Access docVersion to make this reactive to document changes // Access docVersion to make this reactive to document changes
void store.docVersion void store.docVersion
const hidden = store.hiddenPaths 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) // 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 // Merge: ghosts that don't conflict with real docs
const realNames = new Set(docs.map(d => d.name)) const realNames = new Set(docs.map(d => d.name))
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.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) // Search results from worker (also filter hidden)
const hidden = store.hiddenPaths 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 // Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered 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 // 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]) => { watch(
[() => props.path.join('/'), () => store.documentCount],
([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero) // React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable) store.prefs.gallery = documents.value.some(d => d.previewable)
}, { immediate: true }) },
{ immediate: true }
)
</script> </script>
<style scoped> <style scoped>
+33 -7
View File
@@ -53,11 +53,21 @@ const RESULT_LIMIT = 100
// Normalize string for search (remove diacritics, lowercase) // Normalize string for search (remove diacritics, lowercase)
// Haystack adds ^ and $ markers to allow matching start/end of name // Haystack adds ^ and $ markers to allow matching start/end of name
function normalizeHaystack(str: string): string { 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 { 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 // Test if document matches search query
@@ -143,7 +153,11 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
// Slow path: scan all documents // Slow path: scan all documents
const batchSize = 500 const batchSize = 500
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) { for (
let i = 0;
i < recentDocuments.length && results.length < RESULT_LIMIT;
i += batchSize
) {
if (currentSearchId !== searchId) return // Superseded if (currentSearchId !== searchId) return // Superseded
// Process batch // Process batch
@@ -175,7 +189,13 @@ async function performSearch(rawQuery: string, loc: string, searchId: number) {
} }
// Post results to main thread // 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) const sorted = sortResults(docs, query, loc)
postMessage({ postMessage({
type: 'results', type: 'results',
@@ -188,7 +208,8 @@ function postResults(docs: WorkerDoc[], query: string, loc: string, id: number,
// Sort results by relevance // Sort results by relevance
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] { function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
const locsub = loc + '/' const locsub = loc + '/'
return [...docs].sort((a, b) => ( return [...docs].sort(
(a, b) =>
// Current folder first // Current folder first
Number(b.loc === loc) - Number(a.loc === loc) || Number(b.loc === loc) - Number(a.loc === loc) ||
// Then subfolders // Then subfolders
@@ -201,7 +222,7 @@ function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[]
Number(b.name.includes(query)) - Number(a.name.includes(query)) || Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
// Finally by name // Finally by name
collator.compare(a.name, b.name) collator.compare(a.name, b.name)
)) )
} }
// Handle incoming messages // Handle incoming messages
@@ -220,7 +241,12 @@ self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
await performSearch(msg.query, msg.loc, msg.id) await performSearch(msg.query, msg.loc, msg.id)
} else { } else {
// Empty query - no results needed // 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)
} }
} }
} }
+8 -8
View File
@@ -9,27 +9,27 @@
* FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying * 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 // Build proxy configuration for each path
const proxy = {} const proxy = {}
for (const path of paths) { for (const path of paths) {
proxy[path] = { proxy[path] = {
target: backendUrl, target: backendUrl,
changeOrigin: false, changeOrigin: false,
ws: true, ws: true
} }
} }
return { return {
name: "fastapi-vite", name: 'fastapi-vite',
config: () => ({ config: () => ({
server: { proxy }, server: { proxy },
build: { build: {
outDir: "../cista/frontend-build", outDir: '../cista/frontend-build',
emptyOutDir: true, emptyOutDir: true
}, }
}), })
} }
} }
+13 -15
View File
@@ -1,29 +1,29 @@
import { fileURLToPath, URL } from 'node:url' import { URL, fileURLToPath } from 'node:url'
import fastapiVue from './vite-plugin-fastapi.js' import fastapiVue from './vite-plugin-fastapi.js'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import Components from 'unplugin-vue-components/vite'
// @ts-ignore // @ts-ignore
import svgLoader from 'vite-svg-loader' import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
// Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env) // Note: fastapiVue() handles proxy and build output (uses FASTAPI_VUE_BACKEND_URL env)
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
fastapiVue({ paths: ["/api", "/auth", "/files", "/zip", "/preview"] }), fastapiVue({ paths: ['/api', '/auth', '/files', '/zip', '/preview'] }),
vue(), vue(),
svgLoader(), // import svg files svgLoader(), // import svg files
Components(), // auto import components Components() // auto import components
], ],
css: { css: {
preprocessorOptions: { preprocessorOptions: {
less: { less: {
modifyVars: {}, modifyVars: {},
javascriptEnabled: true, javascriptEnabled: true
}, }
}, }
}, },
resolve: { resolve: {
alias: { alias: {
@@ -35,11 +35,9 @@ export default defineConfig({
output: { output: {
manualChunks: { manualChunks: {
// Bundle all SVG icons into a single chunk // Bundle all SVG icons into a single chunk
icons: [ icons: ['/src/assets/svg/index.ts']
'/src/assets/svg/index.ts', }
], }
}, }
}, }
},
},
}) })
+51 -1
View File
@@ -25,6 +25,7 @@ classifiers = [
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"argon2-cffi>=25.1.0", "argon2-cffi>=25.1.0",
"aspose-words>=26.4.0",
"av>=15.0.0", "av>=15.0.0",
"blake3>=1.0.5", "blake3>=1.0.5",
"docopt-ng>=0.9.0", "docopt-ng>=0.9.0",
@@ -115,8 +116,57 @@ filterwarnings = [
"ignore::DeprecationWarning", "ignore::DeprecationWarning",
] ]
[tool.ruff]
target-version = "py311"
[tool.ruff.lint] [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"] isort.known-first-party = ["cista"]
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"] per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
per-file-ignores."scripts/*" = ["T20"] per-file-ignores."scripts/*" = ["T20"]