Compare commits
	
		
			108 Commits
		
	
	
		
			v0.7.0
			...
			259f585968
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 259f585968 | ||
|   | 4bb7d091e4 | ||
|   | 4ae31fe241 | ||
|   | f0c7465944 | ||
|   | a3a6b3771c | ||
|   | 348f8e183e | ||
|   | 37167a41a6 | ||
|   | 63f6008a0a | ||
|   | 4fd769cce2 | ||
|   | 2695fc67f3 | ||
|   | 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__/ | __pycache__/ | ||||||
| *.egg-info/ | *.egg-info/ | ||||||
| /cista/_version.py | /cista/_version.py | ||||||
|  | /cista/wwwroot/* | ||||||
| /dist | /dist | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								cista-front/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -7,12 +7,17 @@ yarn-error.log* | |||||||
| pnpm-debug.log* | pnpm-debug.log* | ||||||
| lerna-debug.log* | lerna-debug.log* | ||||||
|  |  | ||||||
|  | # No locking | ||||||
|  | package-lock.json | ||||||
|  | yarn.lock | ||||||
|  |  | ||||||
| node_modules | node_modules | ||||||
| .DS_Store | .DS_Store | ||||||
| dist | dist | ||||||
| dist-ssr | dist-ssr | ||||||
| coverage | coverage | ||||||
| *.local | *.local | ||||||
|  | components.d.ts | ||||||
|  |  | ||||||
| /cypress/videos/ | /cypress/videos/ | ||||||
| /cypress/screenshots/ | /cypress/screenshots/ | ||||||
|   | |||||||
| @@ -1,13 +1,12 @@ | |||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
| <html lang="en"> | <html lang=en> | ||||||
|   <head> | <meta charset=UTF-8> | ||||||
|     <meta charset="UTF-8"> | <title>Cista</title> | ||||||
|     <link rel="icon" href="/favicon.ico"> | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | <link rel="icon" href="/favicon.ico"> | ||||||
|     <title>Vite App</title> | <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||||
|   </head> | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||||
|   <body> | <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet"> | ||||||
|     <div id="app"></div> | <script type="module" src="/src/main.ts"></script> | ||||||
|     <script type="module" src="/src/main.ts"></script> |  | ||||||
|   </body> | <div id="app"></div> | ||||||
| </html> |  | ||||||
|   | |||||||
							
								
								
									
										1458
									
								
								cista-front/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -1,27 +1,59 @@ | |||||||
| { | { | ||||||
|   "name": "cista-front", |   "name": "front", | ||||||
|   "version": "0.0.0", |   "version": "0.0.0", | ||||||
|   "private": true, |   "private": true, | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "dev": "vite", |     "dev": "vite", | ||||||
|     "build": "run-p type-check \"build-only {@}\" --", |     "build": "run-p type-check \"build-only {@}\" --", | ||||||
|     "preview": "vite preview", |     "preview": "vite preview", | ||||||
|  |     "test:unit": "vitest", | ||||||
|     "build-only": "vite build", |     "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": { |   "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": "^3.3.4", | ||||||
|     "vue-router": "^4.2.5" |     "vue-router": "^4.2.4" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|  |     "@rushstack/eslint-patch": "^1.3.3", | ||||||
|     "@tsconfig/node18": "^18.2.2", |     "@tsconfig/node18": "^18.2.2", | ||||||
|     "@types/node": "^18.18.5", |     "@types/jsdom": "^21.1.3", | ||||||
|     "@vitejs/plugin-vue": "^4.4.0", |     "@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", |     "@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", |     "typescript": "~5.2.0", | ||||||
|     "vite": "^4.4.11", |     "vite": "^4.4.9", | ||||||
|     "vue-tsc": "^1.8.19" |     "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> | <template> | ||||||
|  |   <LoginModal /> | ||||||
|   <header> |   <header> | ||||||
|     <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" /> |     <HeaderMain ref="headerMain" :path="path.pathList"> | ||||||
|  |       <HeaderSelected :path="path.pathList" /> | ||||||
|     <div class="wrapper"> |     </HeaderMain> | ||||||
|       <HelloWorld msg="You did it!" /> |     <BreadCrumb :path="path.pathList" tabindex="-1"/> | ||||||
|  |  | ||||||
|       <nav> |  | ||||||
|         <RouterLink to="/">Home</RouterLink> |  | ||||||
|         <RouterLink to="/about">About</RouterLink> |  | ||||||
|       </nav> |  | ||||||
|     </div> |  | ||||||
|   </header> |   </header> | ||||||
|  |   <main> | ||||||
|   <RouterView /> |     <RouterView :path="path.pathList" /> | ||||||
|  |   </main> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <style scoped> | <script setup lang="ts"> | ||||||
| header { | import { RouterView } from 'vue-router' | ||||||
|   line-height: 1.5; | import type { ComputedRef } from 'vue' | ||||||
|   max-height: 100vh; | 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 { | import { computed } from 'vue' | ||||||
|   display: block; | import Router from '@/router/index' | ||||||
|   margin: 0 auto 2rem; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| nav { | interface Path { | ||||||
|   width: 100%; |   path: string | ||||||
|   font-size: 12px; |   pathList: string[] | ||||||
|   text-align: center; |  | ||||||
|   margin-top: 2rem; |  | ||||||
| } | } | ||||||
|  | const documentStore = useDocumentStore() | ||||||
| nav a.router-link-exact-active { | const path: ComputedRef<Path> = computed(() => { | ||||||
|   color: var(--color-text); |   const p = decodeURIComponent(Router.currentRoute.value.path) | ||||||
| } |   const pathList = p.split('/').filter(value => value !== '') | ||||||
|  |   return { | ||||||
| nav a.router-link-exact-active:hover { |     path: p, | ||||||
|   background-color: transparent; |     pathList | ||||||
| } |  | ||||||
|  |  | ||||||
| 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); |  | ||||||
|   } |   } | ||||||
|  | }) | ||||||
|   .logo { | onMounted(watchConnect) | ||||||
|     margin: 0 2rem 0 0; | 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 | ||||||
|   } |   } | ||||||
|  |   //console.log("key pressed", event) | ||||||
|   header .wrapper { |   // For up/down implement custom fast repeat | ||||||
|     display: flex; |   if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1 | ||||||
|     place-items: flex-start; |   else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1 | ||||||
|     flex-wrap: wrap; |   // 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() | ||||||
|   } |   } | ||||||
|  |   // Select all (toggle); keydown to prevent builtin | ||||||
|   nav { |   else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) { | ||||||
|     text-align: left; |     fileExplorer.toggleSelectAll() | ||||||
|     margin-left: -1rem; |   } | ||||||
|     font-size: 1rem; |   // Keys 1-3 to sort columns | ||||||
|  |   else if ( | ||||||
|     padding: 1rem 0; |     c && | ||||||
|     margin-top: 1rem; |     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 { | :root { | ||||||
|   max-width: 1280px; |   --primary-color: #000; | ||||||
|   margin: 0 auto; |   --primary-background: #ddd; | ||||||
|   padding: 2rem; |   --header-background: var(--soft-color); | ||||||
|  |   --header-color: #ccc; | ||||||
|   font-weight: normal; |   --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)); | ||||||
| } | } | ||||||
|  | @media (prefers-color-scheme: dark) { | ||||||
| a, |   :root { | ||||||
| .green { |     --primary-color: #ddd; | ||||||
|   text-decoration: none; |     --primary-background: var(--soft-color); | ||||||
|   color: hsla(160, 100%, 37%, 1); |     --header-background: #000; | ||||||
|   transition: 0.4s; |     --header-color: #ccc; | ||||||
|  |     --input-background: var(--soft-color); | ||||||
|  |     --input-color: #ddd; | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | @media screen and (max-width: 600px) { | ||||||
| @media (hover: hover) { |   .size, | ||||||
|   a:hover { |   .modified, | ||||||
|     background-color: hsla(160, 100%, 37%, 0.2); |   .summary { | ||||||
|  |     display: none; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | @media screen and (min-width: 1000px) { | ||||||
| @media (min-width: 1024px) { |   :root { | ||||||
|   body { |     --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; |     display: flex; | ||||||
|     place-items: center; |     flex-direction: row-reverse; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     align-items: end; | ||||||
|   } |   } | ||||||
|  |   header .breadcrumb { | ||||||
|   #app { |     flex-shrink: 1; | ||||||
|     display: grid; |   } | ||||||
|     grid-template-columns: 1fr 1fr; |   header .breadcrumb > * { | ||||||
|     padding: 0 2rem; |     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> | ||||||
							
								
								
									
										516
									
								
								cista-front/src/components/FileExplorer.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,516 @@ | |||||||
|  | <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" | ||||||
|  |                 @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', | ||||||
|  |       dir: true, | ||||||
|  |       mtime: now, | ||||||
|  |       size: 0, | ||||||
|  |       sizedisp: formatSize(0), | ||||||
|  |       modified: formatUnixDate(now), | ||||||
|  |       haystack: '', | ||||||
|  |     } | ||||||
|  |     console.log("New") | ||||||
|  |   }, | ||||||
|  |   toggleSelectAll() { | ||||||
|  |     console.log('Select') | ||||||
|  |     allSelected.value = !allSelected.value | ||||||
|  |   }, | ||||||
|  |   toggleSortColumn(column: number) { | ||||||
|  |     const columns = ['', 'name', 'modified', 'size', ''] | ||||||
|  |     toggleSort(columns[column]) | ||||||
|  |   }, | ||||||
|  |   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(--input-color); | ||||||
|  |   background: var(--input-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> | ||||||
							
								
								
									
										103
									
								
								cista-front/src/components/HeaderMain.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,103 @@ | |||||||
|  | <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 :path="props.path" /> | ||||||
|  |       <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, | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  | const props = defineProps({ | ||||||
|  |   path: Array<string> | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | 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> | ||||||
							
								
								
									
										154
									
								
								cista-front/src/components/HeaderSelected.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,154 @@ | |||||||
|  | <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 | ||||||
|  |   const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download' | ||||||
|  |   linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`) | ||||||
|  |   documentStore.selected.clear() | ||||||
|  | } | ||||||
|  | </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> |  | ||||||
							
								
								
									
										252
									
								
								cista-front/src/components/UploadButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,252 @@ | |||||||
|  | <script setup lang="ts"> | ||||||
|  | import { connect, uploadUrl } from '@/repositories/WS'; | ||||||
|  | import { useDocumentStore } from '@/stores/documents' | ||||||
|  | import { collator } from '@/utils'; | ||||||
|  | import { computed, onMounted, onUnmounted, reactive, ref } from 'vue' | ||||||
|  |  | ||||||
|  | const fileInput = ref() | ||||||
|  | const folderInput = ref() | ||||||
|  | const documentStore = useDocumentStore() | ||||||
|  | const props = defineProps({ | ||||||
|  |   path: Array<string> | ||||||
|  | }) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | function uploadHandler(event: Event) { | ||||||
|  |   event.preventDefault() | ||||||
|  |   event.stopPropagation() | ||||||
|  |   // @ts-ignore | ||||||
|  |   let infiles = Array.from(event.dataTransfer?.files || event.target.files) as File[] | ||||||
|  |   if (!infiles.length) return | ||||||
|  |   const loc = props.path!.join('/') | ||||||
|  |   for (const f of infiles) { | ||||||
|  |     f.cloudName = loc + '/' + (f.webkitRelativePath || f.name) | ||||||
|  |     f.cloudPos = 0 | ||||||
|  |   } | ||||||
|  |   const dotfiles = infiles.filter(f => f.cloudName.includes('/.')) | ||||||
|  |   if (dotfiles.length) { | ||||||
|  |     documentStore.error = "Won't upload dotfiles" | ||||||
|  |     console.log("Dotfiles omitted", dotfiles) | ||||||
|  |     infiles = infiles.filter(f => !f.cloudName.includes('/.')) | ||||||
|  |   } | ||||||
|  |   if (!infiles.length) return | ||||||
|  |   infiles.sort((a, b) => collator.compare(a.cloudName, b.cloudName)) | ||||||
|  |   // @ts-ignore | ||||||
|  |   upqueue = upqueue.concat(infiles) | ||||||
|  |   statsAdd(infiles) | ||||||
|  |   startWorker() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const cancelUploads = () => { | ||||||
|  |   upqueue = [] | ||||||
|  |   statReset() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const uprogress_init = { | ||||||
|  |   total: 0, | ||||||
|  |   uploaded: 0, | ||||||
|  |   t0: 0, | ||||||
|  |   tlast: 0, | ||||||
|  |   statbytes: 0, | ||||||
|  |   statdur: 0, | ||||||
|  |   files: [], | ||||||
|  |   filestart: 0, | ||||||
|  |   fileidx: 0, | ||||||
|  |   filecount: 0, | ||||||
|  |   filename: '', | ||||||
|  |   filesize: 0, | ||||||
|  |   filepos: 0, | ||||||
|  | } | ||||||
|  | const uprogress = reactive({...uprogress_init}) | ||||||
|  | const percent = computed(() => uprogress.uploaded / uprogress.total * 100) | ||||||
|  | const speed = computed(() => { | ||||||
|  |   let s = uprogress.statbytes / uprogress.statdur / 1e3 | ||||||
|  |   const tsince = (Date.now() - uprogress.tlast) / 1e3 | ||||||
|  |   if (tsince > 5 / s) return 0  // Less than fifth of previous speed => stalled | ||||||
|  |   if (tsince > 1 / s) return 1 / tsince  // Next block is late or not coming, decay | ||||||
|  |   return s  // "Current speed" | ||||||
|  | }) | ||||||
|  | const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 100 ? 1 : 0) + '\u202FMB/s': 'stalled') | ||||||
|  | setInterval(() => { | ||||||
|  |   if (Date.now() - uprogress.tlast > 3000) { | ||||||
|  |     // Reset | ||||||
|  |     uprogress.statbytes = 0 | ||||||
|  |     uprogress.statdur = 1 | ||||||
|  |   } else { | ||||||
|  |     // Running average by decay | ||||||
|  |     uprogress.statbytes *= .9 | ||||||
|  |     uprogress.statdur *= .9 | ||||||
|  |   } | ||||||
|  | }, 100) | ||||||
|  | const statUpdate = ({name, size, start, end}) => { | ||||||
|  |   if (name !== uprogress.filename) return  // If stats have been reset | ||||||
|  |   const now = Date.now() | ||||||
|  |   uprogress.uploaded = uprogress.filestart + end | ||||||
|  |   uprogress.filepos = end | ||||||
|  |   uprogress.statbytes += end - start | ||||||
|  |   uprogress.statdur += now - uprogress.tlast | ||||||
|  |   uprogress.tlast = now | ||||||
|  |   // File finished? | ||||||
|  |   if (end === size) { | ||||||
|  |     uprogress.filestart += size | ||||||
|  |     statNextFile() | ||||||
|  |     if (++uprogress.fileidx >= uprogress.filecount) statReset() | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | const statNextFile = () => { | ||||||
|  |   const f = uprogress.files.shift() | ||||||
|  |   if (!f) return statReset() | ||||||
|  |   uprogress.filepos = 0 | ||||||
|  |   uprogress.filesize = f.size | ||||||
|  |   uprogress.filename = f.cloudName | ||||||
|  | } | ||||||
|  | const statReset = () => { | ||||||
|  |   Object.assign(uprogress, uprogress_init) | ||||||
|  |   uprogress.t0 = Date.now() | ||||||
|  |   uprogress.tlast = uprogress.t0 + 1 | ||||||
|  | } | ||||||
|  | const statsAdd = (f: Array<File>) => { | ||||||
|  |   if (uprogress.files.length === 0) statReset() | ||||||
|  |   uprogress.total += f.reduce((a, b) => a + b.size, 0) | ||||||
|  |   uprogress.filecount += f.length | ||||||
|  |   uprogress.files = uprogress.files.concat(f) | ||||||
|  |   statNextFile() | ||||||
|  | } | ||||||
|  | let upqueue = [] as File[] | ||||||
|  |  | ||||||
|  | // TODO: Rewrite as WebSocket class | ||||||
|  | const WSCreate = async () => await new Promise<WebSocket>(resolve => { | ||||||
|  |   const ws = connect(uploadUrl, { | ||||||
|  |     open(ev: Event) { resolve(ws) }, | ||||||
|  |     error(ev: Event) { | ||||||
|  |       console.error('Upload socket error', ev) | ||||||
|  |       documentStore.error = 'Upload socket error' | ||||||
|  |     }, | ||||||
|  |     message(ev: MessageEvent) { | ||||||
|  |       const res = JSON.parse(ev!.data) | ||||||
|  |       if ('error' in res) { | ||||||
|  |         console.error('Upload socket error', res.error) | ||||||
|  |         documentStore.error = res.error.message | ||||||
|  |         return | ||||||
|  |       } | ||||||
|  |       if (res.status === 'ack') { | ||||||
|  |         statUpdate(res.req) | ||||||
|  |       } else console.log('Unknown upload response', res) | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  |   // @ts-ignore | ||||||
|  |   ws.sendMsg = (msg: any) => ws.send(JSON.stringify(msg)) | ||||||
|  |   // @ts-ignore | ||||||
|  |   ws.sendData = async (data: any) => { | ||||||
|  |     // Wait until the WS is ready to send another message | ||||||
|  |     uprogress.status = "uploading" | ||||||
|  |     await new Promise(resolve => { | ||||||
|  |       const t = setInterval(() => { | ||||||
|  |         if (ws.bufferedAmount > 1<<20) return | ||||||
|  |         resolve(undefined) | ||||||
|  |         clearInterval(t) | ||||||
|  |       }, 1) | ||||||
|  |     }) | ||||||
|  |     uprogress.status = "processing" | ||||||
|  |     ws.send(data) | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  | const worker = async () => { | ||||||
|  |   const ws = await WSCreate() | ||||||
|  |   while (upqueue.length) { | ||||||
|  |     const f = upqueue[0] | ||||||
|  |     if (f.cloudPos === f.size) { | ||||||
|  |       upqueue.shift() | ||||||
|  |       continue | ||||||
|  |     } | ||||||
|  |     const start = f.cloudPos | ||||||
|  |     const end = Math.min(f.size, start + (1<<20)) | ||||||
|  |     const control = { name: f.cloudName, size: f.size, start, end } | ||||||
|  |     const data = f.slice(start, end) | ||||||
|  |     f.cloudPos = end | ||||||
|  |     // Note: files may get modified during I/O | ||||||
|  |     ws.sendMsg(control) | ||||||
|  |     await ws.sendData(data) | ||||||
|  |   } | ||||||
|  |   if (upqueue.length) startWorker() | ||||||
|  |   uprogress.status = "idle" | ||||||
|  |   workerRunning = false | ||||||
|  | } | ||||||
|  | let workerRunning: any = false | ||||||
|  | const startWorker = () => { | ||||||
|  |   if (workerRunning === false) workerRunning = setTimeout(() => { | ||||||
|  |     workerRunning = true | ||||||
|  |     worker() | ||||||
|  |   }, 0) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   // Need to prevent both to prevent browser from opening the file | ||||||
|  |   addEventListener('dragover', uploadHandler) | ||||||
|  |   addEventListener('drop', uploadHandler) | ||||||
|  | }) | ||||||
|  | onUnmounted(() => { | ||||||
|  |   removeEventListener('dragover', uploadHandler) | ||||||
|  |   removeEventListener('drop', uploadHandler) | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  | <template> | ||||||
|  |   <template> | ||||||
|  |     <input ref="fileInput" @change="uploadHandler" type="file" multiple> | ||||||
|  |     <input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory> | ||||||
|  |   </template> | ||||||
|  |   <SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" /> | ||||||
|  |   <SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" /> | ||||||
|  |   <div class="uploadprogress" v-if="uprogress.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`"> | ||||||
|  |     <div class="statustext"> | ||||||
|  |       <span v-if="uprogress.filecount > 1" class="index"> | ||||||
|  |         [{{ uprogress.fileidx }}/{{ uprogress.filecount }}] | ||||||
|  |       </span> | ||||||
|  |       <span class="filename">{{ uprogress.filename.split('/').pop() }} | ||||||
|  |         <span v-if="uprogress.filesize > 1e7" class="percent"> | ||||||
|  |           {{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }} | ||||||
|  |         </span> | ||||||
|  |       </span> | ||||||
|  |       <span class="position" v-if="uprogress.filesize > 1e7"> | ||||||
|  |         {{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }} | ||||||
|  |       </span> | ||||||
|  |       <span class="speed">{{ speeddisp }}</span> | ||||||
|  |       <button class="close" @click="cancelUploads">❌</button> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .uploadprogress { | ||||||
|  |   --bar: var(--accent-color); | ||||||
|  |   --nobar: var(--header-background); | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   color: var(--primary-color); | ||||||
|  |   position: fixed; | ||||||
|  |   left: 0; | ||||||
|  |   bottom: 0; | ||||||
|  |   width: 100vw; | ||||||
|  | } | ||||||
|  | .statustext { | ||||||
|  |   display: flex; | ||||||
|  |   padding: 0.5rem 0; | ||||||
|  | } | ||||||
|  | span { | ||||||
|  |   color: #ccc; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   text-align: right; | ||||||
|  |   padding: 0 0.5em; | ||||||
|  | } | ||||||
|  | .filename { | ||||||
|  |   color: #fff; | ||||||
|  |   flex: 1 1; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   overflow: hidden; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  |   text-align: left; | ||||||
|  | } | ||||||
|  | .index { min-width: 3.5em } | ||||||
|  | .position { min-width: 4em } | ||||||
|  | .speed { min-width: 4em } | ||||||
|  | </style> | ||||||
| @@ -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 App from './App.vue' | ||||||
| import router from './router' | 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) | 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(router) | ||||||
|  | app.use(ContextMenu) | ||||||
| app.mount('#app') | 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 { createRouter, createWebHashHistory } from 'vue-router' | ||||||
| import HomeView from '../views/HomeView.vue' | import ExplorerView from '@/views/ExplorerView.vue' | ||||||
|  |  | ||||||
| const router = createRouter({ | const router = createRouter({ | ||||||
|   history: createWebHistory(import.meta.env.BASE_URL), |   history: createWebHashHistory(import.meta.env.BASE_URL), | ||||||
|   routes: [ |   routes: [ | ||||||
|     { |     { | ||||||
|       path: '/', |       path: '/:pathMatch(.*)*', | ||||||
|       name: 'home', |       name: 'explorer', | ||||||
|       component: HomeView |       component: ExplorerView | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       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') |  | ||||||
|     } |     } | ||||||
|   ] |   ] | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -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 } |  | ||||||
| }) |  | ||||||
							
								
								
									
										160
									
								
								cista-front/src/stores/documents.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,160 @@ | |||||||
|  | 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[] | ||||||
|  |     }, | ||||||
|  |     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"], |   "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], | ||||||
|   "exclude": ["src/**/__tests__/*"], |   "exclude": ["src/**/__tests__/*"], | ||||||
|   "compilerOptions": { |   "compilerOptions": { | ||||||
|  |     "lib": ["es2021", "DOM"], | ||||||
|  |     "target": "es2021", | ||||||
|     "composite": true, |     "composite": true, | ||||||
|     "baseUrl": ".", |     "baseUrl": ".", | ||||||
|     "paths": { |     "paths": { | ||||||
|   | |||||||
| @@ -6,6 +6,9 @@ | |||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       "path": "./tsconfig.app.json" |       "path": "./tsconfig.app.json" | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "path": "./tsconfig.vitest.json" | ||||||
|     } |     } | ||||||
|   ] |   ] | ||||||
| } | } | ||||||
|   | |||||||