Compare commits
	
		
			98 Commits
		
	
	
		
			v1.1.0
			...
			d42f0f7601
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | d42f0f7601 | ||
|   | 4c51029c9f | ||
|   | 4de2027959 | ||
|   | d5e1304c0d | ||
|   | 54d6ea6332 | ||
|   | c695c09ecc | ||
|   | d36605cd5b | ||
|   | fc1fb3ea5d | ||
|   | 32fa005c62 | ||
|   | fabec4dd7e | ||
|   | ece64f48be | ||
|   | 1f24313d23 | ||
|   | e3af21af91 | ||
|   | 6938740b0f | ||
|   | b25d0fc14b | ||
|   | 5386508e28 | ||
|   | 129250e072 | ||
|   | c2be2ecd31 | ||
|   | dd1d85f412 | ||
|   | 4c7b310f82 | ||
|   | 1250037cfd | ||
|   | cdc936d2d5 | ||
|   | 4f370440d9 | ||
|   | feaa8e315e | ||
|   | 14f7253ece | ||
|   | 9d3d27faf3 | ||
|   | dd235e8f25 | ||
|   | 139ff51dcd | ||
|   | 589e5a682c | ||
|   | 8114d679ef | ||
|   | 32b8e0702c | ||
|   | cc74912bb9 | ||
|   | c3cf4caa9a | ||
|   | b3eacf04f7 | ||
|   | 047facaacb | ||
|   | 41fbd3d122 | ||
|   | 40a45568c1 | ||
|   | 8c6690ea98 | ||
|   | 997e0b8549 | ||
|   | 115bb5db59 | ||
|   | 5f1eb0503a | ||
|   | 4aae194060 | ||
|   | 12eabd29c3 | ||
|   | 589b21f944 | ||
|   | d3f584b738 | ||
|   | 225f2b0651 | ||
|   | b759d8324c | ||
|   | 119aba2b3c | ||
|   | f52d58d645 | ||
|   | 6cba674b30 | ||
|   | 831b2716f7 | ||
|   | 7e5901a2cf | ||
|   | a4f95d730b | ||
|   | 56082cba15 | ||
|   | 3479a0da57 | ||
|   | f99d92b217 | ||
|   | 68a701538b | ||
|   | 05a16e3037 | ||
|   | 52ecbc3d36 | ||
|   | 042f1b7f42 | ||
|   | d27cb2133a | ||
|   | a8ea43194d | ||
|   | 07fe7448cc | ||
|   | 783af44e26 | ||
|   | 0d6180e8a4 | ||
|   | bdc0bbd44f | ||
|   | ba36eaec1b | ||
|   | a435a30c88 | ||
|   | 742b05ed66 | ||
|   | a26dc42d88 | ||
|   | 9002afbc7e | ||
|   | acdd776b92 | ||
| b3fd9637eb | |||
| 2b72508206 | |||
|   | 8cc3ed1a04 | ||
|   | 0d186726b5 | ||
|   | 63bbe84859 | ||
|   | 202f28ff15 | ||
| 41772e6c18 | |||
| e52379d515 | |||
| 74987898c9 | |||
| 859d312913 | |||
| 4bc9cf4534 | |||
| 754d779069 | |||
| 367e4ba0ea | |||
| c2e9a4af05 | |||
| 6cdc37a172 | |||
| 19699564c2 | |||
| 7baf8b3f9b | |||
| 47329ac04e | |||
| f4013d1196 | |||
| 3672156b5e | |||
| f2b37852da | |||
| 708e54d080 | |||
| d051265f40 | |||
| 5cf133465e | |||
| 1c91bf2e87 | |||
| 9cd6f83bec | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -3,4 +3,5 @@ | ||||
| __pycache__/ | ||||
| *.egg-info/ | ||||
| /cista/_version.py | ||||
| /cista/wwwroot/* | ||||
| /dist | ||||
|   | ||||
							
								
								
									
										5
									
								
								cista-front/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -7,12 +7,17 @@ yarn-error.log* | ||||
| pnpm-debug.log* | ||||
| lerna-debug.log* | ||||
|  | ||||
| # No locking | ||||
| package-lock.json | ||||
| yarn.lock | ||||
|  | ||||
| node_modules | ||||
| .DS_Store | ||||
| dist | ||||
| dist-ssr | ||||
| coverage | ||||
| *.local | ||||
| components.d.ts | ||||
|  | ||||
| /cypress/videos/ | ||||
| /cypress/screenshots/ | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <link rel="icon" href="/favicon.ico"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <title>Vite App</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="app"></div> | ||||
|     <script type="module" src="/src/main.ts"></script> | ||||
|   </body> | ||||
| </html> | ||||
| <html lang=en> | ||||
| <meta charset=UTF-8> | ||||
| <title>Cista</title> | ||||
| <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||
| <link rel="icon" href="/favicon.ico"> | ||||
| <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||
| <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet"> | ||||
| <script type="module" src="/src/main.ts"></script> | ||||
|  | ||||
| <div id="app"></div> | ||||
|   | ||||
							
								
								
									
										1458
									
								
								cista-front/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -1,27 +1,59 @@ | ||||
| { | ||||
|   "name": "cista-front", | ||||
|   "name": "front", | ||||
|   "version": "0.0.0", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "run-p type-check \"build-only {@}\" --", | ||||
|     "preview": "vite preview", | ||||
|     "test:unit": "vitest", | ||||
|     "build-only": "vite build", | ||||
|     "type-check": "vue-tsc --noEmit -p tsconfig.app.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", | ||||
|     "format": "prettier --write src/" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "pinia": "^2.1.7", | ||||
|     "@imengyu/vue3-context-menu": "^1.3.3", | ||||
|     "@vueuse/core": "^10.4.1", | ||||
|     "esbuild": "^0.19.5", | ||||
|     "lodash": "^4.17.21", | ||||
|     "lodash-es": "^4.17.21", | ||||
|     "pinia": "^2.1.6", | ||||
|     "pinia-plugin-persistedstate": "^3.2.0", | ||||
|     "unplugin-vue-components": "^0.25.2", | ||||
|     "vite-plugin-rewrite-all": "^1.0.1", | ||||
|     "vite-svg-loader": "^4.0.0", | ||||
|     "vue": "^3.3.4", | ||||
|     "vue-router": "^4.2.5" | ||||
|     "vue-router": "^4.2.4" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@rushstack/eslint-patch": "^1.3.3", | ||||
|     "@tsconfig/node18": "^18.2.2", | ||||
|     "@types/node": "^18.18.5", | ||||
|     "@vitejs/plugin-vue": "^4.4.0", | ||||
|     "@types/jsdom": "^21.1.3", | ||||
|     "@types/lodash-es": "^4.17.10", | ||||
|     "@types/node": "^18.17.17", | ||||
|     "@vitejs/plugin-vue": "^4.3.4", | ||||
|     "@vue/eslint-config-prettier": "^8.0.0", | ||||
|     "@vue/eslint-config-typescript": "^12.0.0", | ||||
|     "@vue/test-utils": "^2.4.1", | ||||
|     "@vue/tsconfig": "^0.4.0", | ||||
|     "npm-run-all2": "^6.1.1", | ||||
|     "babel-eslint": "^10.1.0", | ||||
|     "eslint": "^8.52.0", | ||||
|     "eslint-plugin-vue": "^9.18.1", | ||||
|     "jsdom": "^22.1.0", | ||||
|     "npm-run-all2": "^6.0.6", | ||||
|     "prettier": "^3.0.3", | ||||
|     "typescript": "~5.2.0", | ||||
|     "vite": "^4.4.11", | ||||
|     "vue-tsc": "^1.8.19" | ||||
|     "vite": "^4.4.9", | ||||
|     "vitest": "^0.34.4", | ||||
|     "vue-tsc": "^1.8.11" | ||||
|   }, | ||||
|   "prettier": { | ||||
|     "semi": false, | ||||
|     "singleQuote": true, | ||||
|     "trailingComma": "none", | ||||
|     "arrowParens": "avoid", | ||||
|     "endOfLine": "lf", | ||||
|     "printWidth": 88 | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,85 +1,121 @@ | ||||
| <script setup lang="ts"> | ||||
| import { RouterLink, RouterView } from 'vue-router' | ||||
| import HelloWorld from './components/HelloWorld.vue' | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <LoginModal /> | ||||
|   <header> | ||||
|     <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" /> | ||||
|  | ||||
|     <div class="wrapper"> | ||||
|       <HelloWorld msg="You did it!" /> | ||||
|  | ||||
|       <nav> | ||||
|         <RouterLink to="/">Home</RouterLink> | ||||
|         <RouterLink to="/about">About</RouterLink> | ||||
|       </nav> | ||||
|     </div> | ||||
|     <HeaderMain ref="headerMain"> | ||||
|       <HeaderSelected :path="path.pathList" /> | ||||
|     </HeaderMain> | ||||
|     <BreadCrumb :path="path.pathList" tabindex="-1"/> | ||||
|   </header> | ||||
|  | ||||
|   <RouterView /> | ||||
|   <main> | ||||
|     <RouterView :path="path.pathList" /> | ||||
|   </main> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| header { | ||||
|   line-height: 1.5; | ||||
|   max-height: 100vh; | ||||
| } | ||||
| <script setup lang="ts"> | ||||
| import { RouterView } from 'vue-router' | ||||
| import type { ComputedRef } from 'vue' | ||||
| import type HeaderMain from '@/components/HeaderMain.vue' | ||||
| import { onMounted, onUnmounted, ref } from 'vue' | ||||
| import { watchConnect, watchDisconnect } from '@/repositories/WS' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
|  | ||||
| .logo { | ||||
|   display: block; | ||||
|   margin: 0 auto 2rem; | ||||
| } | ||||
| import { computed } from 'vue' | ||||
| import Router from '@/router/index' | ||||
|  | ||||
| nav { | ||||
|   width: 100%; | ||||
|   font-size: 12px; | ||||
|   text-align: center; | ||||
|   margin-top: 2rem; | ||||
| interface Path { | ||||
|   path: string | ||||
|   pathList: string[] | ||||
| } | ||||
|  | ||||
| nav a.router-link-exact-active { | ||||
|   color: var(--color-text); | ||||
| } | ||||
|  | ||||
| nav a.router-link-exact-active:hover { | ||||
|   background-color: transparent; | ||||
| } | ||||
|  | ||||
| nav a { | ||||
|   display: inline-block; | ||||
|   padding: 0 1rem; | ||||
|   border-left: 1px solid var(--color-border); | ||||
| } | ||||
|  | ||||
| nav a:first-of-type { | ||||
|   border: 0; | ||||
| } | ||||
|  | ||||
| @media (min-width: 1024px) { | ||||
|   header { | ||||
|     display: flex; | ||||
|     place-items: center; | ||||
|     padding-right: calc(var(--section-gap) / 2); | ||||
| const documentStore = useDocumentStore() | ||||
| const path: ComputedRef<Path> = computed(() => { | ||||
|   const p = decodeURIComponent(Router.currentRoute.value.path) | ||||
|   const pathList = p.split('/').filter(value => value !== '') | ||||
|   return { | ||||
|     path: p, | ||||
|     pathList | ||||
|   } | ||||
|  | ||||
|   .logo { | ||||
|     margin: 0 2rem 0 0; | ||||
| }) | ||||
| onMounted(watchConnect) | ||||
| onUnmounted(watchDisconnect) | ||||
| // Update human-readable x seconds ago messages from mtimes | ||||
| setInterval(documentStore.updateModified, 1000) | ||||
| const headerMain = ref<typeof HeaderMain | null>(null) | ||||
| let vert = 0 | ||||
| let timer: any = null | ||||
| const globalShortcutHandler = (event: KeyboardEvent) => { | ||||
|   const fileExplorer = documentStore.fileExplorer as any | ||||
|   if (!fileExplorer) return | ||||
|   const c = fileExplorer.isCursor() | ||||
|   const keyup = event.type === 'keyup' | ||||
|   if (event.repeat) { | ||||
|     if ( | ||||
|       event.key === 'ArrowUp' || | ||||
|       event.key === 'ArrowDown' || | ||||
|       (c && event.code === 'Space') | ||||
|     ) { | ||||
|       event.preventDefault() | ||||
|     } | ||||
|     return | ||||
|   } | ||||
|  | ||||
|   header .wrapper { | ||||
|     display: flex; | ||||
|     place-items: flex-start; | ||||
|     flex-wrap: wrap; | ||||
|   //console.log("key pressed", event) | ||||
|   // For up/down implement custom fast repeat | ||||
|   if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1 | ||||
|   else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1 | ||||
|   // Find: process on keydown so that we can bypass the built-in search hotkey | ||||
|   else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) { | ||||
|     headerMain.value!.toggleSearchInput() | ||||
|   } | ||||
|  | ||||
|   nav { | ||||
|     text-align: left; | ||||
|     margin-left: -1rem; | ||||
|     font-size: 1rem; | ||||
|  | ||||
|     padding: 1rem 0; | ||||
|     margin-top: 1rem; | ||||
|   // Select all (toggle); keydown to prevent builtin | ||||
|   else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) { | ||||
|     fileExplorer.toggleSelectAll() | ||||
|   } | ||||
|   // Keys 1-3 to sort columns | ||||
|   else if ( | ||||
|     c && | ||||
|     keyup && | ||||
|     (event.key === '1' || event.key === '2' || event.key === '3') | ||||
|   ) { | ||||
|     fileExplorer.toggleSortColumn(+event.key) | ||||
|   } | ||||
|   // Rename | ||||
|   else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) { | ||||
|     fileExplorer.cursorRename() | ||||
|   } | ||||
|   // Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey | ||||
|   else if (c && event.code === 'Space') { | ||||
|     if (keyup && !event.altKey && !event.ctrlKey) | ||||
|       fileExplorer.cursorSelect() | ||||
|   } else return | ||||
|   event.preventDefault() | ||||
|   if (!vert) { | ||||
|     if (timer) { | ||||
|       clearTimeout(timer) // Good for either timeout or interval | ||||
|       timer = null | ||||
|     } | ||||
|     return | ||||
|   } | ||||
|   if (!timer) { | ||||
|     // Initial move, then t0 delay until repeats at tr intervals | ||||
|     const select = event.shiftKey | ||||
|     fileExplorer.cursorMove(vert, select) | ||||
|     const t0 = 200, | ||||
|       tr = 30 | ||||
|     timer = setTimeout( | ||||
|       () => | ||||
|         (timer = setInterval(() => { | ||||
|           fileExplorer.cursorMove(vert, select) | ||||
|         }, tr)), | ||||
|       t0 - tr | ||||
|     ) | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| onMounted(() => { | ||||
|   window.addEventListener('keydown', globalShortcutHandler) | ||||
|   window.addEventListener('keyup', globalShortcutHandler) | ||||
| }) | ||||
| onUnmounted(() => { | ||||
|   window.removeEventListener('keydown', globalShortcutHandler) | ||||
|   window.removeEventListener('keyup', globalShortcutHandler) | ||||
| }) | ||||
| export type { Path } | ||||
| </script> | ||||
|   | ||||
| @@ -1,86 +0,0 @@ | ||||
| /* color palette from <https://github.com/vuejs/theme> */ | ||||
| :root { | ||||
|   --vt-c-white: #ffffff; | ||||
|   --vt-c-white-soft: #f8f8f8; | ||||
|   --vt-c-white-mute: #f2f2f2; | ||||
|  | ||||
|   --vt-c-black: #181818; | ||||
|   --vt-c-black-soft: #222222; | ||||
|   --vt-c-black-mute: #282828; | ||||
|  | ||||
|   --vt-c-indigo: #2c3e50; | ||||
|  | ||||
|   --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); | ||||
|   --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); | ||||
|   --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); | ||||
|   --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); | ||||
|  | ||||
|   --vt-c-text-light-1: var(--vt-c-indigo); | ||||
|   --vt-c-text-light-2: rgba(60, 60, 60, 0.66); | ||||
|   --vt-c-text-dark-1: var(--vt-c-white); | ||||
|   --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); | ||||
| } | ||||
|  | ||||
| /* semantic color variables for this project */ | ||||
| :root { | ||||
|   --color-background: var(--vt-c-white); | ||||
|   --color-background-soft: var(--vt-c-white-soft); | ||||
|   --color-background-mute: var(--vt-c-white-mute); | ||||
|  | ||||
|   --color-border: var(--vt-c-divider-light-2); | ||||
|   --color-border-hover: var(--vt-c-divider-light-1); | ||||
|  | ||||
|   --color-heading: var(--vt-c-text-light-1); | ||||
|   --color-text: var(--vt-c-text-light-1); | ||||
|  | ||||
|   --section-gap: 160px; | ||||
| } | ||||
|  | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   :root { | ||||
|     --color-background: var(--vt-c-black); | ||||
|     --color-background-soft: var(--vt-c-black-soft); | ||||
|     --color-background-mute: var(--vt-c-black-mute); | ||||
|  | ||||
|     --color-border: var(--vt-c-divider-dark-2); | ||||
|     --color-border-hover: var(--vt-c-divider-dark-1); | ||||
|  | ||||
|     --color-heading: var(--vt-c-text-dark-1); | ||||
|     --color-text: var(--vt-c-text-dark-2); | ||||
|   } | ||||
| } | ||||
|  | ||||
| *, | ||||
| *::before, | ||||
| *::after { | ||||
|   box-sizing: border-box; | ||||
|   margin: 0; | ||||
|   font-weight: normal; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   min-height: 100vh; | ||||
|   color: var(--color-text); | ||||
|   background: var(--color-background); | ||||
|   transition: | ||||
|     color 0.5s, | ||||
|     background-color 0.5s; | ||||
|   line-height: 1.6; | ||||
|   font-family: | ||||
|     Inter, | ||||
|     -apple-system, | ||||
|     BlinkMacSystemFont, | ||||
|     'Segoe UI', | ||||
|     Roboto, | ||||
|     Oxygen, | ||||
|     Ubuntu, | ||||
|     Cantarell, | ||||
|     'Fira Sans', | ||||
|     'Droid Sans', | ||||
|     'Helvetica Neue', | ||||
|     sans-serif; | ||||
|   font-size: 15px; | ||||
|   text-rendering: optimizeLegibility; | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|   -moz-osx-font-smoothing: grayscale; | ||||
| } | ||||
| @@ -1 +0,0 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg> | ||||
| Before Width: | Height: | Size: 276 B | 
| @@ -1,35 +1,268 @@ | ||||
| @import './base.css'; | ||||
| @charset "UTF-8"; | ||||
|  | ||||
| #app { | ||||
|   max-width: 1280px; | ||||
|   margin: 0 auto; | ||||
|   padding: 2rem; | ||||
|  | ||||
|   font-weight: normal; | ||||
| :root { | ||||
|   --primary-color: #000; | ||||
|   --primary-background: #ddd; | ||||
|   --header-background: var(--soft-color); | ||||
|   --header-color: #ccc; | ||||
|   --input-background: #fff; | ||||
|   --input-color: #000; | ||||
|   --primary-color: #000; | ||||
|   --soft-color: #146; | ||||
|   --accent-color: #f80; | ||||
|   --transition-time: 0.2s; | ||||
|   /* The following are overridden by responsive layouts */ | ||||
|   --root-font-size: 1rem; | ||||
|   --header-font-size: 1rem; | ||||
|   --header-height: calc(6.5 * var(--header-font-size)); | ||||
| } | ||||
|  | ||||
| a, | ||||
| .green { | ||||
|   text-decoration: none; | ||||
|   color: hsla(160, 100%, 37%, 1); | ||||
|   transition: 0.4s; | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   :root { | ||||
|     --primary-color: #ddd; | ||||
|     --primary-background: var(--soft-color); | ||||
|     --header-background: #000; | ||||
|     --header-color: #ccc; | ||||
|     --input-background: var(--soft-color); | ||||
|     --input-color: #ddd; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (hover: hover) { | ||||
|   a:hover { | ||||
|     background-color: hsla(160, 100%, 37%, 0.2); | ||||
| @media screen and (max-width: 600px) { | ||||
|   .size, | ||||
|   .modified, | ||||
|   .summary { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (min-width: 1024px) { | ||||
|   body { | ||||
| @media screen and (min-width: 1000px) { | ||||
|   :root { | ||||
|     --root-font-size: calc(8px + 8 * 100vw / 1000); | ||||
|   } | ||||
|   header .buttons:has(input[type='search']) > div { | ||||
|     display: none; | ||||
|   } | ||||
|   header .buttons > div:has(input[type='search']) { | ||||
|     display: inherit; | ||||
|   } | ||||
| } | ||||
| @media screen and (min-width: 2000px) { | ||||
|   :root { | ||||
|     --root-font-size: 1.5rem; | ||||
|   } | ||||
| } | ||||
| /* Low (landscape) screens: smaller header */ | ||||
| @media screen and (max-height: 600px) { | ||||
|   :root { | ||||
|     --header-font-size: calc(10px + 10 * 100vh / 600);  /* 20px at 600px height */ | ||||
|     --root-font-size: 0.8rem; | ||||
|   } | ||||
|   header .breadcrumb > * { | ||||
|     padding-top: calc(8 + 8 * 100vh / 600) !important; | ||||
|     padding-bottom: calc(8 + 8 * 100vh / 600) !important; | ||||
|   } | ||||
| } | ||||
| @media screen and (max-height: 300px) { | ||||
|   :root { | ||||
|     --header-font-size: 15px;  /* Don't go smaller than this, no benefit */ | ||||
|     --header-height: calc(1.75 * 16px); | ||||
|     --root-font-size: 0.6rem; | ||||
|   } | ||||
|   header .breadcrumb > * { | ||||
|     padding-top: 14px !important; | ||||
|     padding-bottom: 14px !important; | ||||
|   } | ||||
| } | ||||
| @media screen and (orientation: landscape) and (min-width: 700px) { | ||||
|   /* Breadcrumbs and buttons side by side */ | ||||
|   :root { | ||||
|     --header-font-size: calc(8px + 8 * 100vh / 600);  /* 16px (1rem nominal) at 600px height */ | ||||
|   } | ||||
|   header { | ||||
|     display: flex; | ||||
|     place-items: center; | ||||
|     flex-direction: row-reverse; | ||||
|     justify-content: space-between; | ||||
|     align-items: end; | ||||
|   } | ||||
|  | ||||
|   #app { | ||||
|     display: grid; | ||||
|     grid-template-columns: 1fr 1fr; | ||||
|     padding: 0 2rem; | ||||
|   header .breadcrumb { | ||||
|     flex-shrink: 1; | ||||
|   } | ||||
|   header .breadcrumb > * { | ||||
|     flex-shrink: 1; | ||||
|     padding-top: 1rem !important; | ||||
|     padding-bottom: 1rem !important; | ||||
|   } | ||||
| } | ||||
| @media print { | ||||
|   :root { | ||||
|     --primary-color: black; | ||||
|     --primary-background: none; | ||||
|     --header-background: none; | ||||
|     --header-color: black; | ||||
|   } | ||||
|   nav, | ||||
|   .menu, | ||||
|   .rename-button { | ||||
|     display: none; | ||||
|   } | ||||
|   .breadcrumb > a { | ||||
|     color: black !important; | ||||
|     background: none !important; | ||||
|     padding: 0 !important; | ||||
|     margin: 0 !important; | ||||
|     clip-path: none !important; | ||||
|     max-width: none !important; | ||||
|   } | ||||
|   .breadcrumb > a::after { | ||||
|     content: '/'; | ||||
|   } | ||||
|   .breadcrumb svg { | ||||
|     fill: black !important; | ||||
|   } | ||||
|   main { | ||||
|     height: auto !important; | ||||
|     padding-bottom: 0 !important; | ||||
|   } | ||||
|   thead tr { | ||||
|     position: static !important; | ||||
|     background: none !important; | ||||
|     border-bottom: 1pt solid black !important; | ||||
|   } | ||||
|   .selection { | ||||
|     min-width: 0 !important; | ||||
|     padding: 0 !important; | ||||
|   } | ||||
|   .selection input { | ||||
|     display: none; | ||||
|   } | ||||
|   .selection input:checked { | ||||
|     display: inherit; | ||||
|   } | ||||
|   tbody .selection input:checked { | ||||
|     opacity: 1 !important; | ||||
|     transform: scale(0.5); | ||||
|     left: 0; | ||||
|   } | ||||
| } | ||||
| html { | ||||
|   font-size: var(--root-font-size); | ||||
|   overflow: hidden; | ||||
| } | ||||
| /* Hide scrollbar for all browsers */ | ||||
| main::-webkit-scrollbar { | ||||
|   display: none; | ||||
| } | ||||
| main { | ||||
|   -ms-overflow-style: none; /* IE and Edge */ | ||||
|   scrollbar-width: none; /* Firefox */ | ||||
| } | ||||
| body { | ||||
|   background-color: var(--primary-background); | ||||
|   font-size: 1rem; | ||||
|   font-family: 'Roboto'; | ||||
|   color: var(--primary-color); | ||||
|   margin: 0; | ||||
| } | ||||
| tbody .size, | ||||
| tbody .modified { | ||||
|   font-family: 'Roboto Mono'; | ||||
| } | ||||
| header { | ||||
|   background-color: var(--header-background); | ||||
|   color: var(--header-color); | ||||
|   font-size: var(--header-font-size); | ||||
| } | ||||
| main { | ||||
|   height: 100%; | ||||
| } | ||||
| ::selection { | ||||
|   color: #000; | ||||
|   background: yellow !important; | ||||
| } | ||||
| button { | ||||
|   font: inherit; | ||||
|   color: inherit; | ||||
|   margin: 0; | ||||
|   border: 0; | ||||
|   padding: 0; | ||||
|   background: none; | ||||
|   cursor: pointer; | ||||
|   min-width: 1rem; | ||||
|   min-height: 1rem; | ||||
| } | ||||
| input { | ||||
|   margin: 0; | ||||
| } | ||||
| :focus { | ||||
|   outline: none; | ||||
| } | ||||
| a:link, | ||||
| a:visited, | ||||
| a:active, | ||||
| a:hover { | ||||
|   color: var(--primary-color); | ||||
|   text-decoration: none; | ||||
| } | ||||
| table { | ||||
|   border-collapse: collapse; | ||||
|   border-spacing: 0; | ||||
|   border: 0; | ||||
|   gap: 0; | ||||
| } | ||||
| #app { | ||||
|   height: 100%; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
| header nav.headermain { | ||||
|   /* Position so that tooltips can appear on top of other positioned elements */ | ||||
|   position: relative; | ||||
|   z-index: 100; | ||||
| } | ||||
| main { | ||||
|   height: calc(100svh - var(--header-height)); | ||||
|   padding-bottom: 3em; /* convenience space on the bottom */ | ||||
|   overflow-y: scroll; | ||||
| } | ||||
| .spacer { flex-grow: 1 } | ||||
| .smallgap { flex-shrink: 1; width: 2em } | ||||
|  | ||||
| [data-tooltip]:hover:after { | ||||
|   z-index: 101; | ||||
|   content: attr(data-tooltip); | ||||
|   position: absolute; | ||||
|   font-size: 1rem; | ||||
|   text-align: center; | ||||
|   padding: .5rem 1rem; | ||||
|   border-radius: 3rem 0 3rem 0; | ||||
|   box-shadow: 0 0 1rem var(--accent-color); | ||||
|   transform: translate(calc(1rem + -50%), 150%); | ||||
|   background-color: var(--accent-color); | ||||
|   color: var(--primary-color); | ||||
|   white-space: pre; | ||||
|   animation: appearbriefly calc(10 * var(--transition-time)) linear forwards; | ||||
| } | ||||
| .modified [data-tooltip]:hover:after { | ||||
|   transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%)); | ||||
| } | ||||
| @keyframes appearbriefly { | ||||
|   from { | ||||
|     opacity: 0; | ||||
|   } | ||||
|   30% { | ||||
|     opacity: 0; | ||||
|   } | ||||
|   40% { | ||||
|     opacity: 1; | ||||
|   } | ||||
|   90% { | ||||
|     opacity: 1; | ||||
|   } | ||||
|   to { | ||||
|     opacity: 0; | ||||
|   } | ||||
| } | ||||
| .error-message { | ||||
|   padding: .5em; | ||||
|   font-weight: bold; | ||||
|   background: var(--accent-color); | ||||
|   color: #000; | ||||
| } | ||||
|   | ||||
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/add-file.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM18.5 16v7.1h-5.3V16H8.7l7.1-7.1L23 16h-4.5z"/></svg> | ||||
| After Width: | Height: | Size: 158 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/add-folder.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm18.3 9.5V27h-5.6v-7.5H8l7.5-7.5 7.5 7.5h-4.7z"/></svg> | ||||
| After Width: | Height: | Size: 168 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/arrow.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 -32 640 640"><path d="M495.46 365.98c-13.03-13.37-150.24-144.06-150.24-144.06A35.16 35.16 0 0 0 320 211.2a35.06 35.06 0 0 0-25.22 10.72s-137.2 130.7-150.27 144.06c-13 13.38-13.9 37.44 0 51.72 14 14.24 33.4 15.4 50.48 0L320 297.8l125.02 119.9c17.1 15.4 36.55 14.24 50.44 0 13.95-14.3 13.08-38.37 0-51.72z"/></svg> | ||||
| After Width: | Height: | Size: 388 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/arrows-h.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6 -2 44 36"><path d="M12 18H6v4l-6-6 6-6v4h6zm8-4h6v-4l6 6-6 6v-4h-6z"/></svg> | ||||
| After Width: | Height: | Size: 128 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/arrows-v.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -6 16 44"><path d="M8 20v6h4l-6 6-6-6h4v-6zm-4-8V6H0l6-6 6 6H8v6z"/></svg> | ||||
| After Width: | Height: | Size: 126 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/check.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="-48 0 512 512"><path d="M320 96L128 288l-64-64-64 64 128 128 256-256-64-64z"/></svg> | ||||
| After Width: | Height: | Size: 158 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/code.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="-24 8 512 512"><path d="M304 96l-48 48 112 112-112 112 48 48 144-160L304 96zm-160 0L0 256l144 160 48-48L80 256l112-112-48-48z"/></svg> | ||||
| After Width: | Height: | Size: 208 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/cog.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M223.97 175A81 81 0 0 0 143 256c0 44.7 36.27 81.03 80.97 81.03 44.72 0 80.72-36.34 80.72-81.03 0-44.73-36-81-80.8-81zM386.3 302.53l-14.58 35.16 29.47 57.8-36.1 36.1-59.3-28-35.2 14.4-17.87 54.6-2.28 7.24h-51L177.4 418.2l-35.17-14.5-57.9 29.4-36.1-36.1 27.97-59.2-14.47-35.12L0 282.6v-51l61.7-22.1 14.5-35.1-25.96-51.23-3.43-6.72 36.1-36.03 59.3 27.92 35.1-14.5 17.9-54.6 2.3-7.24h51l22.1 61.73 35.07 14.52 58.04-29.4 36.06 36.03-27.96 59.2 14.42 35.17 61.8 20.13v50.97l-61.67 22.18z"/></svg> | ||||
| After Width: | Height: | Size: 563 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/copy.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 36 36"><path d="M26 8h-6V6l-6-6H0v24h12v8h20V14l-6-6zm0 2.83L29.17 14H26v-3.17zm-12-8L17.17 6H14V2.83zM2 2h10v6h6v14H2V2zm28 28H14v-6h6V10h4v6h6v14z"/></svg> | ||||
| After Width: | Height: | Size: 212 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/create-file.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"/></svg> | ||||
| After Width: | Height: | Size: 293 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/create-folder.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm22.8 11.2c0 .3-.2.5-.5.5h-5.2v5.2c0 .3-.2.5-.5.5h-2.8c-.3 0-.5-.2-.5-.5v-5.2H8.1c-.3 0-.5-.2-.5-.5v-2.8c0-.3.2-.5.5-.5h5.2v-5.2c0-.3.2-.5.5-.5h2.8c.3 0 .5.2.5.5v5.2h5.2c.3 0 .5.2.5.5v2.8z"/></svg> | ||||
| After Width: | Height: | Size: 310 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/cross.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M25.3 8.56L17.88 16l7.44 7.44-1.86 1.87L16 17.9l-7.44 7.4-1.86-1.85L14.12 16 6.68 8.56 8.55 6.7 16 14.12l7.44-7.44z"/></svg> | ||||
| After Width: | Height: | Size: 193 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/disk.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M24.27 3.2H6.4a3.2 3.2 0 0 0-3.2 3.2v19.2a3.2 3.2 0 0 0 3.2 3.2h19.2a3.2 3.2 0 0 0 3.2-3.2V8.2l-4.53-5zm-1.87 9.6c0 .88-.72 1.6-1.6 1.6h-9.6a1.6 1.6 0 0 1-1.6-1.6v-8h12.8v8zm-1.6-6.4h-3.2v6.4h3.2V6.4z"/></svg> | ||||
| After Width: | Height: | Size: 278 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/download.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M23 25.9c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3c-.3 0-.6.1-.8.3-.2.2-.3.5-.3.8s.1.6.3.8c.2.2.5.3.8.3.3 0 .6-.1.8-.3.2-.3.3-.5.3-.8zm4.6 0c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3c-.3 0-.6.1-.8.3-.2.2-.3.5-.3.8s.1.6.3.8c.2.2.5.3.8.3.3 0 .6-.1.8-.3.2-.3.3-.5.3-.8zm2.3-4v5.7c0 .5-.2.9-.5 1.2-.3.3-.7.5-1.2.5H1.9c-.5 0-.9-.2-1.2-.5s-.5-.7-.5-1.2v-5.7c0-.5.2-.9.5-1.2.3-.3.7-.5 1.2-.5h8.3l2.4 2.4c.7.7 1.5 1 2.4 1 .9 0 1.7-.3 2.4-1l2.4-2.4h8.3c.5 0 .9.2 1.2.5.4.3.6.7.6 1.2zm-5.8-10.2c.2.5.1.9-.3 1.3l-8 8c-.2.2-.5.3-.8.3-.3 0-.6-.1-.8-.3l-8-8c-.4-.3-.5-.8-.3-1.3S6.5 11 7 11h4.6V3c0-.3.1-.6.3-.8s.5-.3.8-.3h4.6c.3 0 .6.1.8.3s.3.5.3.8v8H23c.5 0 .8.2 1.1.7z"/></svg> | ||||
| After Width: | Height: | Size: 711 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/exclamation.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="448" height="448" viewBox="-136 0 448 448"><path d="M128 312v56q0 6.5-4.75 11.25T112 384H48q-6.5 0-11.25-4.75T32 368v-56q0-6.5 4.75-11.25T48 296h64q6.5 0 11.25 4.75T128 312zm7.5-264l-7 192q-.25 6.5-5.13 11.25T112 256H48q-6.5 0-11.38-4.75T31.5 240l-7-192q-.25-6.5 4.38-11.25T40 32h80q6.5 0 11.13 4.75T135.5 48z"/></svg> | ||||
| After Width: | Height: | Size: 365 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/eye.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 36 36"><path d="M29.715 16c-1.696-2.625-4.018-4.875-6.804-6.304 0.714 1.214 1.089 2.607 1.089 4.018 0 4.411-3.589 8-8 8s-8-3.589-8-8c0-1.411 0.375-2.804 1.089-4.018-2.786 1.429-5.107 3.679-6.804 6.304 3.054 4.714 7.982 8 13.714 8s10.661-3.286 13.714-8zM16.858 9.143c0-0.464-0.393-0.857-0.857-0.857-2.982 0-5.429 2.446-5.429 5.429 0 0.464 0.393 0.857 0.857 0.857s0.857-0.393 0.857-0.857c0-2.036 1.679-3.714 3.714-3.714 0.464 0 0.857-0.393 0.857-0.857zM32 16c0 0.446-0.143 0.857-0.357 1.232-3.286 5.411-9.304 9.054-15.643 9.054s-12.357-3.661-15.643-9.054c-0.214-0.375-0.357-0.786-0.357-1.232s0.143-0.857 0.357-1.232c3.286-5.393 9.304-9.054 15.643-9.054s12.357 3.661 15.643 9.054c0.214 0.375 0.357 0.786 0.357 1.232z"></path></svg> | ||||
| After Width: | Height: | Size: 783 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/find.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="-12 -12 512 512"><path d="M480 416L355.44 291.44C373.22 262.4 384 228.58 384 192 384 85.98 298 0 192 0 85.98 0 0 85.98 0 192c0 106 85.98 192 192 192 36.58 0 70.4-10.78 99.44-28.5L416 480c8.75 8.75 23.25 8.7 32 0l32-32a22.8 22.8 0 0 0 0-32zm-288-96c-70.7 0-128-57.3-128-128S121.3 64 192 64s128 57.3 128 128-57.3 128-128 128z"/></svg> | ||||
| After Width: | Height: | Size: 382 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/fullscreen.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M18.7 6.7h6.6v6.6h-2.6v-4h-4V6.7zm4 16v-4h2.6v6.6h-6.6v-2.6h4zm-16-9.4V6.7h6.6v2.6h-4v4H6.7zm2.6 5.4v4h4v2.6H6.7v-6.6h2.6z"/></svg> | ||||
| After Width: | Height: | Size: 200 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/github.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M256 6.3C114.6 6.3 0 121 0 262.3c0 113 73.4 209 175 243 13 2.3 17.6-5.6 17.6-12.4l-.4-48C121 460.5 106 415 106 415c-11.7-29.5-28.5-37.4-28.5-37.4-23.2-16 1.8-15.6 1.8-15.6 25.7 1.8 39.2 26.4 39.2 26.4 23 39.2 60 27.8 74.5 21.3 2.3-16.5 9-27.8 16.3-34.2C152.3 369 92.6 347 92.6 249c0-28 10-50.8 26.4-68.8-2.6-6.4-11.4-32.5 2.5-67.7 0 0 21.5-7 70.4 26.2 20-5.6 42-8.5 64-8.6 21.3.7 43.2 3 64 9 49-33 70-26 70-26 14 35.3 5 61.4 2.4 67.8 16.3 18 26.2 40.8 26.2 68.7 0 98.4-60 120-117 126.4 9.2 8 17.4 23.4 17.4 47.3l-.2 70.2c0 6.6 4.7 14.6 17.7 12 101.7-34 175-129.7 175-243C512 121 397.5 6 256 6z"/></svg> | ||||
| After Width: | Height: | Size: 698 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/home.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M32 18.45L16 6.03 0 18.45V13.4L16 .96 32 13.4zM28 18v12h-8v-8h-8v8H4V18l12-9z"/></svg> | ||||
| After Width: | Height: | Size: 156 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/info.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 34 34"><path d="M16 .6C7.5.6.6 7.6.6 16c0 8.5 7 15.4 15.4 15.4 8.5 0 15.4-7 15.4-15.4C31.4 7.5 24.4.6 16 .6zm1.4 5.6c1.5 0 2 1 2 1.8 0 1.3-1 2.4-2.7 2.4-1.4 0-2-.7-2-2 0-.8.8-2.2 2.7-2.2zm-3.8 19c-1 0-1.8-.6-1-3.4l1-4.8c.3-.8.4-1 0-1-.2 0-1.5.5-2.3 1l-.5-1c2.5-2 5.3-3 6.6-3 1 0 1.2 1 .6 3l-1.3 5c-.2 1 0 1.2 0 1.2.4 0 1.4-.3 2.4-1l1 .7c-2.4 2-5 3-6 3z"/></svg> | ||||
| After Width: | Height: | Size: 416 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/link.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg> | ||||
| After Width: | Height: | Size: 517 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#26b" rx="64" ry="64"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg> | ||||
| After Width: | Height: | Size: 257 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/loop.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 36 36"><path d="M23.53 8.44l3.13-3.13v9.4h-9.38l4.3-4.3C20.18 8.94 18.18 8 16 8c-4.45 0-8 3.56-8 8s3.55 8 8 8c3.5 0 6.5-2.2 7.55-5.3h2.75c-1.2 4.6-5.3 8-10.3 8-5.9 0-10.64-4.83-10.64-10.7S10.1 5.3 15.96 5.3c2.95 0 5.63 1.2 7.57 3.14z"/></svg> | ||||
| After Width: | Height: | Size: 297 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/menu.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="4 0 24 32"><path d="M16 21.3c1.4 0 2.7 1.3 2.7 2.7s-1.3 2.7-2.7 2.7-2.7-1.3-2.7-2.7 1.3-2.7 2.7-2.7zm0-8c1.4 0 2.7 1.3 2.7 2.7s-1.3 2.7-2.7 2.7-2.7-1.3-2.7-2.7 1.3-2.7 2.7-2.7zm0-2.6c-1.4 0-2.7-1.3-2.7-2.7s1.3-2.7 2.7-2.7 2.7 1.3 2.7 2.7-1.3 2.7-2.7 2.7z"/></svg> | ||||
| After Width: | Height: | Size: 312 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/next.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="2 0 32 32"><path d="M24 4v24h-4V17L10 27V5l10 10V4z"/></svg> | ||||
| After Width: | Height: | Size: 109 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/open.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640"><path d="M576 32H64C28.8 32 0 60.8 0 96v384c0 35.2 28.8 63.36 64 63.36h127.36v-62.72h-128V185.6h513.28v295.04h-128v62.75H576c35.23 0 64-28.2 64-63.4V96c0-35.2-28.77-64-64-64zM83.23 138.56c-13.28 0-24-10.46-24-23.36s10.72-23.36 24-23.36c13.25 0 24 10.46 24 23.36s-10.75 23.36-24 23.36zm64 0c-13.28 0-24-10.46-24-23.36s10.72-23.36 24-23.36c13.25 0 24 10.46 24 23.36s-10.75 23.36-24 23.36zm429.44-3.52h-385.3V95.36h385.27v39.68zM318.34 261.57l-155.27 154.3h96V608H377.6V415.87h96l-155.26-154.3z"/></svg> | ||||
| After Width: | Height: | Size: 587 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/paste.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26 10V5a1 1 0 0 0-1-1h-7V2a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2H3a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h9v6h14l6-6V10h-6zM12 2h4v2h-4V2zM6 8V6h16v2H6zm20 21.17V26h3.17L26 29.17zM30 24h-6v6H14V12h16v12z"/></svg> | ||||
| After Width: | Height: | Size: 269 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/pause.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M4 4h10v24H4zm14 0h10v24H18z"/></svg> | ||||
| After Width: | Height: | Size: 106 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/pencil.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2.5 0 32 32"><path d="M6.5 27.4l1.6-1.6-4.2-4.2-1.6 1.6v1.9h2.3v2.3h1.9zm9.3-16.5c0-.3-.1-.4-.4-.4-.1 0-.2 0-.3.1l-9.7 9.7c-.1.1-.1.2-.1.3 0 .3.1.4.4.4.1 0 .2 0 .3-.1l9.7-9.7c.1-.1.1-.2.1-.3zm-.9-3.5l7.4 7.4L7.4 29.7H0v-7.4L14.9 7.4zm12.2 1.7a2 2 0 0 1-.7 1.6l-3 3L16 6.3l3-2.9a2 2 0 0 1 1.6-.7 2 2 0 0 1 1.6.7l4.2 4.2c.4.4.7.9.7 1.5z"/></svg> | ||||
| After Width: | Height: | Size: 393 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/play.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M6 4l20 12L6 28z"/></svg> | ||||
| After Width: | Height: | Size: 94 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/plus.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M31 12H20V1a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v11H1a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h11v11a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V20h11a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1z"/></svg> | ||||
| After Width: | Height: | Size: 229 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/previous.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M8 28V4h4v11L22 5v22L12 17v11z"/></svg> | ||||
| After Width: | Height: | Size: 108 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/reload.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M24.48 14.8c.37 2.55-.4 5.24-2.4 7.2-2.94 2.9-7.48 3.26-10.82 1.08l2.34-2.28L5 19.6 6.2 28l2.62-2.52c4.72 3.48 11.4 3.15 15.7-1.08 2.48-2.45 3.6-5.7 3.47-8.9l-3.53-.7zM9.92 10c2.94-2.9 7.48-3.26 10.82-1.08L18.4 11.2l8.6 1.2L25.8 4l-2.63 2.52C18.47 3.04 11.77 3.37 7.5 7.6 5 10.05 3.86 13.3 4 16.5l3.52.7c-.37-2.55.4-5.24 2.4-7.2z"/></svg> | ||||
| After Width: | Height: | Size: 407 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/rename.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26.67 4V1.33h-8V4h2.66v24h-2.66v2.67h8V28H24V4zm-8 3.12c0-.14-.26-.3-.43-.42a5.8 5.8 0 0 0-2.45-1.07c-.9-.17-2-.25-3.2-.25-.9 0-1.8.14-2.7.42-.9.28-1.7.62-2.4 1.03a5.7 5.7 0 0 0-1.8 1.62c-.5.6-.7 1.24-.7 1.9 0 .64.1 1.2.5 1.72s.8.77 1.6.77 1.4-.2 1.9-.63c.4-.4.7-.8.7-1.3s-.1-1-.2-1.5c-.2-.5-.2-1-.2-1.3.2-.2.6-.5 1.2-.7.5-.2 1.2-.3 1.8-.3.9 0 1.7.2 2.2.6.5.4.9.9 1.2 1.4.2.5.2 1.6.2 1.6v3.2c0 .36-1.8.9-3.8 1.54s-3.2 1-3.8 1.27c-.5.2-1 .48-1.6.8a5.54 5.54 0 0 0-2.4 2.9c-.2.65-.3 1.36-.3 2.2 0 1.58.5 2.87 1.5 3.85S7.82 27.9 9.4 27.9c1.5 0 2.8-.6 3.8-1.13 1.1-.5 2.1-1.3 3-2.7h.1c.2 1.4.6 2.1 1.26 2.7l.87.07V7.1zm-2.32 15.85a7.96 7.96 0 0 1-1.97 1.76 4.9 4.9 0 0 1-2.7.75c-.97 0-1.76-.28-2.4-.85-.62-.56-.93-1.44-.93-2.64 0-1 .2-1.8.63-2.4.4-.7 1-1.3 1.7-1.8.8-.5 1.65-1 2.58-1.3.92-.4 1.86-.7 3.1-1.1v7.4z"/></svg> | ||||
| After Width: | Height: | Size: 887 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/scissors.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M27.84 22.16A7.15 7.15 0 0 0 22.9 20H22l-2-2 7.98-8c2-2 2-6 0-8L16 14 4.02 2c-2 2-2 6 0 8l8 8-2 2H9.1c-1.7 0-3.5.74-4.94 2.16-2.55 2.55-2.9 6.33-.77 8.45.9 1 2.2 1.4 3.5 1.4a7 7 0 0 0 4.9-2.1 6.86 6.86 0 0 0 2.1-5.8L16 22l2.04 2.05a6.87 6.87 0 0 0 2.1 5.8A7.2 7.2 0 0 0 25.07 32c1.34 0 2.6-.46 3.53-1.4 2.13-2.1 1.8-5.9-.77-8.44zm-16.8 4.26A4.95 4.95 0 0 1 8.44 29c-.5.22-1.02.33-1.5.33-.5 0-1.16-.1-1.67-.6a2.3 2.3 0 0 1-.6-1.64A4 4 0 0 1 5 25.5a3.9 3.9 0 0 1 1-1.53c.44-.46 1-.8 1.52-1.05.5-.23 1.03-.34 1.52-.34.46 0 1.13.1 1.64.6.5.5.6 1.2.6 1.65 0 .5-.1 1.02-.3 1.52zm4.96-5.6a2.83 2.83 0 1 1 0-5.67 2.83 2.83 0 0 1 0 5.68zm10.73 7.9c-.5.5-1.18.6-1.66.6-.5 0-1-.1-1.52-.32a4.92 4.92 0 0 1-2.9-4.08c0-.47.1-1.13.6-1.64.5-.5 1.2-.6 1.65-.6.5 0 1.02.1 1.52.32a5.08 5.08 0 0 1 2.6 2.58c.25.5.37 1.02.37 1.5 0 .47-.1 1.13-.6 1.64z"/></svg> | ||||
| After Width: | Height: | Size: 908 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/shuffle.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M32 8l-8-8v6c-4.1 0-7.2.97-9.55 2.98l-.48.43A29.5 29.5 0 0 1 16.1 13c1.5-1.8 3.63-3 7.9-3v12c-6.8 0-8.3-3-10.2-6.9-1.1-2.1-2.2-4.3-4.3-6.1C7.2 7 4.1 6 0 6v4c6.76 0 8.28 3.04 10.2 6.9 1.08 2.14 2.2 4.36 4.25 6.12C16.8 25.02 19.9 26 24 26v6l8-8-8-8 8-8zM0 22v4c4.1 0 7.2-.97 9.55-2.98l.48-.43C9.17 21.4 8.5 20.1 7.9 19c-1.5 1.8-3.67 3-7.9 3z"/></svg> | ||||
| After Width: | Height: | Size: 417 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/signin.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 2 28 28"><path d="M21.1 16c0 .3-.1.6-.3.8l-9.7 9.7c-.2.2-.5.3-.8.3s-.6-.1-.8-.3c-.2-.2-.3-.5-.3-.8v-5.1h-8c-.3 0-.6-.1-.8-.3-.3-.3-.4-.6-.4-.9v-6.9c0-.3.1-.6.3-.8s.5-.3.8-.3h8V6.3c0-.3.1-.6.3-.8s.5-.3.8-.3.6.1.8.3l9.7 9.7c.3.2.4.5.4.8zm6.3-6.3v12.6c0 1.4-.5 2.6-1.5 3.6s-2.2 1.5-3.6 1.5h-5.7c-.2 0-.3-.1-.4-.2s-.2-.2-.2-.3V26l.1-.4.2-.3.4-.1h5.7c.8 0 1.5-.3 2-.8.6-.6.8-1.2.8-2V9.7c0-.8-.3-1.5-.8-2-.6-.6-1.2-.8-2-.8h-5.8l-.2-.1-.1-.1-.3-.2V5l.2-.3.4-.1h5.7c1.4 0 2.6.5 3.6 1.5s1.5 2.2 1.5 3.6z"/></svg> | ||||
| After Width: | Height: | Size: 554 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/signout.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 28 28"><path d="M12.4 25.7V27l-.2.3-.4.1H6.1c-1.4 0-2.6-.5-3.6-1.5S1 23.7 1 22.3V9.7c0-1.4.5-2.6 1.5-3.6s2.2-1.5 3.6-1.5h5.7c.2 0 .3.1.4.2.1.1.2.2.2.4v.9l-.1.4-.2.3-.4.1H6.1c-.8 0-1.5.3-2 .8-.6.6-.8 1.2-.8 2v12.6c0 .8.3 1.5.8 2 .6.6 1.2.8 2 .8h5.8l.2.1.1.1.1.2.1.2zM29 16c0 .3-.1.6-.3.8L19 26.5c-.2.2-.5.3-.8.3-.3 0-.6-.1-.8-.3a.9.9 0 0 1-.4-.8v-5.1H9c-.3 0-.6-.1-.8-.3-.2-.3-.3-.6-.3-.9v-6.9c0-.3.1-.6.3-.8.2-.2.5-.3.8-.3h8V6.3c0-.3.1-.6.3-.8s.5-.3.8-.3c.3 0 .6.1.8.3l9.7 9.7c.3.2.4.5.4.8z"/></svg> | ||||
| After Width: | Height: | Size: 552 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/skip.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 31 31"><path d="M21.3 8H24v16h-2.7V8zM8 24V8l11.3 8z"/></svg> | ||||
| After Width: | Height: | Size: 114 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/spinner.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" class="spinner" viewBox="0 0 80 80"><path d="M10 40v-3.2c0-.3.1-.6.1-.9.1-.6.1-1.4.2-2.1.2-.8.3-1.6.5-2.5.2-.9.6-1.8.8-2.8.3-1 .8-1.9 1.2-3 .5-1 1.1-2 1.7-3.1.7-1 1.4-2.1 2.2-3.1 1.6-2.1 3.7-3.9 6-5.6 2.3-1.7 5-3 7.9-4.1.7-.2 1.5-.4 2.2-.7.7-.3 1.5-.3 2.3-.5.8-.2 1.5-.3 2.3-.4l1.2-.1.6-.1h.6c1.5 0 2.9-.1 4.5.2.8.1 1.6.1 2.4.3.8.2 1.5.3 2.3.5 3 .8 5.9 2 8.5 3.6 2.6 1.6 4.9 3.4 6.8 5.4 1 1 1.8 2.1 2.7 3.1.8 1.1 1.5 2.1 2.1 3.2.6 1.1 1.2 2.1 1.6 3.1.4 1 .9 2 1.2 3 .3 1 .6 1.9.8 2.7.2.9.3 1.6.5 2.4.1.4.1.7.2 1 0 .3.1.6.1.9.1.6.1 1 .1 1.4.4 1 .4 1.4.4 1.4a4.02 4.02 0 0 1-8 .6v-3.4c0-.2-.1-.5-.1-.8-.1-.6-.1-1.2-.2-1.9s-.3-1.4-.4-2.2c-.2-.8-.5-1.6-.7-2.4-.3-.8-.7-1.7-1.1-2.6-.5-.9-.9-1.8-1.5-2.7-.6-.9-1.2-1.8-1.9-2.7A27.12 27.12 0 0 0 48 13.4c-.6-.2-1.3-.4-1.9-.6-.7-.2-1.3-.3-1.9-.4-1.2-.3-2.8-.4-4.2-.5h-2c-.7 0-1.4.1-2.1.1-.7.1-1.4.1-2 .3-.7.1-1.3.3-2 .4-2.6.7-5.2 1.7-7.5 3.1-2.2 1.4-4.3 2.9-6 4.7-.9.8-1.6 1.8-2.4 2.7-.7.9-1.3 1.9-1.9 2.8-.5 1-1 1.9-1.4 2.8-.4.9-.8 1.8-1 2.6-.3.9-.5 1.6-.7 2.4-.2.7-.3 1.4-.4 2.1-.1.3-.1.6-.2.9 0 .3-.1.6-.1.8 0 .5-.1.9-.1 1.3-.2.7-.2 1.1-.2 1.1z"/></svg> | ||||
| After Width: | Height: | Size: 1.1 KiB | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/stop.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M4 4h24v24H4z"/></svg> | ||||
| After Width: | Height: | Size: 91 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/trash.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="10 40 372 490"><path d="M128 344V168c0-4.5-3.5-8-8-8h-16c-4.5 0-8 3.5-8 8v176c0 4.5 3.5 8 8 8h16c4.5 0 8-3.5 8-8zm64 0V168c0-4.5-3.5-8-8-8h-16c-4.5 0-8 3.5-8 8v176c0 4.5 3.5 8 8 8h16c4.5 0 8-3.5 8-8zm64 0V168c0-4.5-3.5-8-8-8h-16c-4.5 0-8 3.5-8 8v176c0 4.5 3.5 8 8 8h16c4.5 0 8-3.5 8-8zM120 96h112l-12-29.25c-.75-1-3-2.5-4.25-2.75H136.5c-1.5.25-3.5 1.75-4.25 2.75zm232 8v16c0 4.5-3.5 8-8 8h-24v237c0 27.5-18 51-40 51H72c-22 0-40-22.5-40-50V128H8c-4.5 0-8-3.5-8-8v-16c0-4.5 3.5-8 8-8h77.25l17.5-41.75C107.75 42 122.75 32 136 32h80c13.25 0 28.25 10 33.25 22.25L266.75 96H344c4.5 0 8 3.5 8 8z"/></svg> | ||||
| After Width: | Height: | Size: 647 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/triangle.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M40 0v100l60-50"/></svg> | ||||
| After Width: | Height: | Size: 95 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/unfullscreen.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M21.3 10.7h4v2.6h-6.6V6.7h2.6v4zm-2.6 14.6v-6.6h6.6v2.6h-4v4h-2.6zm-8-14.6v-4h2.6v6.6H6.7v-2.6h4zm-4 10.6v-2.6h6.6v6.6h-2.6v-4h-4z"/></svg> | ||||
| After Width: | Height: | Size: 208 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/up-arrow.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16 0L0 16h10v16h12V16h10z"/></svg> | ||||
| After Width: | Height: | Size: 104 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/upload-cloud.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="-1 -3 36 36"><path d="M22.86 15.43q0-.25-.16-.4l-6.3-6.3q-.15-.16-.4-.16t-.4.16L9.3 15q-.18.2-.18.43 0 .25.16.4t.4.17h4v6.3q0 .22.18.4t.4.16h3.44q.23 0 .4-.17t.17-.4V16h4q.22 0 .4-.17t.16-.4zm11.43 5.14q0 2.84-2.06 4.85t-4.85 2H8q-3.3 0-5.65-2.34T0 19.43q0-2.32 1.25-4.3T4.6 12.2l-.03-.77q0-3.8 2.68-6.47t6.47-2.68q2.78 0 5.1 1.56t3.36 4.12q1.27-1.1 2.96-1.1 1.9 0 3.24 1.34t1.34 3.23q0 1.35-.74 2.46 2.32.5 3.82 2.4t1.5 4.23z"/></svg> | ||||
| After Width: | Height: | Size: 508 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/user-cog.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path d="M500 524a192 192 0 1 0-1-383 192 192 0 0 0 1 383z"/><path d="M587 850h1l12-15 2-3-27-4c-16-2-28-17-28-33v-53c0-16 12-31 27-34h1l25-4-12-15c-6-7-9-14-9-22 0-11 6-18 7-21h1l24-27c20-19 31-26 42-26 8 0 15 3 22 8l17 12c1-8 2-17 5-26 4-15 17-26 33-26h8c-21-24-46-44-75-61l-6-3-5 5a209 209 0 0 1-304 0l-5-5-6 3a329 329 0 0 0-158 243l-1 4 3 3a421 421 0 0 0 315 108c31 0 61-1 89-4l2-4z"/><path d="M816 769c0-32-27-58-60-58-32 0-59 26-59 58a59 59 0 0 0 119 0zm119-25v51c0 3-3 8-7 8l-43 7c-2 7-5 14-9 20a486 486 0 0 0 27 38l-2 5c-6 7-37 41-45 41l-6-2-32-25c-6 4-14 7-21 9-1 14-3 29-6 43-1 3-5 6-9 6h-51c-4 0-8-3-8-7l-7-42-21-8-32 24-6 2-6-2c-12-11-29-26-38-39l-2-5 2-5 24-32c-4-7-7-15-9-22l-43-7c-4 0-6-4-6-8v-51c0-3 2-7 6-8l43-6c2-8 5-14 9-21a547 547 0 0 0-27-37l2-6c6-7 37-41 45-41l6 3 32 24 21-9c2-14 3-29 7-42 1-4 4-7 8-7h51c5 0 8 3 9 7l6 42 21 9 33-25 5-2 6 2c13 12 29 26 38 39 2 2 2 4 2 5l-2 6-24 31c4 7 7 15 9 22l43 7c4 1 7 4 7 8z"/></svg> | ||||
| After Width: | Height: | Size: 1009 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/user.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path d="M822 747l-3 3c-116 91-203 108-323 108s-203-15-315-108l-3-3 1-4c16-107 72-193 158-243l6-3 5 5a209 209 0 0 0 304 0l5-5 6 3c85 49 141 136 158 243l1 4zM500 524a192 192 0 1 0-1-383 192 192 0 0 0 1 383z"/></svg> | ||||
| After Width: | Height: | Size: 278 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/volume-high.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="34" height="32" viewBox="0 0 34 32"><path d="M27.8 28.8c-.37 0-.75-.13-1.05-.43a1.5 1.5 0 0 1 0-2.12C29.5 23.5 31 19.87 31 16s-1.5-7.5-4.25-10.25a1.5 1.5 0 0 1 0-2.12c.6-.6 1.54-.6 2.12 0C32.17 6.93 34 11.33 34 16s-1.82 9.07-5.13 12.38c-.3.3-.67.44-1.06.44zM22.5 26c-.38 0-.76-.14-1.06-.43a1.5 1.5 0 0 1 0-2.12 10.5 10.5 0 0 0 0-14.85 1.5 1.5 0 0 1 0-2.12c.6-.6 1.54-.6 2.12 0A13.34 13.34 0 0 1 27.5 16c0 3.6-1.4 7-3.96 9.55-.3.3-.67.44-1.06.44zm-5.32-2.82c-.4 0-.77-.15-1.06-.44-.6-.6-.6-1.54 0-2.12a6.52 6.52 0 0 0 0-9.2c-.6-.58-.6-1.53 0-2.1a1.5 1.5 0 0 1 2.12-.02 9.52 9.52 0 0 1 0 13.44c-.3.3-.68.44-1.06.44zm-4.62-20.7c.8-.8 1.46-.53 1.46.6v25.88c0 1.13-.66 1.4-1.46.6L5 22H0V10h5l7.54-7.54z"/></svg> | ||||
| After Width: | Height: | Size: 753 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/volume-low.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="34" height="32" viewBox="0 0 34 32"><path d="M17.16 23.16c-.4 0-.77-.15-1.06-.44-.6-.6-.6-1.54 0-2.12a6.52 6.52 0 0 0 0-9.2c-.6-.58-.6-1.53 0-2.12a1.5 1.5 0 0 1 2.12 0 9.52 9.52 0 0 1 0 13.44c-.3.3-.68.44-1.06.44zm-4.62-20.7c.8-.8 1.46-.53 1.46.6v25.88c0 1.13-.66 1.4-1.46.6L5 22H0V10h5l7.54-7.54z"/></svg> | ||||
| After Width: | Height: | Size: 353 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/volume-medium.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="34" height="32" viewBox="0 0 34 32"><path d="M22.48 25.98c-.38 0-.76-.14-1.06-.44a1.5 1.5 0 0 1 0-2.12 10.5 10.5 0 0 0 0-14.85 1.5 1.5 0 0 1 0-2.12 1.5 1.5 0 0 1 2.12 0A13.4 13.4 0 0 1 27.5 16c0 3.6-1.4 7-3.96 9.55-.3.3-.67.44-1.06.44zm-5.32-2.82c-.4 0-.77-.15-1.06-.44-.6-.6-.6-1.54 0-2.12a6.52 6.52 0 0 0 0-9.2c-.6-.58-.6-1.53 0-2.12a1.5 1.5 0 0 1 2.12 0 9.52 9.52 0 0 1 0 13.44c-.3.3-.68.44-1.06.44zm-4.62-20.7c.8-.8 1.46-.53 1.46.6v25.88c0 1.13-.66 1.4-1.46.6L5 22H0V10h5l7.54-7.54z"/></svg> | ||||
| After Width: | Height: | Size: 542 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/volume-mute.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="34" height="32" viewBox="0 0 34 32"><path d="M12.54 2.46c.8-.8 1.46-.53 1.46.6v25.88c0 1.13-.66 1.4-1.46.6L5 22H0V10h5l7.54-7.54zM30 19.36V22h-2.65L24 18.65 20.65 22H18v-2.65L21.35 16 18 12.65V10h2.65L24 13.35 27.35 10H30v2.65L26.65 16z"/></svg> | ||||
| After Width: | Height: | Size: 292 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/window-cross.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 426 426"><path d="M406.8 54.2H19.2C8.6 54.2 0 62.8 0 73.4v279.2c0 10.6 8.6 19.2 19.2 19.2h387.6c10.6 0 19.2-8.6 19.2-19.2V73.4c0-10.6-8.6-19.2-19.2-19.2zm-38.4 27.6v.2c10 0 18 8 18 17.8s-8 17.8-18 17.8c-9.8 0-17.8-8-17.8-17.8 0-10 8-18 17.8-18zm-47.8 0l-.2.2c10 0 18 8 18 17.8s-8 17.8-18 17.8-17.8-8-17.8-17.8c0-10 8-18 18-18zm-48 0l-.2.2c10 0 18 8 18 17.8s-8 17.8-18 17.8c-9.8 0-17.8-8-17.8-17.8 0-10 8-18 18-18zm115 251.6H38.4V141.6h349.2v191.8z"/><path d="M293 175l-63.8 64 64 64-16 16.2-64.2-63.8-64 63.7-16-15.6 63.8-64.2-64-64 16-16 64.2 64 64-64 16 16z"/></svg> | ||||
| After Width: | Height: | Size: 621 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/window.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="426" height="426" viewBox="0 0 426 426"><path d="M406.8 54.2H19.2C8.6 54.2 0 62.8 0 73.4v279.2c0 10.6 8.6 19.2 19.2 19.2h387.6c10.6 0 19.2-8.6 19.2-19.2V73.4c0-10.6-8.6-19.2-19.2-19.2zM368.4 82c10 0 18 8 18 17.8s-8 17.8-18 17.8c-9.8 0-17.8-8-17.8-17.8 0-10 8-18 17.8-18zm-48 0c10 0 18 8 18 17.8s-8 17.8-18 17.8-17.8-8-17.8-17.8c0-10 8-18 18-18zm-48 0c10 0 18 8 18 17.8s-8 17.8-18 17.8c-9.8 0-17.8-8-17.8-17.8 0-10 8-18 18-18zm115.2 251.4H38.4V141.6h349.2v191.8z"/></svg> | ||||
| After Width: | Height: | Size: 517 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/wordwrap.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768"><path d="M544.5 352.5q52.5 0 90 37.5t37.5 90-37.5 90-90 37.5H480V672l-96-96 96-96v64.5h72q25.5 0 45-19.5t19.5-45-19.5-45-45-19.5H127.5v-63h417zm96-192v63h-513v-63h513zm-513 447v-63h192v63h-192z"/></svg> | ||||
| After Width: | Height: | Size: 289 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/zoomin.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M13.8 8.7h-1.6v3.4H8.8v1.7h3.4V17h1.7v-3.2h3.2v-1.7h-3.4"/><path d="M25.7 27.7l2-2L21 19l-1-1c1.1-1.5 1.6-3.1 1.6-5 0-2.4-.8-4.5-2.5-6.2S15.4 4.3 13 4.3s-4.5.8-6.2 2.5C5.2 8.5 4.3 10.6 4.3 13s.8 4.5 2.5 6.1c1.7 1.7 3.7 2.5 6.2 2.5 1.9 0 3.6-.5 5-1.6m-5-1c-1.7 0-3.1-.6-4.2-1.8C7.6 16.1 7 14.7 7 13s.6-3.1 1.8-4.2C10 7.6 11.4 7 13 7c1.7 0 3.1.6 4.2 1.8C18.5 9.9 19 11.4 19 13c0 1.7-.6 3.1-1.8 4.2-1.1 1.2-2.5 1.8-4.2 1.8z"/></svg> | ||||
| After Width: | Height: | Size: 498 B | 
							
								
								
									
										1
									
								
								cista-front/src/assets/svg/zoomout.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M8.8 12.1v1.7h8.3v-1.7"/><path d="M25.7 27.7l2-2L21 19l-1-1c1.1-1.5 1.6-3.1 1.6-5 0-2.4-.8-4.5-2.5-6.2S15.4 4.3 13 4.3s-4.5.8-6.2 2.5C5.2 8.5 4.3 10.6 4.3 13s.8 4.5 2.5 6.1c1.7 1.7 3.7 2.5 6.2 2.5 1.9 0 3.6-.5 5-1.6m-5-1c-1.7 0-3.1-.6-4.2-1.8C7.6 16.1 7 14.7 7 13s.6-3.1 1.8-4.2C10 7.6 11.4 7 13 7c1.7 0 3.1.6 4.2 1.8C18.5 9.9 19 11.4 19 13c0 1.7-.6 3.1-1.8 4.2-1.1 1.2-2.5 1.8-4.2 1.8z"/></svg> | ||||
| After Width: | Height: | Size: 464 B | 
							
								
								
									
										154
									
								
								cista-front/src/components/BreadCrumb.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,154 @@ | ||||
| <template> | ||||
|   <nav | ||||
|     class="breadcrumb" | ||||
|     aria-label="Breadcrumb" | ||||
|     @keyup.left.stop="move(-1)" | ||||
|     @keyup.right.stop="move(1)" | ||||
|     @focus="move(0)" | ||||
|   > | ||||
|     <a href="#/" | ||||
|       :ref="el => setLinkRef(0, el)" | ||||
|       :class="{ current: !!isCurrent(0) }" | ||||
|       :aria-current="isCurrent(0)" | ||||
|     > | ||||
|       <component :is="home" /> | ||||
|     </a> | ||||
|     <template v-for="(location, index) in longest" :key="index"> | ||||
|       <a :href="`/#/${longest.slice(0, index + 1).join('/')}/`" | ||||
|         :class="{ current: !!isCurrent(index + 1) }" | ||||
|         :aria-current="isCurrent(index + 1)" | ||||
|         @click.prevent="navigate(index + 1)" | ||||
|         :ref="el => setLinkRef(index + 1, el)" | ||||
|       >{{ location }}</a> | ||||
|     </template> | ||||
|   </nav> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import home from '@/assets/svg/home.svg' | ||||
| import { onBeforeUpdate, ref, watchEffect } from 'vue' | ||||
| import { useRouter } from 'vue-router' | ||||
|  | ||||
| const router = useRouter() | ||||
|  | ||||
| const links = [] as Array<HTMLElement> | ||||
| const setLinkRef = (index: number, el: any) => { if (el) links[index] = el } | ||||
| onBeforeUpdate(() => { links.length = 1 })  // 1 to keep home | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   path: Array<string> | ||||
| }>() | ||||
|  | ||||
| const longest = ref<Array<string>>([]) | ||||
|  | ||||
| const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined | ||||
|  | ||||
| const navigate = (index: number) => { | ||||
|   const link = links[index] | ||||
|   if (!link) throw Error(`No link at index ${index} (path: ${props.path})`) | ||||
|   link.focus() | ||||
|   router.replace(`/${longest.value.slice(0, index).join('/')}`) | ||||
| } | ||||
|  | ||||
| const move = (dir: number) => { | ||||
|   const index = props.path.length + dir | ||||
|   if (index < 0 || index > longest.value.length) return | ||||
|   navigate(index) | ||||
| } | ||||
|  | ||||
| watchEffect(() => { | ||||
|   const longcut = longest.value.slice(0, props.path.length) | ||||
|   const same = longcut.every((value, index) => value === props.path[index]) | ||||
|   if (!same) longest.value = props.path | ||||
|   else if (props.path.length > longcut.length) { | ||||
|     longest.value = longcut.concat(props.path.slice(longcut.length)) | ||||
|   } | ||||
| }) | ||||
| watchEffect(() => { | ||||
|   if (links.length) navigate(props.path.length) | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| :root { | ||||
|   --breadcrumb-background-odd: #2d2d2d; | ||||
|   --breadcrumb-background-even: #404040; | ||||
|   --breadcrumb-color: #ddd; | ||||
|   --breadcrumb-hover-color: #fff; | ||||
|   --breadcrumb-hover-background-odd: #25a; | ||||
|   --breadcrumb-hover-background-even: #812; | ||||
|   --breadcrumb-transtime: 0.3s; | ||||
| } | ||||
| .breadcrumb { | ||||
|   display: flex; | ||||
|   list-style: none; | ||||
|   margin: 0; | ||||
|   padding: 0 1em 0 0; | ||||
| } | ||||
| .breadcrumb > a { | ||||
|   margin: 0 -0.5em 0 -0.5em; | ||||
|   padding: 0; | ||||
|   max-width: 8em; | ||||
|   white-space: nowrap; | ||||
|   text-overflow: ellipsis; | ||||
|   overflow: hidden; | ||||
|   height: 1.5em; | ||||
|   color: var(--breadcrumb-color); | ||||
|   padding: 0.3em 1.5em; | ||||
|   clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0); | ||||
|   transition: all var(--breadcrumb-transtime); | ||||
| } | ||||
| .breadcrumb a:first-child { | ||||
|   margin-left: 0; | ||||
|   padding-left: .2em; | ||||
|   clip-path: none; | ||||
| } | ||||
| .breadcrumb a:last-child { | ||||
|   max-width: none; | ||||
|   clip-path: polygon( | ||||
|     0 0, | ||||
|     calc(100% - 1em) 0, | ||||
|     100% 50%, | ||||
|     calc(100% - 1em) 100%, | ||||
|     0 100%, | ||||
|     1em 50%, | ||||
|     0 0 | ||||
|   ); | ||||
| } | ||||
| .breadcrumb a:only-child { | ||||
|   clip-path: polygon( | ||||
|     0 0, | ||||
|     calc(100% - 1em) 0, | ||||
|     100% 50%, | ||||
|     calc(100% - 1em) 100%, | ||||
|     0 100%, | ||||
|     0 0 | ||||
|   ); | ||||
| } | ||||
| .breadcrumb svg { | ||||
|   /* FIXME: Custom positioning to align it well; needs proper solution */ | ||||
|   padding-left: 0.8em; | ||||
|   width: 1.3em; | ||||
|   height: 1.3em; | ||||
|   fill: var(--breadcrumb-color); | ||||
|   transition: fill var(--breadcrumb-transtime); | ||||
| } | ||||
| .breadcrumb a:nth-child(odd) { | ||||
|   background: var(--breadcrumb-background-odd); | ||||
| } | ||||
| .breadcrumb a:nth-child(even) { | ||||
|   background: var(--breadcrumb-background-even); | ||||
| } | ||||
| .breadcrumb a:nth-child(odd):hover, | ||||
| .breadcrumb a:focus:nth-child(odd) { | ||||
|   background: var(--breadcrumb-hover-background-odd); | ||||
| } | ||||
| .breadcrumb a:nth-child(even):hover, | ||||
| .breadcrumb a:focus:nth-child(even) { | ||||
|   background: var(--breadcrumb-hover-background-even); | ||||
| } | ||||
| .breadcrumb a:hover { color: var(--breadcrumb-hover-color) } | ||||
| .breadcrumb a:hover svg { fill: var(--breadcrumb-hover-color) } | ||||
| .breadcrumb a.current { color: var(--accent-color) } | ||||
| .breadcrumb a.current svg { fill: var(--accent-color) } | ||||
| </style> | ||||
							
								
								
									
										517
									
								
								cista-front/src/components/FileExplorer.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,517 @@ | ||||
| <template> | ||||
|   <table v-if="props.documents.length || editing"> | ||||
|     <thead> | ||||
|       <tr> | ||||
|         <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="menu"></th> | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <tr v-if="editing?.key === 'new'" class="folder"> | ||||
|         <td class="selection"></td> | ||||
|         <td class="name"> | ||||
|           <FileRenameInput | ||||
|             :doc="editing" | ||||
|             :rename="mkdir" | ||||
|             :exit=" | ||||
|               () => { | ||||
|                 editing = null | ||||
|               } | ||||
|             " | ||||
|           /> | ||||
|         </td> | ||||
|         <td class="modified right"> | ||||
|           <time :datetime="new Date(editing.mtime).toISOString().replace('.000', '')">{{ | ||||
|             editing.modified | ||||
|           }}</time> | ||||
|         </td> | ||||
|         <td class="size right">{{ editing.sizedisp }}</td> | ||||
|         <td class="menu"></td> | ||||
|       </tr> | ||||
|       <template | ||||
|         v-for="(doc, index) in sortedDocuments" | ||||
|         :key="doc.key"> | ||||
|         <tr class="folder-change" v-if="showFolderBreadcrumb(index)"> | ||||
|           <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th> | ||||
|         </tr> | ||||
|  | ||||
|         <tr | ||||
|           :id="`file-${doc.key}`" | ||||
|           :class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }" | ||||
|           @click="cursor = cursor === doc ? null : doc" | ||||
|           @contextmenu.prevent="contextMenu($event, doc)" | ||||
|         > | ||||
|           <td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null"> | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               tabindex="-1" | ||||
|               :checked="documentStore.selected.has(doc.key)" | ||||
|               @change=" | ||||
|                 ($event.target as HTMLInputElement).checked | ||||
|                   ? documentStore.selected.add(doc.key) | ||||
|                   : documentStore.selected.delete(doc.key) | ||||
|               " | ||||
|             /> | ||||
|           </td> | ||||
|           <td class="name"> | ||||
|             <template v-if="editing === doc" | ||||
|               ><FileRenameInput | ||||
|                 :doc="doc" | ||||
|                 :rename="rename" | ||||
|                 :exit=" | ||||
|                   () => { | ||||
|                     editing = null | ||||
|                   } | ||||
|                 " | ||||
|             /></template> | ||||
|             <template v-else> | ||||
|               <a | ||||
|                 :href="url_for(doc)" | ||||
|                 tabindex="-1" | ||||
|                 @contextmenu.prevent | ||||
|                 @focus.stop="cursor = doc" | ||||
|                 @blur="ev => { if (!editing) cursor = null }" | ||||
|                 @keyup.left="router.back()" | ||||
|                 @keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }" | ||||
|                 >{{ doc.name }}</a | ||||
|               > | ||||
|               <button | ||||
|                 v-if="cursor == doc" | ||||
|                 class="rename-button" | ||||
|                 @click="() => (editing = doc)" | ||||
|               > | ||||
|                 🖊️ | ||||
|               </button> | ||||
|             </template> | ||||
|           </td> | ||||
|           <td class="modified right"> | ||||
|             <time | ||||
|               :data-tooltip="new Date(1000 * doc.mtime).toISOString().replace('T', '\n').replace('.000Z', ' UTC')" | ||||
|               >{{ doc.modified }}</time | ||||
|             > | ||||
|           </td> | ||||
|           <td class="size right">{{ doc.sizedisp }}</td> | ||||
|           <td class="menu"> | ||||
|             <button | ||||
|               tabindex="-1" | ||||
|               @click.stop="contextMenu($event, doc)" | ||||
|             > | ||||
|               ⋮ | ||||
|             </button> | ||||
|           </td> | ||||
|         </tr> | ||||
|       </template> | ||||
|       <tr class="summary" v-if="props.documents.length > 1"> | ||||
|         <td colspan="3" class="right">{{props.documents.length}} items</td> | ||||
|         <td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td> | ||||
|         <td class="menu"></td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
|   <div v-else class="empty-container">Nothing to see here</div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, computed, watchEffect } from 'vue' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import type { Document } from '@/repositories/Document' | ||||
| import FileRenameInput from './FileRenameInput.vue' | ||||
| import { connect, controlUrl } from '@/repositories/WS' | ||||
| import { collator, formatSize, formatUnixDate } from '@/utils' | ||||
| import { useRouter } from 'vue-router' | ||||
|  | ||||
| const props = withDefaults( | ||||
|   defineProps<{ | ||||
|     path: Array<string> | ||||
|     documents: Document[] | ||||
|   }>(), | ||||
|   {} | ||||
| ) | ||||
| const documentStore = useDocumentStore() | ||||
| 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) | ||||
| // File rename | ||||
| const editing = ref<Document | null>(null) | ||||
| const rename = (doc: Document, newName: string) => { | ||||
|   const oldName = doc.name | ||||
|   const control = connect(controlUrl, { | ||||
|     message(ev: MessageEvent) { | ||||
|       const msg = JSON.parse(ev.data) | ||||
|       if ('error' in msg) { | ||||
|         console.error('Rename failed', msg.error.message, msg.error) | ||||
|         doc.name = oldName | ||||
|       } else { | ||||
|         console.log('Rename succeeded', msg) | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
|   control.onopen = () => { | ||||
|     control.send( | ||||
|       JSON.stringify({ | ||||
|         op: 'rename', | ||||
|         path: `${doc.loc}/${oldName}`, | ||||
|         to: newName | ||||
|       }) | ||||
|     ) | ||||
|   } | ||||
|   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 = { | ||||
|       loc: loc.value, | ||||
|       key: 'new', | ||||
|       name: 'New Folder', | ||||
|       type: 'folder', | ||||
|       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]) | ||||
|   }, | ||||
|   isCursor() { | ||||
|     return cursor.value !== null && editing.value === null | ||||
|   }, | ||||
|   cursorRename() { | ||||
|     editing.value = cursor.value | ||||
|   }, | ||||
|   cursorSelect() { | ||||
|     const doc = cursor.value | ||||
|     if (!doc) return | ||||
|     if (documentStore.selected.has(doc.key)) { | ||||
|       documentStore.selected.delete(doc.key) | ||||
|     } else { | ||||
|       documentStore.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) { | ||||
|       cursor.value = null | ||||
|       return | ||||
|     } | ||||
|     const N = documents.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 | ||||
|     const moveto = increment(index, d) | ||||
|     cursor.value = documents[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) | ||||
|       } | ||||
|     } | ||||
|     // @ts-ignore | ||||
|     scrolltr = tr | ||||
|     if (!scrolltimer) { | ||||
|       scrolltimer = setTimeout(() => { | ||||
|         if (scrolltr) | ||||
|           scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' }) | ||||
|         scrolltimer = null | ||||
|       }, 300) | ||||
|     } | ||||
|     if (moveto === N) focusBreadcrumb() | ||||
|   } | ||||
| }) | ||||
| const focusBreadcrumb = () => { | ||||
|   const el = document.querySelector('.breadcrumb') as HTMLElement | null | ||||
|   if (el) el.focus() | ||||
| } | ||||
| let scrolltimer: any = null | ||||
| let scrolltr: any = null | ||||
| watchEffect(() => { | ||||
|   if (cursor.value && cursor.value !== editing.value) editing.value = null | ||||
|   if (editing.value) cursor.value = editing.value | ||||
|   if (cursor.value) { | ||||
|     const a = document.querySelector( | ||||
|       `#file-${cursor.value.key} .name a` | ||||
|     ) as HTMLAnchorElement | null | ||||
|     if (a) a.focus() | ||||
|   } | ||||
| }) | ||||
| watchEffect(() => { | ||||
|   if (!props.documents.length && cursor.value) { | ||||
|     cursor.value = null | ||||
|     focusBreadcrumb() | ||||
|   } | ||||
| }) | ||||
| const mkdir = (doc: Document, name: string) => { | ||||
|   const control = connect(controlUrl, { | ||||
|     open() { | ||||
|       control.send( | ||||
|         JSON.stringify({ | ||||
|           op: 'mkdir', | ||||
|           path: `${doc.loc}/${name}` | ||||
|         }) | ||||
|       ) | ||||
|     }, | ||||
|     message(ev: MessageEvent) { | ||||
|       const msg = JSON.parse(ev.data) | ||||
|       if ('error' in msg) { | ||||
|         console.error('Mkdir failed', msg.error.message, msg.error) | ||||
|         editing.value = null | ||||
|       } else { | ||||
|         console.log('mkdir', msg) | ||||
|         router.push(`/${doc.loc}/${name}/`) | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
|   doc.name = name // We should get an update from watch but this is quicker | ||||
| } | ||||
|  | ||||
| // 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 selectionIndeterminate = computed({ | ||||
|   get: () => { | ||||
|     return ( | ||||
|       props.documents.length > 0 && | ||||
|       props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) && | ||||
|       !allSelected.value | ||||
|     ) | ||||
|   }, | ||||
|   // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||||
|   set: (value: boolean) => {} | ||||
| }) | ||||
| const allSelected = computed({ | ||||
|   get: () => { | ||||
|     return ( | ||||
|       props.documents.length > 0 && | ||||
|       props.documents.every((doc: Document) => documentStore.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) | ||||
|       } else { | ||||
|         documentStore.selected.delete(doc.key) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| }) | ||||
|  | ||||
| const loc = computed(() => props.path.join('/')) | ||||
|  | ||||
| const contextMenu = (ev: Event, doc: Document) => { | ||||
|   cursor.value = doc | ||||
|   console.log('Context menu', ev, doc) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| table { | ||||
|   width: 100%; | ||||
|   table-layout: fixed; | ||||
| } | ||||
| thead tr { | ||||
|   position: sticky; | ||||
|   top: 0; | ||||
|   z-index: 2; | ||||
| } | ||||
| tbody tr { | ||||
|   position: relative; | ||||
|   z-index: auto; | ||||
| } | ||||
| table thead input[type='checkbox'] { | ||||
|   position: inherit; | ||||
|   width: 1em; | ||||
|   height: 1em; | ||||
|   padding: 0.5rem 0.5em; | ||||
| } | ||||
| table tbody input[type='checkbox'] { | ||||
|   width: 2rem; | ||||
|   height: 2rem; | ||||
| } | ||||
| table .selection { | ||||
|   width: 2rem; | ||||
|   text-align: center; | ||||
|   text-overflow: clip; | ||||
| } | ||||
| table .modified { | ||||
|   width: 8em; | ||||
| } | ||||
| table .size { | ||||
|   width: 5em; | ||||
| } | ||||
| table .menu { | ||||
|   width: 1rem; | ||||
| } | ||||
| tbody td { | ||||
|   font-size: 1.2rem; | ||||
| } | ||||
| table th, | ||||
| table td { | ||||
|   padding: 0 0.5rem; | ||||
|   font-weight: normal; | ||||
|   text-align: left; | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
| } | ||||
| .name { | ||||
|   white-space: nowrap; | ||||
|   position: relative; | ||||
| } | ||||
| .name .rename-button { | ||||
|   position: absolute; | ||||
|   right: 0; | ||||
|   animation: appear calc(5 * var(--transition-time)) linear; | ||||
| } | ||||
| @keyframes appear { | ||||
|   from { | ||||
|     opacity: 0; | ||||
|   } | ||||
|   80% { | ||||
|     opacity: 0; | ||||
|   } | ||||
|   to { | ||||
|     opacity: 1; | ||||
|   } | ||||
| } | ||||
| thead tr { | ||||
|   font-size: var(--header-font-size); | ||||
|   background: linear-gradient(to bottom, #eee, #fff 30%, #ddd); | ||||
|   color: #000; | ||||
|   box-shadow: 0 0 .2rem black; | ||||
| } | ||||
| tbody tr.cursor { | ||||
|   background: var(--accent-color); | ||||
| } | ||||
| .right { | ||||
|   text-align: right; | ||||
| } | ||||
| .sortcolumn:hover { | ||||
|   cursor: pointer; | ||||
| } | ||||
| .sortcolumn:hover::after { | ||||
|   color: var(--accent-color); | ||||
| } | ||||
| .sortcolumn { | ||||
|   padding-right: 1.5rem; | ||||
| } | ||||
| .sortcolumn::after { | ||||
|   content: '▸'; | ||||
|   color: #888; | ||||
|   margin-left: 0.5em; | ||||
|   position: absolute; | ||||
|   transition: all var(--transition-time) linear; | ||||
| } | ||||
| .sortactive::after { | ||||
|   transform: rotate(90deg); | ||||
|   color: var(--accent-color); | ||||
| } | ||||
| .name a { | ||||
|   text-decoration: none; | ||||
| } | ||||
| tbody .selection input { | ||||
|   z-index: 1; | ||||
|   position: absolute; | ||||
|   opacity: 0; | ||||
|   left: 0.5rem; | ||||
|   top: 0; | ||||
| } | ||||
| .selection { | ||||
|   width: 2em; | ||||
|   height: 2em; | ||||
| } | ||||
| .selection input:checked { | ||||
|   opacity: 0.7; | ||||
| } | ||||
| .file .selection::before { | ||||
|   content: '📄'; | ||||
|   font-size: 1.5rem; | ||||
| } | ||||
| .folder .selection::before { | ||||
|   height: 2rem; | ||||
|   content: '📁'; | ||||
|   font-size: 1.5rem; | ||||
| } | ||||
| .empty-container { | ||||
|   padding-top: 3rem; | ||||
|   text-align: center; | ||||
|   font-size: 3rem; | ||||
|   color: var(--accent-color); | ||||
| } | ||||
| .folder-change { | ||||
|   margin-left: -.5rem; | ||||
| } | ||||
| .loc { | ||||
|   color: #888; | ||||
| } | ||||
| .summary { | ||||
|   color: #888; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										59
									
								
								cista-front/src/components/FileRenameInput.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,59 @@ | ||||
| <template> | ||||
|   <input | ||||
|     ref="input" | ||||
|     id="FileRenameInput" | ||||
|     type="text" | ||||
|     autocorrect="off" | ||||
|     v-model="name" | ||||
|     @blur="exit" | ||||
|     @keyup.esc="exit" | ||||
|     @keyup.enter="apply" | ||||
|   /> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import type { Document } from '@/repositories/Document' | ||||
| import { ref, onMounted, nextTick } from 'vue' | ||||
|  | ||||
| const input = ref<HTMLInputElement | null>(null) | ||||
| const name = ref('') | ||||
|  | ||||
| onMounted(() => { | ||||
|   name.value = props.doc.name | ||||
|   const ext = name.value.lastIndexOf('.') | ||||
|   nextTick(() => { | ||||
|     input.value!.focus() | ||||
|     input.value!.setSelectionRange(0, ext > 0 ? ext : name.value.length) | ||||
|   }) | ||||
| }) | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   doc: Document | ||||
|   rename: (doc: Document, newName: string) => void | ||||
|   exit: () => void | ||||
| }>() | ||||
|  | ||||
| const apply = () => { | ||||
|   props.exit() | ||||
|   if ( | ||||
|     props.doc.key !== 'new' && | ||||
|     (name.value === props.doc.name || name.value.length === 0) | ||||
|   ) | ||||
|     return | ||||
|   props.rename(props.doc, name.value) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| input#FileRenameInput { | ||||
|   color: var(--primary-color); | ||||
|   background: var(--primary-background); | ||||
|   border: 0; | ||||
|   border-radius: 0.3rem; | ||||
|   padding: 0.4rem; | ||||
|   margin: -0.4rem; | ||||
|   width: 100%; | ||||
|   outline: none; | ||||
|   font: inherit; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										52
									
								
								cista-front/src/components/FileViewer.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,52 @@ | ||||
| <template> | ||||
|   <object | ||||
|     v-if="props.type === 'pdf'" | ||||
|     :data="dataURL" | ||||
|     type="application/pdf" | ||||
|     width="100%" | ||||
|     height="100%" | ||||
|   ></object> | ||||
|   <a-image | ||||
|     v-else-if="props.type === 'image'" | ||||
|     width="50%" | ||||
|     :src="dataURL" | ||||
|     @click="() => setVisible(true)" | ||||
|     :previewMask="false" | ||||
|     :preview="{ | ||||
|       visibleImg, | ||||
|       onVisibleChange: setVisible | ||||
|     }" | ||||
|   /> | ||||
|   <!-- Unknown case --> | ||||
|   <h1 v-else>Unsupported file type</h1> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { watchEffect, ref } from 'vue' | ||||
| import Router from '@/router/index' | ||||
| import { url_document_get } from '@/repositories/Document' | ||||
|  | ||||
| const dataURL = ref('') | ||||
| watchEffect(() => { | ||||
|   dataURL.value = new URL( | ||||
|     url_document_get + Router.currentRoute.value.path, | ||||
|     location.origin | ||||
|   ).toString() | ||||
| }) | ||||
| const emit = defineEmits({ | ||||
|   visibleImg(value: boolean) { | ||||
|     return value | ||||
|   } | ||||
| }) | ||||
|  | ||||
| function setVisible(value: boolean) { | ||||
|   emit('visibleImg', value) | ||||
| } | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   type?: string | ||||
|   visibleImg: boolean | ||||
| }>() | ||||
| </script> | ||||
|  | ||||
| <style></style> | ||||
							
								
								
									
										100
									
								
								cista-front/src/components/HeaderMain.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,100 @@ | ||||
| <template> | ||||
|   <nav class="headermain"> | ||||
|     <div class="buttons"> | ||||
|       <template v-if="documentStore.error"> | ||||
|         <div class="error-message" @click="documentStore.error = ''">{{ documentStore.error }}</div> | ||||
|         <div class="smallgap"></div> | ||||
|       </template> | ||||
|       <UploadButton /> | ||||
|       <SvgButton | ||||
|         name="create-folder" | ||||
|         data-tooltip="New folder" | ||||
|         @click="() => documentStore.fileExplorer.newFolder()" | ||||
|       /> | ||||
|       <slot></slot> | ||||
|       <div class="spacer smallgap"></div> | ||||
|       <template v-if="showSearchInput"> | ||||
|         <input | ||||
|           ref="search" | ||||
|           type="search" | ||||
|           v-model="documentStore.search" | ||||
|           placeholder="Search words" | ||||
|           class="margin-input" | ||||
|           @keyup.escape="closeSearch" | ||||
|         /> | ||||
|       </template> | ||||
|       <SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" /> | ||||
|       <SvgButton name="cog" @click="settingsMenu" /> | ||||
|     </div> | ||||
|   </nav> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { ref, nextTick } from 'vue' | ||||
| import ContextMenu from '@imengyu/vue3-context-menu' | ||||
|  | ||||
| const documentStore = useDocumentStore() | ||||
| const showSearchInput = ref<boolean>(false) | ||||
| const search = ref<HTMLInputElement | null>() | ||||
| const searchButton = ref<HTMLButtonElement | null>() | ||||
|  | ||||
| const closeSearch = () => { | ||||
|   if (!showSearchInput.value) return  // Already closing | ||||
|   showSearchInput.value = false | ||||
|   documentStore.search = '' | ||||
|   const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement | ||||
|   breadcrumb.focus() | ||||
| } | ||||
| const toggleSearchInput = () => { | ||||
|   showSearchInput.value = !showSearchInput.value | ||||
|   if (!showSearchInput.value) return closeSearch() | ||||
|   nextTick(() => { | ||||
|     const input = search.value | ||||
|     if (input) input.focus() | ||||
|   }) | ||||
| } | ||||
|  | ||||
| const settingsMenu = (e: Event) => { | ||||
|   // show the context menu | ||||
|   const items = [] | ||||
|   if (documentStore.user.isLoggedIn) { | ||||
|     items.push({ label: `Logout ${documentStore.user.username ?? ''}`, onClick: () => documentStore.logout() }) | ||||
|   } else { | ||||
|     items.push({ label: 'Login', onClick: () => documentStore.loginDialog() }) | ||||
|   } | ||||
|   ContextMenu.showContextMenu({ | ||||
|     // @ts-ignore | ||||
|     x: e.target.getBoundingClientRect().right, y: e.target.getBoundingClientRect().bottom, | ||||
|     items, | ||||
|   }) | ||||
| } | ||||
|  | ||||
| defineExpose({ | ||||
|   toggleSearchInput, | ||||
|   closeSearch, | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .buttons { | ||||
|   padding: 0; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   height: 3.5em; | ||||
|   z-index: 10; | ||||
| } | ||||
| .buttons > * { | ||||
|   flex-shrink: 1; | ||||
| } | ||||
| input[type='search'] { | ||||
|   background: var(--input-background); | ||||
|   color: var(--input-color); | ||||
|   border: 0; | ||||
|   border-radius: 0.1em; | ||||
|   padding: 0.5em; | ||||
|   outline: none; | ||||
|   font-size: 1.5em; | ||||
|   max-width: 30vw; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										153
									
								
								cista-front/src/components/HeaderSelected.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,153 @@ | ||||
| <template> | ||||
|   <template v-if="documentStore.selected.size"> | ||||
|     <div class="smallgap"></div> | ||||
|     <p class="select-text">{{ documentStore.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> | ||||
|   </template> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import {connect, controlUrl} from '@/repositories/WS' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { computed } from 'vue' | ||||
| import type { SelectedItems } from '@/repositories/Document' | ||||
|  | ||||
| const documentStore = useDocumentStore() | ||||
| const props = defineProps({ | ||||
|   path: Array<string> | ||||
| }) | ||||
|  | ||||
| const dst = computed(() => props.path!.join('/')) | ||||
| const op = (op: string, dst?: string) => { | ||||
|   const sel = documentStore.selectedFiles | ||||
|   const msg = { | ||||
|     op, | ||||
|     sel: sel.keys.map(key => { | ||||
|       const doc = sel.docs[key] | ||||
|       return doc.loc ? `${doc.loc}/${doc.name}` : doc.name | ||||
|     }) | ||||
|   } | ||||
|   // @ts-ignore | ||||
|   if (dst !== undefined) msg.dst = dst | ||||
|   const control = connect(controlUrl, { | ||||
|     message(ev: WebSocmetMessageEvent) { | ||||
|       const res = JSON.parse(ev.data) | ||||
|       if ('error' in res) { | ||||
|         console.error('Control socket error', msg, res.error) | ||||
|         documentStore.error = res.error.message | ||||
|         return | ||||
|       } else if (res.status === 'ack') { | ||||
|         console.log('Control ack OK', res) | ||||
|         control.close() | ||||
|         documentStore.selected.clear() | ||||
|         return | ||||
|       } else console.log('Unknown control response', msg, res) | ||||
|     } | ||||
|   }) | ||||
|   control.onopen = () => { | ||||
|     control.send(JSON.stringify(msg)) | ||||
|   } | ||||
| } | ||||
|  | ||||
| const linkdl = (href: string) => { | ||||
|   const a = document.createElement('a') | ||||
|   a.href = href | ||||
|   a.download = '' | ||||
|   a.click() | ||||
| } | ||||
|  | ||||
| const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => { | ||||
|   let hdir = '' | ||||
|   let h = handle | ||||
|   console.log('Downloading to filesystem', sel.recursive) | ||||
|   for (const [rel, full, doc] of sel.recursive) { | ||||
|     // Create any missing directories | ||||
|     if (hdir && !rel.startsWith(hdir + '/')) { | ||||
|       hdir = '' | ||||
|       h = handle | ||||
|     } | ||||
|     const r = rel.slice(hdir.length) | ||||
|     for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) { | ||||
|       hdir += `${dir}/` | ||||
|       try { | ||||
|         h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true }) | ||||
|       } catch (error) { | ||||
|         console.error('Failed to create directory', hdir, error) | ||||
|         return | ||||
|       } | ||||
|       console.log('Created', hdir) | ||||
|     } | ||||
|     if (doc.dir) continue // Target was a folder and was created | ||||
|     const name = rel.split('/').pop()!.normalize('NFC') | ||||
|     // Download file | ||||
|     let fileHandle | ||||
|     try { | ||||
|       fileHandle = await h.getFileHandle(name, { create: true }) | ||||
|     } catch (error) { | ||||
|       console.error('Failed to create file', rel, full, hdir + name, error) | ||||
|       return | ||||
|     } | ||||
|     const writable = await fileHandle.createWritable() | ||||
|     const url = `/files/${rel}` | ||||
|     console.log('Fetching', url) | ||||
|     const res = await fetch(url) | ||||
|     if (!res.ok) | ||||
|       throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`) | ||||
|     if (res.body) await res.body.pipeTo(writable) | ||||
|     else { | ||||
|       // Zero-sized files don't have a body, so we need to create an empty file | ||||
|       await writable.truncate(0) | ||||
|       await writable.close() | ||||
|     } | ||||
|     console.log('Saved', hdir + name) | ||||
|   } | ||||
| } | ||||
|  | ||||
| const download = async () => { | ||||
|   const sel = documentStore.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() | ||||
|     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() | ||||
|     return linkdl(`/files/${files[0][1]}`) | ||||
|   } | ||||
|   // Use FileSystem API if multiple files and the browser supports it | ||||
|   if ('showDirectoryPicker' in window) { | ||||
|     try { | ||||
|       // @ts-ignore | ||||
|       const handle = await window.showDirectoryPicker({ | ||||
|         startIn: 'downloads', | ||||
|         mode: 'readwrite' | ||||
|       }) | ||||
|       filesystemdl(sel, handle).then(() => { | ||||
|         documentStore.selected.clear() | ||||
|       }) | ||||
|       return | ||||
|     } catch (e) { | ||||
|       console.error('Download to folder aborted', e) | ||||
|     } | ||||
|   } | ||||
|   // Otherwise, zip and download | ||||
|   linkdl(`/zip/${Array.from(sel.keys).join('+')}/download.zip`) | ||||
|   documentStore.selected.clear() | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| .select-text { | ||||
|   color: var(--accent-color); | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
| } | ||||
| </style> | ||||
| @@ -1,41 +0,0 @@ | ||||
| <script setup lang="ts"> | ||||
| defineProps<{ | ||||
|   msg: string | ||||
| }>() | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div class="greetings"> | ||||
|     <h1 class="green">{{ msg }}</h1> | ||||
|     <h3> | ||||
|       You’ve successfully created a project with | ||||
|       <a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> + | ||||
|       <a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next? | ||||
|     </h3> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| h1 { | ||||
|   font-weight: 500; | ||||
|   font-size: 2.6rem; | ||||
|   position: relative; | ||||
|   top: -10px; | ||||
| } | ||||
|  | ||||
| h3 { | ||||
|   font-size: 1.2rem; | ||||
| } | ||||
|  | ||||
| .greetings h1, | ||||
| .greetings h3 { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| @media (min-width: 1024px) { | ||||
|   .greetings h1, | ||||
|   .greetings h3 { | ||||
|     text-align: left; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										101
									
								
								cista-front/src/components/LoginModal.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,101 @@ | ||||
| <template> | ||||
|   <ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required" @blur="store.user.isOpenLoginModal = false"> | ||||
|     <form @submit.prevent="login"> | ||||
|       <div class="login-container"> | ||||
|         <label for="username">Username:</label> | ||||
|         <input | ||||
|           id="username" | ||||
|           name="username" | ||||
|           autocomplete="username" | ||||
|           spellcheck="false" | ||||
|           autocorrect="off" | ||||
|           required | ||||
|           v-model="loginForm.username" | ||||
|         /> | ||||
|         <label for="password">Password:</label> | ||||
|         <input | ||||
|           id="password" | ||||
|           name="password" | ||||
|           type="password" | ||||
|           autocomplete="current-password" | ||||
|           spellcheck="false" | ||||
|           autocorrect="off" | ||||
|           required | ||||
|           v-model="loginForm.password" | ||||
|         /> | ||||
|       </div> | ||||
|       <h3 class="error-text"> | ||||
|         {{ loginForm.error || '\u00A0' }} | ||||
|       </h3> | ||||
|       <div class="dialog-buttons"> | ||||
|         <div class="spacer"></div> | ||||
|         <input id="submit" type="submit" value="Login" class="button-login" /> | ||||
|       </div> | ||||
|     </form> | ||||
|   </ModalDialog> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { reactive, ref } from 'vue' | ||||
| import { loginUser } from '@/repositories/User' | ||||
| import type { ISimpleError } from '@/repositories/Client' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
|  | ||||
| const confirmLoading = ref<boolean>(false) | ||||
| const store = useDocumentStore() | ||||
|  | ||||
| const loginForm = reactive({ | ||||
|   username: '', | ||||
|   password: '', | ||||
|   error: '' | ||||
| }) | ||||
|  | ||||
| const login = async () => { | ||||
|   try { | ||||
|     loginForm.error = '' | ||||
|     confirmLoading.value = true | ||||
|     const msg = await loginUser(loginForm.username, loginForm.password) | ||||
|     store.login(msg.data.username, !!msg.data.privileged) | ||||
|   } catch (error) { | ||||
|     const httpError = error as ISimpleError | ||||
|     loginForm.error = httpError.message || '🛑 Unknown error' | ||||
|   } finally { | ||||
|     confirmLoading.value = false | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .login-container { | ||||
|   display: grid; | ||||
|   gap: 1rem; | ||||
|   grid-template-columns: 1fr 2fr; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   margin: 1rem 0; | ||||
| } | ||||
| .dialog-buttons { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
| } | ||||
| .button-login { | ||||
|   color: #fff; | ||||
|   background: var(--soft-color); | ||||
|   cursor: pointer; | ||||
|   font-weight: bold; | ||||
|   border: 0; | ||||
|   border-radius: .5rem; | ||||
|   padding: .5rem 2rem; | ||||
|   margin-left: auto; | ||||
|   transition: all var(--transition-time) linear; | ||||
| } | ||||
| .button-login:hover, .button-login:focus { | ||||
|   background: var(--accent-color); | ||||
|   box-shadow: 0 0 .3rem #000; | ||||
| } | ||||
| .error-text { | ||||
|   color: var(--red-color); | ||||
|   height: 1em; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										79
									
								
								cista-front/src/components/ModalDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,79 @@ | ||||
| <template> | ||||
|   <dialog ref="dialog"> | ||||
|     <h1 v-if="props.title">{{ props.title }}</h1> | ||||
|     <div> | ||||
|       <slot> | ||||
|         Dialog with no content | ||||
|         <button onclick="dialog.close()">OK</button> | ||||
|       </slot> | ||||
|     </div> | ||||
|   </dialog> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, onMounted } from 'vue' | ||||
|  | ||||
| const dialog = ref<HTMLDialogElement | null>(null) | ||||
|  | ||||
| const props = withDefaults( | ||||
|   defineProps<{ | ||||
|     title: string | ||||
|   }>(), | ||||
|   { | ||||
|     title: '' | ||||
|   } | ||||
| ) | ||||
| const show = () => { | ||||
|   dialog.value!.showModal() | ||||
| } | ||||
| defineExpose({ show }) | ||||
| onMounted(() => { | ||||
|   show() | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| /* Style for the background */ | ||||
| dialog::backdrop { | ||||
|   content: ''; | ||||
|   display: block; | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   background: #0008; | ||||
|   backdrop-filter: blur(0.4em); | ||||
|   z-index: 1000; | ||||
| } | ||||
|  | ||||
| /* Hide the dialog by default */ | ||||
| dialog[open] { | ||||
|   background: #ddd; | ||||
|   color: black; | ||||
|   display: block; | ||||
|   border: none; | ||||
|   font-size: 1.2rem; | ||||
|   border-radius: 0.5rem; | ||||
|   box-shadow: 0.2rem 0.2rem 1rem #000; | ||||
|   padding: 1rem; | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   z-index: 1001; | ||||
| } | ||||
| input { | ||||
|   font: inherit; | ||||
| } | ||||
| dialog[open] > h1 { | ||||
|   background: var(--soft-color); | ||||
|   color: #fff; | ||||
|   font-size: 1.2rem; | ||||
|   margin: -1rem -1rem 0 -1rem; | ||||
|   padding: 0.5rem 1rem 0.5rem 1rem; | ||||
| } | ||||
|  | ||||
| dialog[open] > div { | ||||
|   padding: 1em 0; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										27
									
								
								cista-front/src/components/NotificationLoading.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,27 @@ | ||||
| <template> | ||||
|   <template v-for="upload in documentStore.uploadingDocuments" :key="upload.key"> | ||||
|     <span>{{ upload.name }}</span> | ||||
|     <div class="progress-container"> | ||||
|       <a-progress :percent="upload.progress" /> | ||||
|       <CloseCircleOutlined class="close-button" @click="dismissUpload(upload.key)" /> | ||||
|     </div> | ||||
|   </template> | ||||
| </template> | ||||
| <script setup lang="ts"> | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| const documentStore = useDocumentStore() | ||||
|  | ||||
| function dismissUpload(key: number) { | ||||
|   documentStore.deleteUploadingDocument(key) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .progress-container { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
| } | ||||
| .close-button:hover { | ||||
|   color: #b81414; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										42
									
								
								cista-front/src/components/SvgButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,42 @@ | ||||
| <template> | ||||
|   <button class="action-button"> | ||||
|     <component :is="icon" /> | ||||
|     <slot></slot> | ||||
|   </button> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { defineAsyncComponent, defineProps } from 'vue' | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   name: string | ||||
| }>() | ||||
|  | ||||
| const icon = defineAsyncComponent(() => import(`@/assets/svg/${props.name}.svg`)) | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| .action-button { | ||||
|   background: none; | ||||
|   border: none; | ||||
|   color: #ccc; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.2s ease; | ||||
|   padding: 0.2em; | ||||
|   width: 3em; | ||||
|   height: 3em; | ||||
| } | ||||
| .action-button:hover, | ||||
| .action-button:focus { | ||||
|   color: #fff; | ||||
|   transform: scale(1.1); | ||||
| } | ||||
| svg { | ||||
|   fill: #ccc; | ||||
|   transform: fill 0.2s ease; | ||||
| } | ||||
| .action-button:hover svg, | ||||
| .action-button:focus svg { | ||||
|   fill: #fff; | ||||
| } | ||||
| </style> | ||||
| @@ -1,88 +0,0 @@ | ||||
| <script setup lang="ts"> | ||||
| import WelcomeItem from './WelcomeItem.vue' | ||||
| import DocumentationIcon from './icons/IconDocumentation.vue' | ||||
| import ToolingIcon from './icons/IconTooling.vue' | ||||
| import EcosystemIcon from './icons/IconEcosystem.vue' | ||||
| import CommunityIcon from './icons/IconCommunity.vue' | ||||
| import SupportIcon from './icons/IconSupport.vue' | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <WelcomeItem> | ||||
|     <template #icon> | ||||
|       <DocumentationIcon /> | ||||
|     </template> | ||||
|     <template #heading>Documentation</template> | ||||
|  | ||||
|     Vue’s | ||||
|     <a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a> | ||||
|     provides you with all information you need to get started. | ||||
|   </WelcomeItem> | ||||
|  | ||||
|   <WelcomeItem> | ||||
|     <template #icon> | ||||
|       <ToolingIcon /> | ||||
|     </template> | ||||
|     <template #heading>Tooling</template> | ||||
|  | ||||
|     This project is served and bundled with | ||||
|     <a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The | ||||
|     recommended IDE setup is | ||||
|     <a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> + | ||||
|     <a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If | ||||
|     you need to test your components and web pages, check out | ||||
|     <a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and | ||||
|     <a href="https://on.cypress.io/component" target="_blank" rel="noopener" | ||||
|       >Cypress Component Testing</a | ||||
|     >. | ||||
|  | ||||
|     <br /> | ||||
|  | ||||
|     More instructions are available in <code>README.md</code>. | ||||
|   </WelcomeItem> | ||||
|  | ||||
|   <WelcomeItem> | ||||
|     <template #icon> | ||||
|       <EcosystemIcon /> | ||||
|     </template> | ||||
|     <template #heading>Ecosystem</template> | ||||
|  | ||||
|     Get official tools and libraries for your project: | ||||
|     <a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>, | ||||
|     <a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>, | ||||
|     <a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and | ||||
|     <a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If | ||||
|     you need more resources, we suggest paying | ||||
|     <a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a> | ||||
|     a visit. | ||||
|   </WelcomeItem> | ||||
|  | ||||
|   <WelcomeItem> | ||||
|     <template #icon> | ||||
|       <CommunityIcon /> | ||||
|     </template> | ||||
|     <template #heading>Community</template> | ||||
|  | ||||
|     Got stuck? Ask your question on | ||||
|     <a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official | ||||
|     Discord server, or | ||||
|     <a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener" | ||||
|       >StackOverflow</a | ||||
|     >. You should also subscribe to | ||||
|     <a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow | ||||
|     the official | ||||
|     <a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a> | ||||
|     twitter account for latest news in the Vue world. | ||||
|   </WelcomeItem> | ||||
|  | ||||
|   <WelcomeItem> | ||||
|     <template #icon> | ||||
|       <SupportIcon /> | ||||
|     </template> | ||||
|     <template #heading>Support Vue</template> | ||||
|  | ||||
|     As an independent project, Vue relies on community backing for its sustainability. You can help | ||||
|     us by | ||||
|     <a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>. | ||||
|   </WelcomeItem> | ||||
| </template> | ||||
							
								
								
									
										96
									
								
								cista-front/src/components/UploadButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,96 @@ | ||||
| <script setup lang="ts"> | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { h, ref } from 'vue' | ||||
|  | ||||
| const fileUploadButton = ref() | ||||
| const folderUploadButton = ref() | ||||
| const documentStore = useDocumentStore() | ||||
| const open = (placement: any) => openNotification(placement) | ||||
|  | ||||
| const isNotificationOpen = ref(false) | ||||
| const openNotification = (placement: any) => { | ||||
|   if (!isNotificationOpen.value) { | ||||
|     /* | ||||
|     api.open({ | ||||
|       message: `Uploading documents`, | ||||
|       description: h(NotificationLoading), | ||||
|       placement, | ||||
|       duration: 0, | ||||
|       onClose: () => { isNotificationOpen.value = false } | ||||
|     });*/ | ||||
|     isNotificationOpen.value = true | ||||
|   } | ||||
| } | ||||
|  | ||||
| function uploadFileHandler() { | ||||
|   fileUploadButton.value.click() | ||||
| } | ||||
|  | ||||
| async function load(file: File, start: number, end: number): Promise<ArrayBuffer> { | ||||
|   const reader = new FileReader() | ||||
|   const load = new Promise<Event>(resolve => (reader.onload = resolve)) | ||||
|   reader.readAsArrayBuffer(file.slice(start, end)) | ||||
|   const event = await load | ||||
|   if (event.target && event.target instanceof FileReader) { | ||||
|     return event.target.result as ArrayBuffer | ||||
|   } else { | ||||
|     throw new Error('Error loading file') | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function sendChunk(file: File, start: number, end: number) { | ||||
|   const ws = documentStore.wsUpload | ||||
|   if (ws) { | ||||
|     const chunk = await load(file, start, end) | ||||
|  | ||||
|     ws.send( | ||||
|       JSON.stringify({ | ||||
|         name: file.name, | ||||
|         size: file.size, | ||||
|         start: start, | ||||
|         end: end | ||||
|       }) | ||||
|     ) | ||||
|     ws.send(chunk) | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function uploadFileChangeHandler(event: Event) { | ||||
|   const target = event.target as HTMLInputElement | ||||
|   const chunkSize = 1 << 20 | ||||
|   if (target && target.files && target.files.length > 0) { | ||||
|     const file = target.files[0] | ||||
|     const numChunks = Math.ceil(file.size / chunkSize) | ||||
|     const document = documentStore.pushUploadingDocuments(file.name) | ||||
|     open('bottomRight') | ||||
|     for (let i = 0; i < numChunks; i++) { | ||||
|       const start = i * chunkSize | ||||
|       const end = Math.min(file.size, start + chunkSize) | ||||
|       const res = await sendChunk(file, start, end) | ||||
|       console.log('progress: ' + (100 * (i + 1)) / numChunks) | ||||
|       console.log('Num Chunks: ' + numChunks) | ||||
|       documentStore.updateUploadingDocuments(document.key, (100 * (i + 1)) / numChunks) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| <template> | ||||
|   <template> | ||||
|     <input | ||||
|       ref="fileUploadButton" | ||||
|       @change="uploadFileChangeHandler" | ||||
|       class="upload-input" | ||||
|       type="file" | ||||
|       multiple | ||||
|     /> | ||||
|     <input | ||||
|       ref="folderUploadButton" | ||||
|       @change="uploadFileChangeHandler" | ||||
|       class="upload-input" | ||||
|       type="file" | ||||
|       webkitdirectory | ||||
|     /> | ||||
|   </template> | ||||
|   <SvgButton name="add-file" data-tooltip="Upload files" @click="fileUploadButton.click()" /> | ||||
|   <SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderUploadButton.click()" /> | ||||
| </template> | ||||
| @@ -1,87 +0,0 @@ | ||||
| <template> | ||||
|   <div class="item"> | ||||
|     <i> | ||||
|       <slot name="icon"></slot> | ||||
|     </i> | ||||
|     <div class="details"> | ||||
|       <h3> | ||||
|         <slot name="heading"></slot> | ||||
|       </h3> | ||||
|       <slot></slot> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| .item { | ||||
|   margin-top: 2rem; | ||||
|   display: flex; | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .details { | ||||
|   flex: 1; | ||||
|   margin-left: 1rem; | ||||
| } | ||||
|  | ||||
| i { | ||||
|   display: flex; | ||||
|   place-items: center; | ||||
|   place-content: center; | ||||
|   width: 32px; | ||||
|   height: 32px; | ||||
|  | ||||
|   color: var(--color-text); | ||||
| } | ||||
|  | ||||
| h3 { | ||||
|   font-size: 1.2rem; | ||||
|   font-weight: 500; | ||||
|   margin-bottom: 0.4rem; | ||||
|   color: var(--color-heading); | ||||
| } | ||||
|  | ||||
| @media (min-width: 1024px) { | ||||
|   .item { | ||||
|     margin-top: 0; | ||||
|     padding: 0.4rem 0 1rem calc(var(--section-gap) / 2); | ||||
|   } | ||||
|  | ||||
|   i { | ||||
|     top: calc(50% - 25px); | ||||
|     left: -26px; | ||||
|     position: absolute; | ||||
|     border: 1px solid var(--color-border); | ||||
|     background: var(--color-background); | ||||
|     border-radius: 8px; | ||||
|     width: 50px; | ||||
|     height: 50px; | ||||
|   } | ||||
|  | ||||
|   .item:before { | ||||
|     content: ' '; | ||||
|     border-left: 1px solid var(--color-border); | ||||
|     position: absolute; | ||||
|     left: 0; | ||||
|     bottom: calc(50% + 25px); | ||||
|     height: calc(50% - 25px); | ||||
|   } | ||||
|  | ||||
|   .item:after { | ||||
|     content: ' '; | ||||
|     border-left: 1px solid var(--color-border); | ||||
|     position: absolute; | ||||
|     left: 0; | ||||
|     top: calc(50% + 25px); | ||||
|     height: calc(50% - 25px); | ||||
|   } | ||||
|  | ||||
|   .item:first-of-type:before { | ||||
|     display: none; | ||||
|   } | ||||
|  | ||||
|   .item:last-of-type:after { | ||||
|     display: none; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,7 +0,0 @@ | ||||
| <template> | ||||
|   <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor"> | ||||
|     <path | ||||
|       d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z" | ||||
|     /> | ||||
|   </svg> | ||||
| </template> | ||||
| @@ -1,7 +0,0 @@ | ||||
| <template> | ||||
|   <svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor"> | ||||
|     <path | ||||
|       d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z" | ||||
|     /> | ||||
|   </svg> | ||||
| </template> | ||||
| @@ -1,7 +0,0 @@ | ||||
| <template> | ||||
|   <svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor"> | ||||
|     <path | ||||
|       d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z" | ||||
|     /> | ||||
|   </svg> | ||||
| </template> | ||||
| @@ -1,7 +0,0 @@ | ||||
| <template> | ||||
|   <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor"> | ||||
|     <path | ||||
|       d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z" | ||||
|     /> | ||||
|   </svg> | ||||
| </template> | ||||
| @@ -1,19 +0,0 @@ | ||||
| <!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license--> | ||||
| <template> | ||||
|   <svg | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     xmlns:xlink="http://www.w3.org/1999/xlink" | ||||
|     aria-hidden="true" | ||||
|     role="img" | ||||
|     class="iconify iconify--mdi" | ||||
|     width="24" | ||||
|     height="24" | ||||
|     preserveAspectRatio="xMidYMid meet" | ||||
|     viewBox="0 0 24 24" | ||||
|   > | ||||
|     <path | ||||
|       d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z" | ||||
|       fill="currentColor" | ||||
|     ></path> | ||||
|   </svg> | ||||
| </template> | ||||
| @@ -6,9 +6,20 @@ import { createPinia } from 'pinia' | ||||
| import App from './App.vue' | ||||
| import router from './router' | ||||
|  | ||||
| import piniaPluginPersistedState from 'pinia-plugin-persistedstate' | ||||
|  | ||||
| import ContextMenu from '@imengyu/vue3-context-menu' | ||||
| import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css' | ||||
|  | ||||
| const app = createApp(App) | ||||
| app.config.errorHandler = err => { | ||||
|   /* handle error */ | ||||
|   console.log(err) | ||||
| } | ||||
|  | ||||
| app.use(createPinia()) | ||||
| const pinia = createPinia() | ||||
| pinia.use(piniaPluginPersistedState) | ||||
| app.use(pinia) | ||||
| app.use(router) | ||||
|  | ||||
| app.use(ContextMenu) | ||||
| app.mount('#app') | ||||
|   | ||||
							
								
								
									
										35
									
								
								cista-front/src/repositories/Client.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,35 @@ | ||||
| class ClientClass { | ||||
|   async post(url: string, data?: Record<string, any>): Promise<any> { | ||||
|     const res = await fetch(url, { | ||||
|       method: 'POST', | ||||
|       headers: { | ||||
|         accept: 'application/json', | ||||
|         'content-type': 'application/json' | ||||
|       }, | ||||
|       body: data !== undefined ? JSON.stringify(data) : undefined | ||||
|     }) | ||||
|     let msg | ||||
|     try { | ||||
|       msg = await res.json() | ||||
|     } catch (e) { | ||||
|       throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`) | ||||
|     } | ||||
|     if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message) | ||||
|     return msg | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const Client = new ClientClass() | ||||
| export interface ISimpleError extends Error { | ||||
|   code: number | ||||
| } | ||||
|  | ||||
| class SimpleError extends Error implements ISimpleError { | ||||
|   code: number | ||||
|   constructor(code: number, message: string) { | ||||
|     super(message) | ||||
|     this.code = code | ||||
|   } | ||||
| } | ||||
|  | ||||
| export default Client | ||||
							
								
								
									
										55
									
								
								cista-front/src/repositories/Document.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,55 @@ | ||||
| export type FUID = string | ||||
|  | ||||
| export type Document = { | ||||
|   loc: string | ||||
|   name: string | ||||
|   key: FUID | ||||
|   size: number | ||||
|   sizedisp: string | ||||
|   mtime: number | ||||
|   modified: string | ||||
|   haystack: string | ||||
|   dir: boolean | ||||
| } | ||||
|  | ||||
| export type errorEvent = { | ||||
|   error: { | ||||
|     code: number | ||||
|     message: string | ||||
|     redirect: string | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Raw types the backend /api/watch sends us | ||||
|  | ||||
| export type FileEntry = { | ||||
|   key: FUID | ||||
|   size: number | ||||
|   mtime: number | ||||
| } | ||||
|  | ||||
| export type DirEntry = { | ||||
|   key: FUID | ||||
|   size: number | ||||
|   mtime: number | ||||
|   dir: DirList | ||||
| } | ||||
|  | ||||
| export type DirList = Record<string, FileEntry | DirEntry> | ||||
|  | ||||
| export type UpdateEntry = { | ||||
|   name: string | ||||
|   deleted?: boolean | ||||
|   key?: FUID | ||||
|   size?: number | ||||
|   mtime?: number | ||||
|   dir?: DirList | ||||
| } | ||||
|  | ||||
| // Helper structure for selections | ||||
| export interface SelectedItems { | ||||
|   keys: FUID[] | ||||
|   docs: Record<FUID, Document> | ||||
|   recursive: Array<[string, string, Document]> | ||||
|   missing: Set<FUID> | ||||
| } | ||||
							
								
								
									
										15
									
								
								cista-front/src/repositories/User.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| import Client from '@/repositories/Client' | ||||
| export const url_login = '/login' | ||||
| export const url_logout = '/logout ' | ||||
|  | ||||
| export async function loginUser(username: string, password: string) { | ||||
|   const user = await Client.post(url_login, { | ||||
|     username, | ||||
|     password | ||||
|   }) | ||||
|   return user | ||||
| } | ||||
| export async function logoutUser() { | ||||
|   const data = await Client.post(url_logout) | ||||
|   return data | ||||
| } | ||||
							
								
								
									
										133
									
								
								cista-front/src/repositories/WS.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,133 @@ | ||||
| import { useDocumentStore } from "@/stores/documents" | ||||
| import type { DirEntry, UpdateEntry, errorEvent } from "./Document" | ||||
|  | ||||
| export const controlUrl = '/api/control' | ||||
| export const uploadUrl = '/api/upload' | ||||
| export const watchUrl = '/api/watch' | ||||
|  | ||||
| let tree = null as DirEntry | null | ||||
| let reconnectDuration = 500 | ||||
| let wsWatch = null as WebSocket | null | ||||
|  | ||||
| export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => { | ||||
|   const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws'))) | ||||
|   for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler) | ||||
|   return webSocket | ||||
| } | ||||
|  | ||||
| export const watchConnect = () => { | ||||
|   if (watchTimeout !== null) { | ||||
|     clearTimeout(watchTimeout) | ||||
|     watchTimeout = null | ||||
|   } | ||||
|   const store = useDocumentStore() | ||||
|   if (store.error !== 'Reconnecting...') store.error = 'Connecting...' | ||||
|   console.log(store.error) | ||||
|  | ||||
|   wsWatch = connect(watchUrl, { | ||||
|     message: handleWatchMessage, | ||||
|     close: watchReconnect, | ||||
|   }) | ||||
|   wsWatch.addEventListener("message", event => { | ||||
|     if (store.connected) return | ||||
|     const msg = JSON.parse(event.data) | ||||
|     if ('error' in msg) { | ||||
|       if (msg.error.code === 401) { | ||||
|         store.user.isLoggedIn = false | ||||
|         store.user.isOpenLoginModal = true | ||||
|       } else { | ||||
|         store.error = msg.error.message | ||||
|       } | ||||
|       return | ||||
|     } | ||||
|     if ("server" in msg) { | ||||
|       console.log('Connected to backend', msg) | ||||
|       store.connected = true | ||||
|       reconnectDuration = 500 | ||||
|       store.error = '' | ||||
|       if (msg.user) store.login(msg.user.username, msg.user.privileged) | ||||
|       else if (store.isUserLogged) store.logout() | ||||
|       if (!msg.server.public && !msg.user) store.user.isOpenLoginModal = true | ||||
|     } | ||||
|   }) | ||||
| } | ||||
|  | ||||
| export const watchDisconnect = () => { | ||||
|   if (!wsWatch) return | ||||
|   wsWatch.close() | ||||
|   wsWatch = null | ||||
| } | ||||
|  | ||||
| let watchTimeout: any = null | ||||
|  | ||||
| const watchReconnect = (event: MessageEvent) => { | ||||
|   const store = useDocumentStore() | ||||
|   if (store.connected) { | ||||
|     console.warn("Disconnected from server", event) | ||||
|     store.connected = false | ||||
|     store.error = 'Reconnecting...' | ||||
|   } | ||||
|   reconnectDuration = Math.min(5000, reconnectDuration + 500) | ||||
|   // The server closes the websocket after errors, so we need to reopen it | ||||
|   if (watchTimeout !== null) clearTimeout(watchTimeout) | ||||
|   watchTimeout = setTimeout(watchConnect, reconnectDuration) | ||||
| } | ||||
|  | ||||
|  | ||||
| const handleWatchMessage = (event: MessageEvent) => { | ||||
|   const msg = JSON.parse(event.data) | ||||
|   switch (true) { | ||||
|     case !!msg.root: | ||||
|       handleRootMessage(msg) | ||||
|       break | ||||
|     case !!msg.update: | ||||
|       handleUpdateMessage(msg) | ||||
|       break | ||||
|     case !!msg.space: | ||||
|       console.log('Watch space', msg.space) | ||||
|       break | ||||
|     case !!msg.error: | ||||
|       handleError(msg) | ||||
|       break | ||||
|     default: | ||||
|   } | ||||
| } | ||||
|  | ||||
| function handleRootMessage({ root }: { root: DirEntry }) { | ||||
|   const store = useDocumentStore() | ||||
|   console.log('Watch root', root) | ||||
|   store.updateRoot(root) | ||||
|   tree = root | ||||
| } | ||||
|  | ||||
| function handleUpdateMessage(updateData: { update: UpdateEntry[] }) { | ||||
|   const store = useDocumentStore() | ||||
|   console.log('Watch update', updateData.update) | ||||
|   if (!tree) return console.error('Watch update before root') | ||||
|   let node: DirEntry = tree | ||||
|   for (const elem of updateData.update) { | ||||
|     if (elem.deleted) { | ||||
|       delete node.dir[elem.name] | ||||
|       break // Deleted elements can't have further children | ||||
|     } | ||||
|     if (elem.name) { | ||||
|       // @ts-ignore | ||||
|       console.log(node, elem.name) | ||||
|       node = node.dir[elem.name] ||= {} | ||||
|     } | ||||
|     if (elem.key !== undefined) node.key = elem.key | ||||
|     if (elem.size !== undefined) node.size = elem.size | ||||
|     if (elem.mtime !== undefined) node.mtime = elem.mtime | ||||
|     if (elem.dir !== undefined) node.dir = elem.dir | ||||
|   } | ||||
|   store.updateRoot(tree) | ||||
| } | ||||
|  | ||||
| function handleError(msg: errorEvent) { | ||||
|   const store = useDocumentStore() | ||||
|   if (msg.error.code === 401) { | ||||
|     store.user.isOpenLoginModal = true | ||||
|     store.user.isLoggedIn = false | ||||
|     return | ||||
|   } | ||||
| } | ||||
| @@ -1,21 +1,13 @@ | ||||
| import { createRouter, createWebHistory } from 'vue-router' | ||||
| import HomeView from '../views/HomeView.vue' | ||||
| import { createRouter, createWebHashHistory } from 'vue-router' | ||||
| import ExplorerView from '@/views/ExplorerView.vue' | ||||
|  | ||||
| const router = createRouter({ | ||||
|   history: createWebHistory(import.meta.env.BASE_URL), | ||||
|   history: createWebHashHistory(import.meta.env.BASE_URL), | ||||
|   routes: [ | ||||
|     { | ||||
|       path: '/', | ||||
|       name: 'home', | ||||
|       component: HomeView | ||||
|     }, | ||||
|     { | ||||
|       path: '/about', | ||||
|       name: 'about', | ||||
|       // route level code-splitting | ||||
|       // this generates a separate chunk (About.[hash].js) for this route | ||||
|       // which is lazy-loaded when the route is visited. | ||||
|       component: () => import('../views/AboutView.vue') | ||||
|       path: '/:pathMatch(.*)*', | ||||
|       name: 'explorer', | ||||
|       component: ExplorerView | ||||
|     } | ||||
|   ] | ||||
| }) | ||||
|   | ||||
| @@ -1,12 +0,0 @@ | ||||
| import { ref, computed } from 'vue' | ||||
| import { defineStore } from 'pinia' | ||||
|  | ||||
| export const useCounterStore = defineStore('counter', () => { | ||||
|   const count = ref(0) | ||||
|   const doubleCount = computed(() => count.value * 2) | ||||
|   function increment() { | ||||
|     count.value++ | ||||
|   } | ||||
|  | ||||
|   return { count, doubleCount, increment } | ||||
| }) | ||||
							
								
								
									
										183
									
								
								cista-front/src/stores/documents.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,183 @@ | ||||
| import type { | ||||
|   Document, | ||||
|   DirEntry, | ||||
|   FileEntry, | ||||
|   FUID, | ||||
|   SelectedItems | ||||
| } from '@/repositories/Document' | ||||
| import { formatSize, formatUnixDate, haystackFormat } from '@/utils' | ||||
| import { defineStore } from 'pinia' | ||||
| import { collator } from '@/utils' | ||||
| import { logoutUser } from '@/repositories/User' | ||||
| import { watchConnect } from '@/repositories/WS' | ||||
|  | ||||
| type FileData = { id: string; mtime: number; size: number; dir: DirectoryData } | ||||
| type DirectoryData = { | ||||
|   [filename: string]: FileData | ||||
| } | ||||
| type User = { | ||||
|   username: string | ||||
|   privileged: boolean | ||||
|   isOpenLoginModal: boolean | ||||
|   isLoggedIn: boolean | ||||
| } | ||||
|  | ||||
| export const useDocumentStore = defineStore({ | ||||
|   id: 'documents', | ||||
|   state: () => ({ | ||||
|     document: [] as Document[], | ||||
|     search: "" as string, | ||||
|     selected: new Set<FUID>(), | ||||
|     uploadingDocuments: [], | ||||
|     uploadCount: 0 as number, | ||||
|     fileExplorer: null, | ||||
|     error: '' as string, | ||||
|     connected: false, | ||||
|     user: { | ||||
|       username: '', | ||||
|       privileged: false, | ||||
|       isLoggedIn: false, | ||||
|       isOpenLoginModal: false | ||||
|     } as User | ||||
|   }), | ||||
|   persist: { | ||||
|     storage: sessionStorage, | ||||
|     paths: ['document'], | ||||
|   }, | ||||
|   actions: { | ||||
|     updateRoot(root: DirEntry | null = null) { | ||||
|       if (!root) { | ||||
|         this.document = [] | ||||
|         return | ||||
|       } | ||||
|       // Transform tree data to flat documents array | ||||
|       let loc = "" | ||||
|       const mapper = ([name, attr]: [string, FileEntry | DirEntry]) => ({ | ||||
|         ...attr, | ||||
|         loc, | ||||
|         name, | ||||
|         sizedisp: formatSize(attr.size), | ||||
|         modified: formatUnixDate(attr.mtime), | ||||
|         haystack: haystackFormat(name), | ||||
|       }) | ||||
|       const queue = [...Object.entries(root.dir ?? {}).map(mapper)] | ||||
|       const docs = [] | ||||
|       for (let doc; (doc = queue.shift()) !== undefined;) { | ||||
|         docs.push(doc) | ||||
|         if ("dir" in doc) { | ||||
|           // Recurse but replace recursive structure with boolean | ||||
|           loc = doc.loc ? `${doc.loc}/${doc.name}` : doc.name | ||||
|           queue.push(...Object.entries(doc.dir).map(mapper)) | ||||
|           // @ts-ignore | ||||
|           doc.dir = true | ||||
|         } | ||||
|         // @ts-ignore | ||||
|         else doc.dir = false | ||||
|       } | ||||
|       // Pre sort directory entries folders first then files, names in natural ordering | ||||
|       docs.sort((a, b) => | ||||
|         // @ts-ignore | ||||
|         b.dir - a.dir || | ||||
|         collator.compare(a.name, b.name) | ||||
|       ) | ||||
|       this.document = docs as Document[] | ||||
|     }, | ||||
|     updateUploadingDocuments(key: number, progress: number) { | ||||
|       for (const d of this.uploadingDocuments) { | ||||
|         if (d.key === key) d.progress = progress | ||||
|       } | ||||
|     }, | ||||
|     pushUploadingDocuments(name: string) { | ||||
|       this.uploadCount++ | ||||
|       const document = { | ||||
|         key: this.uploadCount, | ||||
|         name: name, | ||||
|         progress: 0 | ||||
|       } | ||||
|       this.uploadingDocuments.push(document) | ||||
|       return document | ||||
|     }, | ||||
|     deleteUploadingDocument(key: number) { | ||||
|       this.uploadingDocuments = this.uploadingDocuments.filter(e => e.key !== key) | ||||
|     }, | ||||
|     updateModified() { | ||||
|       for (const d of this.document) { | ||||
|         if ('mtime' in d) d.modified = formatUnixDate(d.mtime) | ||||
|       } | ||||
|     }, | ||||
|     login(username: string, privileged: boolean) { | ||||
|       this.user.username = username | ||||
|       this.user.privileged = privileged | ||||
|       this.user.isLoggedIn = true | ||||
|       this.user.isOpenLoginModal = false | ||||
|       if (!this.connected) watchConnect() | ||||
|     }, | ||||
|     loginDialog() { | ||||
|       this.user.isOpenLoginModal = true | ||||
|     }, | ||||
|     async logout() { | ||||
|       console.log("Logout") | ||||
|       await logoutUser() | ||||
|       this.$reset() | ||||
|       history.go() // Reload page | ||||
|     } | ||||
|   }, | ||||
|   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 | ||||
|     }, | ||||
|     selectedFiles(): SelectedItems { | ||||
|       const selected = this.selected | ||||
|       const found = new Set<FUID>() | ||||
|       const ret: SelectedItems = { | ||||
|         missing: new Set(), | ||||
|         docs: {}, | ||||
|         keys: [], | ||||
|         recursive: [], | ||||
|       } | ||||
|       for (const doc of this.document) { | ||||
|         if (selected.has(doc.key)) { | ||||
|           found.add(doc.key) | ||||
|           ret.keys.push(doc.key) | ||||
|           ret.docs[doc.key] = doc | ||||
|         } | ||||
|       } | ||||
|       // What did we not select? | ||||
|       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) { | ||||
|         if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`) | ||||
|         relnames.add(rel) | ||||
|         ret.recursive.push([rel, full, doc]) | ||||
|       } | ||||
|       for (const key of ret.keys) { | ||||
|         const base = ret.docs[key] | ||||
|         const basepath = base.loc ? `${base.loc}/${base.name}` : base.name | ||||
|         const nremove = base.loc.length | ||||
|         add(base.name, basepath, base) | ||||
|         for (const doc of this.document) { | ||||
|           if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') { | ||||
|             const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name | ||||
|             const rel = full.slice(nremove) | ||||
|             add(rel, full, doc) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       // Sort by rel (name stored as on download) | ||||
|       ret.recursive.sort((a, b) => collator.compare(a[0], b[0])) | ||||
|  | ||||
|       return ret | ||||
|     } | ||||
|   } | ||||
| }) | ||||
							
								
								
									
										96
									
								
								cista-front/src/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,96 @@ | ||||
| export function determineFileType(inputString: string): 'file' | 'folder' { | ||||
|   if (inputString.includes('.') && !inputString.endsWith('.')) { | ||||
|     return 'file' | ||||
|   } else { | ||||
|     return 'folder' | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function formatSize(size: number) { | ||||
|   if (size === 0) return 'empty' | ||||
|   for (const unit of [null, 'kB', 'MB', 'GB', 'TB', 'PB', 'EB']) { | ||||
|     if (size < 1e4) | ||||
|       return ( | ||||
|         size.toLocaleString().replace(',', '\u202F') + (unit ? `\u202F${unit}` : '') | ||||
|       ) | ||||
|     size = Math.round(size / 1000) | ||||
|   } | ||||
|   return 'huge' | ||||
| } | ||||
|  | ||||
| export function formatUnixDate(t: number) { | ||||
|   const date = new Date(t * 1000) | ||||
|   const now = new Date() | ||||
|   const diff = date.getTime() - now.getTime() | ||||
|   const adiff = Math.abs(diff) | ||||
|   const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) | ||||
|   if (adiff <= 5000) return 'now' | ||||
|   if (adiff <= 60000) { | ||||
|     return formatter.format(Math.round(diff / 1000), 'second').replace(' ago', '').replaceAll(' ', '\u202F') | ||||
|   } | ||||
|   if (adiff <= 3600000) { | ||||
|     return formatter.format(Math.round(diff / 60000), 'minute').replace('utes', '').replace('ute', '').replaceAll(' ', '\u202F') | ||||
|   } | ||||
|   if (adiff <= 86400000) { | ||||
|     return formatter.format(Math.round(diff / 3600000), 'hour').replaceAll(' ', '\u202F') | ||||
|   } | ||||
|   if (adiff <= 604800000) { | ||||
|     return formatter.format(Math.round(diff / 86400000), 'day').replaceAll(' ', '\u202F') | ||||
|   } | ||||
|   let d = date.toLocaleDateString('en-ie', { | ||||
|     weekday: 'short', | ||||
|     year: 'numeric', | ||||
|     month: 'short', | ||||
|     day: 'numeric' | ||||
|   }).replace("Sept", "Sep") | ||||
|   if (d.length === 14) d = d.replace(' ', ' \u2007')  // dom < 10 alignment (add figure space) | ||||
|   d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0')  // nobr spaces, thin w/ date but not weekday | ||||
|   d = d.slice(0, -4) + d.slice(-2)  // Two digit year is enough | ||||
|   return d | ||||
| } | ||||
|  | ||||
| export function getFileExtension(filename: string) { | ||||
|   const parts = filename.split('.') | ||||
|   if (parts.length > 1) { | ||||
|     return parts[parts.length - 1] | ||||
|   } else { | ||||
|     return '' // No hay extensión | ||||
|   } | ||||
| } | ||||
| interface FileTypes { | ||||
|   [key: string]: string[] | ||||
| } | ||||
|  | ||||
| const filetypes: FileTypes = { | ||||
|   video: ['avi', 'mkv', 'mov', 'mp4', 'webm'], | ||||
|   image: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'], | ||||
|   pdf: ['pdf'], | ||||
| } | ||||
|  | ||||
| export function getFileType(name: string): string { | ||||
|   const ext = name.split('.').pop()?.toLowerCase() | ||||
|   if (!ext || ext.length === name.length) return 'unknown' | ||||
|   return Object.keys(filetypes).find(type => filetypes[type].includes(ext)) || 'unknown' | ||||
| } | ||||
|  | ||||
| // Prebuilt for fast & consistent sorting | ||||
| export const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' }) | ||||
|  | ||||
| // Preformat document names for faster search | ||||
| export function haystackFormat(str: string) { | ||||
|   const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() | ||||
|   return '^' + based + '$' | ||||
| } | ||||
|  | ||||
|  | ||||
| // Preformat search string for faster search | ||||
| export function needleFormat(query: string) { | ||||
|   const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() | ||||
|   return {based, words: based.split(/\W+/)} | ||||
| } | ||||
|  | ||||
| // Test if haystack includes needle | ||||
| export function localeIncludes(haystack: string, filter: { based: string, words: string[] }) { | ||||
|   const {based, words} = filter | ||||
|   return haystack.includes(based) || words && words.every(word => haystack.includes(word)) | ||||
| } | ||||
| @@ -1,15 +0,0 @@ | ||||
| <template> | ||||
|   <div class="about"> | ||||
|     <h1>This is an about page</h1> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <style> | ||||
| @media (min-width: 1024px) { | ||||
|   .about { | ||||
|     min-height: 100vh; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										58
									
								
								cista-front/src/views/ExplorerView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,58 @@ | ||||
| <template> | ||||
|   <FileExplorer | ||||
|     ref="fileExplorer" | ||||
|     :key="Router.currentRoute.value.path" | ||||
|     :path="props.path" | ||||
|     :documents="documents" | ||||
|     v-if="props.path" | ||||
|   /> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { watchEffect, ref, computed } from 'vue' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import Router from '@/router/index' | ||||
| import { needleFormat, localeIncludes, collator } from '@/utils'; | ||||
|  | ||||
| const documentStore = useDocumentStore() | ||||
| const fileExplorer = ref() | ||||
| const props = defineProps({ | ||||
|   path: Array<string> | ||||
| }) | ||||
| const documents = computed(() => { | ||||
|   if (!props.path) return [] | ||||
|   const loc = props.path.join('/') | ||||
|   // List the current location | ||||
|   if (!documentStore.search) return documentStore.document.filter(doc => doc.loc === loc) | ||||
|   // Find up to 100 newest documents that match the search | ||||
|   const search = documentStore.search | ||||
|   const needle = needleFormat(search) | ||||
|   let limit = 100 | ||||
|   let docs = [] | ||||
|   for (const doc of documentStore.recentDocuments) { | ||||
|     if (localeIncludes(doc.haystack, needle)) { | ||||
|       docs.push(doc) | ||||
|       if (--limit === 0) break | ||||
|     } | ||||
|   } | ||||
|   // Organize by folder, by relevance | ||||
|   const locsub = loc + '/' | ||||
|   docs.sort((a, b) => ( | ||||
|     // @ts-ignore | ||||
|     (b.loc === loc) - (a.loc === loc) || | ||||
|     // @ts-ignore | ||||
|     (b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) || | ||||
|     collator.compare(a.loc, b.loc) || | ||||
|     // @ts-ignore | ||||
|     (a.type === 'file') - (b.type === 'file') || | ||||
|     // @ts-ignore | ||||
|     b.name.includes(search) - a.name.includes(search) || | ||||
|     collator.compare(a.name, b.name) | ||||
|   )) | ||||
|   return docs | ||||
| }) | ||||
|  | ||||
| watchEffect(() => { | ||||
|   documentStore.fileExplorer = fileExplorer.value | ||||
| }) | ||||
| </script> | ||||
| @@ -1,9 +0,0 @@ | ||||
| <script setup lang="ts"> | ||||
| import TheWelcome from '../components/TheWelcome.vue' | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <main> | ||||
|     <TheWelcome /> | ||||
|   </main> | ||||
| </template> | ||||
| @@ -3,6 +3,8 @@ | ||||
|   "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], | ||||
|   "exclude": ["src/**/__tests__/*"], | ||||
|   "compilerOptions": { | ||||
|     "lib": ["es2021", "DOM"], | ||||
|     "target": "es2021", | ||||
|     "composite": true, | ||||
|     "baseUrl": ".", | ||||
|     "paths": { | ||||
|   | ||||
| @@ -6,6 +6,9 @@ | ||||
|     }, | ||||
|     { | ||||
|       "path": "./tsconfig.app.json" | ||||
|     }, | ||||
|     { | ||||
|       "path": "./tsconfig.vitest.json" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
|   | ||||