Frontend created and rewritten a few times, with some backend fixes #1

Merged
leo merged 110 commits from plaintable into main 2023-11-08 20:38:40 +00:00
35 changed files with 727 additions and 6286 deletions
Showing only changes of commit 119aba2b3c - Show all commits

View File

@ -7,6 +7,9 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# No locking
package-lock.json
node_modules
.DS_Store
dist

View File

@ -8,11 +8,13 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AppNavigation: typeof import('./src/components/AppNavigation.vue')['default']
BreadCrumb: typeof import('./src/components/BreadCrumb.vue')['default']
FileExplorer: typeof import('./src/components/FileExplorer.vue')['default']
FileRenameInput: typeof import('./src/components/FileRenameInput.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']
ModalDialog: typeof import('./src/components/ModalDialog.vue')['default']
NotificationLoading: typeof import('./src/components/NotificationLoading.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

File diff suppressed because it is too large Load Diff

View File

@ -36,8 +36,9 @@
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.4.1",
"@vue/tsconfig": "^0.4.0",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"babel-eslint": "^10.1.0",
"eslint": "^8.52.0",
"eslint-plugin-vue": "^9.18.1",
"jsdom": "^22.1.0",
"npm-run-all2": "^6.0.6",
"prettier": "^3.0.3",
@ -45,5 +46,13 @@
"vite": "^4.4.9",
"vitest": "^0.34.4",
"vue-tsc": "^1.8.11"
},
"prettier": {
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 88
}
}

View File

@ -3,17 +3,22 @@
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 {
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';
import Router from '@/router/index'
interface Path {
path: string;
pathList: string[];
path: string
pathList: string[]
}
const documentStore = useDocumentStore()
const path: ComputedRef<Path> = computed(() => {
@ -31,11 +36,17 @@
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)
const wsWatch = createWebSocket(
url_document_watch_ws,
documentHandler.handleWebSocketMessage
)
const wsUpload = createWebSocket(
url_document_upload_ws,
documentUploadHandler.handleWebSocketMessage
)
documentStore.wsWatch = wsWatch;
documentStore.wsUpload = wsUpload;
documentStore.wsWatch = wsWatch
documentStore.wsUpload = wsUpload
})
export type { Path }

View File

@ -29,7 +29,10 @@ body {
color: var(--font-color);
margin: 0;
}
a:link, a:visited, a:active, a:hover {
a:link,
a:visited,
a:active,
a:hover {
color: var(--primary-color);
text-decoration: none;
}

View File

@ -1,17 +1,10 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import Breadcrumb from '@/components/Breadcrumb.vue'
const props = withDefaults(
defineProps<{
path: Array<string>
}>(),
{},
{}
)
function generateUrl(pathIndex: number) {
return "/" + props.path.slice(0, pathIndex + 1).join('/')
}
</script>
<template>
@ -63,16 +56,17 @@ function generateUrl(pathIndex: number) {
{{{svg "triangle"}}}
</div>
-->
<Breadcrumb :path="props.path"/>
<BreadCrumb :path="props.path" />
</nav>
</template>
<style scoped>
nav, span{
nav,
span {
color: var(--primary-color);
}
span:hover, .last{
color: var(--blue-color)
span:hover,
.last {
color: var(--blue-color);
}
</style>

View File

@ -0,0 +1,105 @@
<template>
<div class="breadcrumb">
<a href="#/"><component :is="home" /></a>
<template v-for="(location, index) in props.path" :key="index">
<a :href="`/#/${props.path.slice(0, index + 1).join('/')}/`">{{
decodeURIComponent(location)
}}</a>
</template>
</div>
</template>
<script setup lang="ts">
import home from '@/assets/svg/home.svg'
import { withDefaults, defineProps } from 'vue'
const props = withDefaults(
defineProps<{
path: Array<string>
}>(),
{}
)
</script>
<style>
:root {
--breadcrumb-background-odd: #2d2d2d;
--breadcrumb-background-even: #404040;
--breadcrumb-color: #ddd;
--breadcrumb-hover-color: #fff;
--breadcrumb-hover-background-odd: #25a;
--breadcrumb-hover-background-even: #812;
--breadcrumb-transtime: 0.3s;
}
.breadcrumb {
display: flex;
list-style: none;
margin: 0;
padding: 0 1em 0 0;
}
.breadcrumb > a {
margin: 0 -0.7rem 0 -0.7rem;
padding: 0;
max-width: 8em;
font-size: 1.3em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1em;
color: var(--breadcrumb-color);
padding: 0.3em 1.5em;
clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0);
transition: all var(--breadcrumb-transtime);
}
.breadcrumb a:first-child {
margin-left: 0;
padding-left: 0;
clip-path: none;
}
.breadcrumb a:last-child {
max-width: none;
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
100% 50%,
calc(100% - 1em) 100%,
0 100%,
1em 50%,
0 0
);
}
.breadcrumb a:only-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
100% 50%,
calc(100% - 1em) 100%,
0 100%,
0 0
);
}
.breadcrumb svg {
/* FIXME: Custom positioning to align it well; needs proper solution */
transform: translate(0.3rem, -0.3rem) scale(80%);
fill: var(--breadcrumb-color);
transition: fill var(--breadcrumb-transtime);
}
.breadcrumb a:nth-child(odd) {
background: var(--breadcrumb-background-odd);
}
.breadcrumb a:nth-child(even) {
background: var(--breadcrumb-background-even);
}
.breadcrumb a:nth-child(odd):hover {
background: var(--breadcrumb-hover-background-odd);
}
.breadcrumb a:nth-child(even):hover {
background: var(--breadcrumb-hover-background-even);
}
.breadcrumb a:hover {
color: var(--breadcrumb-hover-color);
}
.breadcrumb a:hover svg {
fill: var(--breadcrumb-hover-color);
}
</style>

