Frontend created and rewritten a few times, with some backend fixes (#1)
The software is fully operational. Reviewed-on: #1
1
.gitignore
vendored
|
@ -3,4 +3,5 @@
|
|||
__pycache__/
|
||||
*.egg-info/
|
||||
/cista/_version.py
|
||||
/cista/wwwroot/*
|
||||
/dist
|
||||
|
|
5
cista-front/.gitignore
vendored
|
@ -7,12 +7,17 @@ yarn-error.log*
|
|||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# No locking
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
components.d.ts
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<html lang=en>
|
||||
<meta charset=UTF-8>
|
||||
<title>Cista</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<div id="app"></div>
|
||||
|
|
1458
cista-front/package-lock.json
generated
|
@ -1,27 +1,59 @@
|
|||
{
|
||||
"name": "cista-front",
|
||||
"name": "front",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false"
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.7",
|
||||
"@imengyu/vue3-context-menu": "^1.3.3",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"esbuild": "^0.19.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"pinia": "^2.1.6",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"unplugin-vue-components": "^0.25.2",
|
||||
"vite-plugin-rewrite-all": "^1.0.1",
|
||||
"vite-svg-loader": "^4.0.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.5"
|
||||
"vue-router": "^4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.3",
|
||||
"@tsconfig/node18": "^18.2.2",
|
||||
"@types/node": "^18.18.5",
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"@types/jsdom": "^21.1.3",
|
||||
"@types/lodash-es": "^4.17.10",
|
||||
"@types/node": "^18.17.17",
|
||||
"@vitejs/plugin-vue": "^4.3.4",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"npm-run-all2": "^6.1.1",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-plugin-vue": "^9.18.1",
|
||||
"jsdom": "^22.1.0",
|
||||
"npm-run-all2": "^6.0.6",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "~5.2.0",
|
||||
"vite": "^4.4.11",
|
||||
"vue-tsc": "^1.8.19"
|
||||
"vite": "^4.4.9",
|
||||
"vitest": "^0.34.4",
|
||||
"vue-tsc": "^1.8.11"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 88
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,85 +1,121 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginModal />
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
</HeaderMain>
|
||||
<BreadCrumb :path="path.pathList" tabindex="-1"/>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
<main>
|
||||
<RouterView :path="path.pathList" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
}
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
import { computed } from 'vue'
|
||||
import Router from '@/router/index'
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
interface Path {
|
||||
path: string
|
||||
pathList: string[]
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
const documentStore = useDocumentStore()
|
||||
const path: ComputedRef<Path> = computed(() => {
|
||||
const p = decodeURIComponent(Router.currentRoute.value.path)
|
||||
const pathList = p.split('/').filter(value => value !== '')
|
||||
return {
|
||||
path: p,
|
||||
pathList
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
})
|
||||
onMounted(watchConnect)
|
||||
onUnmounted(watchDisconnect)
|
||||
// Update human-readable x seconds ago messages from mtimes
|
||||
setInterval(documentStore.updateModified, 1000)
|
||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||
let vert = 0
|
||||
let timer: any = null
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const fileExplorer = documentStore.fileExplorer as any
|
||||
if (!fileExplorer) return
|
||||
const c = fileExplorer.isCursor()
|
||||
const keyup = event.type === 'keyup'
|
||||
if (event.repeat) {
|
||||
if (
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
(c && event.code === 'Space')
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
return
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
//console.log("key pressed", event)
|
||||
// For up/down implement custom fast repeat
|
||||
if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1
|
||||
else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1
|
||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
// Select all (toggle); keydown to prevent builtin
|
||||
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||
fileExplorer.toggleSelectAll()
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
// Keys 1-3 to sort columns
|
||||
else if (
|
||||
c &&
|
||||
keyup &&
|
||||
(event.key === '1' || event.key === '2' || event.key === '3')
|
||||
) {
|
||||
fileExplorer.toggleSortColumn(+event.key)
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
// Rename
|
||||
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
||||
fileExplorer.cursorRename()
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||
else if (c && event.code === 'Space') {
|
||||
if (keyup && !event.altKey && !event.ctrlKey)
|
||||
fileExplorer.cursorSelect()
|
||||
} else return
|
||||
event.preventDefault()
|
||||
if (!vert) {
|
||||
if (timer) {
|
||||
clearTimeout(timer) // Good for either timeout or interval
|
||||
timer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!timer) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const select = event.shiftKey
|
||||
fileExplorer.cursorMove(vert, select)
|
||||
const t0 = 200,
|
||||
tr = 30
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
(timer = setInterval(() => {
|
||||
fileExplorer.cursorMove(vert, select)
|
||||
}, tr)),
|
||||
t0 - tr
|
||||
)
|
||||
}
|
||||
}
|
||||
</style>
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', globalShortcutHandler)
|
||||
window.addEventListener('keyup', globalShortcutHandler)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', globalShortcutHandler)
|
||||
window.removeEventListener('keyup', globalShortcutHandler)
|
||||
})
|
||||
export type { Path }
|
||||
</script>
|
||||
|
|
|
@ -1,86 +0,0 @@
|
|||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
Before Width: | Height: | Size: 276 B |
|
@ -1,35 +1,268 @@
|
|||
@import './base.css';
|
||||
@charset "UTF-8";
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
|
||||
font-weight: normal;
|
||||
:root {
|
||||
--primary-color: #000;
|
||||
--primary-background: #ddd;
|
||||
--header-background: var(--soft-color);
|
||||
--header-color: #ccc;
|
||||
--input-background: #fff;
|
||||
--input-color: #000;
|
||||
--primary-color: #000;
|
||||
--soft-color: #146;
|
||||
--accent-color: #f80;
|
||||
--transition-time: 0.2s;
|
||||
/* The following are overridden by responsive layouts */
|
||||
--root-font-size: 1rem;
|
||||
--header-font-size: 1rem;
|
||||
--header-height: calc(6.5 * var(--header-font-size));
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--primary-color: #ddd;
|
||||
--primary-background: var(--soft-color);
|
||||
--header-background: #000;
|
||||
--header-color: #ccc;
|
||||
--input-background: var(--soft-color);
|
||||
--input-color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
@media screen and (max-width: 600px) {
|
||||
.size,
|
||||
.modified,
|
||||
.summary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1000px) {
|
||||
:root {
|
||||
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
||||
}
|
||||
header .buttons:has(input[type='search']) > div {
|
||||
display: none;
|
||||
}
|
||||
header .buttons > div:has(input[type='search']) {
|
||||
display: inherit;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 2000px) {
|
||||
:root {
|
||||
--root-font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
/* Low (landscape) screens: smaller header */
|
||||
@media screen and (max-height: 600px) {
|
||||
:root {
|
||||
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
|
||||
--root-font-size: 0.8rem;
|
||||
}
|
||||
header .breadcrumb > * {
|
||||
padding-top: calc(8 + 8 * 100vh / 600) !important;
|
||||
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-height: 300px) {
|
||||
:root {
|
||||
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
|
||||
--header-height: calc(1.75 * 16px);
|
||||
--root-font-size: 0.6rem;
|
||||
}
|
||||
header .breadcrumb > * {
|
||||
padding-top: 14px !important;
|
||||
padding-bottom: 14px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (orientation: landscape) and (min-width: 700px) {
|
||||
/* Breadcrumbs and buttons side by side */
|
||||
:root {
|
||||
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
}
|
||||
header .breadcrumb {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
header .breadcrumb > * {
|
||||
flex-shrink: 1;
|
||||
padding-top: 1rem !important;
|
||||
padding-bottom: 1rem !important;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
:root {
|
||||
--primary-color: black;
|
||||
--primary-background: none;
|
||||
--header-background: none;
|
||||
--header-color: black;
|
||||
}
|
||||
nav,
|
||||
.menu,
|
||||
.rename-button {
|
||||
display: none;
|
||||
}
|
||||
.breadcrumb > a {
|
||||
color: black !important;
|
||||
background: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
clip-path: none !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
.breadcrumb > a::after {
|
||||
content: '/';
|
||||
}
|
||||
.breadcrumb svg {
|
||||
fill: black !important;
|
||||
}
|
||||
main {
|
||||
height: auto !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
thead tr {
|
||||
position: static !important;
|
||||
background: none !important;
|
||||
border-bottom: 1pt solid black !important;
|
||||
}
|
||||
.selection {
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.selection input {
|
||||
display: none;
|
||||
}
|
||||
.selection input:checked {
|
||||
display: inherit;
|
||||
}
|
||||
tbody .selection input:checked {
|
||||
opacity: 1 !important;
|
||||
transform: scale(0.5);
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
html {
|
||||
font-size: var(--root-font-size);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Hide scrollbar for all browsers */
|
||||
main::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
main {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
body {
|
||||
background-color: var(--primary-background);
|
||||
font-size: 1rem;
|
||||
font-family: 'Roboto';
|
||||
color: var(--primary-color);
|
||||
margin: 0;
|
||||
}
|
||||
tbody .size,
|
||||
tbody .modified {
|
||||
font-family: 'Roboto Mono';
|
||||
}
|
||||
header {
|
||||
background-color: var(--header-background);
|
||||
color: var(--header-color);
|
||||
font-size: var(--header-font-size);
|
||||
}
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
::selection {
|
||||
color: #000;
|
||||
background: yellow !important;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
min-width: 1rem;
|
||||
min-height: 1rem;
|
||||
}
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
a:link,
|
||||
a:visited,
|
||||
a:active,
|
||||
a:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
border: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
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 router from './router'
|
||||
|
||||
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
|
||||
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.config.errorHandler = err => {
|
||||
/* handle error */
|
||||
console.log(err)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedState)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.use(ContextMenu)
|
||||
app.mount('#app')
|
||||
|
|
35
cista-front/src/repositories/Client.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
class ClientClass {
|
||||
async post(url: string, data?: Record<string, any>): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined
|
||||
})
|
||||
let msg
|
||||
try {
|
||||
msg = await res.json()
|
||||
} catch (e) {
|
||||
throw new SimpleError(res.status, `🛑 ${res.status} ${res.statusText}`)
|
||||
}
|
||||
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
export const Client = new ClientClass()
|
||||
export interface ISimpleError extends Error {
|
||||
code: number
|
||||
}
|
||||
|
||||
class SimpleError extends Error implements ISimpleError {
|
||||
code: number
|
||||
constructor(code: number, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export default Client
|
55
cista-front/src/repositories/Document.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
export type FUID = string
|
||||
|
||||
export type Document = {
|
||||
loc: string
|
||||
name: string
|
||||
key: FUID
|
||||
size: number
|
||||
sizedisp: string
|
||||
mtime: number
|
||||
modified: string
|
||||
haystack: string
|
||||
dir: boolean
|
||||
}
|
||||
|
||||
export type errorEvent = {
|
||||
error: {
|
||||
code: number
|
||||
message: string
|
||||
redirect: string
|
||||
}
|
||||
}
|
||||
|
||||
// Raw types the backend /api/watch sends us
|
||||
|
||||
export type FileEntry = {
|
||||
key: FUID
|
||||
size: number
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export type DirEntry = {
|
||||
key: FUID
|
||||
size: number
|
||||
mtime: number
|
||||
dir: DirList
|
||||
}
|
||||
|
||||
export type DirList = Record<string, FileEntry | DirEntry>
|
||||
|
||||
export type UpdateEntry = {
|
||||
name: string
|
||||
deleted?: boolean
|
||||
key?: FUID
|
||||
size?: number
|
||||
mtime?: number
|
||||
dir?: DirList
|
||||
}
|
||||
|
||||
// Helper structure for selections
|
||||
export interface SelectedItems {
|
||||
keys: FUID[]
|
||||
docs: Record<FUID, Document>
|
||||
recursive: Array<[string, string, Document]>
|
||||
missing: Set<FUID>
|
||||
}
|
15
cista-front/src/repositories/User.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import Client from '@/repositories/Client'
|
||||
export const url_login = '/login'
|
||||
export const url_logout = '/logout '
|
||||
|
||||
export async function loginUser(username: string, password: string) {
|
||||
const user = await Client.post(url_login, {
|
||||
username,
|
||||
password
|
||||
})
|
||||
return user
|
||||
}
|
||||
export async function logoutUser() {
|
||||
const data = await Client.post(url_logout)
|
||||
return data
|
||||
}
|
133
cista-front/src/repositories/WS.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
import { useDocumentStore } from "@/stores/documents"
|
||||
import type { DirEntry, UpdateEntry, errorEvent } from "./Document"
|
||||
|
||||
export const controlUrl = '/api/control'
|
||||
export const uploadUrl = '/api/upload'
|
||||
export const watchUrl = '/api/watch'
|
||||
|
||||
let tree = null as DirEntry | null
|
||||
let reconnectDuration = 500
|
||||
let wsWatch = null as WebSocket | null
|
||||
|
||||
export const connect = (path: string, handlers: Partial<Record<keyof WebSocketEventMap, any>>) => {
|
||||
const webSocket = new WebSocket(new URL(path, location.origin.replace(/^http/, 'ws')))
|
||||
for (const [event, handler] of Object.entries(handlers)) webSocket.addEventListener(event, handler)
|
||||
return webSocket
|
||||
}
|
||||
|
||||
export const watchConnect = () => {
|
||||
if (watchTimeout !== null) {
|
||||
clearTimeout(watchTimeout)
|
||||
watchTimeout = null
|
||||
}
|
||||
const store = useDocumentStore()
|
||||
if (store.error !== 'Reconnecting...') store.error = 'Connecting...'
|
||||
console.log(store.error)
|
||||
|
||||
wsWatch = connect(watchUrl, {
|
||||
message: handleWatchMessage,
|
||||
close: watchReconnect,
|
||||
})
|
||||
wsWatch.addEventListener("message", event => {
|
||||
if (store.connected) return
|
||||
const msg = JSON.parse(event.data)
|
||||
if ('error' in msg) {
|
||||
if (msg.error.code === 401) {
|
||||
store.user.isLoggedIn = false
|
||||
store.user.isOpenLoginModal = true
|
||||
} else {
|
||||
store.error = msg.error.message
|
||||
}
|
||||
return
|
||||
}
|
||||
if ("server" in msg) {
|
||||
console.log('Connected to backend', msg)
|
||||
store.connected = true
|
||||
reconnectDuration = 500
|
||||
store.error = ''
|
||||
if (msg.user) store.login(msg.user.username, msg.user.privileged)
|
||||
else if (store.isUserLogged) store.logout()
|
||||
if (!msg.server.public && !msg.user) store.user.isOpenLoginModal = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const watchDisconnect = () => {
|
||||
if (!wsWatch) return
|
||||
wsWatch.close()
|
||||
wsWatch = null
|
||||
}
|
||||
|
||||
let watchTimeout: any = null
|
||||
|
||||
const watchReconnect = (event: MessageEvent) => {
|
||||
const store = useDocumentStore()
|
||||
if (store.connected) {
|
||||
console.warn("Disconnected from server", event)
|
||||
store.connected = false
|
||||
store.error = 'Reconnecting...'
|
||||
}
|
||||
reconnectDuration = Math.min(5000, reconnectDuration + 500)
|
||||
// The server closes the websocket after errors, so we need to reopen it
|
||||
if (watchTimeout !== null) clearTimeout(watchTimeout)
|
||||
watchTimeout = setTimeout(watchConnect, reconnectDuration)
|
||||
}
|
||||
|
||||
|
||||
const handleWatchMessage = (event: MessageEvent) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
switch (true) {
|
||||
case !!msg.root:
|
||||
handleRootMessage(msg)
|
||||
break
|
||||
case !!msg.update:
|
||||
handleUpdateMessage(msg)
|
||||
break
|
||||
case !!msg.space:
|
||||
console.log('Watch space', msg.space)
|
||||
break
|
||||
case !!msg.error:
|
||||
handleError(msg)
|
||||
break
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
function handleRootMessage({ root }: { root: DirEntry }) {
|
||||
const store = useDocumentStore()
|
||||
console.log('Watch root', root)
|
||||
store.updateRoot(root)
|
||||
tree = root
|
||||
}
|
||||
|
||||
function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
|
||||
const store = useDocumentStore()
|
||||
console.log('Watch update', updateData.update)
|
||||
if (!tree) return console.error('Watch update before root')
|
||||
let node: DirEntry = tree
|
||||
for (const elem of updateData.update) {
|
||||
if (elem.deleted) {
|
||||
delete node.dir[elem.name]
|
||||
break // Deleted elements can't have further children
|
||||
}
|
||||
if (elem.name) {
|
||||
// @ts-ignore
|
||||
console.log(node, elem.name)
|
||||
node = node.dir[elem.name] ||= {}
|
||||
}
|
||||
if (elem.key !== undefined) node.key = elem.key
|
||||
if (elem.size !== undefined) node.size = elem.size
|
||||
if (elem.mtime !== undefined) node.mtime = elem.mtime
|
||||
if (elem.dir !== undefined) node.dir = elem.dir
|
||||
}
|
||||
store.updateRoot(tree)
|
||||
}
|
||||
|
||||
function handleError(msg: errorEvent) {
|
||||
const store = useDocumentStore()
|
||||
if (msg.error.code === 401) {
|
||||
store.user.isOpenLoginModal = true
|
||||
store.user.isLoggedIn = false
|
||||
return
|
||||
}
|
||||
}
|
|
@ -1,21 +1,13 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import ExplorerView from '@/views/ExplorerView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AboutView.vue')
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'explorer',
|
||||
component: ExplorerView
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
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"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["es2021", "DOM"],
|
||||
"target": "es2021",
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|