Compare commits
27 Commits
main
...
acdd776b92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acdd776b92 | ||
| b3fd9637eb | |||
| 2b72508206 | |||
|
|
8cc3ed1a04 | ||
|
|
0d186726b5 | ||
|
|
63bbe84859 | ||
|
|
202f28ff15 | ||
| 41772e6c18 | |||
| e52379d515 | |||
| 74987898c9 | |||
| 859d312913 | |||
| 4bc9cf4534 | |||
| 754d779069 | |||
| 367e4ba0ea | |||
| c2e9a4af05 | |||
| 6cdc37a172 | |||
| 19699564c2 | |||
| 7baf8b3f9b | |||
| 47329ac04e | |||
| f4013d1196 | |||
| 3672156b5e | |||
| f2b37852da | |||
| 708e54d080 | |||
| d051265f40 | |||
| 5cf133465e | |||
| 1c91bf2e87 | |||
| 9cd6f83bec |
37
cista-front/components.d.ts
vendored
Normal file
37
cista-front/components.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
|
||||
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
AForm: typeof import('ant-design-vue/es')['Form']
|
||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||
AImage: typeof import('ant-design-vue/es')['Image']
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
|
||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
|
||||
APopover: typeof import('ant-design-vue/es')['Popover']
|
||||
AppNavigation: typeof import('./src/components/AppNavigation.vue')['default']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ATable: typeof import('ant-design-vue/es')['Table']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
FileCarousel: typeof import('./src/components/FileCarousel.vue')['default']
|
||||
FileExplorer: typeof import('./src/components/FileExplorer.vue')['default']
|
||||
FileViewer: typeof import('./src/components/FileViewer.vue')['default']
|
||||
HeaderMain: typeof import('./src/components/HeaderMain.vue')['default']
|
||||
LoginModal: typeof import('./src/components/LoginModal.vue')['default']
|
||||
NotificationLoading: typeof import('./src/components/NotificationLoading.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
UploadButton: typeof import('./src/components/UploadButton.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Vite Vasanko</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
5746
cista-front/package-lock.json
generated
5746
cista-front/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,50 @@
|
||||
{
|
||||
"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",
|
||||
"@ant-design/icons-vue": "^7.0.0",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"ant-design-vue": "^4.0.3",
|
||||
"axios": "^1.5.0",
|
||||
"esbuild": "^0.19.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"pinia": "^2.1.6",
|
||||
"unplugin-vue-components": "^0.25.2",
|
||||
"vite-plugin-rewrite-all": "^1.0.1",
|
||||
"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",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { watchEffect } from 'vue'
|
||||
import createWebSocket from '@/repositories/WS'
|
||||
import { url_document_watch_ws, url_document_upload_ws, DocumentHandler, DocumentUploadHandler } from '@/repositories/Document'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import HeaderMain from './components/HeaderMain.vue'
|
||||
import AppNavigation from './components/AppNavigation.vue'
|
||||
import Router from './router/index';
|
||||
|
||||
interface Path {
|
||||
path: string;
|
||||
pathList: string[];
|
||||
}
|
||||
const documentStore = useDocumentStore()
|
||||
const path: ComputedRef<Path> = computed( () => {
|
||||
const pathList = Router.currentRoute.value.path
|
||||
.split('/')
|
||||
.filter( value => value !== '')
|
||||
|
||||
return {
|
||||
path: Router.currentRoute.value.path,
|
||||
pathList
|
||||
}
|
||||
})
|
||||
// Update human-readable x seconds ago messages from mtimes
|
||||
setInterval(documentStore.updateModified, 1000)
|
||||
watchEffect(() => {
|
||||
const documentHandler = new DocumentHandler()
|
||||
const documentUploadHandler = new DocumentUploadHandler()
|
||||
const wsWatch = createWebSocket(url_document_watch_ws, documentHandler.handleWebSocketMessage)
|
||||
const wsUpload = createWebSocket(url_document_upload_ws, documentUploadHandler.handleWebSocketMessage)
|
||||
|
||||
documentStore.wsWatch = wsWatch;
|
||||
documentStore.wsUpload = wsUpload;
|
||||
})
|
||||
|
||||
export type { Path }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<header class="wrapper">
|
||||
<HeaderMain WS="WS"></HeaderMain>
|
||||
<AppNavigation :path="path.pathList"></AppNavigation>
|
||||
</header>
|
||||
|
||||
<RouterView />
|
||||
<RouterView class="page-container" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
.wrapper{
|
||||
background-color: var(--primary-background);
|
||||
padding: 0.2em 0.5em;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
.page-container{
|
||||
flex-grow: 2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,105 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
|
||||
font-weight: normal;
|
||||
:root {
|
||||
--primary-background: #181818;
|
||||
--secondary-background: #ffffff;
|
||||
--font-color: #333333;
|
||||
--table-background: #535353;
|
||||
--primary-color: #ffffff;
|
||||
--secondary-color: #ccc;
|
||||
--blue-color: #66ffeb;
|
||||
--red-color: #ff4d4f;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--primary-background: #181818;
|
||||
--secondary-background: #333333;
|
||||
--font-color: #ffffff;
|
||||
--table-background: #535353;
|
||||
--primary-color: #ffffff;
|
||||
--secondary-color: #ccc;
|
||||
--blue-color: #66ffeb;
|
||||
--red-color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
body {
|
||||
background-color: var(--table-background);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
.ant-breadcrumb-separator, .ant-breadcrumb-link, .ant-breadcrumb .anticon {
|
||||
color: var(--primary-color) !important;
|
||||
font-size: 1.2em !important;
|
||||
}
|
||||
#app{
|
||||
height: 100%;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
flex-direction: column;
|
||||
background-color: var(--secondary-background);
|
||||
}
|
||||
|
||||
.ant-image-preview-mask{
|
||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
||||
backdrop-filter: blur(15px) !important;
|
||||
}
|
||||
.ant-table-cell:hover{
|
||||
background-color: initial !important;
|
||||
}
|
||||
.ant-table-wrapper .ant-table-tbody >tr.ant-table-row-selected >td,
|
||||
.ant-table-wrapper .ant-table-tbody >tr.ant-table-row-selected:hover>td {
|
||||
background-color: transparent;
|
||||
}
|
||||
.ant-form-item .ant-form-item-label >label{
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ant-input-group >.ant-input-group-addon:last-child .ant-input-search-button:not(.ant-btn-primary):hover{
|
||||
color : var(--blue-color) !important
|
||||
}
|
||||
.ant-input-search .ant-input:hover,
|
||||
.ant-input-search .ant-input:focus {
|
||||
border-color: var(--blue-color);
|
||||
}
|
||||
.ant-input{
|
||||
background-color:var(--table-background);
|
||||
border-color: var(--table-background);
|
||||
color: var(--font-color)
|
||||
}
|
||||
.ant-table-wrapper .ant-table-thead>tr>th {
|
||||
background-color: var(--table-background);
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-thead th.ant-table-column-has-sorters:hover {
|
||||
background-color: var(--table-background);
|
||||
}
|
||||
|
||||
.ant-table-content {
|
||||
background-color: var(--secondary-background);
|
||||
}
|
||||
|
||||
.ant-table-cell-row-hover,
|
||||
.ant-table-wrapper .ant-table-tbody>tr.ant-table-row:hover>td {
|
||||
background-color: var(--primary-background) !important;
|
||||
}
|
||||
|
||||
.ant-table-column-title,
|
||||
.ant-table-column-sorter,
|
||||
.ant-table-column-sort,
|
||||
.ant-table-cell,
|
||||
a {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
.ant-table-cell>button>.anticon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.ant-notification-close-x{
|
||||
color: var(--secondary-background);
|
||||
}
|
||||
.ant-empty-description{
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.ant-modal .ant-modal-content{
|
||||
background-color: var(--secondary-background);
|
||||
}
|
||||
.ant-modal .ant-modal-close-x{
|
||||
color: var(--font-color);
|
||||
}
|
||||
}
|
||||
|
||||
43
cista-front/src/components/AppNavigation.vue
Normal file
43
cista-front/src/components/AppNavigation.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { HomeOutlined } from '@ant-design/icons-vue';
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
path: Array<string>
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
|
||||
function generateUrl(pathIndex: number) {
|
||||
return "/" + props.path.slice(0, pathIndex + 1).join('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<RouterLink to="/">
|
||||
<home-outlined />
|
||||
</RouterLink>
|
||||
</a-breadcrumb-item>
|
||||
|
||||
<a-breadcrumb-item v-for="(location, index) in path" :key="index">
|
||||
<RouterLink :to ="generateUrl(index)">
|
||||
<span :class="(index === path.length - 1) && 'last' ">{{ decodeURIComponent(location) }}</span>
|
||||
</RouterLink>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
nav, span{
|
||||
color: var(--primary-color);
|
||||
}
|
||||
span:hover, .last{
|
||||
color: var(--blue-color)
|
||||
|
||||
}
|
||||
</style>
|
||||
137
cista-front/src/components/FileCarousel.vue
Normal file
137
cista-front/src/components/FileCarousel.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="carousel" ref="fileCarousel">
|
||||
<a-page-header :style="{ visibility: idle ? 'hidden' : 'visible' }">
|
||||
<template #extra>
|
||||
<a-button type="text" class="action-button" :onclick="toggle" :icon="h(isFullscreen? FullscreenExitOutlined: FullscreenOutlined)" />
|
||||
<a-button type="text" class="action-button" :onclick="redirectBack" :icon="h(CloseOutlined)" />
|
||||
</template>
|
||||
</a-page-header>
|
||||
<a-row class="slider">
|
||||
<a-col :span="2" class="centered-vertically">
|
||||
<div class="custom-slick-arrow slick-arrow slick-prev centered" @click="next(-1)" style="left: 10px; z-index: 1">
|
||||
<LeftOutlined v-show="!idle" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="20" class="centered">
|
||||
<FileViewer v-if="!documentStore.loading" :visibleImg="visible" @visibleImg="setVisible" :type="fileType"></FileViewer>
|
||||
</a-col>
|
||||
<a-col :span="2" class="centered-vertically right">
|
||||
<div class="custom-slick-arrow slick-arrow slick-prev centered" @click="next(1)" style="right: 10px">
|
||||
<RightOutlined v-show="!idle" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { h, ref, watchEffect } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useIdle, useFullscreen } from '@vueuse/core'
|
||||
import { LeftOutlined, RightOutlined, FullscreenOutlined, FullscreenExitOutlined, CloseOutlined } from '@ant-design/icons-vue';
|
||||
import { getFileType } from '@/utils'
|
||||
import Router from '@/router/index';
|
||||
import FileViewer from './FileViewer.vue';
|
||||
|
||||
const fileCarousel = ref<HTMLElement | null>(null)
|
||||
const { isFullscreen, toggle } = useFullscreen(fileCarousel)
|
||||
const visible = ref<boolean>(false);
|
||||
const documentStore = useDocumentStore()
|
||||
const fileType = ref<string| undefined>(undefined);
|
||||
watchEffect(() => {
|
||||
if(documentStore.mainDocument[0] && documentStore.mainDocument[0].type === 'file'){
|
||||
const fileExt = documentStore.mainDocument[0].ext
|
||||
fileType.value = getFileType(fileExt)
|
||||
}
|
||||
})
|
||||
function setVisible(value: boolean) {
|
||||
visible.value = value;
|
||||
}
|
||||
|
||||
const { idle } = useIdle(2000)
|
||||
|
||||
function redirectBack() {
|
||||
const currentPath = Router.currentRoute.value.path;
|
||||
const pathParts = currentPath.split('/').filter(part => part); // Remove empty parts
|
||||
// Ensure there's always at least one part in the path
|
||||
if (pathParts.length <= 1) {
|
||||
// If it's empty, set it to the base URL
|
||||
pathParts[0] = '/';
|
||||
} else {
|
||||
pathParts.pop();
|
||||
}
|
||||
const newPath = pathParts.join('/');
|
||||
Router.push(newPath);
|
||||
}
|
||||
function next(direction: number){
|
||||
const path = decodeURIComponent(new String(Router.currentRoute.value.path) as string)
|
||||
const name = documentStore.getNextDocumentInRoute(direction, path)
|
||||
let nextFileLocation : string[] | string = path.split('/')
|
||||
nextFileLocation.pop()
|
||||
nextFileLocation.push(name)
|
||||
nextFileLocation = nextFileLocation.join('/')
|
||||
Router.push(nextFileLocation)
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
:deep(.slick-arrow.custom-slick-arrow) {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 60px;
|
||||
color: var(--primary-color);
|
||||
transition: ease-in all 0.3s;
|
||||
opacity: 0.3;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:deep(.slick-arrow.custom-slick-arrow:before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.slick-arrow.custom-slick-arrow:hover) {
|
||||
color: var(--primary-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slider {
|
||||
height: 80vh;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.centered-vertically {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 0;
|
||||
font-size: 1.5em;
|
||||
opacity: 0.5;
|
||||
color: var(--secondary-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--blue-color);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-page-header {
|
||||
padding: 0;
|
||||
}
|
||||
.carousel{
|
||||
margin: 0;
|
||||
height: inherit;
|
||||
background-color: var(--table-background);
|
||||
}
|
||||
</style>
|
||||
208
cista-front/src/components/FileExplorer.vue
Normal file
208
cista-front/src/components/FileExplorer.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<main>
|
||||
<context-holder />
|
||||
<!-- <h2 v-if="!documentStore.loading && documentStore.error"> {{ documentStore.error }} </h2> -->
|
||||
<div class="carousel-container" v-if="!documentStore.loading && documentStore.mainDocument[0] && documentStore.mainDocument[0].type === 'file'">
|
||||
<FileCarousel></FileCarousel>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
v-else-if="!documentStore.loading && documentStore.mainDocument"
|
||||
:pagination=false
|
||||
:row-selection="{ selectedRowKeys: state.selectedRowKeys, onChange: onSelectChange }"
|
||||
:columns="columns"
|
||||
:data-source="documentStore.mainDocument"
|
||||
>
|
||||
<template #headerCell="{column}"></template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="editable-cell" :class="record.type === 'folder' ? 'folder' : 'file'">
|
||||
<div v-if="editableData[record.key]" class="action-container editable-cell-input-wrapper">
|
||||
<a-input class="name" v-model:value="editableData[record.key].name" @pressEnter="save(record.key)" />
|
||||
<CheckOutlined class="edit-action editable-cell-icon-check" @click="save(record.key)" />
|
||||
</div>
|
||||
<div v-else class="action-container editable-cell-text-wrapper">
|
||||
<a v-if="record.type === 'folder'" class="name" :href="`#${linkBasePath}/${record.name}`">{{record.name}}</a>
|
||||
<a v-else class="name" :href="`${filesBasePath}/${record.name}`">{{record.name}}</a>
|
||||
<edit-outlined class="edit-action editable-cell-icon" @click="edit(record.key)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-popover trigger="click">
|
||||
<template #content>
|
||||
<div class="more-action">
|
||||
<div class="action-container">
|
||||
<a :href="`${record.type === 'folder'? linkBasePath+'#' : filesBasePath}/${record.name}`">
|
||||
<a-button type="text" class="action-button" :icon="h(ImportOutlined)"/>
|
||||
</a>
|
||||
Open
|
||||
</div>
|
||||
<div v-if="record.type === 'folder-file'" class="action-container">
|
||||
<a :href="`${filesBasePath}/${record.name}`" download>
|
||||
<a-button type="text" class="action-button" :icon="h(DownloadOutlined)"/>
|
||||
</a>
|
||||
Download
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="text" class="action-button" @click="edit(record.key)" :icon="h(EditOutlined)"/> Rename
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button
|
||||
type="text"
|
||||
class="action-button"
|
||||
@click="share(`${record.type === 'folder'? linkBasePath+'/#' : filesBasePath}/${record.name}`)"
|
||||
:icon="h(LinkOutlined)"
|
||||
/> Share
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="text" class="action-button" :icon="h(CopyOutlined)"/> Copy
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="text" class="action-button" :icon="h(ScissorOutlined)"/> Cut
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="text" class="action-button" :icon="h(DeleteOutlined)"/> Delete
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-button type="text" class="action-button" :icon="h(EllipsisOutlined)" />
|
||||
</a-popover>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
</a-table>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, h, computed, reactive, watchEffect } from 'vue'
|
||||
import type { UnwrapRef } from 'vue'
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { EditOutlined, ImportOutlined, CheckOutlined,CopyOutlined, ScissorOutlined, LinkOutlined, DownloadOutlined, DeleteOutlined, EllipsisOutlined } from '@ant-design/icons-vue'
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import Router from '@/router/index';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { Document, FolderDocument } from '@/repositories/Document';
|
||||
import FileCarousel from './FileCarousel.vue';
|
||||
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
type Key = string | number;
|
||||
const documentStore = useDocumentStore()
|
||||
const editableData: UnwrapRef<Record<string, Document>> = reactive({});
|
||||
const state = reactive<{
|
||||
selectedRowKeys: Key[];
|
||||
}>({
|
||||
selectedRowKeys: [],
|
||||
});
|
||||
|
||||
const linkBasePath = computed(()=>{
|
||||
const path = Router.currentRoute.value.path
|
||||
return path === '/' ? '' : path
|
||||
})
|
||||
const filesBasePath = computed(() => `/files${linkBasePath.value}`)
|
||||
|
||||
const columns = ref<TableColumnsType>([
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
width: '70%',
|
||||
key: 'name',
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
sorter: (a: Document, b: Document) => a.name.localeCompare(b.name),
|
||||
},
|
||||
{
|
||||
title: 'Modified',
|
||||
dataIndex: 'modified',
|
||||
className: 'column-date',
|
||||
responsive: ['lg'],
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
defaultSortOrder: 'descend',
|
||||
sorter: (a: FolderDocument, b: FolderDocument) => a.mtime - b.mtime,
|
||||
key: 'modified',
|
||||
},
|
||||
{
|
||||
// TODO BETTER SORT FOR MULTPLE SIZE OR CUSTOM PIPE TO kB to MB / GB
|
||||
title: 'Size',
|
||||
dataIndex: 'sizedisp',
|
||||
className: 'column-size',
|
||||
responsive: ['lg'],
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
sorter: (a: FolderDocument, b: FolderDocument) => a.size - b.size,
|
||||
key: 'size',
|
||||
},
|
||||
{
|
||||
width: '5%',
|
||||
key: 'action',
|
||||
},
|
||||
])
|
||||
|
||||
const onSelectChange = (selectedRowKeys: Key[]) => {
|
||||
const newSelectedRowKeys: Document[] = []
|
||||
selectedRowKeys.forEach( key => {
|
||||
if(documentStore.mainDocument){
|
||||
const found = documentStore.mainDocument.find( e=> e.key === key )
|
||||
if(found) newSelectedRowKeys.push(found)
|
||||
}
|
||||
})
|
||||
documentStore.setSelectedDocuments(newSelectedRowKeys)
|
||||
state.selectedRowKeys = selectedRowKeys;
|
||||
};
|
||||
const edit = (key: number) => {
|
||||
editableData[key] = cloneDeep(documentStore.mainDocument.filter(item => key === item.key)[0]);
|
||||
};
|
||||
const save = (key: number) => {
|
||||
Object.assign(documentStore.mainDocument.filter(item => key === item.key)[0], editableData[key]);
|
||||
delete editableData[key];
|
||||
};
|
||||
const share = async (url : string) => {
|
||||
await navigator.clipboard.writeText(location.origin + url)
|
||||
messageApi.success("Link successfully copied to the clipboard");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.column-date, .column-size {
|
||||
text-align: right;
|
||||
}
|
||||
main {
|
||||
padding: 5px;
|
||||
height: 100%;
|
||||
}
|
||||
.more-action{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
}
|
||||
.action-container{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.name{
|
||||
max-width: 70%;
|
||||
}
|
||||
.edit-action{
|
||||
min-width: 5%;
|
||||
}
|
||||
.carousel-container{
|
||||
height: inherit;
|
||||
}
|
||||
.file .name::before {
|
||||
content: '📄 ';
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.folder .name::before {
|
||||
content: '📁 ';
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.editable-cell-text-wrapper .editable-cell-icon {
|
||||
visibility: hidden; /* Oculta el ícono de manera predeterminada */
|
||||
}
|
||||
|
||||
.editable-cell-text-wrapper:hover .editable-cell-icon {
|
||||
visibility: visible; /* Muestra el ícono al hacer hover en el contenedor */
|
||||
}
|
||||
</style>
|
||||
55
cista-front/src/components/FileViewer.vue
Normal file
55
cista-front/src/components/FileViewer.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<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>
|
||||
160
cista-front/src/components/HeaderMain.vue
Normal file
160
cista-front/src/components/HeaderMain.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import LoginModal from '@/components/LoginModal.vue'
|
||||
import UploadButton from '@/components/UploadButton.vue'
|
||||
import { InfoCircleOutlined, SettingOutlined, PlusSquareOutlined, SearchOutlined, DeleteOutlined, DownloadOutlined, FileAddFilled, LinkOutlined, FolderAddFilled, FileFilled, FolderFilled } from '@ant-design/icons-vue'
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
const searchQuery = ref<string>('')
|
||||
const showSearchInput = ref<boolean>(false)
|
||||
const isLoading = ref<boolean>(false)
|
||||
|
||||
const toggleSearchInput = () => {
|
||||
showSearchInput.value = !showSearchInput.value;
|
||||
if (!showSearchInput.value) {
|
||||
searchQuery.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const executeSearch = () => {
|
||||
isLoading.value = true;
|
||||
console.log(
|
||||
documentStore.mainDocument
|
||||
)
|
||||
setTimeout(() => {
|
||||
isLoading.value = false;
|
||||
// Perform your search logic here
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
function createFileHandler() {
|
||||
console.log("Creating file")
|
||||
}
|
||||
|
||||
function uploadFolderHandler() {
|
||||
console.log("Uploading Folder")
|
||||
}
|
||||
function createFolderHandler() {
|
||||
console.log("Uploading Folder")
|
||||
}
|
||||
function searchHandler() {
|
||||
console.log("Searching ...")
|
||||
}
|
||||
function newViewHandler() {
|
||||
console.log("Creating new view ...")
|
||||
}
|
||||
function preferencesHandler() {
|
||||
console.log("Preferences ...")
|
||||
}
|
||||
function about() {
|
||||
console.log("About ...")
|
||||
}
|
||||
function deleteHandler(){
|
||||
if(documentStore.selectedDocuments){
|
||||
documentStore.selectedDocuments.forEach(document => {
|
||||
documentStore.deleteDocument(document)
|
||||
})
|
||||
}
|
||||
}
|
||||
function share(){
|
||||
console.log("Share ...")
|
||||
}
|
||||
function download(){
|
||||
console.log("Download ...")
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="actions-container">
|
||||
<div class="actions-list">
|
||||
<UploadButton />
|
||||
|
||||
<a-tooltip title="Upload folder from disk">
|
||||
<a-button @click="uploadFolderHandler" type="text" class="action-button" :icon="h(FolderAddFilled)" />
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="Create file">
|
||||
<a-button @click="createFileHandler" type="text" class="action-button" :icon="h(FileFilled)" />
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="Create folder">
|
||||
<a-button @click="createFolderHandler" type="text" class="action-button" :icon="h(FolderFilled)" />
|
||||
</a-tooltip>
|
||||
<!-- TODO ADD CONDITIONAL RENDER -->
|
||||
<template v-if="documentStore.selectedDocuments && documentStore.selectedDocuments.length > 0">
|
||||
<a-tooltip title="Share">
|
||||
<a-button type="text" @click="share" class="action-button" :icon="h(LinkOutlined)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip title="Download Zip">
|
||||
<a-button type="text" @click="download" class="action-button" :icon="h(DownloadOutlined)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip title="Delete">
|
||||
<a-button type="text" @click="deleteHandler" class="action-button" :icon="h(DeleteOutlined)" />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
<div class="actions-list">
|
||||
<LoginModal></LoginModal>
|
||||
<template v-if="showSearchInput">
|
||||
<a-input-search
|
||||
v-model="searchQuery"
|
||||
class="margin-input"
|
||||
v-on:keyup.enter="executeSearch"
|
||||
@search="executeSearch"
|
||||
:loading="isLoading"
|
||||
/>
|
||||
</template>
|
||||
<a-tooltip title="Search">
|
||||
<a-button @click="toggleSearchInput" type="text" class="action-button" :icon="h(SearchOutlined)" />
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="Create new view">
|
||||
<a-button @click="newViewHandler" type="text" class="action-button" :icon="h(PlusSquareOutlined)" />
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="Preferences">
|
||||
<a-button @click="preferencesHandler" type="text" class="action-button" :icon="h(SettingOutlined)" />
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="About">
|
||||
<a-button @click="about" type="text" class="action-button" :icon="h(InfoCircleOutlined)" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.actions-container,
|
||||
.actions-list {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 0;
|
||||
font-size: 1.5em;
|
||||
color: var(--secondary-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--blue-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
|
||||
.actions-container,
|
||||
.actions-list {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
.margin-input{
|
||||
margin-top: 5px;
|
||||
}
|
||||
</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>
|
||||
90
cista-front/src/components/LoginModal.vue
Normal file
90
cista-front/src/components/LoginModal.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<a-tooltip title="Login">
|
||||
<template v-if="DocumentStore.isUserLogged">
|
||||
<a-button @click="logout" type="text" class="action-button" :icon="h(UserDeleteOutlined)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button @click="showModal" type="text" class="action-button" :icon="h(UserOutlined)" />
|
||||
</template>
|
||||
</a-tooltip>
|
||||
<a-modal v-model:open="DocumentStore.user.isOpenLoginModal" :confirm-loading="confirmLoading" okText="Login" @ok="login">
|
||||
<div class="login-container">
|
||||
<a-form :model="loginForm">
|
||||
<a-form-item label="Username" prop="username" :rules="[{ required: true, message: 'Please input your username!' }]">
|
||||
<a-input v-model:value="loginForm.username" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Password" prop="password" :rules="[{ required: true, message: 'Please input your password!' }]">
|
||||
<a-input type="password" v-model:value="loginForm.password" />
|
||||
</a-form-item>
|
||||
<h3 v-if="loginForm.error.length > 0" class="error-text">{{loginForm.error}}</h3>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, h } from 'vue';
|
||||
import { UserOutlined, UserDeleteOutlined } from '@ant-design/icons-vue';
|
||||
import { useDocumentStore } from '@/stores/documents';
|
||||
import { loginUser, logoutUser } from '@/repositories/User';
|
||||
import type { ISimpleError } from '@/repositories/Client';
|
||||
|
||||
const DocumentStore = useDocumentStore();
|
||||
const confirmLoading = ref<boolean>(false);
|
||||
|
||||
const showModal = () => {
|
||||
DocumentStore.user.isOpenLoginModal = true;
|
||||
};
|
||||
const logout = async () => {
|
||||
try {
|
||||
await logoutUser();
|
||||
} catch (error) {} finally {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
error: '',
|
||||
});
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
loginForm.value.error = '';
|
||||
confirmLoading.value = true;
|
||||
const user = await loginUser(loginForm.value.username, loginForm.value.password);
|
||||
if(user){
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
const httpError = error as ISimpleError
|
||||
if(httpError.name){
|
||||
loginForm.value.error = httpError.message
|
||||
}
|
||||
}finally{
|
||||
confirmLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 30vh;
|
||||
}
|
||||
.button-login {
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--secondary-background);
|
||||
}
|
||||
.ant-btn-primary:not(:disabled):hover{
|
||||
background-color: var(--blue-color);
|
||||
|
||||
}
|
||||
.error-text {
|
||||
color :var(--red-color)
|
||||
}
|
||||
</style>
|
||||
|
||||
28
cista-front/src/components/NotificationLoading.vue
Normal file
28
cista-front/src/components/NotificationLoading.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<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 { CloseCircleOutlined } from '@ant-design/icons-vue';
|
||||
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>
|
||||
@@ -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>
|
||||
92
cista-front/src/components/UploadButton.vue
Normal file
92
cista-front/src/components/UploadButton.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import NotificationLoading from '@/components/NotificationLoading.vue'
|
||||
import { FileAddFilled } from '@ant-design/icons-vue'
|
||||
import { notification } from 'ant-design-vue';
|
||||
import type { NotificationPlacement } from 'ant-design-vue';
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
const [api, contextHolder] = notification.useNotification();
|
||||
|
||||
const fileUploadButton = ref()
|
||||
const documentStore = useDocumentStore();
|
||||
const open = (placement: NotificationPlacement) => openNotification(placement);
|
||||
|
||||
const isNotificationOpen = ref(false);
|
||||
const openNotification = (placement: NotificationPlacement) => {
|
||||
if(!isNotificationOpen.value){
|
||||
api.open({
|
||||
message: `Uploading documents`,
|
||||
description: h(NotificationLoading),
|
||||
placement,
|
||||
duration: 0,
|
||||
onClose: () => { isNotificationOpen.value = false }
|
||||
});
|
||||
isNotificationOpen.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function uploadFileHandler() {
|
||||
fileUploadButton.value.click()
|
||||
}
|
||||
|
||||
async function load(file: File, start: number, end: number): Promise<ArrayBuffer> {
|
||||
const reader = new FileReader();
|
||||
const load = new Promise<Event>((resolve) => (reader.onload = resolve));
|
||||
reader.readAsArrayBuffer(file.slice(start, end));
|
||||
const event = await load;
|
||||
if (event.target && event.target instanceof FileReader) {
|
||||
return event.target.result as ArrayBuffer;
|
||||
} else {
|
||||
throw new Error('Error loading file' );
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChunk(file :File, start: number, end: number) {
|
||||
const ws = documentStore.wsUpload;
|
||||
if(ws){
|
||||
const chunk = await load(file, start, end)
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
start: start,
|
||||
end: end
|
||||
}))
|
||||
ws.send(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFileChangeHandler(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const chunkSize = 1 << 20
|
||||
if (target && target.files && target.files.length > 0) {
|
||||
const file = target.files[0];
|
||||
const numChunks = Math.ceil(file.size / chunkSize)
|
||||
const document = documentStore.pushUploadingDocuments(file.name)
|
||||
open('bottomRight')
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
const start = i * chunkSize
|
||||
const end = Math.min(file.size, start + chunkSize)
|
||||
const res = await sendChunk(file, start, end)
|
||||
console.log( 'progress: '+ ( ( 100 * (i + 1) ) / numChunks) )
|
||||
console.log( 'Num Chunks: '+ numChunks )
|
||||
documentStore.updateUploadingDocuments( document.key, ((100 * (i + 1) ) / numChunks))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<a-tooltip title="Upload files from disk">
|
||||
<a-button @click="uploadFileHandler" type="text" class="action-button" :icon="h(FileAddFilled)" />
|
||||
<input ref="fileUploadButton" @change="uploadFileChangeHandler" class="upload-input" type="file" onclick="this.value=null;" />
|
||||
</a-tooltip>
|
||||
<contextHolder />
|
||||
</template>
|
||||
<style scoped>
|
||||
/* Extends styles from HeaderMain.vue too */
|
||||
.upload-input{
|
||||
display: none;
|
||||
}
|
||||
</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>
|
||||
@@ -1,14 +1,21 @@
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue';
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.config.errorHandler = (err) => {
|
||||
/* handle error */
|
||||
console.log(err)
|
||||
}
|
||||
app.use(createPinia())
|
||||
|
||||
app.use(Antd);
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
app.mount('#app')
|
||||
42
cista-front/src/repositories/Client.ts
Normal file
42
cista-front/src/repositories/Client.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import axios, {AxiosError} from 'axios'
|
||||
|
||||
/* Base domain for all request */
|
||||
export const baseURL = import.meta.env.VITE_URL_DOCUMENT
|
||||
|
||||
/* Config Client*/
|
||||
const Client = axios.create({
|
||||
baseURL: baseURL,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
Client.interceptors.response.use(
|
||||
(response) => {
|
||||
// Any status code that lie within the range of 2xx cause this function to trigger
|
||||
// Do something with response data
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<any>) => {
|
||||
const msg = error.response && error.response.data && error.response.data.error ?
|
||||
error.response.data.error.message : 'Unexpected error'
|
||||
const code = error.code ? Number(error.response?.status) : 500
|
||||
const standardizedError = new SimpleError(code, msg)
|
||||
|
||||
return Promise.reject(standardizedError)
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
114
cista-front/src/repositories/Document.ts
Normal file
114
cista-front/src/repositories/Document.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { FileStructure, DocumentStore } from '@/stores/documents'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { getFileExtension } from '@/utils'
|
||||
import Client from '@/repositories/Client'
|
||||
|
||||
|
||||
type BaseDocument = {
|
||||
name: string;
|
||||
key?: number | string;
|
||||
};
|
||||
|
||||
export type FolderDocument = BaseDocument & {
|
||||
type: 'folder' | 'folder-file';
|
||||
size: number;
|
||||
sizedisp: string;
|
||||
mtime: number;
|
||||
modified: string;
|
||||
};
|
||||
|
||||
export type FileDocument = BaseDocument & {
|
||||
type: 'file';
|
||||
ext: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type errorEvent = {
|
||||
error: {
|
||||
code : number;
|
||||
message: string;
|
||||
redirect: string;
|
||||
}
|
||||
};
|
||||
|
||||
export type Document = FolderDocument | FileDocument;
|
||||
|
||||
|
||||
export const url_document_watch_ws = '/api/watch'
|
||||
export const url_document_upload_ws = '/api/upload'
|
||||
export const url_document_get ='/files'
|
||||
|
||||
export class DocumentHandler {
|
||||
constructor( private store: DocumentStore = useDocumentStore() ) {
|
||||
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this);
|
||||
}
|
||||
|
||||
handleWebSocketMessage(event: MessageEvent) {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (true) {
|
||||
case !!msg.root:
|
||||
this.handleRootMessage(msg);
|
||||
break;
|
||||
case !!msg.update:
|
||||
this.handleUpdateMessage(msg);
|
||||
break;
|
||||
case !!msg.error:
|
||||
this.handleError(msg);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
private handleRootMessage({ root }: { root: FileStructure }) {
|
||||
if (this.store && this.store.root) {
|
||||
this.store.user.isLoggedIn = true;
|
||||
this.store.root = root;
|
||||
}
|
||||
}
|
||||
|
||||
private handleUpdateMessage(updateData: { update: FileStructure[] }) {
|
||||
const root = updateData.update[0]
|
||||
if(root) {
|
||||
this.store.user.isLoggedIn = true;
|
||||
this.store.root = root
|
||||
}
|
||||
}
|
||||
private handleError(msg: errorEvent){
|
||||
if(msg.error.code === 401){
|
||||
this.store.user.isOpenLoginModal = true;
|
||||
this.store.user.isLoggedIn = false;
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DocumentUploadHandler {
|
||||
constructor( private store: DocumentStore = useDocumentStore() ) {
|
||||
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this);
|
||||
}
|
||||
|
||||
handleWebSocketMessage(event: MessageEvent) {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (true) {
|
||||
case !!msg.written:
|
||||
this.handleWrittenMessage(msg);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
private handleWrittenMessage(msg : { written : number}) {
|
||||
// if (this.store && this.store.root) this.store.root = root;
|
||||
console.log('Written message', msg.written)
|
||||
}
|
||||
}
|
||||
export async function fetchFile(path: string): Promise<FileDocument>{
|
||||
const file = await Client.get(url_document_get + path)
|
||||
const name = path.substring(1 , path.length)
|
||||
return {
|
||||
name,
|
||||
data: file.data,
|
||||
type: 'file',
|
||||
ext: getFileExtension(name)
|
||||
}
|
||||
}
|
||||
23
cista-front/src/repositories/User.ts
Normal file
23
cista-front/src/repositories/User.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
import Client from '@/repositories/Client'
|
||||
export const url_login = '/login'
|
||||
export const url_logout = '/logout '
|
||||
|
||||
export async function loginUser(username : string, password: string){
|
||||
try {
|
||||
const user = await Client.post(url_login, {
|
||||
username, password
|
||||
})
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
export async function logoutUser(){
|
||||
try {
|
||||
const data = await Client.post(url_logout)
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
8
cista-front/src/repositories/WS.ts
Normal file
8
cista-front/src/repositories/WS.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
function createWebSocket(url: string, eventHandler: (event: MessageEvent) => void) {
|
||||
const urlObject = new URL(url, location.origin.replace( /^http/, 'ws'));
|
||||
const webSocket = new WebSocket(urlObject);
|
||||
webSocket.onmessage = eventHandler;
|
||||
return webSocket;
|
||||
}
|
||||
|
||||
export default createWebSocket
|
||||
@@ -1,22 +1,14 @@
|
||||
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: '/:pathMatch(.*)*',
|
||||
name: 'explorer',
|
||||
component: ExplorerView,
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AboutView.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
165
cista-front/src/stores/documents.ts
Normal file
165
cista-front/src/stores/documents.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Document, FolderDocument } from '@/repositories/Document';
|
||||
import type { ISimpleError } from '@/repositories/Client';
|
||||
import { fetchFile } from '@/repositories/Document'
|
||||
import { formatSize, formatUnixDate } from '@/utils';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
|
||||
type FileData = { id: string, mtime: number, size: number, dir: DirectoryData};
|
||||
type DirectoryData = {
|
||||
[filename: string]: FileData;
|
||||
};
|
||||
export type FileStructure = {id: string, mtime: number, size: number, dir: DirectoryData};
|
||||
type User = {
|
||||
isOpenLoginModal: boolean,
|
||||
isLoggedIn : boolean,
|
||||
}
|
||||
|
||||
export type DocumentStore = {
|
||||
root: FileStructure,
|
||||
document: Document[],
|
||||
loading: boolean,
|
||||
uploadingDocuments: Array<{key: number, name: string, progress: number}>,
|
||||
uploadCount: number,
|
||||
wsWatch: WebSocket | undefined,
|
||||
wsUpload: WebSocket | undefined,
|
||||
selectedDocuments: Document[],
|
||||
user: User,
|
||||
error: string,
|
||||
}
|
||||
|
||||
export const useDocumentStore = defineStore({
|
||||
id: 'documents',
|
||||
state: (): DocumentStore => ({
|
||||
root: {} as FileStructure,
|
||||
document: [] as Document[],
|
||||
loading: true as boolean,
|
||||
uploadingDocuments: [],
|
||||
uploadCount: 0 as number,
|
||||
wsWatch: undefined,
|
||||
wsUpload: undefined,
|
||||
selectedDocuments: [] as Document[],
|
||||
error: '' as string,
|
||||
user: { isLoggedIn: false, isOpenLoginModal: false } as User
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setActualDocument(location: string){
|
||||
this.loading = true;
|
||||
let data = this.root
|
||||
const dataMapped = [];
|
||||
const locations = location.split('/').slice(1)
|
||||
// Get data target location
|
||||
locations.forEach(location => {
|
||||
location = decodeURIComponent(location)
|
||||
if(data && data.dir){
|
||||
for (const key in data.dir) {
|
||||
if(key === location) data = data.dir[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Transform data
|
||||
for (const [name, attr] of Object.entries(data.dir)) {
|
||||
const {id, size, mtime, dir} = attr
|
||||
const element: Document = {
|
||||
name,
|
||||
key: id,
|
||||
size,
|
||||
sizedisp: formatSize(size),
|
||||
mtime,
|
||||
modified: formatUnixDate(mtime),
|
||||
type: dir === undefined ? 'folder-file' : 'folder',
|
||||
}
|
||||
dataMapped.push(element)
|
||||
}
|
||||
// Pre sort directory entries folders first then files, names in natural ordering
|
||||
dataMapped.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "folder" ? -1 : 1)
|
||||
this.document = dataMapped
|
||||
this.loading = false;
|
||||
},
|
||||
async setActualDocumentFile(path: string){
|
||||
this.loading = true;
|
||||
const file = await fetchFile(path)
|
||||
this.document = [file];
|
||||
this.loading = false;
|
||||
},
|
||||
setSelectedDocuments(document: Document[]){
|
||||
this.selectedDocuments = document
|
||||
},
|
||||
deleteDocument(document: Document){
|
||||
this.document = this.document.filter(e => document.key !== e.key)
|
||||
this.selectedDocuments = this.selectedDocuments.filter(e => document.key !== e.key)
|
||||
},
|
||||
updateUploadingDocuments(key: number, progress: number){
|
||||
for (const d of this.uploadingDocuments) {
|
||||
if(d.key === key) d.progress = progress
|
||||
}
|
||||
},
|
||||
pushUploadingDocuments(name: string){
|
||||
this.uploadCount++;
|
||||
const document = {
|
||||
key: this.uploadCount,
|
||||
name: name,
|
||||
progress: 0
|
||||
}
|
||||
this.uploadingDocuments.push(document)
|
||||
return document
|
||||
},
|
||||
deleteUploadingDocument(key: number){
|
||||
this.uploadingDocuments = this.uploadingDocuments.filter((e)=> e.key !== key)
|
||||
},
|
||||
getNextDocumentInRoute(direction: number, path: string){
|
||||
const locations = path.split('/').slice(1)
|
||||
locations.pop()
|
||||
let data = this.root
|
||||
const actualDirArr = []
|
||||
// Get data target location
|
||||
locations.forEach(location => {
|
||||
// location = decodeURIComponent(location)
|
||||
if(data && data.dir){
|
||||
for (const key in data.dir) {
|
||||
if(key === location) data = data.dir[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
//Store in a temporary array
|
||||
for (const key in data.dir) {
|
||||
actualDirArr.push({
|
||||
name: key,
|
||||
content: data.dir[key]
|
||||
})
|
||||
}
|
||||
const actualFileName = decodeURIComponent(this.mainDocument[0].name).split('/').pop()
|
||||
let index = actualDirArr.findIndex(e => e.name === actualFileName)
|
||||
|
||||
if(index < 1 && direction === -1 ){
|
||||
index = actualDirArr.length -1
|
||||
}else if(index >= actualDirArr.length - 1 && direction === 1){
|
||||
index = 0
|
||||
}else {
|
||||
index = index + direction
|
||||
}
|
||||
return actualDirArr[index].name
|
||||
},
|
||||
updateModified() {
|
||||
for (const d of this.document) {
|
||||
if ("mtime" in d) d.modified = formatUnixDate(d.mtime)
|
||||
}
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
mainDocument(): Document[] {
|
||||
return this.document;
|
||||
},
|
||||
rootSize(): number | undefined {
|
||||
if(this.root) return this.root.size
|
||||
},
|
||||
rootMain(): DirectoryData | undefined {
|
||||
if(this.root) return this.root.dir
|
||||
},
|
||||
isUserLogged(): boolean{
|
||||
return this.user.isLoggedIn
|
||||
}
|
||||
},
|
||||
});
|
||||
67
cista-front/src/utils/index.ts
Normal file
67
cista-front/src/utils/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export function determineFileType(inputString: string): "file" | "folder" {
|
||||
if (inputString.includes('.') && !inputString.endsWith('.')) {
|
||||
return 'file';
|
||||
} else {
|
||||
return 'folder';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSize(size: number) {
|
||||
for (const unit of [null, 'kB', 'MB', 'GB', 'TB', 'PB', 'EB']) {
|
||||
if (size < 1e5) 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 formatter = new Intl.RelativeTimeFormat('en', { numeric:
|
||||
'auto' })
|
||||
if (Math.abs(diff) <= 5000) {
|
||||
return 'now'
|
||||
}
|
||||
if (Math.abs(diff) <= 60000) {
|
||||
return formatter.format(Math.round(diff / 1000), 'second')
|
||||
}
|
||||
|
||||
if (Math.abs(diff) <= 3600000) {
|
||||
return formatter.format(Math.round(diff / 60000), 'minute')
|
||||
}
|
||||
|
||||
if (Math.abs(diff) <= 86400000) {
|
||||
return formatter.format(Math.round(diff / 3600000), 'hour')
|
||||
}
|
||||
|
||||
if (Math.abs(diff) <= 604800000) {
|
||||
return formatter.format(Math.round(diff / 86400000), 'day')
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export function getFileExtension(filename: string) {
|
||||
const parts = filename.split(".");
|
||||
if (parts.length > 1) {
|
||||
return parts[parts.length - 1];
|
||||
} else {
|
||||
return ""; // No hay extensión
|
||||
}
|
||||
}
|
||||
export function getFileType(extension: string): string {
|
||||
const videoExtensions = ["mp4", "avi", "mkv", "mov"];
|
||||
const imageExtensions = ["jpg", "jpeg", "png", "gif"];
|
||||
const pdfExtensions = ["pdf"];
|
||||
|
||||
if (videoExtensions.includes(extension)) {
|
||||
return "video";
|
||||
} else if (imageExtensions.includes(extension)) {
|
||||
return "image";
|
||||
} else if (pdfExtensions.includes(extension)) {
|
||||
return "pdf";
|
||||
} else {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
36
cista-front/src/views/ExplorerView.vue
Normal file
36
cista-front/src/views/ExplorerView.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<FileExplorer></FileExplorer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import Router from '@/router/index';
|
||||
import FileExplorer from '@/components/FileExplorer.vue';
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
|
||||
function isFile(path: string) {
|
||||
if (path.includes('.') && !path.endsWith('.')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(async () => {
|
||||
const path = new String(Router.currentRoute.value.path) as string
|
||||
const file = isFile(path)
|
||||
if(!file){
|
||||
documentStore.setActualDocument(path.toString())
|
||||
}else {
|
||||
documentStore.setActualDocumentFile(path)
|
||||
}
|
||||
setTimeout( () => {
|
||||
documentStore.loading = false
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,9 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
</template>
|
||||
@@ -6,6 +6,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
9
cista-front/tsconfig.vitest.json
Normal file
9
cista-front/tsconfig.vitest.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,32 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
import Components from "unplugin-vue-components/vite";
|
||||
import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
|
||||
|
||||
// @ts-ignore
|
||||
import pluginRewriteAll from 'vite-plugin-rewrite-all';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
pluginRewriteAll(),
|
||||
// Ant Design configuration
|
||||
Components({
|
||||
resolvers: [
|
||||
AntDesignVueResolver({ importStyle:"less" })
|
||||
],
|
||||
}),
|
||||
],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
modifyVars: {},
|
||||
javascriptEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
from cista._version import __version__
|
||||
|
||||
__version__ # Public API
|
||||
|
||||
@@ -7,7 +7,7 @@ import cista
|
||||
from cista import app, config, droppy, serve, server80
|
||||
from cista.util import pwgen
|
||||
|
||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||
|
||||
doc = f"""Cista {cista.__version__} - A file storage for the web.
|
||||
|
||||
@@ -34,6 +34,7 @@ User management:
|
||||
--password Reset password
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
# Dev mode doesn't catch exceptions
|
||||
if "--dev" in sys.argv:
|
||||
@@ -45,6 +46,7 @@ def main():
|
||||
print("Error:", e)
|
||||
return 1
|
||||
|
||||
|
||||
def _main():
|
||||
args = docopt(doc)
|
||||
if args["--user"]:
|
||||
@@ -64,19 +66,25 @@ def _main():
|
||||
if not necessary_opts:
|
||||
# Maybe run without arguments
|
||||
print(doc)
|
||||
print("No config file found! Get started with:\n cista -l :8000 /path/to/files, or\n cista -l example.com --import-droppy # Uses Droppy files\n")
|
||||
print(
|
||||
"No config file found! Get started with:\n cista -l :8000 /path/to/files, or\n cista -l example.com --import-droppy # Uses Droppy files\n"
|
||||
)
|
||||
return 1
|
||||
settings = {}
|
||||
if import_droppy:
|
||||
if exists:
|
||||
raise ValueError(f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}")
|
||||
raise ValueError(
|
||||
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}"
|
||||
)
|
||||
settings = droppy.readconf()
|
||||
if path: settings["path"] = path
|
||||
if listen: settings["listen"] = listen
|
||||
if path:
|
||||
settings["path"] = path
|
||||
if listen:
|
||||
settings["listen"] = listen
|
||||
operation = config.update_config(settings)
|
||||
print(f"Config {operation}: {config.conffile}")
|
||||
# Prepare to serve
|
||||
domain = unix = port = None
|
||||
unix = None
|
||||
url, _ = serve.parse_listen(config.config.listen)
|
||||
if not config.config.path.is_dir():
|
||||
raise ValueError(f"No such directory: {config.config.path}")
|
||||
@@ -88,6 +96,7 @@ def _main():
|
||||
# Run the server
|
||||
serve.run(dev=dev)
|
||||
|
||||
|
||||
def _confdir(args):
|
||||
if args["-c"]:
|
||||
# Custom config directory
|
||||
@@ -99,6 +108,7 @@ def _confdir(args):
|
||||
confdir = confdir.parent
|
||||
config.conffile = config.conffile.with_parent(confdir)
|
||||
|
||||
|
||||
def _user(args):
|
||||
_confdir(args)
|
||||
config.load_config()
|
||||
@@ -123,5 +133,6 @@ def _user(args):
|
||||
if res == "read":
|
||||
print(" No changes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
24
cista/api.py
24
cista/api.py
@@ -12,15 +12,18 @@ from cista.util.apphelpers import asend, websocket_wrapper
|
||||
bp = Blueprint("api", url_prefix="/api")
|
||||
fileserver = FileServer()
|
||||
|
||||
|
||||
@bp.before_server_start
|
||||
async def start_fileserver(app, _):
|
||||
await fileserver.start()
|
||||
|
||||
|
||||
@bp.after_server_stop
|
||||
async def stop_fileserver(app, _):
|
||||
await fileserver.stop()
|
||||
|
||||
@bp.websocket('upload')
|
||||
|
||||
@bp.websocket("upload")
|
||||
@websocket_wrapper
|
||||
async def upload(req, ws):
|
||||
alink = fileserver.alink
|
||||
@@ -28,7 +31,9 @@ async def upload(req, ws):
|
||||
req = None
|
||||
text = await ws.recv()
|
||||
if not isinstance(text, str):
|
||||
raise ValueError(f"Expected JSON control, got binary len(data) = {len(text)}")
|
||||
raise ValueError(
|
||||
f"Expected JSON control, got binary len(data) = {len(text)}"
|
||||
)
|
||||
req = msgspec.json.decode(text, type=FileRange)
|
||||
pos = req.start
|
||||
data = None
|
||||
@@ -41,9 +46,10 @@ async def upload(req, ws):
|
||||
# Report success
|
||||
res = StatusMsg(status="ack", req=req)
|
||||
await asend(ws, res)
|
||||
#await ws.drain()
|
||||
# await ws.drain()
|
||||
|
||||
@bp.websocket('download')
|
||||
|
||||
@bp.websocket("download")
|
||||
@websocket_wrapper
|
||||
async def download(req, ws):
|
||||
alink = fileserver.alink
|
||||
@@ -51,18 +57,21 @@ async def download(req, ws):
|
||||
req = None
|
||||
text = await ws.recv()
|
||||
if not isinstance(text, str):
|
||||
raise ValueError(f"Expected JSON control, got binary len(data) = {len(text)}")
|
||||
raise ValueError(
|
||||
f"Expected JSON control, got binary len(data) = {len(text)}"
|
||||
)
|
||||
req = msgspec.json.decode(text, type=FileRange)
|
||||
pos = req.start
|
||||
while pos < req.end:
|
||||
end = min(req.end, pos + (1<<20))
|
||||
end = min(req.end, pos + (1 << 20))
|
||||
data = typing.cast(bytes, await alink(("download", req.name, pos, end)))
|
||||
await asend(ws, data)
|
||||
pos += len(data)
|
||||
# Report success
|
||||
res = StatusMsg(status="ack", req=req)
|
||||
await asend(ws, res)
|
||||
#await ws.drain()
|
||||
# await ws.drain()
|
||||
|
||||
|
||||
@bp.websocket("control")
|
||||
@websocket_wrapper
|
||||
@@ -71,6 +80,7 @@ async def control(req, ws):
|
||||
await asyncio.to_thread(cmd)
|
||||
await asend(ws, StatusMsg(status="ack", req=cmd))
|
||||
|
||||
|
||||
@bp.websocket("watch")
|
||||
@websocket_wrapper
|
||||
async def watch(req, ws):
|
||||
|
||||
18
cista/app.py
18
cista/app.py
@@ -2,7 +2,6 @@ import mimetypes
|
||||
from importlib.resources import files
|
||||
from urllib.parse import unquote
|
||||
|
||||
from html5tagger import E
|
||||
from sanic import Blueprint, Sanic, raw
|
||||
from sanic.exceptions import Forbidden, NotFound
|
||||
|
||||
@@ -16,15 +15,18 @@ app.blueprint(auth.bp)
|
||||
app.blueprint(bp)
|
||||
app.exception(Exception)(handle_sanic_exception)
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def main_start(app, loop):
|
||||
config.load_config()
|
||||
await watching.start(app, loop)
|
||||
|
||||
|
||||
@app.after_server_stop
|
||||
async def main_stop(app, loop):
|
||||
await watching.stop(app, loop)
|
||||
|
||||
|
||||
@app.on_request
|
||||
async def use_session(req):
|
||||
req.ctx.session = session.get(req)
|
||||
@@ -41,13 +43,21 @@ async def use_session(req):
|
||||
if origin and origin.split("//", 1)[1] != req.host:
|
||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
def http_fileserver(app, _):
|
||||
bp = Blueprint("fileserver")
|
||||
bp.on_request(auth.verify)
|
||||
bp.static("/files/", config.config.path, use_content_range=True, stream_large_files=True, directory_view=True)
|
||||
bp.static(
|
||||
"/files/",
|
||||
config.config.path,
|
||||
use_content_range=True,
|
||||
stream_large_files=True,
|
||||
directory_view=True,
|
||||
)
|
||||
app.blueprint(bp)
|
||||
|
||||
|
||||
@app.get("/<path:path>", static=True)
|
||||
async def wwwroot(req, path=""):
|
||||
"""Frontend files only"""
|
||||
@@ -55,6 +65,8 @@ async def wwwroot(req, path=""):
|
||||
try:
|
||||
index = files("cista").joinpath("wwwroot", name).read_bytes()
|
||||
except OSError as e:
|
||||
raise NotFound(f"File not found: /{path}", extra={"name": name, "exception": repr(e)})
|
||||
raise NotFound(
|
||||
f"File not found: /{path}", extra={"name": name, "exception": repr(e)}
|
||||
)
|
||||
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
return raw(index, content_type=mime)
|
||||
|
||||
@@ -12,10 +12,12 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
||||
from cista import config, session
|
||||
|
||||
_argon = argon2.PasswordHasher()
|
||||
_droppyhash = re.compile(r'^([a-f0-9]{64})\$([a-f0-9]{8})$')
|
||||
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
||||
|
||||
|
||||
def _pwnorm(password):
|
||||
return normalize('NFC', password).strip().encode()
|
||||
return normalize("NFC", password).strip().encode()
|
||||
|
||||
|
||||
def login(username: str, password: str):
|
||||
un = _pwnorm(username)
|
||||
@@ -49,33 +51,51 @@ def login(username: str, password: str):
|
||||
u.lastSeen = now
|
||||
return u
|
||||
|
||||
|
||||
def set_password(user: config.User, password: str):
|
||||
user.hash = _argon.hash(_pwnorm(password))
|
||||
|
||||
|
||||
class LoginResponse(msgspec.Struct):
|
||||
user: str = ""
|
||||
privileged: bool = False
|
||||
error: str = ""
|
||||
|
||||
|
||||
def verify(request, privileged=False):
|
||||
"""Raise Unauthorized or Forbidden if the request is not authorized"""
|
||||
if privileged:
|
||||
if request.ctx.user:
|
||||
if request.ctx.user.privileged: return
|
||||
if request.ctx.user.privileged:
|
||||
return
|
||||
raise Forbidden("Access Forbidden: Only for privileged users")
|
||||
elif config.config.public or request.ctx.user: return
|
||||
elif config.config.public or request.ctx.user:
|
||||
return
|
||||
raise Unauthorized("Login required", "cookie", context={"redirect": "/login"})
|
||||
|
||||
|
||||
bp = Blueprint("auth")
|
||||
|
||||
|
||||
@bp.get("/login")
|
||||
async def login_page(request):
|
||||
doc = Document("Cista Login")
|
||||
with doc.div(id="login"):
|
||||
with doc.form(method="POST", autocomplete="on"):
|
||||
doc.h1("Login")
|
||||
doc.input(name="username", placeholder="Username", autocomplete="username", required=True).br
|
||||
doc.input(type="password", name="password", placeholder="Password", autocomplete="current-password", required=True).br
|
||||
doc.input(
|
||||
name="username",
|
||||
placeholder="Username",
|
||||
autocomplete="username",
|
||||
required=True,
|
||||
).br
|
||||
doc.input(
|
||||
type="password",
|
||||
name="password",
|
||||
placeholder="Password",
|
||||
autocomplete="current-password",
|
||||
required=True,
|
||||
).br
|
||||
doc.input(type="submit", value="Login")
|
||||
s = session.get(request)
|
||||
if s:
|
||||
@@ -84,7 +104,12 @@ async def login_page(request):
|
||||
doc.input(type="submit", value=f"Logout {name}")
|
||||
flash = request.cookies.message
|
||||
if flash:
|
||||
doc.dialog(flash, id="flash", open=True, style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8")
|
||||
doc.dialog(
|
||||
flash,
|
||||
id="flash",
|
||||
open=True,
|
||||
style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8",
|
||||
)
|
||||
res = html(doc)
|
||||
if flash:
|
||||
res.cookies.delete_cookie("flash")
|
||||
@@ -92,6 +117,7 @@ async def login_page(request):
|
||||
session.delete(res)
|
||||
return res
|
||||
|
||||
|
||||
@bp.post("/login")
|
||||
async def login_post(request):
|
||||
try:
|
||||
@@ -118,6 +144,7 @@ async def login_post(request):
|
||||
session.create(res, username)
|
||||
return res
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
async def logout_post(request):
|
||||
s = request.ctx.session
|
||||
|
||||
@@ -17,26 +17,28 @@ class Config(msgspec.Struct):
|
||||
users: dict[str, User] = {}
|
||||
links: dict[str, Link] = {}
|
||||
|
||||
|
||||
class User(msgspec.Struct, omit_defaults=True):
|
||||
privileged: bool = False
|
||||
hash: str = ""
|
||||
lastSeen: int = 0
|
||||
|
||||
|
||||
class Link(msgspec.Struct, omit_defaults=True):
|
||||
location: str
|
||||
creator: str = ""
|
||||
expires: int = 0
|
||||
|
||||
|
||||
config = None
|
||||
conffile = Path.home() / ".local/share/cista/db.toml"
|
||||
|
||||
|
||||
def derived_secret(*params, len=8) -> bytes:
|
||||
"""Used to derive secret keys from the main secret"""
|
||||
# Each part is made the same length by hashing first
|
||||
combined = b"".join(
|
||||
sha256(
|
||||
p if isinstance(p, bytes) else f"{p}".encode()
|
||||
).digest()
|
||||
sha256(p if isinstance(p, bytes) else f"{p}".encode()).digest()
|
||||
for p in [config.secret, *params]
|
||||
)
|
||||
# Output a bytes of the desired length
|
||||
@@ -48,11 +50,13 @@ def enc_hook(obj):
|
||||
return obj.as_posix()
|
||||
raise TypeError
|
||||
|
||||
|
||||
def dec_hook(typ, obj):
|
||||
if typ is Path:
|
||||
return Path(obj)
|
||||
raise TypeError
|
||||
|
||||
|
||||
def config_update(modify):
|
||||
global config
|
||||
if not conffile.exists():
|
||||
@@ -93,21 +97,28 @@ def config_update(modify):
|
||||
config = c
|
||||
return "modified" if old else "created"
|
||||
|
||||
|
||||
def modifies_config(modify):
|
||||
"""Decorator for functions that modify the config file"""
|
||||
|
||||
@wraps(modify)
|
||||
def wrapper(*args, **kwargs):
|
||||
m = lambda c: modify(c, *args, **kwargs)
|
||||
def m(c):
|
||||
return modify(c, *args, **kwargs)
|
||||
|
||||
# Retry modification in case of write collision
|
||||
while (c := config_update(m)) == "collision":
|
||||
time.sleep(0.01)
|
||||
return c
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def load_config():
|
||||
global config
|
||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
@modifies_config
|
||||
def update_config(conf: Config, changes: dict) -> Config:
|
||||
"""Create/update the config with new values, respecting changes done by others."""
|
||||
@@ -116,6 +127,7 @@ def update_config(conf: Config, changes: dict) -> Config:
|
||||
settings.update(changes)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
@modifies_config
|
||||
def update_user(conf: Config, name: str, changes: dict) -> Config:
|
||||
"""Create/update a user with new values, respecting changes done by others."""
|
||||
@@ -126,6 +138,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
||||
u = User()
|
||||
if "password" in changes:
|
||||
from . import auth
|
||||
|
||||
auth.set_password(u, changes["password"])
|
||||
del changes["password"]
|
||||
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
||||
@@ -134,6 +147,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
||||
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||
|
||||
|
||||
@modifies_config
|
||||
def del_user(conf: Config, name: str) -> Config:
|
||||
"""Delete named user account."""
|
||||
|
||||
@@ -11,6 +11,7 @@ def readconf() -> dict:
|
||||
cf["listen"] = _droppy_listeners(cf)
|
||||
return cf | db
|
||||
|
||||
|
||||
def _droppy_listeners(cf):
|
||||
"""Convert Droppy listeners to our format, for typical cases but not in full."""
|
||||
for listener in cf["listeners"]:
|
||||
@@ -20,15 +21,19 @@ def _droppy_listeners(cf):
|
||||
continue
|
||||
socket = listener.get("socket")
|
||||
if socket:
|
||||
if isinstance(socket, list): socket = socket[0]
|
||||
if isinstance(socket, list):
|
||||
socket = socket[0]
|
||||
return f"{socket}"
|
||||
port = listener["port"]
|
||||
if isinstance(port, list): port = port[0]
|
||||
if isinstance(port, list):
|
||||
port = port[0]
|
||||
host = listener["host"]
|
||||
if isinstance(host, list): host = host[0]
|
||||
if host in ("127.0.0.1", "::", "localhost"): return f":{port}"
|
||||
if isinstance(host, list):
|
||||
host = host[0]
|
||||
if host in ("127.0.0.1", "::", "localhost"):
|
||||
return f":{port}"
|
||||
return f"{host}:{port}"
|
||||
except (KeyError, IndexError):
|
||||
continue
|
||||
# If none matched, fallback to Droppy default
|
||||
return f"0.0.0.0:8989"
|
||||
return "0.0.0.0:8989"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import asyncio
|
||||
import os
|
||||
import unicodedata
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from cista import config
|
||||
from cista.util import filename
|
||||
@@ -13,6 +11,7 @@ def fuid(stat) -> str:
|
||||
"""Unique file ID. Stays the same on renames and modification."""
|
||||
return config.derived_secret("filekey-inode", stat.st_dev, stat.st_ino).hex()
|
||||
|
||||
|
||||
class File:
|
||||
def __init__(self, filename):
|
||||
self.path = config.config.path / filename
|
||||
@@ -30,23 +29,24 @@ class File:
|
||||
self.writable = True
|
||||
|
||||
def write(self, pos, buffer, *, file_size=None):
|
||||
assert self.fd is not None
|
||||
if not self.writable:
|
||||
# Create/open file
|
||||
self.open_rw()
|
||||
assert self.fd is not None
|
||||
if file_size is not None:
|
||||
os.ftruncate(self.fd, file_size)
|
||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||
os.write(self.fd, buffer)
|
||||
|
||||
def __getitem__(self, slice):
|
||||
assert self.fd is not None
|
||||
if self.fd is None:
|
||||
self.open_ro()
|
||||
assert self.fd is not None
|
||||
os.lseek(self.fd, slice.start, os.SEEK_SET)
|
||||
l = slice.stop - slice.start
|
||||
data = os.read(self.fd, l)
|
||||
if len(data) < l: raise EOFError("Error reading requested range")
|
||||
size = slice.stop - slice.start
|
||||
data = os.read(self.fd, size)
|
||||
if len(data) < size:
|
||||
raise EOFError("Error reading requested range")
|
||||
return data
|
||||
|
||||
def close(self):
|
||||
@@ -59,10 +59,11 @@ class File:
|
||||
|
||||
|
||||
class FileServer:
|
||||
|
||||
async def start(self):
|
||||
self.alink = AsyncLink()
|
||||
self.worker = asyncio.get_event_loop().run_in_executor(None, self.worker_thread, self.alink.to_sync)
|
||||
self.worker = asyncio.get_event_loop().run_in_executor(
|
||||
None, self.worker_thread, self.alink.to_sync
|
||||
)
|
||||
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
||||
|
||||
async def stop(self):
|
||||
@@ -91,4 +92,4 @@ class FileServer:
|
||||
def download(self, name, start, end):
|
||||
name = filename.sanitize(name)
|
||||
f = self.cache[name]
|
||||
return f[start: end]
|
||||
return f[start:end]
|
||||
|
||||
@@ -11,19 +11,24 @@ from cista.util import filename
|
||||
|
||||
## Control commands
|
||||
|
||||
|
||||
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
||||
def __call__(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MkDir(ControlBase):
|
||||
path: str
|
||||
|
||||
def __call__(self):
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
path.mkdir(parents=False, exist_ok=False)
|
||||
|
||||
|
||||
class Rename(ControlBase):
|
||||
path: str
|
||||
to: str
|
||||
|
||||
def __call__(self):
|
||||
to = filename.sanitize(self.to)
|
||||
if "/" in to:
|
||||
@@ -31,17 +36,21 @@ class Rename(ControlBase):
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
path.rename(path.with_name(to))
|
||||
|
||||
|
||||
class Rm(ControlBase):
|
||||
sel: list[str]
|
||||
|
||||
def __call__(self):
|
||||
root = config.config.path
|
||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||
for p in sel:
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
|
||||
|
||||
class Mv(ControlBase):
|
||||
sel: list[str]
|
||||
dst: str
|
||||
|
||||
def __call__(self):
|
||||
root = config.config.path
|
||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||
@@ -51,9 +60,11 @@ class Mv(ControlBase):
|
||||
for p in sel:
|
||||
shutil.move(p, dst)
|
||||
|
||||
|
||||
class Cp(ControlBase):
|
||||
sel: list[str]
|
||||
dst: str
|
||||
|
||||
def __call__(self):
|
||||
root = config.config.path
|
||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||
@@ -62,30 +73,41 @@ class Cp(ControlBase):
|
||||
raise BadRequest("The destination must be a directory")
|
||||
for p in sel:
|
||||
# Note: copies as dst rather than in dst unless name is appended.
|
||||
shutil.copytree(p, dst / p.name, dirs_exist_ok=True, ignore_dangling_symlinks=True)
|
||||
shutil.copytree(
|
||||
p, dst / p.name, dirs_exist_ok=True, ignore_dangling_symlinks=True
|
||||
)
|
||||
|
||||
|
||||
## File uploads and downloads
|
||||
|
||||
|
||||
class FileRange(msgspec.Struct):
|
||||
name: str
|
||||
size: int
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
class StatusMsg(msgspec.Struct):
|
||||
status: str
|
||||
req: FileRange
|
||||
|
||||
|
||||
class ErrorMsg(msgspec.Struct):
|
||||
error: dict[str, Any]
|
||||
|
||||
|
||||
## Directory listings
|
||||
|
||||
|
||||
class FileEntry(msgspec.Struct):
|
||||
id: str
|
||||
size: int
|
||||
mtime: int
|
||||
|
||||
|
||||
class DirEntry(msgspec.Struct):
|
||||
id: str
|
||||
size: int
|
||||
mtime: int
|
||||
dir: DirList
|
||||
@@ -104,30 +126,30 @@ class DirEntry(msgspec.Struct):
|
||||
|
||||
@property
|
||||
def props(self):
|
||||
return {
|
||||
k: v
|
||||
for k, v in self.__struct_fields__
|
||||
if k != "dir"
|
||||
}
|
||||
return {k: v for k, v in self.__struct_fields__ if k != "dir"}
|
||||
|
||||
|
||||
DirList = dict[str, FileEntry | DirEntry]
|
||||
|
||||
|
||||
class UpdateEntry(msgspec.Struct, omit_defaults=True):
|
||||
"""Updates the named entry in the tree. Fields that are set replace old values. A list of entries recurses directories."""
|
||||
|
||||
name: str = ""
|
||||
deleted: bool = False
|
||||
id: str | None = None
|
||||
size: int | None = None
|
||||
mtime: int | None = None
|
||||
dir: DirList | None = None
|
||||
|
||||
|
||||
def make_dir_data(root):
|
||||
if len(root) == 2:
|
||||
if len(root) == 3:
|
||||
return FileEntry(*root)
|
||||
size, mtime, listing = root
|
||||
id_, size, mtime, listing = root
|
||||
converted = {}
|
||||
for name, data in listing.items():
|
||||
converted[name] = make_dir_data(data)
|
||||
sz = sum(x.size for x in converted.values())
|
||||
mt = max(x.mtime for x in converted.values())
|
||||
return DirEntry(sz, max(mt, mtime), converted)
|
||||
return DirEntry(id_, sz, max(mt, mtime), converted)
|
||||
|
||||
@@ -10,6 +10,7 @@ from cista import config, server80
|
||||
def run(dev=False):
|
||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||
from .app import app
|
||||
|
||||
url, opts = parse_listen(config.config.listen)
|
||||
# Silence Sanic's warning about running in production rather than debug
|
||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||
@@ -21,20 +22,33 @@ def run(dev=False):
|
||||
domain = opts["host"]
|
||||
check_cert(confdir / domain, domain)
|
||||
opts["ssl"] = str(confdir / domain) # type: ignore
|
||||
app.prepare(**opts, motd=False, dev=dev, auto_reload=dev, reload_dir={confdir, wwwroot}, access_log=True) # type: ignore
|
||||
app.prepare(
|
||||
**opts,
|
||||
motd=False,
|
||||
dev=dev,
|
||||
auto_reload=dev,
|
||||
reload_dir={confdir, wwwroot},
|
||||
access_log=True,
|
||||
) # type: ignore
|
||||
Sanic.serve()
|
||||
|
||||
|
||||
def check_cert(certdir, domain):
|
||||
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
||||
return
|
||||
# TODO: Use certbot to fetch a cert
|
||||
raise ValueError(f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}")
|
||||
raise ValueError(
|
||||
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}"
|
||||
)
|
||||
|
||||
|
||||
def parse_listen(listen):
|
||||
if listen.startswith("/"):
|
||||
unix = Path(listen).resolve()
|
||||
if not unix.parent.exists():
|
||||
raise ValueError(f"Directory for unix socket does not exist: {unix.parent}/")
|
||||
raise ValueError(
|
||||
f"Directory for unix socket does not exist: {unix.parent}/"
|
||||
)
|
||||
return "http://localhost", {"unix": unix}
|
||||
elif re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
|
||||
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
|
||||
|
||||
@@ -2,6 +2,7 @@ from sanic import Sanic, exceptions, response
|
||||
|
||||
app = Sanic("server80")
|
||||
|
||||
|
||||
# Send all HTTP users to HTTPS
|
||||
@app.exception(exceptions.NotFound, exceptions.MethodNotSupported)
|
||||
def redirect_everything_else(request, exception):
|
||||
@@ -10,6 +11,7 @@ def redirect_everything_else(request, exception):
|
||||
return response.redirect(f"https://{server}{path}", status=308)
|
||||
return response.text("Bad Request. Please use HTTPS!", status=400)
|
||||
|
||||
|
||||
# ACME challenge for LetsEncrypt
|
||||
@app.get("/.well-known/acme-challenge/<challenge>")
|
||||
async def letsencrypt(request, challenge):
|
||||
@@ -18,4 +20,5 @@ async def letsencrypt(request, challenge):
|
||||
except KeyError:
|
||||
return response.text(f"ACME challenge not found: {challenge}", status=404)
|
||||
|
||||
|
||||
acme_challenges = {}
|
||||
|
||||
@@ -4,16 +4,21 @@ import jwt
|
||||
|
||||
from cista.config import derived_secret
|
||||
|
||||
session_secret = lambda: derived_secret("session")
|
||||
|
||||
def session_secret():
|
||||
return derived_secret("session")
|
||||
|
||||
|
||||
max_age = 365 * 86400 # Seconds since last login
|
||||
|
||||
|
||||
def get(request):
|
||||
try:
|
||||
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"])
|
||||
except Exception as e:
|
||||
s = None
|
||||
except Exception:
|
||||
return False if "s" in request.cookies else None
|
||||
|
||||
|
||||
def create(res, username, **kwargs):
|
||||
data = {
|
||||
"exp": int(time()) + max_age,
|
||||
@@ -23,12 +28,14 @@ def create(res, username, **kwargs):
|
||||
s = jwt.encode(data, session_secret())
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||
|
||||
|
||||
def update(res, s, **kwargs):
|
||||
s.update(kwargs)
|
||||
s = jwt.encode(s, session_secret())
|
||||
max_age = max(1, s["exp"] - int(time())) # type: ignore
|
||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||
|
||||
|
||||
def delete(res):
|
||||
res.cookies.delete_cookie("s")
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ def asend(ws, msg):
|
||||
"""Send JSON message or bytes to a websocket"""
|
||||
return ws.send(msg if isinstance(msg, bytes) else msgspec.json.encode(msg).decode())
|
||||
|
||||
|
||||
def jres(data, **kwargs):
|
||||
"""JSON Sanic response, using msgspec encoding"""
|
||||
return raw(msgspec.json.encode(data), content_type="application/json", **kwargs)
|
||||
|
||||
|
||||
async def handle_sanic_exception(request, e):
|
||||
logger.exception(e)
|
||||
context, code = {}, 500
|
||||
@@ -30,7 +32,9 @@ async def handle_sanic_exception(request, e):
|
||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||
# Non-browsers get JSON errors
|
||||
if "text/html" not in request.headers.accept:
|
||||
return jres(ErrorMsg({"code": code, "message": message, **context}), status=code)
|
||||
return jres(
|
||||
ErrorMsg({"code": code, "message": message, **context}), status=code
|
||||
)
|
||||
# Redirections flash the error message via cookies
|
||||
if "redirect" in context:
|
||||
res = redirect(context["redirect"])
|
||||
@@ -39,8 +43,10 @@ async def handle_sanic_exception(request, e):
|
||||
# Otherwise use Sanic's default error page
|
||||
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full()
|
||||
|
||||
|
||||
def websocket_wrapper(handler):
|
||||
"""Decorator for websocket handlers that catches exceptions and sends them back to the client"""
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapper(request, ws, *args, **kwargs):
|
||||
try:
|
||||
@@ -55,4 +61,5 @@ def websocket_wrapper(handler):
|
||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -11,6 +11,7 @@ class LRUCache:
|
||||
maxage (float): Max age for items in cache in seconds.
|
||||
cache (list): Internal list storing the cache items.
|
||||
"""
|
||||
|
||||
def __init__(self, open: callable, *, capacity: int, maxage: float):
|
||||
"""
|
||||
Initialize LRUCache.
|
||||
|
||||
@@ -6,6 +6,7 @@ def generate(n=4):
|
||||
wl = list(words)
|
||||
return ".".join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))
|
||||
|
||||
|
||||
# A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word
|
||||
words: list = """
|
||||
able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead
|
||||
|
||||
@@ -8,6 +8,7 @@ import inotify.adapters
|
||||
import msgspec
|
||||
|
||||
from cista import config
|
||||
from cista.fileio import fuid
|
||||
from cista.protocol import DirEntry, FileEntry, UpdateEntry
|
||||
|
||||
pubsub = {}
|
||||
@@ -15,9 +16,18 @@ tree = {"": None}
|
||||
tree_lock = threading.Lock()
|
||||
rootpath: Path = None # type: ignore
|
||||
quit = False
|
||||
modified_flags = "IN_CREATE", "IN_DELETE", "IN_DELETE_SELF", "IN_MODIFY", "IN_MOVE_SELF", "IN_MOVED_FROM", "IN_MOVED_TO"
|
||||
modified_flags = (
|
||||
"IN_CREATE",
|
||||
"IN_DELETE",
|
||||
"IN_DELETE_SELF",
|
||||
"IN_MODIFY",
|
||||
"IN_MOVE_SELF",
|
||||
"IN_MOVED_FROM",
|
||||
"IN_MOVED_TO",
|
||||
)
|
||||
disk_usage = None
|
||||
|
||||
|
||||
def watcher_thread(loop):
|
||||
global disk_usage
|
||||
|
||||
@@ -36,7 +46,8 @@ def watcher_thread(loop):
|
||||
refreshdl = time.monotonic() + 60.0
|
||||
|
||||
for event in i.event_gen():
|
||||
if quit: return
|
||||
if quit:
|
||||
return
|
||||
# Disk usage update
|
||||
du = shutil.disk_usage(rootpath)
|
||||
if du != disk_usage:
|
||||
@@ -44,8 +55,10 @@ def watcher_thread(loop):
|
||||
asyncio.run_coroutine_threadsafe(broadcast(format_du()), loop)
|
||||
break
|
||||
# Do a full refresh?
|
||||
if time.monotonic() > refreshdl: break
|
||||
if event is None: continue
|
||||
if time.monotonic() > refreshdl:
|
||||
break
|
||||
if event is None:
|
||||
continue
|
||||
_, flags, path, filename = event
|
||||
if not any(f in modified_flags for f in flags):
|
||||
continue
|
||||
@@ -58,50 +71,72 @@ def watcher_thread(loop):
|
||||
break
|
||||
i = None # Free the inotify object
|
||||
|
||||
|
||||
def format_du():
|
||||
return msgspec.json.encode({"space": {
|
||||
"disk": disk_usage.total,
|
||||
"used": disk_usage.used,
|
||||
"free": disk_usage.free,
|
||||
"storage": tree[""].size,
|
||||
}}).decode()
|
||||
return msgspec.json.encode(
|
||||
{
|
||||
"space": {
|
||||
"disk": disk_usage.total,
|
||||
"used": disk_usage.used,
|
||||
"free": disk_usage.free,
|
||||
"storage": tree[""].size,
|
||||
}
|
||||
}
|
||||
).decode()
|
||||
|
||||
|
||||
def format_tree():
|
||||
root = tree[""]
|
||||
return msgspec.json.encode({"update": [
|
||||
UpdateEntry(size=root.size, mtime=root.mtime, dir=root.dir)
|
||||
]}).decode()
|
||||
return msgspec.json.encode(
|
||||
{
|
||||
"update": [
|
||||
UpdateEntry(id=root.id, size=root.size, mtime=root.mtime, dir=root.dir)
|
||||
]
|
||||
}
|
||||
).decode()
|
||||
|
||||
|
||||
def walk(path: Path) -> DirEntry | FileEntry | None:
|
||||
try:
|
||||
s = path.stat()
|
||||
id_ = fuid(s)
|
||||
mtime = int(s.st_mtime)
|
||||
if path.is_file():
|
||||
return FileEntry(s.st_size, mtime)
|
||||
return FileEntry(id_, s.st_size, mtime)
|
||||
|
||||
tree = {p.name: v for p in path.iterdir() if not p.name.startswith('.') if (v := walk(p)) is not None}
|
||||
tree = {
|
||||
p.name: v
|
||||
for p in path.iterdir()
|
||||
if not p.name.startswith(".")
|
||||
if (v := walk(p)) is not None
|
||||
}
|
||||
if tree:
|
||||
size = sum(v.size for v in tree.values())
|
||||
mtime = max(mtime, max(v.mtime for v in tree.values()))
|
||||
else:
|
||||
size = 0
|
||||
return DirEntry(size, mtime, tree)
|
||||
return DirEntry(id_, size, mtime, tree)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as e:
|
||||
print("OS error walking path", path, e)
|
||||
return None
|
||||
|
||||
|
||||
def update(relpath: PurePosixPath, loop):
|
||||
"""Called by inotify updates, check the filesystem and broadcast any changes."""
|
||||
new = walk(rootpath / relpath)
|
||||
with tree_lock:
|
||||
update = update_internal(relpath, new)
|
||||
if not update: return # No changes
|
||||
if not update:
|
||||
return # No changes
|
||||
msg = msgspec.json.encode({"update": update}).decode()
|
||||
asyncio.run_coroutine_threadsafe(broadcast(msg), loop)
|
||||
|
||||
def update_internal(relpath: PurePosixPath, new: DirEntry | FileEntry | None) -> list[UpdateEntry]:
|
||||
|
||||
def update_internal(
|
||||
relpath: PurePosixPath, new: DirEntry | FileEntry | None
|
||||
) -> list[UpdateEntry]:
|
||||
path = "", *relpath.parts
|
||||
old = tree
|
||||
elems = []
|
||||
@@ -142,25 +177,31 @@ def update_internal(relpath: PurePosixPath, new: DirEntry | FileEntry | None) ->
|
||||
u = UpdateEntry(name)
|
||||
if new:
|
||||
parent[name] = new
|
||||
if u.size != new.size: u.size = new.size
|
||||
if u.mtime != new.mtime: u.mtime = new.mtime
|
||||
if u.size != new.size:
|
||||
u.size = new.size
|
||||
if u.mtime != new.mtime:
|
||||
u.mtime = new.mtime
|
||||
if isinstance(new, DirEntry):
|
||||
if u.dir == new.dir: u.dir = new.dir
|
||||
if u.dir == new.dir:
|
||||
u.dir = new.dir
|
||||
else:
|
||||
del parent[name]
|
||||
u.deleted = True
|
||||
update.append(u)
|
||||
return update
|
||||
|
||||
|
||||
async def broadcast(msg):
|
||||
for queue in pubsub.values():
|
||||
await queue.put_nowait(msg)
|
||||
|
||||
|
||||
async def start(app, loop):
|
||||
config.load_config()
|
||||
app.ctx.watcher = threading.Thread(target=watcher_thread, args=[loop])
|
||||
app.ctx.watcher.start()
|
||||
|
||||
|
||||
async def stop(app, loop):
|
||||
global quit
|
||||
quit = True
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
@media (min-width: 1024px){.about{min-height:100vh;display:flex;align-items:center}}
|
||||
@@ -1 +0,0 @@
|
||||
import{_ as e,o as t,c as o,a as s}from"./index-689b26c8.js";const _={},c={class:"about"},a=s("h1",null,"This is an about page",-1),n=[a];function i(r,u){return t(),o("div",c,n)}const l=e(_,[["render",i]]);export{l as default};
|
||||
1
cista/wwwroot/assets/index-213aec14.css
Normal file
1
cista/wwwroot/assets/index-213aec14.css
Normal file
File diff suppressed because one or more lines are too long
512
cista/wwwroot/assets/index-25722397.js
Normal file
512
cista/wwwroot/assets/index-25722397.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
: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, .29);--vt-c-divider-light-2: rgba(60, 60, 60, .12);--vt-c-divider-dark-1: rgba(84, 84, 84, .65);--vt-c-divider-dark-2: rgba(84, 84, 84, .48);--vt-c-text-light-1: var(--vt-c-indigo);--vt-c-text-light-2: rgba(60, 60, 60, .66);--vt-c-text-dark-1: var(--vt-c-white);--vt-c-text-dark-2: rgba(235, 235, 235, .64)}: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:400}body{min-height:100vh;color:var(--color-text);background:var(--color-background);transition:color .5s,background-color .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}#app{max-width:1280px;margin:0 auto;padding:2rem;font-weight:400}a,.green{text-decoration:none;color:#00bd7e;transition:.4s}@media (hover: hover){a:hover{background-color:#00bd7e33}}@media (min-width: 1024px){body{display:flex;place-items:center}#app{display:grid;grid-template-columns:1fr 1fr;padding:0 2rem}}h1[data-v-a47c673d]{font-weight:500;font-size:2.6rem;position:relative;top:-10px}h3[data-v-a47c673d]{font-size:1.2rem}.greetings h1[data-v-a47c673d],.greetings h3[data-v-a47c673d]{text-align:center}@media (min-width: 1024px){.greetings h1[data-v-a47c673d],.greetings h3[data-v-a47c673d]{text-align:left}}header[data-v-85852c48]{line-height:1.5;max-height:100vh}.logo[data-v-85852c48]{display:block;margin:0 auto 2rem}nav[data-v-85852c48]{width:100%;font-size:12px;text-align:center;margin-top:2rem}nav a.router-link-exact-active[data-v-85852c48]{color:var(--color-text)}nav a.router-link-exact-active[data-v-85852c48]:hover{background-color:transparent}nav a[data-v-85852c48]{display:inline-block;padding:0 1rem;border-left:1px solid var(--color-border)}nav a[data-v-85852c48]:first-of-type{border:0}@media (min-width: 1024px){header[data-v-85852c48]{display:flex;place-items:center;padding-right:calc(var(--section-gap) / 2)}.logo[data-v-85852c48]{margin:0 2rem 0 0}header .wrapper[data-v-85852c48]{display:flex;place-items:flex-start;flex-wrap:wrap}nav[data-v-85852c48]{text-align:left;margin-left:-1rem;font-size:1rem;padding:1rem 0;margin-top:1rem}}.item[data-v-fd0742eb]{margin-top:2rem;display:flex;position:relative}.details[data-v-fd0742eb]{flex:1;margin-left:1rem}i[data-v-fd0742eb]{display:flex;place-items:center;place-content:center;width:32px;height:32px;color:var(--color-text)}h3[data-v-fd0742eb]{font-size:1.2rem;font-weight:500;margin-bottom:.4rem;color:var(--color-heading)}@media (min-width: 1024px){.item[data-v-fd0742eb]{margin-top:0;padding:.4rem 0 1rem calc(var(--section-gap) / 2)}i[data-v-fd0742eb]{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[data-v-fd0742eb]:before{content:" ";border-left:1px solid var(--color-border);position:absolute;left:0;bottom:calc(50% + 25px);height:calc(50% - 25px)}.item[data-v-fd0742eb]:after{content:" ";border-left:1px solid var(--color-border);position:absolute;left:0;top:calc(50% + 25px);height:calc(50% - 25px)}.item[data-v-fd0742eb]:first-of-type:before{display:none}.item[data-v-fd0742eb]:last-of-type:after{display:none}}
|
||||
@@ -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 |
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<script type="module" crossorigin src="/assets/index-689b26c8.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-9f680dd7.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Vite Vasanko</title>
|
||||
<script type="module" crossorigin src="/assets/index-25722397.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-213aec14.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "cista-storage",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -13,11 +13,13 @@ def setup_temp_dir():
|
||||
config.config = config.Config(path=Path(tmpdirname), listen=":0")
|
||||
yield Path(tmpdirname)
|
||||
|
||||
|
||||
def test_mkdir(setup_temp_dir):
|
||||
cmd = MkDir(path="new_folder")
|
||||
cmd()
|
||||
assert (setup_temp_dir / "new_folder").is_dir()
|
||||
|
||||
|
||||
def test_rename(setup_temp_dir):
|
||||
(setup_temp_dir / "old_name").mkdir()
|
||||
cmd = Rename(path="old_name", to="new_name")
|
||||
@@ -25,12 +27,14 @@ def test_rename(setup_temp_dir):
|
||||
assert not (setup_temp_dir / "old_name").exists()
|
||||
assert (setup_temp_dir / "new_name").is_dir()
|
||||
|
||||
|
||||
def test_rm(setup_temp_dir):
|
||||
(setup_temp_dir / "folder_to_remove").mkdir()
|
||||
cmd = Rm(sel=["folder_to_remove"])
|
||||
cmd()
|
||||
assert not (setup_temp_dir / "folder_to_remove").exists()
|
||||
|
||||
|
||||
def test_mv(setup_temp_dir):
|
||||
(setup_temp_dir / "folder_to_move").mkdir()
|
||||
(setup_temp_dir / "destination").mkdir()
|
||||
@@ -39,6 +43,7 @@ def test_mv(setup_temp_dir):
|
||||
assert not (setup_temp_dir / "folder_to_move").exists()
|
||||
assert (setup_temp_dir / "destination" / "folder_to_move").is_dir()
|
||||
|
||||
|
||||
def test_cp(setup_temp_dir):
|
||||
(setup_temp_dir / "folder_to_copy").mkdir()
|
||||
(setup_temp_dir / "destination").mkdir()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from time import sleep
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from cista.util.lrucache import LRUCache # Replace with actual import
|
||||
|
||||
|
||||
@@ -12,24 +10,28 @@ def mock_open(key):
|
||||
mock.content = f"content-{key}"
|
||||
return mock
|
||||
|
||||
|
||||
def test_contains():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
assert "key1" not in cache
|
||||
cache["key1"]
|
||||
assert "key1" in cache
|
||||
|
||||
|
||||
def test_getitem():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
assert cache["key1"].content == "content-key1"
|
||||
|
||||
|
||||
def test_capacity():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
item1 = cache["key1"]
|
||||
item2 = cache["key2"]
|
||||
cache["key2"]
|
||||
cache["key3"]
|
||||
assert "key1" not in cache
|
||||
item1.close.assert_called()
|
||||
|
||||
|
||||
def test_expiry():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=0.1)
|
||||
item = cache["key1"]
|
||||
@@ -38,6 +40,7 @@ def test_expiry():
|
||||
assert "key1" not in cache
|
||||
item.close.assert_called()
|
||||
|
||||
|
||||
def test_close():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
item = cache["key1"]
|
||||
@@ -45,6 +48,7 @@ def test_close():
|
||||
assert "key1" not in cache
|
||||
item.close.assert_called()
|
||||
|
||||
|
||||
def test_lru_mechanism():
|
||||
cache = LRUCache(open=mock_open, capacity=2, maxage=10)
|
||||
item1 = cache["key1"]
|
||||
|
||||
Reference in New Issue
Block a user