View File

@ -1,76 +0,0 @@
<template>
<div class="breadcrumb">
<a href="#/"><component :is="home"/></a>
<template v-for="(location, index) in props.path">
<a :href="`/#/${props.path.slice(0, index + 1).join('/')}/`">{{ decodeURIComponent(location) }}</a>
</template>
</div>
</template>
<script setup lang="ts">
import home from '@/assets/svg/home.svg'
import { withDefaults, defineProps } from 'vue'
const props = withDefaults(
defineProps<{
path: Array<string>
}>(),
{},
)
</script>
<style>
:root {
--breadcrumb-background-odd: #2d2d2d;
--breadcrumb-background-even: #404040;
--breadcrumb-color: #ddd;
--breadcrumb-hover-color: #fff;
--breadcrumb-hover-background-odd: #25a;
--breadcrumb-hover-background-even: #812;
--breadcrumb-transtime: 0.3s;
}
.breadcrumb {
display: flex;
list-style: none;
margin: 0;
padding: 0 1em 0 0;
}
.breadcrumb > a {
margin: 0 -0.7rem 0 -.7rem;
padding: 0;
max-width: 8em;
font-size: 1.3em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1em;
color: var(--breadcrumb-color);
padding: .3em 1.5em;
clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0);
transition: all var(--breadcrumb-transtime);
}
.breadcrumb a:first-child {
margin-left: 0;
padding-left: 0;
clip-path: none;
}
.breadcrumb a:last-child {
max-width: none;
clip-path: polygon(0 0, calc(100% - 1em) 0, 100% 50%, calc(100% - 1em) 100%, 0 100%, 1em 50%, 0 0);
}
.breadcrumb a:only-child {
clip-path: polygon(0 0, calc(100% - 1em) 0, 100% 50%, calc(100% - 1em) 100%, 0 100%, 0 0);
}
.breadcrumb svg {
/* FIXME: Custom positioning to align it well; needs proper solution */
transform: translate(.3rem, -.3rem) scale(80%);
fill: var(--breadcrumb-color);
transition: fill var(--breadcrumb-transtime);
}
.breadcrumb a:nth-child(odd) { background: var(--breadcrumb-background-odd); }
.breadcrumb a:nth-child(even) { background: var(--breadcrumb-background-even) }
.breadcrumb a:nth-child(odd):hover { background: var(--breadcrumb-hover-background-odd); }
.breadcrumb a:nth-child(even):hover { background: var(--breadcrumb-hover-background-even); }
.breadcrumb a:hover { color: var(--breadcrumb-hover-color); }
.breadcrumb a:hover svg { fill: var(--breadcrumb-hover-color); }
</style>

View File

@ -1,69 +0,0 @@
<template>
<dialog ref="dialog">
<h1 v-if="title">{{ title }}</h1>
<div>
<slot>Dialog with no content</slot>
</div>
<button onclick="dialog.close()">OK</button>
</dialog>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const dialog = ref<HTMLDialogElement | null>(null)
const props = withDefaults(
defineProps<{
title: string
}>(),
{
title: '',
},
)
onMounted(() => {
dialog.value!.showModal()
})
</script>
<style>
/* Style for the background */
body:has(dialog[open])::before {
content: '';
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0008;
backdrop-filter: blur(.2em);
z-index: 1000;
}
/* Hide the dialog by default */
dialog[open] {
display: block;
border: none;
border-radius: .5rem;
box-shadow: .2rem .2rem 1rem #000;
padding: 1rem;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
}
dialog[open] > h1 {
background: #00f;
color: #fff;
font-size: 1rem;
margin: -1rem -1rem 0 -1rem;
padding: .5rem 1rem .5rem 1rem;
}
dialog[open] > div {
padding: 1em 0;
}
</style>

View File

