Compare commits
	
		
			10 Commits
		
	
	
		
			dd37238510
			...
			v0.4.0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 938c5ca657 | ||
|   | e0aef07783 | ||
|   | 36826a83c1 | ||
|   | 6880f82c19 | ||
|   | 5dd1bd9bdc | ||
|   | 41e8c78ecd | ||
|   | dc4bb494f3 | ||
|   | 9b58b887b4 | ||
|   | 07848907f3 | ||
|   | 7a08f7cbe2 | 
							
								
								
									
										67
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,19 +1,23 @@ | ||||
| # Web File Storage | ||||
|  | ||||
| Run directly from repository with Hatch (or use pip install as usual): | ||||
|  | ||||
| ```sh | ||||
| hatch run cista -l :3000 /path/to/files | ||||
| ``` | ||||
|  | ||||
| Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script). | ||||
| The Python package installs a `cista` executable. Use `hatch shell` to initiate and install in a virtual environment, or `pip install` it on your system. Alternatively `hatch run cista` may be used to skip the shell step but stay virtual. `pip install hatch` first if needed. | ||||
|  | ||||
| Create your user account: | ||||
|  | ||||
| ```sh | ||||
| hatch run cista --user admin --privileged | ||||
| cista --user admin --privileged | ||||
| ``` | ||||
|  | ||||
| ## Running the server | ||||
|  | ||||
| Serve your files on localhost:8000: | ||||
|  | ||||
| ```sh | ||||
| cista -l :8000 /path/to/files | ||||
| ``` | ||||
|  | ||||
| The Git repository does not contain a frontend build, so you should first do that... | ||||
|  | ||||
| ## Build frontend | ||||
|  | ||||
| Frontend needs to be built before using and after any frontend changes: | ||||
| @@ -25,3 +29,50 @@ npm run build | ||||
| ``` | ||||
|  | ||||
| This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`. | ||||
|  | ||||
| ## Development setup | ||||
|  | ||||
| For rapid turnaround during development, you should run `npm run dev` Vite development server on the Vue frontend. While that is running, start the backend on another terminal `hatch run cista --dev -l :8000` and connect to the frontend. | ||||
|  | ||||
| The backend and the frontend will each reload automatically at any code or config changes. | ||||
|  | ||||
| ## System deployment | ||||
|  | ||||
| Clone the repository to `/srv/cista/cista-storage` or other suitable location accessible to the storage user account you plan to use. `sudo -u storage -s` and build the frontend if you hadn't already. | ||||
|  | ||||
| Create **/etc/systemd/system/cista@.service**: | ||||
|  | ||||
| ```ini | ||||
| [Unit] | ||||
| Description=Cista storage %i | ||||
|  | ||||
| [Service] | ||||
| User=storage | ||||
| WorkingDirectory=/srv/cista/cista-storage | ||||
| ExecStart=hatch run cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/ | ||||
| TimeoutStopSec=2 | ||||
| Restart=always | ||||
|  | ||||
| [Install] | ||||
| WantedBy=multi-user.target | ||||
| ``` | ||||
|  | ||||
| This assumes you may want to run multiple separate storages, each having their files under `/media/storage/<domain>` and configuration under `/srv/cista/<domain>/`. Instead of numeric ports, we use UNIX sockets for convenience. | ||||
|  | ||||
| ```sh | ||||
| systemctl daemon-reload | ||||
| systemctl enable --now cista@foo.example.com | ||||
| systemctl enable --now cista@bar.example.com | ||||
| ``` | ||||
|  | ||||
| Exposing this publicly online is the most convenient using the [Caddy](https://caddyserver.com/) web server but you can of course use Nginx or others as well. Or even run the server with `-l domain.example.com` given TLS certificates in the config folder. | ||||
|  | ||||
| **/etc/caddy/Caddyfile**: | ||||
|  | ||||
| ```Caddyfile | ||||
| foo.example.com, bar.example.com { | ||||
|     reverse_proxy unix//srv/cista/{host}/socket | ||||
| } | ||||
| ``` | ||||
|  | ||||
| Using the `{host}` placeholder we can just put all the domains on the same block. That's the full server configuration you need. `systemctl enable --now caddy` or `systemctl restart caddy` for the config to take effect. | ||||
|   | ||||
| @@ -17,7 +17,7 @@ import type { ComputedRef } from 'vue' | ||||
| import type HeaderMain from '@/components/HeaderMain.vue' | ||||
| import { onMounted, onUnmounted, ref, watchEffect } from 'vue' | ||||
| import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { useMainStore } from '@/stores/main' | ||||
|  | ||||
| import { computed } from 'vue' | ||||
| import Router from '@/router/index' | ||||
| @@ -27,7 +27,7 @@ interface Path { | ||||
|   pathList: string[] | ||||
|   query: string | ||||
| } | ||||
| const documentStore = useDocumentStore() | ||||
| const store = useMainStore() | ||||
| const path: ComputedRef<Path> = computed(() => { | ||||
|   const p = decodeURIComponent(Router.currentRoute.value.path).split('//') | ||||
|   const pathList = p[0].split('/').filter(value => value !== '') | ||||
| @@ -39,7 +39,7 @@ const path: ComputedRef<Path> = computed(() => { | ||||
|   } | ||||
| }) | ||||
| watchEffect(() => { | ||||
|   document.title = path.value.path.replace(/\/$/, '').split('/').pop() || documentStore.server.name || 'Cista Storage' | ||||
|   document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage' | ||||
| }) | ||||
| onMounted(loadSession) | ||||
| onMounted(watchConnect) | ||||
| @@ -48,7 +48,7 @@ const headerMain = ref<typeof HeaderMain | null>(null) | ||||
| let vert = 0 | ||||
| let timer: any = null | ||||
| const globalShortcutHandler = (event: KeyboardEvent) => { | ||||
|   const fileExplorer = documentStore.fileExplorer as any | ||||
|   const fileExplorer = store.fileExplorer as any | ||||
|   if (!fileExplorer) return | ||||
|   const c = fileExplorer.isCursor() | ||||
|   const keyup = event.type === 'keyup' | ||||
| @@ -124,3 +124,4 @@ onUnmounted(() => { | ||||
| }) | ||||
| export type { Path } | ||||
| </script> | ||||
| @/stores/main | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
|     aria-label="Breadcrumb" | ||||
|     @keyup.left.stop="move(-1)" | ||||
|     @keyup.right.stop="move(1)" | ||||
|     @focus="move(0)" | ||||
|     @keyup.enter="move(0)" | ||||
|   > | ||||
|     <a href="#/" | ||||
|       :ref="el => setLinkRef(0, el)" | ||||
| @@ -48,9 +48,11 @@ const navigate = (index: number) => { | ||||
|   if (!link) throw Error(`No link at index ${index} (path: ${props.path})`) | ||||
|   const url = `/${longest.value.slice(0, index).join('/')}/` | ||||
|   const here = `/${longest.value.join('/')}/` | ||||
|   const current = decodeURIComponent(location.hash.slice(1).split('//')[0]) | ||||
|   const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') | ||||
|   if (here.startsWith(current)) router.replace(u) | ||||
|   else router.push(u) | ||||
|   link.focus() | ||||
|   if (here.startsWith(location.hash.slice(1))) router.replace(url) | ||||
|   else router.push(url) | ||||
| } | ||||
|  | ||||
| const move = (dir: number) => { | ||||
|   | ||||
| @@ -5,9 +5,9 @@ | ||||
|         <th class="selection"> | ||||
|           <input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate"> | ||||
|         </th> | ||||
|         <th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th> | ||||
|         <th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th> | ||||
|         <th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th> | ||||
|         <th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th> | ||||
|         <th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th> | ||||
|         <th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th> | ||||
|         <th class="menu"></th> | ||||
|       </tr> | ||||
|     </thead> | ||||
| @@ -17,11 +17,11 @@ | ||||
|         <td class="name"> | ||||
|           <FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" /> | ||||
|         </td> | ||||
|         <FileModified :doc=editing /> | ||||
|         <FileModified :doc=editing :key=nowkey /> | ||||
|         <FileSize :doc=editing /> | ||||
|         <td class="menu"></td> | ||||
|       </tr> | ||||
|       <template v-for="(doc, index) in sortedDocuments" :key="doc.key"> | ||||
|       <template v-for="(doc, index) in documents" :key="doc.key"> | ||||
|         <tr class="folder-change" v-if="showFolderBreadcrumb(index)"> | ||||
|           <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th> | ||||
|         </tr> | ||||
| @@ -36,11 +36,11 @@ | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               tabindex="-1" | ||||
|               :checked="documentStore.selected.has(doc.key)" | ||||
|               :checked="store.selected.has(doc.key)" | ||||
|               @change=" | ||||
|                 ($event.target as HTMLInputElement).checked | ||||
|                   ? documentStore.selected.add(doc.key) | ||||
|                   : documentStore.selected.delete(doc.key) | ||||
|                   ? store.selected.add(doc.key) | ||||
|                   : store.selected.delete(doc.key) | ||||
|               " | ||||
|             /> | ||||
|           </td> | ||||
| @@ -50,7 +50,7 @@ | ||||
|             </template> | ||||
|             <template v-else> | ||||
|               <a | ||||
|                 :href="url_for(doc)" | ||||
|                 :href="doc.url" | ||||
|                 tabindex="-1" | ||||
|                 @contextmenu.prevent | ||||
|                 @focus.stop="cursor = doc" | ||||
| @@ -61,7 +61,7 @@ | ||||
|               <button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊️</button> | ||||
|             </template> | ||||
|           </td> | ||||
|           <FileModified :doc=doc /> | ||||
|           <FileModified :doc=doc :key=nowkey /> | ||||
|           <FileSize :doc=doc /> | ||||
|           <td class="menu"> | ||||
|             <button tabindex="-1" @click.stop="contextMenu($event, doc)">⋮</button> | ||||
| @@ -79,28 +79,26 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, computed, watchEffect, onMounted, onUnmounted } from 'vue' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import type { Document } from '@/repositories/Document' | ||||
| import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' | ||||
| import { useMainStore } from '@/stores/main' | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import FileRenameInput from './FileRenameInput.vue' | ||||
| import { connect, controlUrl } from '@/repositories/WS' | ||||
| import { collator, formatSize, formatUnixDate } from '@/utils' | ||||
| import { formatSize } from '@/utils' | ||||
| import { useRouter } from 'vue-router' | ||||
| import ContextMenu from '@imengyu/vue3-context-menu' | ||||
| import type { SortOrder } from '@/utils/docsort' | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   path: Array<string> | ||||
|   documents: Document[] | ||||
|   documents: Doc[] | ||||
| }>() | ||||
| const documentStore = useDocumentStore() | ||||
| const store = useMainStore() | ||||
| const router = useRouter() | ||||
| const url_for = (doc: Document) => { | ||||
|   const p = doc.loc ? `${doc.loc}/${doc.name}` : doc.name | ||||
|   return doc.dir ? `#/${p}/` : `/files/${p}` | ||||
| } | ||||
| const cursor = ref<Document | null>(null) | ||||
| const cursor = shallowRef<Doc | null>(null) | ||||
| // File rename | ||||
| const editing = ref<Document | null>(null) | ||||
| const rename = (doc: Document, newName: string) => { | ||||
| const editing = shallowRef<Doc | null>(null) | ||||
| const rename = (doc: Doc, newName: string) => { | ||||
|   const oldName = doc.name | ||||
|   const control = connect(controlUrl, { | ||||
|     message(ev: MessageEvent) { | ||||
| @@ -124,35 +122,25 @@ const rename = (doc: Document, newName: string) => { | ||||
|   } | ||||
|   doc.name = newName // We should get an update from watch but this is quicker | ||||
| } | ||||
| const sortedDocuments = computed(() => sorted(props.documents as Document[])) | ||||
| const showFolderBreadcrumb = (i: number) => { | ||||
|   const docs = sortedDocuments.value | ||||
|   const docloc = docs[i].loc | ||||
|   return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc | ||||
| } | ||||
| defineExpose({ | ||||
|   newFolder() { | ||||
|     const now = Date.now() / 1000 | ||||
|     editing.value = { | ||||
|     const now = Math.floor(Date.now() / 1000) | ||||
|     editing.value = new Doc({ | ||||
|       loc: loc.value, | ||||
|       key: 'new', | ||||
|       name: 'New Folder', | ||||
|       dir: true, | ||||
|       mtime: now, | ||||
|       size: 0, | ||||
|       sizedisp: formatSize(0), | ||||
|       modified: formatUnixDate(now), | ||||
|       haystack: '', | ||||
|     } | ||||
|     console.log("New") | ||||
|     }) | ||||
|   }, | ||||
|   toggleSelectAll() { | ||||
|     console.log('Select') | ||||
|     allSelected.value = !allSelected.value | ||||
|   }, | ||||
|   toggleSortColumn(column: number) { | ||||
|     const columns = ['', 'name', 'modified', 'size', ''] | ||||
|     toggleSort(columns[column]) | ||||
|     const order = ['', 'name', 'modified', 'size', ''][column] | ||||
|     if (order) store.toggleSort(order as SortOrder) | ||||
|   }, | ||||
|   isCursor() { | ||||
|     return cursor.value !== null && editing.value === null | ||||
| @@ -163,36 +151,36 @@ defineExpose({ | ||||
|   cursorSelect() { | ||||
|     const doc = cursor.value | ||||
|     if (!doc) return | ||||
|     if (documentStore.selected.has(doc.key)) { | ||||
|       documentStore.selected.delete(doc.key) | ||||
|     if (store.selected.has(doc.key)) { | ||||
|       store.selected.delete(doc.key) | ||||
|     } else { | ||||
|       documentStore.selected.add(doc.key) | ||||
|       store.selected.add(doc.key) | ||||
|     } | ||||
|     this.cursorMove(1) | ||||
|   }, | ||||
|   cursorMove(d: number, select = false) { | ||||
|     // Move cursor up or down (keyboard navigation) | ||||
|     const documents = sortedDocuments.value | ||||
|     if (documents.length === 0) { | ||||
|     const docs = props.documents | ||||
|     if (docs.length === 0) { | ||||
|       cursor.value = null | ||||
|       return | ||||
|     } | ||||
|     const N = documents.length | ||||
|     const N = docs.length | ||||
|     const mod = (a: number, b: number) => ((a % b) + b) % b | ||||
|     const increment = (i: number, d: number) => mod(i + d, N + 1) | ||||
|     const index = | ||||
|       cursor.value !== null ? documents.indexOf(cursor.value) : documents.length | ||||
|       cursor.value !== null ? docs.indexOf(cursor.value) : docs.length | ||||
|     const moveto = increment(index, d) | ||||
|     cursor.value = documents[moveto] ?? null | ||||
|     cursor.value = docs[moveto] ?? null | ||||
|     const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null | ||||
|     if (select) { | ||||
|       // Go forwards, possibly wrapping over the end; the last entry is not toggled | ||||
|       let [begin, end] = d > 0 ? [index, moveto] : [moveto, index] | ||||
|       for (let p = begin; p !== end; p = increment(p, 1)) { | ||||
|         if (p === N) continue | ||||
|         const key = documents[p].key | ||||
|         if (documentStore.selected.has(key)) documentStore.selected.delete(key) | ||||
|         else documentStore.selected.add(key) | ||||
|         const key = docs[p].key | ||||
|         if (store.selected.has(key)) store.selected.delete(key) | ||||
|         else store.selected.add(key) | ||||
|       } | ||||
|     } | ||||
|     // @ts-ignore | ||||
| @@ -229,14 +217,14 @@ watchEffect(() => { | ||||
|     focusBreadcrumb() | ||||
|   } | ||||
| }) | ||||
| // Update human-readable x seconds ago messages from mtimes | ||||
| let nowkey = ref(0) | ||||
| let modifiedTimer: any = null | ||||
| const updateModified = () => { | ||||
|   for (const doc of props.documents) doc.modified = formatUnixDate(doc.mtime) | ||||
|   nowkey.value = Math.floor(Date.now() / 1000) | ||||
| } | ||||
| onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) }) | ||||
| onUnmounted(() => { clearInterval(modifiedTimer) }) | ||||
| const mkdir = (doc: Document, name: string) => { | ||||
| const mkdir = (doc: Doc, name: string) => { | ||||
|   const control = connect(controlUrl, { | ||||
|     open() { | ||||
|       control.send( | ||||
| @@ -253,34 +241,24 @@ const mkdir = (doc: Document, name: string) => { | ||||
|         editing.value = null | ||||
|       } else { | ||||
|         console.log('mkdir', msg) | ||||
|         router.push(doc.loc ? `/${doc.loc}/${name}/` : `/${name}/`) | ||||
|         router.push(doc.urlrouter) | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
|   doc.name = name // We should get an update from watch but this is quicker | ||||
|   // We should get an update from watch but this is quicker | ||||
|   doc.name = name | ||||
|   doc.key = crypto.randomUUID() | ||||
| } | ||||
|  | ||||
| // Column sort | ||||
| const toggleSort = (name: string) => { | ||||
|   sort.value = sort.value === name ? '' : name | ||||
| } | ||||
| const sort = ref<string>('') | ||||
| const sortCompare = { | ||||
|   name: (a: Document, b: Document) => collator.compare(a.name, b.name), | ||||
|   modified: (a: Document, b: Document) => b.mtime - a.mtime, | ||||
|   size: (a: Document, b: Document) => b.size - a.size | ||||
| } | ||||
| const sorted = (documents: Document[]) => { | ||||
|   const cmp = sortCompare[sort.value as keyof typeof sortCompare] | ||||
|   const sorted = [...documents] | ||||
|   if (cmp) sorted.sort(cmp) | ||||
|   return sorted | ||||
| const showFolderBreadcrumb = (i: number) => { | ||||
|   const docs = props.documents | ||||
|   const docloc = docs[i].loc | ||||
|   return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc | ||||
| } | ||||
| const selectionIndeterminate = computed({ | ||||
|   get: () => { | ||||
|     return ( | ||||
|       props.documents.length > 0 && | ||||
|       props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) && | ||||
|       props.documents.some((doc: Doc) => store.selected.has(doc.key)) && | ||||
|       !allSelected.value | ||||
|     ) | ||||
|   }, | ||||
| @@ -291,16 +269,16 @@ const allSelected = computed({ | ||||
|   get: () => { | ||||
|     return ( | ||||
|       props.documents.length > 0 && | ||||
|       props.documents.every((doc: Document) => documentStore.selected.has(doc.key)) | ||||
|       props.documents.every((doc: Doc) => store.selected.has(doc.key)) | ||||
|     ) | ||||
|   }, | ||||
|   set: (value: boolean) => { | ||||
|     console.log('Setting allSelected', value) | ||||
|     for (const doc of props.documents) { | ||||
|       if (value) { | ||||
|         documentStore.selected.add(doc.key) | ||||
|         store.selected.add(doc.key) | ||||
|       } else { | ||||
|         documentStore.selected.delete(doc.key) | ||||
|         store.selected.delete(doc.key) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| @@ -308,9 +286,13 @@ const allSelected = computed({ | ||||
|  | ||||
| const loc = computed(() => props.path.join('/')) | ||||
|  | ||||
| const contextMenu = (ev: Event, doc: Document) => { | ||||
| const contextMenu = (ev: MouseEvent, doc: Doc) => { | ||||
|   cursor.value = doc | ||||
|   console.log('Context menu', ev, doc) | ||||
|   ContextMenu.showContextMenu({ | ||||
|     x: ev.x, y: ev.y, items: [ | ||||
|       { label: 'Rename', onClick: () => { editing.value = doc } }, | ||||
|     ], | ||||
|   }) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| @@ -458,3 +440,4 @@ tbody .selection input { | ||||
|   color: #888; | ||||
| } | ||||
| </style> | ||||
| @/stores/main | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import type { Document } from '@/repositories/Document' | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import { computed } from 'vue' | ||||
|  | ||||
| const datetime = computed(() => | ||||
| @@ -17,6 +17,6 @@ const tooltip = computed(() => | ||||
| ) | ||||
|  | ||||
| const props = defineProps<{ | ||||
|     doc: Document | ||||
|     doc: Doc | ||||
| }>() | ||||
| </script> | ||||
|   | ||||
| @@ -12,7 +12,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import type { Document } from '@/repositories/Document' | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import { ref, onMounted, nextTick } from 'vue' | ||||
|  | ||||
| const input = ref<HTMLInputElement | null>(null) | ||||
| @@ -28,8 +28,8 @@ onMounted(() => { | ||||
| }) | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   doc: Document | ||||
|   rename: (doc: Document, newName: string) => void | ||||
|   doc: Doc | ||||
|   rename: (doc: Doc, newName: string) => void | ||||
|   exit: () => void | ||||
| }>() | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import type { Document } from '@/repositories/Document' | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import { computed } from 'vue' | ||||
|  | ||||
| const sizeClass = computed(() => { | ||||
| @@ -12,7 +12,7 @@ const sizeClass = computed(() => { | ||||
| }) | ||||
|  | ||||
| const props = defineProps<{ | ||||
|     doc: Document | ||||
|     doc: Doc | ||||
| }>() | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -1,15 +1,15 @@ | ||||
| <template> | ||||
|   <nav class="headermain"> | ||||
|     <div class="buttons"> | ||||
|       <template v-if="documentStore.error"> | ||||
|         <div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div> | ||||
|       <template v-if="store.error"> | ||||
|         <div class="error-message" @click="store.error = ''">{{ store.error }}</div> | ||||
|         <div class="smallgap"></div> | ||||
|       </template> | ||||
|       <UploadButton :path="props.path" /> | ||||
|       <SvgButton | ||||
|         name="create-folder" | ||||
|         data-tooltip="New folder" | ||||
|         @click="() => documentStore.fileExplorer!.newFolder()" | ||||
|         @click="() => store.fileExplorer!.newFolder()" | ||||
|       /> | ||||
|       <slot></slot> | ||||
|       <div class="spacer smallgap"></div> | ||||
| @@ -18,7 +18,6 @@ | ||||
|           ref="search" | ||||
|           type="search" | ||||
|           :value="query" | ||||
|           @blur="ev => { if (!query) closeSearch(ev) }" | ||||
|           @input="updateSearch" | ||||
|           placeholder="Search words" | ||||
|           class="margin-input" | ||||
| @@ -32,15 +31,19 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { useMainStore } from '@/stores/main' | ||||
| import { ref, nextTick, watchEffect } from 'vue' | ||||
| import ContextMenu from '@imengyu/vue3-context-menu' | ||||
| import router from '@/router'; | ||||
|  | ||||
| const documentStore = useDocumentStore() | ||||
| const store = useMainStore() | ||||
| const showSearchInput = ref<boolean>(false) | ||||
| const search = ref<HTMLInputElement | null>() | ||||
| const searchButton = ref<HTMLButtonElement | null>() | ||||
|   const props = defineProps<{ | ||||
|   path: Array<string> | ||||
|   query: string | ||||
| }>() | ||||
|  | ||||
| const closeSearch = (ev: Event) => { | ||||
|   if (!showSearchInput.value) return  // Already closing | ||||
| @@ -54,9 +57,9 @@ const updateSearch = (ev: Event) => { | ||||
|   let p = props.path.join('/') | ||||
|   p = p ? `/${p}` : '' | ||||
|   const url = q ? `${p}//${q}` : (p || '/') | ||||
|   console.log("Update search", url) | ||||
|   if (!props.query && q) router.push(url) | ||||
|   else router.replace(url) | ||||
|   const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') | ||||
|   if (!props.query && q) router.push(u) | ||||
|   else router.replace(u) | ||||
| } | ||||
| const toggleSearchInput = (ev: Event) => { | ||||
|   showSearchInput.value = !showSearchInput.value | ||||
| @@ -72,10 +75,10 @@ watchEffect(() => { | ||||
| const settingsMenu = (e: Event) => { | ||||
|   // show the context menu | ||||
|   const items = [] | ||||
|   if (documentStore.user.isLoggedIn) { | ||||
|     items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() }) | ||||
|   if (store.user.isLoggedIn) { | ||||
|     items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() }) | ||||
|   } else { | ||||
|     items.push({ label: 'Login', onClick: () => documentStore.loginDialog() }) | ||||
|     items.push({ label: 'Login', onClick: () => store.loginDialog() }) | ||||
|   } | ||||
|   ContextMenu.showContextMenu({ | ||||
|     // @ts-ignore | ||||
| @@ -83,11 +86,6 @@ const settingsMenu = (e: Event) => { | ||||
|     items, | ||||
|   }) | ||||
| } | ||||
| const props = defineProps<{ | ||||
|   path: Array<string> | ||||
|   query: string | ||||
| }>() | ||||
|  | ||||
| defineExpose({ | ||||
|   toggleSearchInput, | ||||
|   closeSearch, | ||||
| @@ -116,3 +114,4 @@ input[type='search'] { | ||||
|   max-width: 30vw; | ||||
| } | ||||
| </style> | ||||
| @/stores/main | ||||
|   | ||||
| @@ -1,29 +1,29 @@ | ||||
| <template> | ||||
|   <template v-if="documentStore.selected.size"> | ||||
|   <template v-if="store.selected.size"> | ||||
|     <div class="smallgap"></div> | ||||
|     <p class="select-text">{{ documentStore.selected.size }} selected ➤</p> | ||||
|     <p class="select-text">{{ store.selected.size }} selected ➤</p> | ||||
|     <SvgButton name="download" data-tooltip="Download" @click="download" /> | ||||
|     <SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" /> | ||||
|     <SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" /> | ||||
|     <SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" /> | ||||
|     <button class="action-button unselect" data-tooltip="Unselect all" @click="documentStore.selected.clear()">❌</button> | ||||
|     <button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()">❌</button> | ||||
|   </template> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import {connect, controlUrl} from '@/repositories/WS' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { useMainStore } from '@/stores/main' | ||||
| import { computed } from 'vue' | ||||
| import type { SelectedItems } from '@/repositories/Document' | ||||
|  | ||||
| const documentStore = useDocumentStore() | ||||
| const store = useMainStore() | ||||
| const props = defineProps({ | ||||
|   path: Array<string> | ||||
| }) | ||||
|  | ||||
| const dst = computed(() => props.path!.join('/')) | ||||
| const op = (op: string, dst?: string) => { | ||||
|   const sel = documentStore.selectedFiles | ||||
|   const sel = store.selectedFiles | ||||
|   const msg = { | ||||
|     op, | ||||
|     sel: sel.keys.map(key => { | ||||
| @@ -38,12 +38,12 @@ const op = (op: string, dst?: string) => { | ||||
|       const res = JSON.parse(ev.data) | ||||
|       if ('error' in res) { | ||||
|         console.error('Control socket error', msg, res.error) | ||||
|         documentStore.error = res.error.message | ||||
|         store.error = res.error.message | ||||
|         return | ||||
|       } else if (res.status === 'ack') { | ||||
|         console.log('Control ack OK', res) | ||||
|         control.close() | ||||
|         documentStore.selected.clear() | ||||
|         store.selected.clear() | ||||
|         return | ||||
|       } else console.log('Unknown control response', msg, res) | ||||
|     } | ||||
| @@ -108,17 +108,17 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl | ||||
| } | ||||
|  | ||||
| const download = async () => { | ||||
|   const sel = documentStore.selectedFiles | ||||
|   const sel = store.selectedFiles | ||||
|   console.log('Download', sel) | ||||
|   if (sel.keys.length === 0) { | ||||
|     console.warn('Attempted download but no files found. Missing selected keys:', sel.missing) | ||||
|     documentStore.selected.clear() | ||||
|     store.selected.clear() | ||||
|     return | ||||
|   } | ||||
|   // Plain old a href download if only one file (ignoring any folders) | ||||
|   const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir) | ||||
|   if (files.length === 1) { | ||||
|     documentStore.selected.clear() | ||||
|     store.selected.clear() | ||||
|     return linkdl(`/files/${files[0][1]}`) | ||||
|   } | ||||
|   // Use FileSystem API if multiple files and the browser supports it | ||||
| @@ -130,7 +130,7 @@ const download = async () => { | ||||
|         mode: 'readwrite' | ||||
|       }) | ||||
|       filesystemdl(sel, handle).then(() => { | ||||
|         documentStore.selected.clear() | ||||
|         store.selected.clear() | ||||
|       }) | ||||
|       return | ||||
|     } catch (e) { | ||||
| @@ -140,7 +140,7 @@ const download = async () => { | ||||
|   // Otherwise, zip and download | ||||
|   const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download' | ||||
|   linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`) | ||||
|   documentStore.selected.clear() | ||||
|   store.selected.clear() | ||||
| } | ||||
| </script> | ||||
|  | ||||
| @@ -152,3 +152,4 @@ const download = async () => { | ||||
|   text-overflow: ellipsis; | ||||
| } | ||||
| </style> | ||||
| @/stores/main | ||||
|   | ||||
| @@ -39,10 +39,10 @@ | ||||
| import { reactive, ref } from 'vue' | ||||
| import { loginUser } from '@/repositories/User' | ||||
| import type { ISimpleError } from '@/repositories/Client' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { useMainStore } from '@/stores/main' | ||||
|  | ||||
| const confirmLoading = ref<boolean>(false) | ||||
| const store = useDocumentStore() | ||||
| const store = useMainStore() | ||||
|  | ||||
| const loginForm = reactive({ | ||||
|   username: '', | ||||
| @@ -99,3 +99,4 @@ const login = async () => { | ||||
|   height: 1em; | ||||
| } | ||||
| </style> | ||||
| @/stores/main | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| <script setup lang="ts"> | ||||
| import { connect, uploadUrl } from '@/repositories/WS'; | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { useMainStore } from '@/stores/main' | ||||
| import { collator } from '@/utils'; | ||||
| import { computed, onMounted, onUnmounted, reactive, ref } from 'vue' | ||||
|  | ||||
| const fileInput = ref() | ||||
| const folderInput = ref() | ||||
| const documentStore = useDocumentStore() | ||||
| const store = useMainStore() | ||||
| const props = defineProps({ | ||||
|   path: Array<string> | ||||
| }) | ||||
| @@ -16,14 +16,51 @@ type CloudFile = { | ||||
|   cloudName: string | ||||
|   cloudPos: number | ||||
| } | ||||
|  | ||||
| function pasteHandler(event: ClipboardEvent) { | ||||
|   const items = Array.from(event.clipboardData?.items ?? []) | ||||
|   const infiles = [] as File[] | ||||
|   const dirs = [] as FileSystemDirectoryEntry[] | ||||
|   for (const item of items) { | ||||
|     if (item.kind !== 'file') continue | ||||
|     const entry = item.webkitGetAsEntry() | ||||
|     if (entry?.isFile) { | ||||
|       const file = item.getAsFile() | ||||
|       if (file) infiles.push(file) | ||||
|     } else if (entry?.isDirectory) { | ||||
|       dirs.push(entry as FileSystemDirectoryEntry) | ||||
|     } | ||||
|   } | ||||
|   if (infiles.length || dirs.length) { | ||||
|     event.preventDefault() | ||||
|     uploadFiles(infiles) | ||||
|     for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`) | ||||
|   } | ||||
| } | ||||
| const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => { | ||||
|   const reader = entry.createReader() | ||||
|   const entries = await new Promise<any[]>(resolve => reader.readEntries(resolve)) | ||||
|   const cloudfiles = [] as CloudFile[] | ||||
|   for (const entry of entries) { | ||||
|     const cloudName = `${loc}/${entry.name}` | ||||
|     if (entry.isFile) { | ||||
|       const file = await new Promise(resolve => entry.file(resolve)) as File | ||||
|       cloudfiles.push({file, cloudName, cloudPos: 0}) | ||||
|     } else if (entry.isDirectory) { | ||||
|       await pasteDirectory(entry, cloudName) | ||||
|     } | ||||
|   } | ||||
|   if (cloudfiles.length) uploadCloudFiles(cloudfiles) | ||||
| } | ||||
| function uploadHandler(event: Event) { | ||||
|   event.preventDefault() | ||||
|   // @ts-ignore | ||||
|   const input = event.target as HTMLInputElement | null | ||||
|   const infiles = Array.from((input ?? (event as DragEvent).dataTransfer)?.files ?? []) as File[] | ||||
|   if (input) input.value = '' | ||||
|   if (!infiles.length) return | ||||
|   if (infiles.length) uploadFiles(infiles) | ||||
| } | ||||
|  | ||||
| const uploadFiles = (infiles: File[]) => { | ||||
|   const loc = props.path!.join('/') | ||||
|   let files = [] | ||||
|   for (const file of infiles) { | ||||
| @@ -33,9 +70,12 @@ function uploadHandler(event: Event) { | ||||
|       cloudPos: 0, | ||||
|     }) | ||||
|   } | ||||
|   uploadCloudFiles(files) | ||||
| } | ||||
| const uploadCloudFiles = (files: CloudFile[]) => { | ||||
|   const dotfiles = files.filter(f => f.cloudName.includes('/.')) | ||||
|   if (dotfiles.length) { | ||||
|     documentStore.error = "Won't upload dotfiles" | ||||
|     store.error = "Won't upload dotfiles" | ||||
|     console.log("Dotfiles omitted", dotfiles) | ||||
|     files = files.filter(f => !f.cloudName.includes('/.')) | ||||
|   } | ||||
| @@ -131,13 +171,13 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => { | ||||
|     open(ev: Event) { resolve(ws) }, | ||||
|     error(ev: Event) { | ||||
|       console.error('Upload socket error', ev) | ||||
|       documentStore.error = 'Upload socket error' | ||||
|       store.error = 'Upload socket error' | ||||
|     }, | ||||
|     message(ev: MessageEvent) { | ||||
|       const res = JSON.parse(ev!.data) | ||||
|       if ('error' in res) { | ||||
|         console.error('Upload socket error', res.error) | ||||
|         documentStore.error = res.error.message | ||||
|         store.error = res.error.message | ||||
|         return | ||||
|       } | ||||
|       if (res.status === 'ack') { | ||||
| @@ -194,8 +234,10 @@ onMounted(() => { | ||||
|   // Need to prevent both to prevent browser from opening the file | ||||
|   addEventListener('dragover', uploadHandler) | ||||
|   addEventListener('drop', uploadHandler) | ||||
|   addEventListener('paste', pasteHandler) | ||||
| }) | ||||
| onUnmounted(() => { | ||||
|   removeEventListener('paste', pasteHandler) | ||||
|   removeEventListener('dragover', uploadHandler) | ||||
|   removeEventListener('drop', uploadHandler) | ||||
| }) | ||||
| @@ -260,3 +302,4 @@ span { | ||||
| .position { min-width: 4em } | ||||
| .speed { min-width: 4em } | ||||
| </style> | ||||
| @/stores/main | ||||
|   | ||||
| @@ -1,17 +1,42 @@ | ||||
| import { formatSize, formatUnixDate, haystackFormat } from "@/utils" | ||||
|  | ||||
| export type FUID = string | ||||
|  | ||||
| export type Document = { | ||||
| export type DocProps = { | ||||
|   loc: string | ||||
|   name: string | ||||
|   key: FUID | ||||
|   size: number | ||||
|   sizedisp: string | ||||
|   mtime: number | ||||
|   modified: string | ||||
|   haystack: string | ||||
|   dir: boolean | ||||
| } | ||||
|  | ||||
| export class Doc { | ||||
|   private _name: string = "" | ||||
|   public loc: string = "" | ||||
|   public key: FUID = "" | ||||
|   public size: number = 0 | ||||
|   public mtime: number = 0 | ||||
|   public haystack: string = "" | ||||
|   public dir: boolean = false | ||||
|  | ||||
|   constructor(props: Partial<DocProps> = {}) { Object.assign(this, props) } | ||||
|   get name() { return this._name } | ||||
|   set name(name: string) { | ||||
|     if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`) | ||||
|     this._name = name | ||||
|     this.haystack = haystackFormat(name) | ||||
|   } | ||||
|   get sizedisp(): string { return formatSize(this.size) } | ||||
|   get modified(): string { return formatUnixDate(this.mtime) } | ||||
|   get url(): string { | ||||
|     const p = this.loc ? `${this.loc}/${this.name}` : this.name | ||||
|     return this.dir ? '/#/' + `${p}/`.replaceAll('#', '%23') : `/files/${p}`.replaceAll('?', '%3F').replaceAll('#', '%23') | ||||
|   } | ||||
|   get urlrouter(): string { | ||||
|     return this.url.replace(/^\/#/, '') | ||||
|   } | ||||
| } | ||||
| export type errorEvent = { | ||||
|   error: { | ||||
|     code: number | ||||
| @@ -36,7 +61,7 @@ export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry> | ||||
| // Helper structure for selections | ||||
| export interface SelectedItems { | ||||
|   keys: FUID[] | ||||
|   docs: Record<FUID, Document> | ||||
|   recursive: Array<[string, string, Document]> | ||||
|   docs: Record<FUID, Doc> | ||||
|   recursive: Array<[string, string, Doc]> | ||||
|   missing: Set<FUID> | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { useDocumentStore } from "@/stores/documents" | ||||
| import { useMainStore } from "@/stores/main" | ||||
| import type { FileEntry, UpdateEntry, errorEvent } from "./Document" | ||||
|  | ||||
| export const controlUrl = '/api/control' | ||||
| @@ -6,22 +6,26 @@ export const uploadUrl = '/api/upload' | ||||
| export const watchUrl = '/api/watch' | ||||
|  | ||||
| let tree = [] as FileEntry[] | ||||
| let reconnectDuration = 500 | ||||
| let reconnDelay = 500 | ||||
| let wsWatch = null as WebSocket | null | ||||
|  | ||||
| export const loadSession = () => { | ||||
|   const store = useDocumentStore() | ||||
|   const s = localStorage['cista-files'] | ||||
|   if (!s) return false | ||||
|   const store = useMainStore() | ||||
|   try { | ||||
|     tree = JSON.parse(sessionStorage["cista-files"]) | ||||
|     tree = JSON.parse(s) | ||||
|     store.updateRoot(tree) | ||||
|     console.log(`Loaded session with ${tree.length} items cached`) | ||||
|     return true | ||||
|   } catch (error) { | ||||
|     console.log("Loading session failed", error) | ||||
|     return false | ||||
|   } | ||||
| } | ||||
|  | ||||
| const saveSession = () => { | ||||
|   sessionStorage["cista-files"] = JSON.stringify(tree) | ||||
|   localStorage["cista-files"] = JSON.stringify(tree) | ||||
| } | ||||
|  | ||||
| export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => { | ||||
| @@ -35,7 +39,7 @@ export const watchConnect = () => { | ||||
|     clearTimeout(watchTimeout) | ||||
|     watchTimeout = null | ||||
|   } | ||||
|   const store = useDocumentStore() | ||||
|   const store = useMainStore() | ||||
|   if (store.error !== 'Reconnecting...') store.error = 'Connecting...' | ||||
|   console.log(store.error) | ||||
|  | ||||
| @@ -59,7 +63,7 @@ export const watchConnect = () => { | ||||
|       console.log('Connected to backend', msg) | ||||
|       store.server = msg.server | ||||
|       store.connected = true | ||||
|       reconnectDuration = 500 | ||||
|       reconnDelay = 500 | ||||
|       store.error = '' | ||||
|       if (msg.user) store.login(msg.user.username, msg.user.privileged) | ||||
|       else if (store.isUserLogged) store.logout() | ||||
| @@ -77,16 +81,16 @@ export const watchDisconnect = () => { | ||||
| let watchTimeout: any = null | ||||
|  | ||||
| const watchReconnect = (event: MessageEvent) => { | ||||
|   const store = useDocumentStore() | ||||
|   const store = useMainStore() | ||||
|   if (store.connected) { | ||||
|     console.warn("Disconnected from server", event) | ||||
|     store.connected = false | ||||
|     store.error = 'Reconnecting...' | ||||
|   } | ||||
|   reconnectDuration = Math.min(5000, reconnectDuration + 500) | ||||
|   reconnDelay = Math.min(5000, reconnDelay + 500) | ||||
|   // The server closes the websocket after errors, so we need to reopen it | ||||
|   if (watchTimeout !== null) clearTimeout(watchTimeout) | ||||
|   watchTimeout = setTimeout(watchConnect, reconnectDuration) | ||||
|   watchTimeout = setTimeout(watchConnect, reconnDelay) | ||||
| } | ||||
|  | ||||
|  | ||||
| @@ -110,7 +114,7 @@ const handleWatchMessage = (event: MessageEvent) => { | ||||
| } | ||||
|  | ||||
| function handleRootMessage({ root }: { root: FileEntry[] }) { | ||||
|   const store = useDocumentStore() | ||||
|   const store = useMainStore() | ||||
|   console.log('Watch root', root) | ||||
|   store.updateRoot(root) | ||||
|   tree = root | ||||
| @@ -118,7 +122,7 @@ function handleRootMessage({ root }: { root: FileEntry[] }) { | ||||
| } | ||||
|  | ||||
| function handleUpdateMessage(updateData: { update: UpdateEntry[] }) { | ||||
|   const store = useDocumentStore() | ||||
|   const store = useMainStore() | ||||
|   const update = updateData.update | ||||
|   console.log('Watch update', update) | ||||
|   if (!tree) return console.error('Watch update before root') | ||||
| @@ -142,7 +146,7 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) { | ||||
| } | ||||
|  | ||||
| function handleError(msg: errorEvent) { | ||||
|   const store = useDocumentStore() | ||||
|   const store = useMainStore() | ||||
|   if (msg.error.code === 401) { | ||||
|     store.user.isOpenLoginModal = true | ||||
|     store.user.isLoggedIn = false | ||||
|   | ||||
| @@ -1,14 +1,12 @@ | ||||
| import type { Document, FileEntry, FUID, SelectedItems } from '@/repositories/Document' | ||||
| import { formatSize, formatUnixDate, haystackFormat } from '@/utils' | ||||
| import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document' | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import { defineStore } from 'pinia' | ||||
| import { collator } from '@/utils' | ||||
| import { logoutUser } from '@/repositories/User' | ||||
| import { watchConnect } from '@/repositories/WS' | ||||
| import { shallowRef } from 'vue' | ||||
| import { sorted, type SortOrder } from '@/utils/docsort' | ||||
| 
 | ||||
| type FileData = { id: string; mtime: number; size: number; dir: DirectoryData } | ||||
| type DirectoryData = { | ||||
|   [filename: string]: FileData | ||||
| } | ||||
| type User = { | ||||
|   username: string | ||||
|   privileged: boolean | ||||
| @@ -16,15 +14,20 @@ type User = { | ||||
|   isLoggedIn: boolean | ||||
| } | ||||
| 
 | ||||
| export const useDocumentStore = defineStore({ | ||||
|   id: 'documents', | ||||
| export const useMainStore = defineStore({ | ||||
|   id: 'main', | ||||
|   state: () => ({ | ||||
|     document: [] as Document[], | ||||
|     document: shallowRef<Doc[]>([]), | ||||
|     selected: new Set<FUID>(), | ||||
|     query: '' as string, | ||||
|     fileExplorer: null as any, | ||||
|     error: '' as string, | ||||
|     connected: false, | ||||
|     server: {} as Record<string, any>, | ||||
|     prefs: { | ||||
|       sortListing: '' as SortOrder, | ||||
|       sortFiltered: '' as SortOrder, | ||||
|     }, | ||||
|     user: { | ||||
|       username: '', | ||||
|       privileged: false, | ||||
| @@ -32,26 +35,26 @@ export const useDocumentStore = defineStore({ | ||||
|       isOpenLoginModal: false | ||||
|     } as User | ||||
|   }), | ||||
|   persist: { | ||||
|     paths: ['prefs'], | ||||
|   }, | ||||
|   actions: { | ||||
|     updateRoot(root: FileEntry[]) { | ||||
|       const docs = [] | ||||
|       let loc = [] as string[] | ||||
|       for (const [level, name, key, mtime, size, isfile] of root) { | ||||
|         loc = loc.slice(0, level - 1) | ||||
|         docs.push({ | ||||
|         docs.push(new Doc({ | ||||
|           name, | ||||
|           loc: level ? loc.join('/') : '/', | ||||
|           key, | ||||
|           size, | ||||
|           sizedisp: formatSize(size), | ||||
|           mtime, | ||||
|           modified: formatUnixDate(mtime), | ||||
|           haystack: haystackFormat(name), | ||||
|           dir: !isfile, | ||||
|         }) | ||||
|         })) | ||||
|         loc.push(name) | ||||
|       } | ||||
|       this.document = docs as Document[] | ||||
|       this.document = docs | ||||
|     }, | ||||
|     login(username: string, privileged: boolean) { | ||||
|       this.user.username = username | ||||
| @@ -67,23 +70,18 @@ export const useDocumentStore = defineStore({ | ||||
|       console.log("Logout") | ||||
|       await logoutUser() | ||||
|       this.$reset() | ||||
|       localStorage.clear() | ||||
|       history.go() // Reload page
 | ||||
|     } | ||||
|     }, | ||||
|     toggleSort(name: SortOrder) { | ||||
|       if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name | ||||
|       else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name | ||||
|     }, | ||||
|   }, | ||||
|   getters: { | ||||
|     isUserLogged(): boolean { | ||||
|       return this.user.isLoggedIn | ||||
|     }, | ||||
|     recentDocuments(): Document[] { | ||||
|       const ret = [...this.document] | ||||
|       ret.sort((a, b) => b.mtime - a.mtime) | ||||
|       return ret | ||||
|     }, | ||||
|     largeDocuments(): Document[] { | ||||
|       const ret = [...this.document] | ||||
|       ret.sort((a, b) => b.size - a.size) | ||||
|       return ret | ||||
|     }, | ||||
|     sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, | ||||
|     isUserLogged(): boolean { return this.user.isLoggedIn }, | ||||
|     recentDocuments(): Doc[] { return sorted(this.document, 'modified') }, | ||||
|     selectedFiles(): SelectedItems { | ||||
|       const selected = this.selected | ||||
|       const found = new Set<FUID>() | ||||
| @@ -104,7 +102,7 @@ export const useDocumentStore = defineStore({ | ||||
|       for (const key of selected) if (!found.has(key)) ret.missing.add(key) | ||||
|       // Build a flat list including contents recursively
 | ||||
|       const relnames = new Set<string>() | ||||
|       function add(rel: string, full: string, doc: Document) { | ||||
|       function add(rel: string, full: string, doc: Doc) { | ||||
|         if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`) | ||||
|         relnames.add(rel) | ||||
|         ret.recursive.push([rel, full, doc]) | ||||
							
								
								
									
										15
									
								
								frontend/src/utils/docsort.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								frontend/src/utils/docsort.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import { collator } from '@/utils' | ||||
|  | ||||
| export const ordering = { | ||||
|   name: (a: Doc, b: Doc) => collator.compare(a.name, b.name), | ||||
|   modified: (a: Doc, b: Doc) => b.mtime - a.mtime, | ||||
|   size: (a: Doc, b: Doc) => b.size - a.size | ||||
| } | ||||
| export type SortOrder = keyof typeof ordering | '' | ||||
| export const sorted = (documents: Doc[], order: SortOrder) => { | ||||
|   if (!order) return documents | ||||
|   const sorted = [...documents] | ||||
|   sorted.sort(ordering[order]) | ||||
|   return sorted | ||||
| } | ||||
| @@ -86,7 +86,7 @@ export function haystackFormat(str: string) { | ||||
| // Preformat search string for faster search | ||||
| export function needleFormat(query: string) { | ||||
|   const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() | ||||
|   return {based, words: based.split(/\W+/)} | ||||
|   return {based, words: based.split(/\s+/)} | ||||
| } | ||||
|  | ||||
| // Test if haystack includes needle | ||||
|   | ||||
| @@ -10,11 +10,12 @@ | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { watchEffect, ref, computed } from 'vue' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { useMainStore } from '@/stores/main' | ||||
| import Router from '@/router/index' | ||||
| import { needleFormat, localeIncludes, collator } from '@/utils'; | ||||
| import { sorted } from '@/utils/docsort'; | ||||
|  | ||||
| const documentStore = useDocumentStore() | ||||
| const store = useMainStore() | ||||
| const fileExplorer = ref() | ||||
| const props = defineProps<{ | ||||
|   path: Array<string> | ||||
| @@ -24,19 +25,25 @@ const documents = computed(() => { | ||||
|   const loc = props.path.join('/') | ||||
|   const query = props.query | ||||
|   // List the current location | ||||
|   if (!query) return documentStore.document.filter(doc => doc.loc === loc) | ||||
|   if (!query) return sorted( | ||||
|     store.document.filter(doc => doc.loc === loc), | ||||
|     store.prefs.sortListing, | ||||
|   ) | ||||
|   // Find up to 100 newest documents that match the search | ||||
|   const needle = needleFormat(query) | ||||
|   let limit = 100 | ||||
|   let docs = [] | ||||
|   for (const doc of documentStore.recentDocuments) { | ||||
|   for (const doc of store.recentDocuments) { | ||||
|     if (localeIncludes(doc.haystack, needle)) { | ||||
|       docs.push(doc) | ||||
|       if (--limit === 0) break | ||||
|     } | ||||
|   } | ||||
|   // Organize by folder, by relevance | ||||
|   const locsub = loc + '/' | ||||
|   // Custom sort override in effect? | ||||
|   const order = store.prefs.sortFiltered | ||||
|   if (order) return sorted(docs, order) | ||||
|   // Sort by relevance - current folder, then subfolders, then others | ||||
|   docs.sort((a, b) => ( | ||||
|     // @ts-ignore | ||||
|     (b.loc === loc) - (a.loc === loc) || | ||||
| @@ -53,6 +60,7 @@ const documents = computed(() => { | ||||
| }) | ||||
|  | ||||
| watchEffect(() => { | ||||
|   documentStore.fileExplorer = fileExplorer.value | ||||
|   store.fileExplorer = fileExplorer.value | ||||
|   store.query = props.query | ||||
| }) | ||||
| </script> | ||||
|   | ||||
| @@ -29,7 +29,7 @@ dependencies = [ | ||||
| ] | ||||
|  | ||||
| [project.urls] | ||||
| Homepage = "" | ||||
| Homepage = "https://git.zi.fi/Vasanko/cista-storage" | ||||
|  | ||||
| [project.scripts] | ||||
| cista = "cista.__main__:main" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user