27 Commits

Author SHA1 Message Date
Leo Vasanko
acdd776b92 Formatting and fix Internal Server Error on upload 2023-10-28 20:20:34 +00:00
b3fd9637eb Login, Download and visuals update 2023-10-28 04:13:01 -05:00
2b72508206 Search Bar UI update 2023-10-27 15:32:21 +00:00
Leo Vasanko
8cc3ed1a04 Human-readable sizes 2023-10-27 08:28:03 +03:00
Leo Vasanko
0d186726b5 Prettier file listing, using browser instead of viewer for file display (for now), sorting improved, modified timestamps improved. 2023-10-27 07:53:30 +03:00
Leo Vasanko
63bbe84859 Provide file/dir id from server. The value will stay the same if the file is renamed/moved, but will change if a file is replaced by another with the same name. 2023-10-27 07:51:51 +03:00
Leo Vasanko
202f28ff15 Add esbuild dependency 2023-10-27 04:34:27 +03:00
41772e6c18 Fix get files #build_commit 2023-10-26 10:01:54 -05:00
e52379d515 Fix get files #build_commit 2023-10-26 09:48:12 -05:00
74987898c9 Fix get files #build_commit 2023-10-26 09:34:24 -05:00
859d312913 Fix get files 2023-10-26 09:14:24 -05:00
4bc9cf4534 Update endpoints and URL bases configuration 2023-10-26 09:06:07 -05:00
754d779069 Test frontend #only_compile 2023-10-26 00:10:52 -05:00
367e4ba0ea Test frontend #only_compile 2023-10-26 00:08:06 -05:00
c2e9a4af05 Test frontend #only_compile 2023-10-26 00:06:45 -05:00
6cdc37a172 Test frontend #only_compile 2023-10-26 00:05:11 -05:00
19699564c2 Test frontend #only_compile 2023-10-26 00:02:45 -05:00
7baf8b3f9b Test frontend #only_compile 2023-10-25 23:55:50 -05:00
47329ac04e Test frontend #only_compile 2023-10-25 23:46:21 -05:00
f4013d1196 Test frontend #only_compile 2023-10-25 23:34:20 -05:00
3672156b5e Test frontend #only_compile 2023-10-25 23:31:13 -05:00
f2b37852da Test frontend #only_compile 2023-10-25 21:56:45 -05:00
708e54d080 Test frontend #only_compile 2023-10-25 21:52:08 -05:00
d051265f40 Test frontend #only_compile 2023-10-25 21:47:45 -05:00
5cf133465e Test frontend #only_compile 2023-10-25 21:40:44 -05:00
1c91bf2e87 Test frontend #only_compile 2023-10-25 21:26:37 -05:00
9cd6f83bec Initialize VUE project with WS connection, main pages, and various small features 2023-10-25 18:43:44 -05:00
66 changed files with 7341 additions and 1350 deletions

37
cista-front/components.d.ts vendored Normal file
View 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']
}
}

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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);
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>

View 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>

View 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>

View File

@@ -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>
Vues
<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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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')

View 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

View 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)
}
}

View 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
}
}

View 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

View File

@@ -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')
}
]
})

View File

@@ -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 }
})

View 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
}
},
});

View 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";
}
}

View File

@@ -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>

View 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>

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>

View File

@@ -6,6 +6,9 @@
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"lib": [],
"types": ["node", "jsdom"]
}
}

View File

@@ -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))

View File

@@ -1 +1,3 @@
from cista._version import __version__
__version__ # Public API

View File

@@ -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())

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"

View File

@@ -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]

View File

@@ -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)

View File

@@ -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}

View File

@@ -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 = {}

View File

@@ -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")

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -1 +0,0 @@
@media (min-width: 1024px){.about{min-height:100vh;display:flex;align-items:center}}

View File

@@ -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};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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}}

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,6 @@
{
"name": "cista-storage",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -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()

View File

@@ -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"]