@ -3,22 +3,63 @@
<table v-if="props.documents.length">
<thead>
<tr>
<th class="selection"><input type="checkbox" v-model="allSelected" :indeterminate="selectionIndeterminate"></th>
<th class="sortcolumn" :class="{sortactive: sort === 'name'}" @click="toggleSort('name')">Name</th>
<th class="sortcolumn modified right" :class="{sortactive: sort === 'modified'}" @click="toggleSort('modified')">Modified</th>
<th class="sortcolumn size right" :class="{sortactive: sort === 'size'}" @click="toggleSort('size')">Size</th>
<th class="selection">
<input
type="checkbox"
v-model="allSelected"
:indeterminate="selectionIndeterminate"
/>
</th>
<th
class="sortcolumn"
:class="{ sortactive: sort === 'name' }"
@click="toggleSort('name')"
>
Name
</th>
<th
class="sortcolumn modified right"
:class="{ sortactive: sort === 'modified' }"
@click="toggleSort('modified')"
>
Modified
</th>
<th
class="sortcolumn size right"
:class="{ sortactive: sort === 'size' }"
@click="toggleSort('size')"
>
Size
</th>
</tr>
</thead>
<tbody>
<tr v-for="doc of sorted(props.documents as FolderDocument[])" :key="doc.key" :class="doc.type === 'folder' ? 'folder' : 'file'">
<tr
v-for="doc of sorted(props.documents as FolderDocument[])"
:key="doc.key"
:class="doc.type === 'folder' ? 'folder' : 'file'"
>
<td class="selection">
<input type="checkbox" :checked="doc.key in documentStore.selected" @change="documentStore.selected.add(doc.key)">
<input
type="checkbox"
:checked="doc.key in documentStore.selected"
@change="documentStore.selected.add(doc.key)"
/>
</td>
<td class="name">
<template v-if="editing === doc"><FileRenameInput :doc="doc" :rename="rename" :exit="() => { editing = null}"/></template>
<template v-if="editing === doc"
><FileRenameInput
:doc="doc"
:rename="rename"
:exit="
() => {
editing = null
}
"
/></template>
<template v-else>
<a :href="url_for(doc)">{{ doc.name }}</a>
<button @click="() => editing = doc">🖊</button>
<button @click="() => (editing = doc)">🖊</button>
</template>
</td>
<td class="right">{{ doc.modified }}</td>
@ -35,16 +76,16 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import type { Document, FolderDocument } from '@/repositories/Document';
import type { Document, FolderDocument } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue'
import createWebSocket from '@/repositories/WS';
import createWebSocket from '@/repositories/WS'
const props = withDefaults(
defineProps<{
path: string,
documents: Document[],
path: string
documents: Document[]
}>(),
{},
{}
)
const documentStore = useDocumentStore()
@ -53,41 +94,44 @@ const linkBasePath = computed(()=>{
return path === '/' ? '' : path
})
const filesBasePath = computed(() => `/files${linkBasePath.value}`)
const url_for = (doc: FolderDocument) => (
doc.type === "folder" ?
`#${linkBasePath.value}/${doc.name}` :
`${filesBasePath.value}/${doc.name}`
)
const url_for = (doc: FolderDocument) =>
doc.type === 'folder'
? `#${linkBasePath.value}/${doc.name}`
: `${filesBasePath.value}/${doc.name}`
// File rename
const editing = ref<FolderDocument | null>(null)
const rename = (doc: FolderDocument, newName: string) => {
const oldName = doc.name
const control = createWebSocket("/api/control", (ev: MessageEvent) => {
const control = createWebSocket('/api/control', (ev: MessageEvent) => {
const msg = JSON.parse(ev.data)
if ("error" in msg) {
console.error("Rename failed", msg.error.message, msg.error)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log("Rename succeeded", msg)
console.log('Rename succeeded', msg)
}
})
control.onopen = () => {
control.send(JSON.stringify({
"op": "rename",
"path": `${linkBasePath.value}/${oldName}`,
"to": newName
}))
control.send(
JSON.stringify({
op: 'rename',
path: `${linkBasePath.value}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker
}
// Column sort
const toggleSort = (name: string) => { sort.value = sort.value === name ? "" : name }
const sort = ref<string>("")
const toggleSort = (name: string) => {
sort.value = sort.value === name ? '' : name
}
const sort = ref<string>('')
const sortCompare = {
"name": (a: Document, b: Document) => a.name.localeCompare(b.name),
"modified": (a: FolderDocument, b: FolderDocument) => b.mtime - a.mtime,
"size": (a: FolderDocument, b: FolderDocument) => b.size - a.size
name: (a: Document, b: Document) => a.name.localeCompare(b.name),
modified: (a: FolderDocument, b: FolderDocument) => b.mtime - a.mtime,
size: (a: FolderDocument, b: FolderDocument) => b.size - a.size
}
const sorted = (documents: FolderDocument[]) => {
const cmp = sortCompare[sort.value as keyof typeof sortCompare]
@ -97,13 +141,21 @@ const sorted = (documents: FolderDocument[]) => {
}
const selectionIndeterminate = computed({
get: () => {
return props.documents.length > 0 && props.documents.some((doc: Document) => doc.key in documentStore.selected) && !allSelected.value
return (
props.documents.length > 0 &&
props.documents.some((doc: Document) => doc.key in documentStore.selected) &&
!allSelected.value
)
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
set: (value: boolean) => {}
})
const allSelected = computed({
get: () => {
return props.documents.length > 0 && props.documents.every((doc: Document) => doc.key in documentStore.selected)
return (
props.documents.length > 0 &&
props.documents.every((doc: Document) => doc.key in documentStore.selected)
)
},
set: (value: boolean) => {
for (const doc of props.documents) {
@ -122,14 +174,19 @@ table {
width: 100%;
table-layout: fixed;
}
table input[type=checkbox] {
table input[type='checkbox'] {
width: 1em;
height: 1em;
}
table .modified { width: 10em; }
table .size { width: 6em; }
table th, table td {
padding: .5em;
table .modified {
width: 10em;
}
table .size {
width: 6em;
}
table th,
table td {
padding: 0.5em;
font-weight: normal;
text-align: left;
white-space: nowrap;
@ -177,9 +234,9 @@ tbody tr:hover {
padding-right: 1.7em;
}
.sortcolumn::after {
content: "▸";
content: '▸';
color: #888;
margin: 0 1em 0 .5em;
margin: 0 1em 0 0.5em;
position: absolute;
transition: all 0.2s linear;
}

View File

@ -6,7 +6,7 @@
:value="doc.name"
@keyup.esc="exit"
@keyup.enter="apply"
>
/>
</template>
<script setup lang="ts">

View File

@ -2,38 +2,36 @@
<object
v-if="props.type === 'pdf'"
:data="dataURL"
type="application/pdf" width="100%"
type="application/pdf"
width="100%"
height="100%"
>
</object>
></object>
<a-image
v-else-if="props.type === 'image'"
width="50%"
:src="dataURL"
@click="() => setVisible(true)"
:previewMask=false
:previewMask="false"
:preview="{
visibleImg,
onVisibleChange: setVisible,
onVisibleChange: setVisible
}"
/>
<!-- Unknown case -->
<h1 v-else>
Unsupported file type
</h1>
<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';
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();
).toString()
})
const emit = defineEmits({
visibleImg(value: boolean) {
@ -45,7 +43,6 @@ function setVisible(value: boolean) {
emit('visibleImg', value)
}
const props = defineProps<{
type?: string
visibleImg: boolean

View File

@ -2,14 +2,14 @@
import { useDocumentStore } from '@/stores/documents'
import LoginModal from '@/components/LoginModal.vue'
import UploadButton from '@/components/UploadButton.vue'
import { ref } from 'vue';
import { ref } from 'vue'
const documentStore = useDocumentStore()
const searchQuery = ref<string>('')
const showSearchInput = ref<boolean>(false)
const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value;
showSearchInput.value = !showSearchInput.value
if (!showSearchInput.value) {
searchQuery.value = ''
}
@ -18,40 +18,10 @@ const toggleSearchInput = () => {
const executeSearch = (ev: InputEvent) => {
// FIXME: Make reactive instead of this update handler
const query = (ev.target as HTMLInputElement).value
console.log("Searching", query)
console.log('Searching', query)
documentStore.setFilter(query)
console.log("Filtered")
console.log('Filtered')
}
function createFileHandler() {
console.log("Creating file")
}
function uploadFolderHandler() {
console.log("Uploading Folder")
}
function createFolderHandler() {
console.log("Uploading Folder")
}
function newViewHandler() {
console.log("Creating new view ...")
}
function preferencesHandler() {
console.log("Preferences ...")
}
function about() {
console.log("About ...")
}
function deleteHandler(){
console.log("Delete ...")
}
function share(){
console.log("Share ...")
}
function download(){
console.log("Download ...")
}
</script>
<template>
@ -88,7 +58,7 @@ function download(){
<div class="actions-list">
<LoginModal></LoginModal>
<template v-if="showSearchInput">
<input type="search" v-model="searchQuery" class="margin-input">
<input type="search" v-model="searchQuery" class="margin-input" />
</template>
<!--
@ -108,7 +78,6 @@ function download(){
<a-button @click="about" type="text" class="action-button" :icon="h(InfoCircleOutlined)" />
</a-tooltip>
-->
</div>
</div>
</template>
@ -136,7 +105,6 @@ function download(){
}
@media only screen and (max-width: 600px) {
.actions-container,
.actions-list {
gap: 6px;
@ -147,14 +115,14 @@ function download(){
}
.path {
box-shadow: 0 0 0.5em rgba(0,0,0,.15);
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.15);
overflow: hidden;
white-space: nowrap;
width: 100%;
z-index: 1;
flex: 0 0 1.5rem;
order: 1;
font-size: .9rem;
font-size: 0.9rem;
position: relative;
}
</style>

View File

@ -1,4 +1,33 @@
<template>
<button v-if="store.isUserLogged" @click="logout" class="action-button">
Logout
</button>
<ModalDialog v-else title="Login">
<form @submit="login">
<label for="username">Username:</label
><input
id="username"
name="username"
autocomplete="username"
required
v-model="loginForm.username"
/>
<label for="password">Password:</label
><input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
v-model="loginForm.password"
/>
<h3 v-if="loginForm.error.length > 0" class="error-text">
{{ loginForm.error }}
</h3>
<input type="submit" />
</form>
</ModalDialog>
<!--
<a-tooltip title="Login">
@ -11,53 +40,41 @@
</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 { useDocumentStore } from '@/stores/documents';
import { loginUser, logoutUser } from '@/repositories/User';
import type { ISimpleError } from '@/repositories/Client';
import { ref } from 'vue'
import { loginUser, logoutUser } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useDocumentStore } from '@/stores/documents'
const DocumentStore = useDocumentStore();
const confirmLoading = ref<boolean>(false);
const confirmLoading = ref<boolean>(false)
const store = useDocumentStore()
const showModal = () => {
DocumentStore.user.isOpenLoginModal = true;
};
const logout = async () => {
try {
await logoutUser();
} catch (error) {} finally {
location.reload();
await logoutUser()
} finally {
location.reload()
}
}
const loginForm = ref({
username: '',
password: '',
error: '',
});
error: ''
})
const login = async () => {
try {
loginForm.value.error = '';
confirmLoading.value = true;
const user = await loginUser(loginForm.value.username, loginForm.value.password);
loginForm.value.error = ''
confirmLoading.value = true
const user = await loginUser(loginForm.value.username, loginForm.value.password)
if (user) {
location.reload();
location.reload()
}
} catch (error) {
const httpError = error as ISimpleError
@ -65,17 +82,17 @@ const login = async () => {
loginForm.value.error = httpError.message
}
} finally {
confirmLoading.value = false;
confirmLoading.value = false
}
}
};
</script>
<style scoped>
.login-container {
display: flex;
display: grid;
grid-template-columns: 1fr 2fr;
justify-content: center;
align-items: center;
min-height: 30vh;
}
.button-login {
background-color: var(--secondary-color);
@ -83,9 +100,8 @@ const login = async () => {
}
.ant-btn-primary:not(:disabled):hover {
background-color: var(--blue-color);
}
.error-text {
color :var(--red-color)
color: var(--red-color);
}
</style>

View File

@ -0,0 +1,71 @@
<template>
<dialog ref="dialog">
<h1 v-if="props.title">{{ props.title }}</h1>
<div>
<slot>
Dialog with no content
<button onclick="dialog.close()">OK</button>
</slot>
</div>
</dialog>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const dialog = ref<HTMLDialogElement | null>(null)
const props = withDefaults(
defineProps<{
title: string
}>(),
{
title: ''
}
)
onMounted(() => {
dialog.value!.showModal()
})
</script>
<style>
/* Style for the background */
body:has(dialog[open])::before {
content: '';
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0008;
backdrop-filter: blur(0.2em);
z-index: 1000;
}
/* Hide the dialog by default */
dialog[open] {
display: block;
border: none;
border-radius: 0.5rem;
box-shadow: 0.2rem 0.2rem 1rem #000;
padding: 1rem;
position: fixed;
top: 0;
left: 0;
z-index: 1001;
}
dialog[open] > h1 {
background: #00f;
color: #fff;
font-size: 1rem;
margin: -1rem -1rem 0 -1rem;
padding: 0.5rem 1rem 0.5rem 1rem;
}
dialog[open] > div {
padding: 1em 0;
}
</style>

View File

@ -1,12 +1,12 @@
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import { h, ref } from 'vue';
import { h, ref } from 'vue'
const fileUploadButton = ref()
const documentStore = useDocumentStore();
const open = (placement: any) => openNotification(placement);
const documentStore = useDocumentStore()
const open = (placement: any) => openNotification(placement)
const isNotificationOpen = ref(false);
const isNotificationOpen = ref(false)
const openNotification = (placement: any) => {
if (!isNotificationOpen.value) {
/*
@ -17,47 +17,48 @@ const openNotification = (placement: any) => {
duration: 0,
onClose: () => { isNotificationOpen.value = false }
});*/
isNotificationOpen.value = true;
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;
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;
return event.target.result as ArrayBuffer
} else {
throw new Error('Error loading file' );
throw new Error('Error loading file')
}
}
async function sendChunk(file: File, start: number, end: number) {
const ws = documentStore.wsUpload;
const ws = documentStore.wsUpload
if (ws) {
const chunk = await load(file, start, end)
ws.send(JSON.stringify({
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 target = event.target as HTMLInputElement
const chunkSize = 1 << 20
if (target && target.files && target.files.length > 0) {
const file = target.files[0];
const file = target.files[0]
const numChunks = Math.ceil(file.size / chunkSize)
const document = documentStore.pushUploadingDocuments(file.name)
open('bottomRight')
@ -65,14 +66,15 @@ async function uploadFileChangeHandler(event: Event) {
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('progress: ' + (100 * (i + 1)) / numChunks)
console.log('Num Chunks: ' + numChunks)
documentStore.updateUploadingDocuments( document.key, ((100 * (i + 1) ) / numChunks))
documentStore.updateUploadingDocuments(document.key, (100 * (i + 1)) / numChunks)
}
}
}
</script>
<template>
(buttons here)
<!--
<a-tooltip title="Upload files from disk">

View File

@ -7,7 +7,7 @@ import App from './App.vue'
import router from './router'
const app = createApp(App)
app.config.errorHandler = (err) => {
app.config.errorHandler = err => {
/* handle error */
console.log(err)
}

View File

@ -4,14 +4,16 @@ export const baseURL = import.meta.env.VITE_URL_DOCUMENT
class ClientClass {
async post(url: string, data?: Record<string, any>): Promise<any> {
const res = await fetch(`${baseURL}/`, {
method: "POST",
method: 'POST',
headers: {
accept: 'application/json',
"content-type": 'application/json',
'content-type': 'application/json'
},
body: data !== undefined ? JSON.stringify(data) : undefined,
body: data !== undefined ? JSON.stringify(data) : undefined
})
return await res.json()
const msg = await res.json()
if ('error' in msg) throw new SimpleError(msg.error.code, msg.error.message)
return msg
}
}

View File

@ -1,12 +1,12 @@
import type { DocumentStore } from '@/stores/documents'
import { useDocumentStore } from '@/stores/documents'
export type FUID = string;
export type FUID = string
type BaseDocument = {
name: string
key: FUID
};
}
export type FolderDocument = BaseDocument & {
type: 'folder' | 'file'
@ -14,17 +14,17 @@ export type FolderDocument = BaseDocument & {
sizedisp: string
mtime: number
modified: string
};
}
export type Document = FolderDocument
export type errorEvent = {
error: {
code : number;
message: string;
redirect: string;
code: number
message: string
redirect: string
}
}
};
// Raw types the backend /api/watch sends us
@ -58,33 +58,33 @@ export const url_document_get ='/files'
export class DocumentHandler {
constructor(private store: DocumentStore = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this);
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data);
const msg = JSON.parse(event.data)
switch (true) {
case !!msg.root:
this.handleRootMessage(msg);
break;
this.handleRootMessage(msg)
break
case !!msg.update:
this.handleUpdateMessage(msg);
break;
this.handleUpdateMessage(msg)
break
case !!msg.error:
this.handleError(msg);
break;
this.handleError(msg)
break
default:
}
}
private handleRootMessage({ root }: { root: DirEntry }) {
if (this.store && this.store.root) {
this.store.user.isLoggedIn = true;
this.store.root = root;
this.store.user.isLoggedIn = true
this.store.root = root
}
}
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
let node: DirEntry = this.store.root;
let node: DirEntry = this.store.root
for (const elem of updateData.update) {
if (elem.deleted) {
delete node.dir[elem.name]
@ -102,8 +102,8 @@ export class DocumentHandler {
}
private handleError(msg: errorEvent) {
if (msg.error.code === 401) {
this.store.user.isOpenLoginModal = true;
this.store.user.isLoggedIn = false;
this.store.user.isOpenLoginModal = true
this.store.user.isLoggedIn = false
return
}
}
@ -111,15 +111,15 @@ export class DocumentHandler {
export class DocumentUploadHandler {
constructor(private store: DocumentStore = useDocumentStore()) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this);
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this)
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data);
const msg = JSON.parse(event.data)
switch (true) {
case !!msg.written:
this.handleWrittenMessage(msg);
break;
this.handleWrittenMessage(msg)
break
default:
}
}

View File

@ -1,23 +1,15 @@
import Client from '@/repositories/Client'
export const url_login = '/login'
export const url_logout = '/logout '
export async function loginUser(username: string, password: string) {
try {
const user = await Client.post(url_login, {
username, password
username,
password
})
return user;
} catch (error) {
throw error
}
return user
}
export async function logoutUser() {
try {
const data = await Client.post(url_logout)
return data;
} catch (error) {
throw error
}
return data
}

View File

@ -1,8 +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;
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

@ -7,8 +7,8 @@ const router = createRouter({
{
path: '/:pathMatch(.*)*',
name: 'explorer',
component: ExplorerView,
},
component: ExplorerView
}
]
})

View File

@ -1,28 +1,34 @@
import type { Document, DirEntry, FileEntry, FUID, DirList } from '@/repositories/Document'
import type {
Document,
DirEntry,
FileEntry,
FUID,
DirList
} from '@/repositories/Document'
import { formatSize, formatUnixDate } from '@/utils'
import { defineStore } from 'pinia'
// @ts-ignore
import { localeIncludes } from 'locale-includes'
type FileData = { id: string, mtime: number, size: number, dir: DirectoryData};
type FileData = { id: string; mtime: number; size: number; dir: DirectoryData }
type DirectoryData = {
[filename: string]: FileData;
};
[filename: string]: FileData
}
type User = {
isOpenLoginModal: boolean,
isLoggedIn : boolean,
isOpenLoginModal: boolean
isLoggedIn: boolean
}
export type DocumentStore = {
root: DirEntry,
document: Document[],
selected: Set<FUID>,
uploadingDocuments: Array<{key: number, name: string, progress: number}>,
uploadCount: number,
wsWatch: WebSocket | undefined,
wsUpload: WebSocket | undefined,
user: User,
error: string,
root: DirEntry
document: Document[]
selected: Set<FUID>
uploadingDocuments: Array<{ key: number; name: string; progress: number }>
uploadCount: number
wsWatch: WebSocket | undefined
wsUpload: WebSocket | undefined
user: User
error: string
}
export const useDocumentStore = defineStore({
@ -52,27 +58,34 @@ export const useDocumentStore = defineStore({
sizedisp: formatSize(size),
mtime,
modified: formatUnixDate(mtime),
type: "dir" in attr ? 'folder' : 'file',
type: 'dir' in attr ? 'folder' : 'file'
}
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)
dataMapped.sort((a, b) =>
a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'folder' ? -1 : 1
)
this.document = dataMapped
},
setFilter(filter: string) {
function traverseDir(data: DirEntry | FileEntry, path: string) {
if (!("dir" in data)) return
if (!('dir' in data)) return
for (const [name, attr] of Object.entries(data.dir)) {
const fullname = `${path}/${name}`
if (localeIncludes(name, filter, {usage: "search", sensitivity: "base"})) {
if (
localeIncludes(name, filter, {
usage: 'search',
sensitivity: 'base'
})
) {
matched[fullname.slice(1)] = attr // No initial slash on name
}
traverseDir(attr, fullname)
}
}
const matched: any = {}
traverseDir(this.root, "")
traverseDir(this.root, '')
this.updateTable(matched)
},
setActualDocument(location: string) {
@ -83,14 +96,19 @@ export const useDocumentStore = defineStore({
// Navigate to target folder
for (const dirname of location.split('/').slice(1)) {
if (!dirname) continue
if (!("dir" in data)) throw Error("Target folder not available")
if (!('dir' in data)) throw Error('Target folder not available')
actualDirArr.push(dirname)
data = data.dir[dirname]
}
} catch (error) {
console.error("Cannot show requested folder", location, actualDirArr.join('/'), error)
console.error(
'Cannot show requested folder',
location,
actualDirArr.join('/'),
error
)
}
if (!("dir" in data)) {
if (!('dir' in data)) {
// Target folder not available
this.document = []
return
@ -103,7 +121,7 @@ export const useDocumentStore = defineStore({
}
},
pushUploadingDocuments(name: string) {
this.uploadCount++;
this.uploadCount++
const document = {
key: this.uploadCount,
name: name,
@ -113,20 +131,20 @@ export const useDocumentStore = defineStore({
return document
},
deleteUploadingDocument(key: number) {
this.uploadingDocuments = this.uploadingDocuments.filter((e)=> e.key !== key)
this.uploadingDocuments = this.uploadingDocuments.filter(e => e.key !== key)
},
updateModified() {
for (const d of this.document) {
if ("mtime" in d) d.modified = formatUnixDate(d.mtime)
if ('mtime' in d) d.modified = formatUnixDate(d.mtime)
}
}
},
},
getters: {
mainDocument(): Document[] {
return this.document;
return this.document
},
isUserLogged(): boolean {
return this.user.isLoggedIn
}
},
});
}
})

View File

@ -1,26 +1,28 @@
export function determineFileType(inputString: string): "file" | "folder" {
export function determineFileType(inputString: string): 'file' | 'folder' {
if (inputString.includes('.') && !inputString.endsWith('.')) {
return 'file';
return 'file'
} else {
return 'folder';
return 'folder'
}
}
export function formatSize(size: number) {
if (size === 0) return 'empty'
for (const unit of [null, 'kB', 'MB', 'GB', 'TB', 'PB', 'EB']) {
if (size < 1e4) return size.toLocaleString().replace(',', '\u202F') + (unit ? `\u202F${unit}` : '')
if (size < 1e4)
return (
size.toLocaleString().replace(',', '\u202F') + (unit ? `\u202F${unit}` : '')
)
size = Math.round(size / 1000)
}
return "huge"
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' })
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
if (Math.abs(diff) <= 5000) {
return 'now'
}
@ -40,29 +42,34 @@ export function formatUnixDate(t: number) {
return formatter.format(Math.round(diff / 86400000), 'day')
}
return date.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })
return date.toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
export function getFileExtension(filename: string) {
const parts = filename.split(".");
const parts = filename.split('.')
if (parts.length > 1) {
return parts[parts.length - 1];
return parts[parts.length - 1]
} else {
return ""; // No hay extensión
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"];
const videoExtensions = ['mp4', 'avi', 'mkv', 'mov']
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif']
const pdfExtensions = ['pdf']
if (videoExtensions.includes(extension)) {
return "video";
return 'video'
} else if (imageExtensions.includes(extension)) {
return "image";
return 'image'
} else if (pdfExtensions.includes(extension)) {
return "pdf";
return 'pdf'
} else {
return "unknown";
return 'unknown'
}
}

View File

@ -16,8 +16,8 @@
<script setup lang="ts">
import { watchEffect } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import Router from '@/router/index';
import FileExplorer from '@/components/FileExplorer.vue';
import Router from '@/router/index'
import FileExplorer from '@/components/FileExplorer.vue'
const documentStore = useDocumentStore()
@ -26,21 +26,23 @@ watchEffect(async () => {
documentStore.setActualDocument(path.toString())
})
function beforeEnter(el) {
el.style.transform = 'translateX(100%)'
function beforeEnter(el: Element) {
const elem = el as HTMLElement
elem.style.transform = 'translateX(100%)'
}
function enter(el, done) {
function enter(el: Element, done: () => void) {
const elem = el as HTMLElement
setTimeout(() => {
el.style.transform = 'translateX(0)'
elem.style.transform = 'translateX(0)'
done()
}, 0)
}
function leave(el, done) {
el.style.transform = 'translateX(-100%)'
function leave(el: Element, done: () => void) {
const elem = el as HTMLElement
elem.style.transform = 'translateX(-100%)'
setTimeout(done, 300) // Assuming 300ms is your transition duration
}
</script>
<style scoped>

View File

@ -6,6 +6,7 @@ import vue from '@vitejs/plugin-vue'
// @ts-ignore
import pluginRewriteAll from 'vite-plugin-rewrite-all'
import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite'
// Development mode:
// npm run dev # Run frontend that proxies to dev_backend
@ -21,7 +22,8 @@ export default defineConfig({
plugins: [
vue(),
pluginRewriteAll(),
svgLoader(),
svgLoader(), // import svg files
Components(), // auto import components
],
css: {
preprocessorOptions: {

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 @@
@charset "UTF-8";: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}@media (prefers-color-scheme: dark){:root{--primary-background: #333;--secondary-background: #666;--font-color: #ffffff;--table-background: #535353;--primary-color: #ffffff;--secondary-color: #ccc;--blue-color: #66ffeb;--red-color: #ff4d4f}}body{background-color:var(--primary-background);font-family:Roboto,sans-serif;margin:0}a:link,a:visited,a:active,a:hover{color:var(--primary-color);text-decoration:none}table{border-collapse:collapse;border:0;gap:0}#app{height:100%;display:flex;flex-direction:column;background-color:var(--secondary-background)}.login-container[data-v-b14929a5]{display:flex;justify-content:center;align-items:center;min-height:30vh}.button-login[data-v-b14929a5]{background-color:var(--secondary-color);color:var(--secondary-background)}.ant-btn-primary[data-v-b14929a5]:not(:disabled):hover{background-color:var(--blue-color)}.error-text[data-v-b14929a5]{color:var(--red-color)}.upload-input[data-v-8cbd2944]{display:none}.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)}.action-button:hover{color:var(--blue-color)!important}@media only screen and (max-width: 600px){.actions-container,.actions-list{gap:6px}}.margin-input{margin-top:5px}.path{box-shadow:0 0 .5em #00000026;overflow:hidden;white-space:nowrap;width:100%;z-index:1;flex:0 0 1.5rem;order:1;font-size:.9rem;position:relative}nav[data-v-953811b0],span[data-v-953811b0]{color:var(--primary-color)}span[data-v-953811b0]:hover,.last[data-v-953811b0]{color:var(--blue-color)}input#FileRenameInput{color:#8f8;border:0;padding:0;width:90%;outline:none;background:transparent}table{width:100%;table-layout:fixed}table input[type=checkbox]{width:1em;height:1em}table .modified{width:10em}table .size{width:6em}table th,table td{padding:.5em;font-weight:400;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.name{white-space:nowrap;text-overflow:initial;overflow:initial}.name button{visibility:hidden;padding-left:1em}.name:hover button{visibility:visible}.name button{cursor:pointer;border:0;background:transparent}thead tr{border:1px solid #ddd;background:#ddd}tbody tr{background:#444;color:#ddd}tbody tr:hover{background:#00f8}.right{text-align:right}.selection{width:2em}.sortcolumn:hover{cursor:pointer}.sortcolumn:hover:after{color:#f80}.sortcolumn{padding-right:1.7em}.sortcolumn:after{content:"▸";color:#888;margin:0 1em 0 .5em;position:absolute;transition:transform .2s linear}.sortactive:after{transform:rotate(90deg);color:#000}main{padding:5px;height:100%}.more-action{display:flex;flex-direction:column;justify-content:start}.action-container{display:flex;align-items:center}.edit-action{min-width:5%}.carousel-container{height:inherit}.name a{text-decoration:none}.file .name:before{content:"📄 ";font-size:1.5em}.folder .name:before{content:"📁 ";font-size:1.5em}.wrapper[data-v-aa2747c4]{background-color:var(--primary-background);padding:.2em .5em;display:flex;flex-direction:column;gap:10px}.page-container[data-v-aa2747c4]{flex-grow:2;padding:0}

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang=en>
<script type="module" crossorigin src="/assets/index-68773a87.js"></script>
<link rel="stylesheet" href="/assets/index-d4bfeeb6.css">
<script type="module" crossorigin src="/assets/index-2034a7a8.js"></script>
<link rel="stylesheet" href="/assets/index-c3cea0a2.css">
<meta charset=UTF-8>
<title>Cista</title>
@ -10,5 +10,6 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Emoji&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<div id="app"></div>
<div id="app"></div>

17
package-lock.json generated
View File

@ -1,17 +0,0 @@
{
"name": "cista-storage",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"locale-includes": "^1.0.5"
}
},
"node_modules/locale-includes": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/locale-includes/-/locale-includes-1.0.5.tgz",
"integrity": "sha512-8pcOkyBbMZvHGskk3gbi+o6dYSOmkLJ+hh1lle+LaULxB2YtwNrCMEhgpAJb3WruTUC2cSEu71bOe6im6DuCuA=="
}
}
}

View File

@ -1,5 +0,0 @@
{
"dependencies": {
"locale-includes": "^1.0.5"
}
}