Compare commits
27 Commits
v0.4.0
...
acdd776b92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acdd776b92 | ||
| b3fd9637eb | |||
| 2b72508206 | |||
|
|
8cc3ed1a04 | ||
|
|
0d186726b5 | ||
|
|
63bbe84859 | ||
|
|
202f28ff15 | ||
| 41772e6c18 | |||
| e52379d515 | |||
| 74987898c9 | |||
| 859d312913 | |||
| 4bc9cf4534 | |||
| 754d779069 | |||
| 367e4ba0ea | |||
| c2e9a4af05 | |||
| 6cdc37a172 | |||
| 19699564c2 | |||
| 7baf8b3f9b | |||
| 47329ac04e | |||
| f4013d1196 | |||
| 3672156b5e | |||
| f2b37852da | |||
| 708e54d080 | |||
| d051265f40 | |||
| 5cf133465e | |||
| 1c91bf2e87 | |||
| 9cd6f83bec |
1
.gitignore
vendored
@@ -3,5 +3,4 @@
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
/cista/_version.py
|
||||
/cista/wwwroot/*
|
||||
/dist
|
||||
|
||||
71
README.md
@@ -1,78 +1,25 @@
|
||||
# Web File Storage
|
||||
|
||||
The Python package installs a `cista` executable. Use `hatch shell` to initiate and install in a virtual environment, or `pip install` it on your system. Alternatively `hatch run cista` may be used to skip the shell step but stay virtual. `pip install hatch` first if needed.
|
||||
Run directly from repository with Hatch (or use pip install as usual):
|
||||
```sh
|
||||
hatch run cista -l :3000 /path/to/files
|
||||
```
|
||||
|
||||
Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script).
|
||||
|
||||
Create your user account:
|
||||
|
||||
```sh
|
||||
cista --user admin --privileged
|
||||
hatch run cista --user admin --privileged
|
||||
```
|
||||
|
||||
## Running the server
|
||||
|
||||
Serve your files on localhost:8000:
|
||||
|
||||
```sh
|
||||
cista -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
The Git repository does not contain a frontend build, so you should first do that...
|
||||
|
||||
## Build frontend
|
||||
|
||||
Frontend needs to be built before using and after any frontend changes:
|
||||
Prebuilt frontend is provided in repository but for any changes it will need to be manually rebuilt:
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
cd cista-front
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`.
|
||||
|
||||
## Development setup
|
||||
|
||||
For rapid turnaround during development, you should run `npm run dev` Vite development server on the Vue frontend. While that is running, start the backend on another terminal `hatch run cista --dev -l :8000` and connect to the frontend.
|
||||
|
||||
The backend and the frontend will each reload automatically at any code or config changes.
|
||||
|
||||
## System deployment
|
||||
|
||||
Clone the repository to `/srv/cista/cista-storage` or other suitable location accessible to the storage user account you plan to use. `sudo -u storage -s` and build the frontend if you hadn't already.
|
||||
|
||||
Create **/etc/systemd/system/cista@.service**:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Cista storage %i
|
||||
|
||||
[Service]
|
||||
User=storage
|
||||
WorkingDirectory=/srv/cista/cista-storage
|
||||
ExecStart=hatch run cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/
|
||||
TimeoutStopSec=2
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
This assumes you may want to run multiple separate storages, each having their files under `/media/storage/<domain>` and configuration under `/srv/cista/<domain>/`. Instead of numeric ports, we use UNIX sockets for convenience.
|
||||
|
||||
```sh
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cista@foo.example.com
|
||||
systemctl enable --now cista@bar.example.com
|
||||
```
|
||||
|
||||
Exposing this publicly online is the most convenient using the [Caddy](https://caddyserver.com/) web server but you can of course use Nginx or others as well. Or even run the server with `-l domain.example.com` given TLS certificates in the config folder.
|
||||
|
||||
**/etc/caddy/Caddyfile**:
|
||||
|
||||
```Caddyfile
|
||||
foo.example.com, bar.example.com {
|
||||
reverse_proxy unix//srv/cista/{host}/socket
|
||||
}
|
||||
```
|
||||
|
||||
Using the `{host}` placeholder we can just put all the domains on the same block. That's the full server configuration you need. `systemctl enable --now caddy` or `systemctl restart caddy` for the config to take effect.
|
||||
|
||||
5
frontend/.gitignore → cista-front/.gitignore
vendored
@@ -7,17 +7,12 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# No locking
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
components.d.ts
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
37
cista-front/components.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
|
||||
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
AForm: typeof import('ant-design-vue/es')['Form']
|
||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||
AImage: typeof import('ant-design-vue/es')['Image']
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
|
||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
|
||||
APopover: typeof import('ant-design-vue/es')['Popover']
|
||||
AppNavigation: typeof import('./src/components/AppNavigation.vue')['default']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ATable: typeof import('ant-design-vue/es')['Table']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
FileCarousel: typeof import('./src/components/FileCarousel.vue')['default']
|
||||
FileExplorer: typeof import('./src/components/FileExplorer.vue')['default']
|
||||
FileViewer: typeof import('./src/components/FileViewer.vue')['default']
|
||||
HeaderMain: typeof import('./src/components/HeaderMain.vue')['default']
|
||||
LoginModal: typeof import('./src/components/LoginModal.vue')['default']
|
||||
NotificationLoading: typeof import('./src/components/NotificationLoading.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
UploadButton: typeof import('./src/components/UploadButton.vue')['default']
|
||||
}
|
||||
}
|
||||
13
cista-front/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Vite Vasanko</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
5732
cista-front/package-lock.json
generated
Normal file
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "cista-frontend",
|
||||
"name": "front",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -13,16 +13,16 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@imengyu/vue3-context-menu": "^1.3.3",
|
||||
"@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",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"unplugin-vue-components": "^0.25.2",
|
||||
"vite-plugin-rewrite-all": "^1.0.1",
|
||||
"vite-svg-loader": "^4.0.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4"
|
||||
},
|
||||
@@ -37,9 +37,8 @@
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-plugin-vue": "^9.18.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",
|
||||
@@ -47,13 +46,5 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
BIN
cista-front/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
241
cista-front/public/old-index.html
Executable file
@@ -0,0 +1,241 @@
|
||||
<!DOCTYPE html>
|
||||
<title>Storage</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
max-width: 100ch;
|
||||
margin: 0 auto;
|
||||
padding: 1em;
|
||||
background-color: #333;
|
||||
color: #eee;
|
||||
}
|
||||
td {
|
||||
text-align: right;
|
||||
padding: .5em;
|
||||
}
|
||||
td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
<div>
|
||||
<h2>Quick file upload</h2>
|
||||
<p>Uses parallel WebSocket connections for increased bandwidth /api/upload</p>
|
||||
<input type=file id=fileInput>
|
||||
<progress id=progressBar value=0 max=1></progress>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Files</h2>
|
||||
<ul id=file_list></ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let files = {}
|
||||
let flatfiles = {}
|
||||
|
||||
function createWatchSocket() {
|
||||
const wsurl = new URL("/api/watch", location.href.replace(/^http/, 'ws'))
|
||||
const ws = new WebSocket(wsurl)
|
||||
ws.onmessage = event => {
|
||||
msg = JSON.parse(event.data)
|
||||
if (msg.update) {
|
||||
tree_update(msg.update)
|
||||
file_list(files)
|
||||
} else {
|
||||
console.log("Unkonwn message from watch socket", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createWatchSocket()
|
||||
|
||||
function tree_update(msg) {
|
||||
console.log("Tree update", msg)
|
||||
let node = files
|
||||
for (const elem of msg) {
|
||||
if (elem.deleted) {
|
||||
const p = node.dir[elem.name].path
|
||||
delete node.dir[elem.name]
|
||||
delete flatfiles[p]
|
||||
break
|
||||
}
|
||||
if (elem.name !== undefined) node = node.dir[elem.name] ||= {}
|
||||
if (elem.size !== undefined) node.size = elem.size
|
||||
if (elem.mtime !== undefined) node.mtime = elem.mtime
|
||||
if (elem.dir !== undefined) node.dir = elem.dir
|
||||
}
|
||||
// Update paths and flatfiles
|
||||
files.path = "/"
|
||||
const nodes = [files]
|
||||
flatfiles = {}
|
||||
while (node = nodes.pop()) {
|
||||
flatfiles[node.path] = node
|
||||
if (node.dir === undefined) continue
|
||||
for (const name of Object.keys(node.dir)) {
|
||||
const child = node.dir[name]
|
||||
child.path = node.path + name + (child.dir === undefined ? "" : "/")
|
||||
nodes.push(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||
|
||||
const compare_path = (a, b) => collator.compare(a.path, b.path)
|
||||
const compare_time = (a, b) => a.mtime > b.mtime
|
||||
|
||||
function file_list(files) {
|
||||
const table = document.getElementById("file_list")
|
||||
const sorted = Object.values(flatfiles).sort(compare_time)
|
||||
table.innerHTML = ""
|
||||
for (const f of sorted) {
|
||||
const {path, size, mtime} = f
|
||||
const tr = document.createElement("tr")
|
||||
const name_td = document.createElement("td")
|
||||
const size_td = document.createElement("td")
|
||||
const mtime_td = document.createElement("td")
|
||||
const a = document.createElement("a")
|
||||
table.appendChild(tr)
|
||||
tr.appendChild(name_td)
|
||||
tr.appendChild(size_td)
|
||||
tr.appendChild(mtime_td)
|
||||
name_td.appendChild(a)
|
||||
size_td.textContent = size
|
||||
mtime_td.textContent = formatUnixDate(mtime)
|
||||
a.textContent = path
|
||||
a.href = `/files${path}`
|
||||
/*a.onclick = event => {
|
||||
if (window.showSaveFilePicker) {
|
||||
event.preventDefault()
|
||||
download_ws(name, size)
|
||||
}
|
||||
}
|
||||
a.download = ""*/
|
||||
}
|
||||
}
|
||||
|
||||
function formatUnixDate(t) {
|
||||
const date = new Date(t * 1000)
|
||||
const now = new Date()
|
||||
const diff = date - now
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
async function download_ws(name, size) {
|
||||
const fh = await window.showSaveFilePicker({
|
||||
suggestedName: name,
|
||||
})
|
||||
const writer = await fh.createWritable()
|
||||
writer.truncate(size)
|
||||
const wsurl = new URL("/api/download", location.href.replace(/^http/, 'ws'))
|
||||
const ws = new WebSocket(wsurl)
|
||||
let pos = 0
|
||||
ws.onopen = () => {
|
||||
console.log("Downloading over WebSocket", name, size)
|
||||
ws.send(JSON.stringify({name, start: 0, end: size, size}))
|
||||
}
|
||||
ws.onmessage = event => {
|
||||
if (typeof event.data === 'string') {
|
||||
const msg = JSON.parse(event.data)
|
||||
console.log("Download finished", msg)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
console.log("Received chunk", name, pos, pos + event.data.size)
|
||||
pos += event.data.size
|
||||
writer.write(event.data)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (pos < size) {
|
||||
console.log("Download aborted", name, pos)
|
||||
writer.truncate(pos)
|
||||
}
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
|
||||
const fileInput = document.getElementById("fileInput")
|
||||
const progress = document.getElementById("progressBar")
|
||||
const numConnections = 2
|
||||
const chunkSize = 1<<20
|
||||
const wsConnections = new Set()
|
||||
|
||||
//for (let i = 0; i < numConnections; i++) createUploadWS()
|
||||
|
||||
function createUploadWS() {
|
||||
const wsurl = new URL("/api/upload", location.href.replace(/^http/, 'ws'))
|
||||
const ws = new WebSocket(wsurl)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
ws.onopen = () => {
|
||||
wsConnections.add(ws)
|
||||
console.log("Upload socket connected")
|
||||
}
|
||||
ws.onmessage = event => {
|
||||
msg = JSON.parse(event.data)
|
||||
if (msg.written) progress.value += +msg.written
|
||||
else console.log(`Error: ${msg.error}`)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
wsConnections.delete(ws)
|
||||
console.log("Upload socket disconnected, reconnecting...")
|
||||
setTimeout(createUploadWS, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async function load(file, start, end) {
|
||||
const reader = new FileReader()
|
||||
const load = new Promise(resolve => reader.onload = resolve)
|
||||
reader.readAsArrayBuffer(file.slice(start, end))
|
||||
const event = await load
|
||||
return event.target.result
|
||||
}
|
||||
|
||||
async function sendChunk(file, start, end, 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)
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", async function() {
|
||||
const file = this.files[0]
|
||||
const numChunks = Math.ceil(file.size / chunkSize)
|
||||
progress.value = 0
|
||||
progress.max = file.size
|
||||
|
||||
console.log(wsConnections)
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
const ws = Array.from(wsConnections)[i % wsConnections.size]
|
||||
const start = i * chunkSize
|
||||
const end = Math.min(file.size, start + chunkSize)
|
||||
const res = await sendChunk(file, start, end, ws)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
65
cista-front/src/App.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
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 class="wrapper">
|
||||
<HeaderMain WS="WS"></HeaderMain>
|
||||
<AppNavigation :path="path.pathList"></AppNavigation>
|
||||
</header>
|
||||
|
||||
<RouterView class="page-container" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrapper{
|
||||
background-color: var(--primary-background);
|
||||
padding: 0.2em 0.5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.page-container{
|
||||
flex-grow: 2;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
105
cista-front/src/assets/main.css
Normal file
@@ -0,0 +1,105 @@
|
||||
: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: #181818;
|
||||
--secondary-background: #333333;
|
||||
--font-color: #ffffff;
|
||||
--table-background: #535353;
|
||||
--primary-color: #ffffff;
|
||||
--secondary-color: #ccc;
|
||||
--blue-color: #66ffeb;
|
||||
--red-color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
body {
|
||||
background-color: var(--table-background);
|
||||
}
|
||||
.ant-breadcrumb-separator, .ant-breadcrumb-link, .ant-breadcrumb .anticon {
|
||||
color: var(--primary-color) !important;
|
||||
font-size: 1.2em !important;
|
||||
}
|
||||
#app{
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--secondary-background);
|
||||
}
|
||||
|
||||
.ant-image-preview-mask{
|
||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
||||
backdrop-filter: blur(15px) !important;
|
||||
}
|
||||
.ant-table-cell:hover{
|
||||
background-color: initial !important;
|
||||
}
|
||||
.ant-table-wrapper .ant-table-tbody >tr.ant-table-row-selected >td,
|
||||
.ant-table-wrapper .ant-table-tbody >tr.ant-table-row-selected:hover>td {
|
||||
background-color: transparent;
|
||||
}
|
||||
.ant-form-item .ant-form-item-label >label{
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ant-input-group >.ant-input-group-addon:last-child .ant-input-search-button:not(.ant-btn-primary):hover{
|
||||
color : var(--blue-color) !important
|
||||
}
|
||||
.ant-input-search .ant-input:hover,
|
||||
.ant-input-search .ant-input:focus {
|
||||
border-color: var(--blue-color);
|
||||
}
|
||||
.ant-input{
|
||||
background-color:var(--table-background);
|
||||
border-color: var(--table-background);
|
||||
color: var(--font-color)
|
||||
}
|
||||
.ant-table-wrapper .ant-table-thead>tr>th {
|
||||
background-color: var(--table-background);
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-thead th.ant-table-column-has-sorters:hover {
|
||||
background-color: var(--table-background);
|
||||
}
|
||||
|
||||
.ant-table-content {
|
||||
background-color: var(--secondary-background);
|
||||
}
|
||||
|
||||
.ant-table-cell-row-hover,
|
||||
.ant-table-wrapper .ant-table-tbody>tr.ant-table-row:hover>td {
|
||||
background-color: var(--primary-background) !important;
|
||||
}
|
||||
|
||||
.ant-table-column-title,
|
||||
.ant-table-column-sorter,
|
||||
.ant-table-column-sort,
|
||||
.ant-table-cell,
|
||||
a {
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
.ant-table-cell>button>.anticon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.ant-notification-close-x{
|
||||
color: var(--secondary-background);
|
||||
}
|
||||
.ant-empty-description{
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.ant-modal .ant-modal-content{
|
||||
background-color: var(--secondary-background);
|
||||
}
|
||||
.ant-modal .ant-modal-close-x{
|
||||
color: var(--font-color);
|
||||
}
|
||||
}
|
||||
|
||||
43
cista-front/src/components/AppNavigation.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { HomeOutlined } from '@ant-design/icons-vue';
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
path: Array<string>
|
||||
}>(),
|
||||
{},
|
||||
)
|
||||
|
||||
function generateUrl(pathIndex: number) {
|
||||
return "/" + props.path.slice(0, pathIndex + 1).join('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<RouterLink to="/">
|
||||
<home-outlined />
|
||||
</RouterLink>
|
||||
</a-breadcrumb-item>
|
||||
|
||||
<a-breadcrumb-item v-for="(location, index) in path" :key="index">
|
||||
<RouterLink :to ="generateUrl(index)">
|
||||
<span :class="(index === path.length - 1) && 'last' ">{{ decodeURIComponent(location) }}</span>
|
||||
</RouterLink>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
nav, span{
|
||||
color: var(--primary-color);
|
||||
}
|
||||
span:hover, .last{
|
||||
color: var(--blue-color)
|
||||
|
||||
}
|
||||
</style>
|
||||
137
cista-front/src/components/FileCarousel.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="carousel" ref="fileCarousel">
|
||||
<a-page-header :style="{ visibility: idle ? 'hidden' : 'visible' }">
|
||||
<template #extra>
|
||||
<a-button type="text" class="action-button" :onclick="toggle" :icon="h(isFullscreen? FullscreenExitOutlined: FullscreenOutlined)" />
|
||||
<a-button type="text" class="action-button" :onclick="redirectBack" :icon="h(CloseOutlined)" />
|
||||
</template>
|
||||
</a-page-header>
|
||||
<a-row class="slider">
|
||||
<a-col :span="2" class="centered-vertically">
|
||||
<div class="custom-slick-arrow slick-arrow slick-prev centered" @click="next(-1)" style="left: 10px; z-index: 1">
|
||||
<LeftOutlined v-show="!idle" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="20" class="centered">
|
||||
<FileViewer v-if="!documentStore.loading" :visibleImg="visible" @visibleImg="setVisible" :type="fileType"></FileViewer>
|
||||
</a-col>
|
||||
<a-col :span="2" class="centered-vertically right">
|
||||
<div class="custom-slick-arrow slick-arrow slick-prev centered" @click="next(1)" style="right: 10px">
|
||||
<RightOutlined v-show="!idle" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { h, ref, watchEffect } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { useIdle, useFullscreen } from '@vueuse/core'
|
||||
import { LeftOutlined, RightOutlined, FullscreenOutlined, FullscreenExitOutlined, CloseOutlined } from '@ant-design/icons-vue';
|
||||
import { getFileType } from '@/utils'
|
||||
import Router from '@/router/index';
|
||||
import FileViewer from './FileViewer.vue';
|
||||
|
||||
const fileCarousel = ref<HTMLElement | null>(null)
|
||||
const { isFullscreen, toggle } = useFullscreen(fileCarousel)
|
||||
const visible = ref<boolean>(false);
|
||||
const documentStore = useDocumentStore()
|
||||
const fileType = ref<string| undefined>(undefined);
|
||||
watchEffect(() => {
|
||||
if(documentStore.mainDocument[0] && documentStore.mainDocument[0].type === 'file'){
|
||||
const fileExt = documentStore.mainDocument[0].ext
|
||||
fileType.value = getFileType(fileExt)
|
||||
}
|
||||
})
|
||||
function setVisible(value: boolean) {
|
||||
visible.value = value;
|
||||
}
|
||||
|
||||
const { idle } = useIdle(2000)
|
||||
|
||||
function redirectBack() {
|
||||
const currentPath = Router.currentRoute.value.path;
|
||||
const pathParts = currentPath.split('/').filter(part => part); // Remove empty parts
|
||||
// Ensure there's always at least one part in the path
|
||||
if (pathParts.length <= 1) {
|
||||
// If it's empty, set it to the base URL
|
||||
pathParts[0] = '/';
|
||||
} else {
|
||||
pathParts.pop();
|
||||
}
|
||||
const newPath = pathParts.join('/');
|
||||
Router.push(newPath);
|
||||
}
|
||||
function next(direction: number){
|
||||
const path = decodeURIComponent(new String(Router.currentRoute.value.path) as string)
|
||||
const name = documentStore.getNextDocumentInRoute(direction, path)
|
||||
let nextFileLocation : string[] | string = path.split('/')
|
||||
nextFileLocation.pop()
|
||||
nextFileLocation.push(name)
|
||||
nextFileLocation = nextFileLocation.join('/')
|
||||
Router.push(nextFileLocation)
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
:deep(.slick-arrow.custom-slick-arrow) {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 60px;
|
||||
color: var(--primary-color);
|
||||
transition: ease-in all 0.3s;
|
||||
opacity: 0.3;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:deep(.slick-arrow.custom-slick-arrow:before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.slick-arrow.custom-slick-arrow:hover) {
|
||||
color: var(--primary-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slider {
|
||||
height: 80vh;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.centered-vertically {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 0;
|
||||
font-size: 1.5em;
|
||||
opacity: 0.5;
|
||||
color: var(--secondary-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--blue-color);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-page-header {
|
||||
padding: 0;
|
||||
}
|
||||
.carousel{
|
||||
margin: 0;
|
||||
height: inherit;
|
||||
background-color: var(--table-background);
|
||||
}
|
||||
</style>
|
||||
208
cista-front/src/components/FileExplorer.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<main>
|
||||
<context-holder />
|
||||
<!-- <h2 v-if="!documentStore.loading && documentStore.error"> {{ documentStore.error }} </h2> -->
|
||||
<div class="carousel-container" v-if="!documentStore.loading && documentStore.mainDocument[0] && documentStore.mainDocument[0].type === 'file'">
|
||||
<FileCarousel></FileCarousel>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
v-else-if="!documentStore.loading && documentStore.mainDocument"
|
||||
:pagination=false
|
||||
:row-selection="{ selectedRowKeys: state.selectedRowKeys, onChange: onSelectChange }"
|
||||
:columns="columns"
|
||||
:data-source="documentStore.mainDocument"
|
||||
>
|
||||
<template #headerCell="{column}"></template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="editable-cell" :class="record.type === 'folder' ? 'folder' : 'file'">
|
||||
<div v-if="editableData[record.key]" class="action-container editable-cell-input-wrapper">
|
||||
<a-input class="name" v-model:value="editableData[record.key].name" @pressEnter="save(record.key)" />
|
||||
<CheckOutlined class="edit-action editable-cell-icon-check" @click="save(record.key)" />
|
||||
</div>
|
||||
<div v-else class="action-container editable-cell-text-wrapper">
|
||||
<a v-if="record.type === 'folder'" class="name" :href="`#${linkBasePath}/${record.name}`">{{record.name}}</a>
|
||||
<a v-else class="name" :href="`${filesBasePath}/${record.name}`">{{record.name}}</a>
|
||||
<edit-outlined class="edit-action editable-cell-icon" @click="edit(record.key)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-popover trigger="click">
|
||||
<template #content>
|
||||
<div class="more-action">
|
||||
<div class="action-container">
|
||||
<a :href="`${record.type === 'folder'? linkBasePath+'#' : filesBasePath}/${record.name}`">
|
||||
<a-button type="text" class="action-button" :icon="h(ImportOutlined)"/>
|
||||
</a>
|
||||
Open
|
||||
</div>
|
||||
<div v-if="record.type === 'folder-file'" class="action-container">
|
||||
<a :href="`${filesBasePath}/${record.name}`" download>
|
||||
<a-button type="text" class="action-button" :icon="h(DownloadOutlined)"/>
|
||||
</a>
|
||||
Download
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="text" class="action-button" @click="edit(record.key)" :icon="h(EditOutlined)"/> Rename
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button
|
||||
type="text"
|
||||
class="action-button"
|
||||
@click="share(`${record.type === 'folder'? linkBasePath+'/#' : filesBasePath}/${record.name}`)"
|
||||
:icon="h(LinkOutlined)"
|
||||
/> Share
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="text" class="action-button" :icon="h(CopyOutlined)"/> Copy
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="text" class="action-button" :icon="h(ScissorOutlined)"/> Cut
|
||||
</div>
|
||||
<div class="action-container">
|
||||
<a-button type="text" class="action-button" :icon="h(DeleteOutlined)"/> Delete
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-button type="text" class="action-button" :icon="h(EllipsisOutlined)" />
|
||||
</a-popover>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
</a-table>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, h, computed, reactive, watchEffect } from 'vue'
|
||||
import type { UnwrapRef } from 'vue'
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { EditOutlined, ImportOutlined, CheckOutlined,CopyOutlined, ScissorOutlined, LinkOutlined, DownloadOutlined, DeleteOutlined, EllipsisOutlined } from '@ant-design/icons-vue'
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import Router from '@/router/index';
|
||||
import { message } from 'ant-design-vue';
|
||||
import type { Document, FolderDocument } from '@/repositories/Document';
|
||||
import FileCarousel from './FileCarousel.vue';
|
||||
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
type Key = string | number;
|
||||
const documentStore = useDocumentStore()
|
||||
const editableData: UnwrapRef<Record<string, Document>> = reactive({});
|
||||
const state = reactive<{
|
||||
selectedRowKeys: Key[];
|
||||
}>({
|
||||
selectedRowKeys: [],
|
||||
});
|
||||
|
||||
const linkBasePath = computed(()=>{
|
||||
const path = Router.currentRoute.value.path
|
||||
return path === '/' ? '' : path
|
||||
})
|
||||
const filesBasePath = computed(() => `/files${linkBasePath.value}`)
|
||||
|
||||
const columns = ref<TableColumnsType>([
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
width: '70%',
|
||||
key: 'name',
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
sorter: (a: Document, b: Document) => a.name.localeCompare(b.name),
|
||||
},
|
||||
{
|
||||
title: 'Modified',
|
||||
dataIndex: 'modified',
|
||||
className: 'column-date',
|
||||
responsive: ['lg'],
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
defaultSortOrder: 'descend',
|
||||
sorter: (a: FolderDocument, b: FolderDocument) => a.mtime - b.mtime,
|
||||
key: 'modified',
|
||||
},
|
||||
{
|
||||
// TODO BETTER SORT FOR MULTPLE SIZE OR CUSTOM PIPE TO kB to MB / GB
|
||||
title: 'Size',
|
||||
dataIndex: 'sizedisp',
|
||||
className: 'column-size',
|
||||
responsive: ['lg'],
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
sorter: (a: FolderDocument, b: FolderDocument) => a.size - b.size,
|
||||
key: 'size',
|
||||
},
|
||||
{
|
||||
width: '5%',
|
||||
key: 'action',
|
||||
},
|
||||
])
|
||||
|
||||
const onSelectChange = (selectedRowKeys: Key[]) => {
|
||||
const newSelectedRowKeys: Document[] = []
|
||||
selectedRowKeys.forEach( key => {
|
||||
if(documentStore.mainDocument){
|
||||
const found = documentStore.mainDocument.find( e=> e.key === key )
|
||||
if(found) newSelectedRowKeys.push(found)
|
||||
}
|
||||
})
|
||||
documentStore.setSelectedDocuments(newSelectedRowKeys)
|
||||
state.selectedRowKeys = selectedRowKeys;
|
||||
};
|
||||
const edit = (key: number) => {
|
||||
editableData[key] = cloneDeep(documentStore.mainDocument.filter(item => key === item.key)[0]);
|
||||
};
|
||||
const save = (key: number) => {
|
||||
Object.assign(documentStore.mainDocument.filter(item => key === item.key)[0], editableData[key]);
|
||||
delete editableData[key];
|
||||
};
|
||||
const share = async (url : string) => {
|
||||
await navigator.clipboard.writeText(location.origin + url)
|
||||
messageApi.success("Link successfully copied to the clipboard");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.column-date, .column-size {
|
||||
text-align: right;
|
||||
}
|
||||
main {
|
||||
padding: 5px;
|
||||
height: 100%;
|
||||
}
|
||||
.more-action{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
}
|
||||
.action-container{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.name{
|
||||
max-width: 70%;
|
||||
}
|
||||
.edit-action{
|
||||
min-width: 5%;
|
||||
}
|
||||
.carousel-container{
|
||||
height: inherit;
|
||||
}
|
||||
.file .name::before {
|
||||
content: '📄 ';
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.folder .name::before {
|
||||
content: '📁 ';
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.editable-cell-text-wrapper .editable-cell-icon {
|
||||
visibility: hidden; /* Oculta el ícono de manera predeterminada */
|
||||
}
|
||||
|
||||
.editable-cell-text-wrapper:hover .editable-cell-icon {
|
||||
visibility: visible; /* Muestra el ícono al hacer hover en el contenedor */
|
||||
}
|
||||
</style>
|
||||
55
cista-front/src/components/FileViewer.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<object
|
||||
v-if="props.type === 'pdf'"
|
||||
:data= "dataURL"
|
||||
type="application/pdf" width="100%"
|
||||
height="100%"
|
||||
>
|
||||
</object>
|
||||
<a-image
|
||||
v-else-if="props.type === 'image'"
|
||||
width="50%"
|
||||
:src="dataURL"
|
||||
@click="() => setVisible(true)"
|
||||
:previewMask=false
|
||||
:preview="{
|
||||
visibleImg,
|
||||
onVisibleChange: setVisible,
|
||||
}"
|
||||
/>
|
||||
<!-- Unknown case -->
|
||||
<h1 v-else>
|
||||
Unsupported file type
|
||||
</h1>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect, ref } from 'vue'
|
||||
import Router from '@/router/index';
|
||||
import { url_document_get } from '@/repositories/Document';
|
||||
|
||||
const dataURL = ref('')
|
||||
watchEffect(()=>{
|
||||
dataURL.value = new URL(
|
||||
url_document_get + Router.currentRoute.value.path,
|
||||
location.origin
|
||||
).toString();
|
||||
})
|
||||
const emit = defineEmits({
|
||||
visibleImg(value: boolean){
|
||||
return value
|
||||
}
|
||||
})
|
||||
|
||||
function setVisible(value: boolean) {
|
||||
emit('visibleImg', value)
|
||||
}
|
||||
|
||||
|
||||
const props = defineProps < {
|
||||
type?: string
|
||||
visibleImg: boolean
|
||||
} > ()
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
160
cista-front/src/components/HeaderMain.vue
Normal file
@@ -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>
|
||||
90
cista-front/src/components/LoginModal.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<a-tooltip title="Login">
|
||||
<template v-if="DocumentStore.isUserLogged">
|
||||
<a-button @click="logout" type="text" class="action-button" :icon="h(UserDeleteOutlined)" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button @click="showModal" type="text" class="action-button" :icon="h(UserOutlined)" />
|
||||
</template>
|
||||
</a-tooltip>
|
||||
<a-modal v-model:open="DocumentStore.user.isOpenLoginModal" :confirm-loading="confirmLoading" okText="Login" @ok="login">
|
||||
<div class="login-container">
|
||||
<a-form :model="loginForm">
|
||||
<a-form-item label="Username" prop="username" :rules="[{ required: true, message: 'Please input your username!' }]">
|
||||
<a-input v-model:value="loginForm.username" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Password" prop="password" :rules="[{ required: true, message: 'Please input your password!' }]">
|
||||
<a-input type="password" v-model:value="loginForm.password" />
|
||||
</a-form-item>
|
||||
<h3 v-if="loginForm.error.length > 0" class="error-text">{{loginForm.error}}</h3>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, h } from 'vue';
|
||||
import { UserOutlined, UserDeleteOutlined } from '@ant-design/icons-vue';
|
||||
import { useDocumentStore } from '@/stores/documents';
|
||||
import { loginUser, logoutUser } from '@/repositories/User';
|
||||
import type { ISimpleError } from '@/repositories/Client';
|
||||
|
||||
const DocumentStore = useDocumentStore();
|
||||
const confirmLoading = ref<boolean>(false);
|
||||
|
||||
const showModal = () => {
|
||||
DocumentStore.user.isOpenLoginModal = true;
|
||||
};
|
||||
const logout = async () => {
|
||||
try {
|
||||
await logoutUser();
|
||||
} catch (error) {} finally {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
error: '',
|
||||
});
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
loginForm.value.error = '';
|
||||
confirmLoading.value = true;
|
||||
const user = await loginUser(loginForm.value.username, loginForm.value.password);
|
||||
if(user){
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
const httpError = error as ISimpleError
|
||||
if(httpError.name){
|
||||
loginForm.value.error = httpError.message
|
||||
}
|
||||
}finally{
|
||||
confirmLoading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 30vh;
|
||||
}
|
||||
.button-login {
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--secondary-background);
|
||||
}
|
||||
.ant-btn-primary:not(:disabled):hover{
|
||||
background-color: var(--blue-color);
|
||||
|
||||
}
|
||||
.error-text {
|
||||
color :var(--red-color)
|
||||
}
|
||||
</style>
|
||||
|
||||
28
cista-front/src/components/NotificationLoading.vue
Normal file
@@ -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>
|
||||
92
cista-front/src/components/UploadButton.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import NotificationLoading from '@/components/NotificationLoading.vue'
|
||||
import { FileAddFilled } from '@ant-design/icons-vue'
|
||||
import { notification } from 'ant-design-vue';
|
||||
import type { NotificationPlacement } from 'ant-design-vue';
|
||||
import { h, ref } from 'vue';
|
||||
|
||||
const [api, contextHolder] = notification.useNotification();
|
||||
|
||||
const fileUploadButton = ref()
|
||||
const documentStore = useDocumentStore();
|
||||
const open = (placement: NotificationPlacement) => openNotification(placement);
|
||||
|
||||
const isNotificationOpen = ref(false);
|
||||
const openNotification = (placement: NotificationPlacement) => {
|
||||
if(!isNotificationOpen.value){
|
||||
api.open({
|
||||
message: `Uploading documents`,
|
||||
description: h(NotificationLoading),
|
||||
placement,
|
||||
duration: 0,
|
||||
onClose: () => { isNotificationOpen.value = false }
|
||||
});
|
||||
isNotificationOpen.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function uploadFileHandler() {
|
||||
fileUploadButton.value.click()
|
||||
}
|
||||
|
||||
async function load(file: File, start: number, end: number): Promise<ArrayBuffer> {
|
||||
const reader = new FileReader();
|
||||
const load = new Promise<Event>((resolve) => (reader.onload = resolve));
|
||||
reader.readAsArrayBuffer(file.slice(start, end));
|
||||
const event = await load;
|
||||
if (event.target && event.target instanceof FileReader) {
|
||||
return event.target.result as ArrayBuffer;
|
||||
} else {
|
||||
throw new Error('Error loading file' );
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChunk(file :File, start: number, end: number) {
|
||||
const ws = documentStore.wsUpload;
|
||||
if(ws){
|
||||
const chunk = await load(file, start, end)
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
start: start,
|
||||
end: end
|
||||
}))
|
||||
ws.send(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFileChangeHandler(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const chunkSize = 1 << 20
|
||||
if (target && target.files && target.files.length > 0) {
|
||||
const file = target.files[0];
|
||||
const numChunks = Math.ceil(file.size / chunkSize)
|
||||
const document = documentStore.pushUploadingDocuments(file.name)
|
||||
open('bottomRight')
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
const start = i * chunkSize
|
||||
const end = Math.min(file.size, start + chunkSize)
|
||||
const res = await sendChunk(file, start, end)
|
||||
console.log( 'progress: '+ ( ( 100 * (i + 1) ) / numChunks) )
|
||||
console.log( 'Num Chunks: '+ numChunks )
|
||||
documentStore.updateUploadingDocuments( document.key, ((100 * (i + 1) ) / numChunks))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<a-tooltip title="Upload files from disk">
|
||||
<a-button @click="uploadFileHandler" type="text" class="action-button" :icon="h(FileAddFilled)" />
|
||||
<input ref="fileUploadButton" @change="uploadFileChangeHandler" class="upload-input" type="file" onclick="this.value=null;" />
|
||||
</a-tooltip>
|
||||
<contextHolder />
|
||||
</template>
|
||||
<style scoped>
|
||||
/* Extends styles from HeaderMain.vue too */
|
||||
.upload-input{
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
21
cista-front/src/main.ts
Normal file
@@ -0,0 +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')
|
||||
42
cista-front/src/repositories/Client.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import axios, {AxiosError} from 'axios'
|
||||
|
||||
/* Base domain for all request */
|
||||
export const baseURL = import.meta.env.VITE_URL_DOCUMENT
|
||||
|
||||
/* Config Client*/
|
||||
const Client = axios.create({
|
||||
baseURL: baseURL,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
Client.interceptors.response.use(
|
||||
(response) => {
|
||||
// Any status code that lie within the range of 2xx cause this function to trigger
|
||||
// Do something with response data
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<any>) => {
|
||||
const msg = error.response && error.response.data && error.response.data.error ?
|
||||
error.response.data.error.message : 'Unexpected error'
|
||||
const code = error.code ? Number(error.response?.status) : 500
|
||||
const standardizedError = new SimpleError(code, msg)
|
||||
|
||||
return Promise.reject(standardizedError)
|
||||
}
|
||||
)
|
||||
|
||||
export interface ISimpleError extends Error {
|
||||
code : number
|
||||
}
|
||||
|
||||
class SimpleError extends Error implements ISimpleError {
|
||||
code : number
|
||||
constructor(code: number, message:string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export default Client
|
||||
114
cista-front/src/repositories/Document.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { FileStructure, DocumentStore } from '@/stores/documents'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import { getFileExtension } from '@/utils'
|
||||
import Client from '@/repositories/Client'
|
||||
|
||||
|
||||
type BaseDocument = {
|
||||
name: string;
|
||||
key?: number | string;
|
||||
};
|
||||
|
||||
export type FolderDocument = BaseDocument & {
|
||||
type: 'folder' | 'folder-file';
|
||||
size: number;
|
||||
sizedisp: string;
|
||||
mtime: number;
|
||||
modified: string;
|
||||
};
|
||||
|
||||
export type FileDocument = BaseDocument & {
|
||||
type: 'file';
|
||||
ext: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type errorEvent = {
|
||||
error: {
|
||||
code : number;
|
||||
message: string;
|
||||
redirect: string;
|
||||
}
|
||||
};
|
||||
|
||||
export type Document = FolderDocument | FileDocument;
|
||||
|
||||
|
||||
export const url_document_watch_ws = '/api/watch'
|
||||
export const url_document_upload_ws = '/api/upload'
|
||||
export const url_document_get ='/files'
|
||||
|
||||
export class DocumentHandler {
|
||||
constructor( private store: DocumentStore = useDocumentStore() ) {
|
||||
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this);
|
||||
}
|
||||
|
||||
handleWebSocketMessage(event: MessageEvent) {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (true) {
|
||||
case !!msg.root:
|
||||
this.handleRootMessage(msg);
|
||||
break;
|
||||
case !!msg.update:
|
||||
this.handleUpdateMessage(msg);
|
||||
break;
|
||||
case !!msg.error:
|
||||
this.handleError(msg);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
private handleRootMessage({ root }: { root: FileStructure }) {
|
||||
if (this.store && this.store.root) {
|
||||
this.store.user.isLoggedIn = true;
|
||||
this.store.root = root;
|
||||
}
|
||||
}
|
||||
|
||||
private handleUpdateMessage(updateData: { update: FileStructure[] }) {
|
||||
const root = updateData.update[0]
|
||||
if(root) {
|
||||
this.store.user.isLoggedIn = true;
|
||||
this.store.root = root
|
||||
}
|
||||
}
|
||||
private handleError(msg: errorEvent){
|
||||
if(msg.error.code === 401){
|
||||
this.store.user.isOpenLoginModal = true;
|
||||
this.store.user.isLoggedIn = false;
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class DocumentUploadHandler {
|
||||
constructor( private store: DocumentStore = useDocumentStore() ) {
|
||||
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this);
|
||||
}
|
||||
|
||||
handleWebSocketMessage(event: MessageEvent) {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (true) {
|
||||
case !!msg.written:
|
||||
this.handleWrittenMessage(msg);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
private handleWrittenMessage(msg : { written : number}) {
|
||||
// if (this.store && this.store.root) this.store.root = root;
|
||||
console.log('Written message', msg.written)
|
||||
}
|
||||
}
|
||||
export async function fetchFile(path: string): Promise<FileDocument>{
|
||||
const file = await Client.get(url_document_get + path)
|
||||
const name = path.substring(1 , path.length)
|
||||
return {
|
||||
name,
|
||||
data: file.data,
|
||||
type: 'file',
|
||||
ext: getFileExtension(name)
|
||||
}
|
||||
}
|
||||
23
cista-front/src/repositories/User.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
|
||||
import Client from '@/repositories/Client'
|
||||
export const url_login = '/login'
|
||||
export const url_logout = '/logout '
|
||||
|
||||
export async function loginUser(username : string, password: string){
|
||||
try {
|
||||
const user = await Client.post(url_login, {
|
||||
username, password
|
||||
})
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
export async function logoutUser(){
|
||||
try {
|
||||
const data = await Client.post(url_logout)
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
8
cista-front/src/repositories/WS.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
function createWebSocket(url: string, eventHandler: (event: MessageEvent) => void) {
|
||||
const urlObject = new URL(url, location.origin.replace( /^http/, 'ws'));
|
||||
const webSocket = new WebSocket(urlObject);
|
||||
webSocket.onmessage = eventHandler;
|
||||
return webSocket;
|
||||
}
|
||||
|
||||
export default createWebSocket
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import ExplorerView from '@/views/ExplorerView.vue'
|
||||
import ExplorerView from '../views/ExplorerView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
@@ -7,8 +7,8 @@ const router = createRouter({
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'explorer',
|
||||
component: ExplorerView
|
||||
}
|
||||
component: ExplorerView,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
165
cista-front/src/stores/documents.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Document, FolderDocument } from '@/repositories/Document';
|
||||
import type { ISimpleError } from '@/repositories/Client';
|
||||
import { fetchFile } from '@/repositories/Document'
|
||||
import { formatSize, formatUnixDate } from '@/utils';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
|
||||
type FileData = { id: string, mtime: number, size: number, dir: DirectoryData};
|
||||
type DirectoryData = {
|
||||
[filename: string]: FileData;
|
||||
};
|
||||
export type FileStructure = {id: string, mtime: number, size: number, dir: DirectoryData};
|
||||
type User = {
|
||||
isOpenLoginModal: boolean,
|
||||
isLoggedIn : boolean,
|
||||
}
|
||||
|
||||
export type DocumentStore = {
|
||||
root: FileStructure,
|
||||
document: Document[],
|
||||
loading: boolean,
|
||||
uploadingDocuments: Array<{key: number, name: string, progress: number}>,
|
||||
uploadCount: number,
|
||||
wsWatch: WebSocket | undefined,
|
||||
wsUpload: WebSocket | undefined,
|
||||
selectedDocuments: Document[],
|
||||
user: User,
|
||||
error: string,
|
||||
}
|
||||
|
||||
export const useDocumentStore = defineStore({
|
||||
id: 'documents',
|
||||
state: (): DocumentStore => ({
|
||||
root: {} as FileStructure,
|
||||
document: [] as Document[],
|
||||
loading: true as boolean,
|
||||
uploadingDocuments: [],
|
||||
uploadCount: 0 as number,
|
||||
wsWatch: undefined,
|
||||
wsUpload: undefined,
|
||||
selectedDocuments: [] as Document[],
|
||||
error: '' as string,
|
||||
user: { isLoggedIn: false, isOpenLoginModal: false } as User
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setActualDocument(location: string){
|
||||
this.loading = true;
|
||||
let data = this.root
|
||||
const dataMapped = [];
|
||||
const locations = location.split('/').slice(1)
|
||||
// Get data target location
|
||||
locations.forEach(location => {
|
||||
location = decodeURIComponent(location)
|
||||
if(data && data.dir){
|
||||
for (const key in data.dir) {
|
||||
if(key === location) data = data.dir[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Transform data
|
||||
for (const [name, attr] of Object.entries(data.dir)) {
|
||||
const {id, size, mtime, dir} = attr
|
||||
const element: Document = {
|
||||
name,
|
||||
key: id,
|
||||
size,
|
||||
sizedisp: formatSize(size),
|
||||
mtime,
|
||||
modified: formatUnixDate(mtime),
|
||||
type: dir === undefined ? 'folder-file' : 'folder',
|
||||
}
|
||||
dataMapped.push(element)
|
||||
}
|
||||
// Pre sort directory entries folders first then files, names in natural ordering
|
||||
dataMapped.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "folder" ? -1 : 1)
|
||||
this.document = dataMapped
|
||||
this.loading = false;
|
||||
},
|
||||
async setActualDocumentFile(path: string){
|
||||
this.loading = true;
|
||||
const file = await fetchFile(path)
|
||||
this.document = [file];
|
||||
this.loading = false;
|
||||
},
|
||||
setSelectedDocuments(document: Document[]){
|
||||
this.selectedDocuments = document
|
||||
},
|
||||
deleteDocument(document: Document){
|
||||
this.document = this.document.filter(e => document.key !== e.key)
|
||||
this.selectedDocuments = this.selectedDocuments.filter(e => document.key !== e.key)
|
||||
},
|
||||
updateUploadingDocuments(key: number, progress: number){
|
||||
for (const d of this.uploadingDocuments) {
|
||||
if(d.key === key) d.progress = progress
|
||||
}
|
||||
},
|
||||
pushUploadingDocuments(name: string){
|
||||
this.uploadCount++;
|
||||
const document = {
|
||||
key: this.uploadCount,
|
||||
name: name,
|
||||
progress: 0
|
||||
}
|
||||
this.uploadingDocuments.push(document)
|
||||
return document
|
||||
},
|
||||
deleteUploadingDocument(key: number){
|
||||
this.uploadingDocuments = this.uploadingDocuments.filter((e)=> e.key !== key)
|
||||
},
|
||||
getNextDocumentInRoute(direction: number, path: string){
|
||||
const locations = path.split('/').slice(1)
|
||||
locations.pop()
|
||||
let data = this.root
|
||||
const actualDirArr = []
|
||||
// Get data target location
|
||||
locations.forEach(location => {
|
||||
// location = decodeURIComponent(location)
|
||||
if(data && data.dir){
|
||||
for (const key in data.dir) {
|
||||
if(key === location) data = data.dir[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
//Store in a temporary array
|
||||
for (const key in data.dir) {
|
||||
actualDirArr.push({
|
||||
name: key,
|
||||
content: data.dir[key]
|
||||
})
|
||||
}
|
||||
const actualFileName = decodeURIComponent(this.mainDocument[0].name).split('/').pop()
|
||||
let index = actualDirArr.findIndex(e => e.name === actualFileName)
|
||||
|
||||
if(index < 1 && direction === -1 ){
|
||||
index = actualDirArr.length -1
|
||||
}else if(index >= actualDirArr.length - 1 && direction === 1){
|
||||
index = 0
|
||||
}else {
|
||||
index = index + direction
|
||||
}
|
||||
return actualDirArr[index].name
|
||||
},
|
||||
updateModified() {
|
||||
for (const d of this.document) {
|
||||
if ("mtime" in d) d.modified = formatUnixDate(d.mtime)
|
||||
}
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
mainDocument(): Document[] {
|
||||
return this.document;
|
||||
},
|
||||
rootSize(): number | undefined {
|
||||
if(this.root) return this.root.size
|
||||
},
|
||||
rootMain(): DirectoryData | undefined {
|
||||
if(this.root) return this.root.dir
|
||||
},
|
||||
isUserLogged(): boolean{
|
||||
return this.user.isLoggedIn
|
||||
}
|
||||
},
|
||||
});
|
||||
67
cista-front/src/utils/index.ts
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
36
cista-front/src/views/ExplorerView.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<FileExplorer></FileExplorer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watchEffect } from 'vue'
|
||||
import { useDocumentStore } from '@/stores/documents'
|
||||
import Router from '@/router/index';
|
||||
import FileExplorer from '@/components/FileExplorer.vue';
|
||||
|
||||
const documentStore = useDocumentStore()
|
||||
|
||||
function isFile(path: string) {
|
||||
if (path.includes('.') && !path.endsWith('.')) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(async () => {
|
||||
const path = new String(Router.currentRoute.value.path) as string
|
||||
const file = isFile(path)
|
||||
if(!file){
|
||||
documentStore.setActualDocument(path.toString())
|
||||
}else {
|
||||
documentStore.setActualDocumentFile(path)
|
||||
}
|
||||
setTimeout( () => {
|
||||
documentStore.loading = false
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -3,8 +3,6 @@
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["es2021", "DOM"],
|
||||
"target": "es2021",
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
@@ -3,6 +3,7 @@
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
41
cista-front/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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))
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: "../cista/wwwroot",
|
||||
emptyOutDir: true,
|
||||
}
|
||||
})
|
||||
0
cista/__init__.py
Normal file → Executable file
9
cista/__main__.py
Normal file → Executable file
@@ -67,14 +67,14 @@ def _main():
|
||||
# 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",
|
||||
"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}",
|
||||
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}"
|
||||
)
|
||||
settings = droppy.readconf()
|
||||
if path:
|
||||
@@ -95,7 +95,6 @@ def _main():
|
||||
print(f"Serving {config.config.path} at {url}{extra}")
|
||||
# Run the server
|
||||
serve.run(dev=dev)
|
||||
return 0
|
||||
|
||||
|
||||
def _confdir(args):
|
||||
@@ -105,9 +104,9 @@ def _confdir(args):
|
||||
if confdir.exists() and not confdir.is_dir():
|
||||
if confdir.name != config.conffile.name:
|
||||
raise ValueError("Config path is not a directory")
|
||||
# Accidentally pointed to the db.toml, use parent
|
||||
# Accidentally pointed to the cista.toml, use parent
|
||||
confdir = confdir.parent
|
||||
config.conffile = confdir / config.conffile.name
|
||||
config.conffile = config.conffile.with_parent(confdir)
|
||||
|
||||
|
||||
def _user(args):
|
||||
|
||||
58
cista/api.py
@@ -1,13 +1,12 @@
|
||||
import asyncio
|
||||
import typing
|
||||
from secrets import token_bytes
|
||||
|
||||
import msgspec
|
||||
from sanic import Blueprint
|
||||
|
||||
from cista import __version__, config, watching
|
||||
from cista import watching
|
||||
from cista.fileio import FileServer
|
||||
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
||||
from cista.protocol import ControlBase, FileRange, StatusMsg
|
||||
from cista.util.apphelpers import asend, websocket_wrapper
|
||||
|
||||
bp = Blueprint("api", url_prefix="/api")
|
||||
@@ -33,28 +32,21 @@ async def upload(req, ws):
|
||||
text = await ws.recv()
|
||||
if not isinstance(text, str):
|
||||
raise ValueError(
|
||||
f"Expected JSON control, got binary len(data) = {len(text)}",
|
||||
f"Expected JSON control, got binary len(data) = {len(text)}"
|
||||
)
|
||||
req = msgspec.json.decode(text, type=FileRange)
|
||||
pos = req.start
|
||||
while True:
|
||||
data = await ws.recv()
|
||||
if not isinstance(data, bytes):
|
||||
break
|
||||
if len(data) > req.end - pos:
|
||||
raise ValueError(
|
||||
f"Expected up to {req.end - pos} bytes, got {len(data)} bytes"
|
||||
)
|
||||
data = None
|
||||
while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes):
|
||||
sentsize = await alink(("upload", req.name, pos, data, req.size))
|
||||
pos += typing.cast(int, sentsize)
|
||||
if pos >= req.end:
|
||||
break
|
||||
if pos != req.end:
|
||||
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
||||
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
||||
# Report success
|
||||
res = StatusMsg(status="ack", req=req)
|
||||
await asend(ws, res)
|
||||
# await ws.drain()
|
||||
|
||||
|
||||
@bp.websocket("download")
|
||||
@@ -66,7 +58,7 @@ async def download(req, ws):
|
||||
text = await ws.recv()
|
||||
if not isinstance(text, str):
|
||||
raise ValueError(
|
||||
f"Expected JSON control, got binary len(data) = {len(text)}",
|
||||
f"Expected JSON control, got binary len(data) = {len(text)}"
|
||||
)
|
||||
req = msgspec.json.decode(text, type=FileRange)
|
||||
pos = req.start
|
||||
@@ -78,46 +70,28 @@ async def download(req, ws):
|
||||
# Report success
|
||||
res = StatusMsg(status="ack", req=req)
|
||||
await asend(ws, res)
|
||||
# await ws.drain()
|
||||
|
||||
|
||||
@bp.websocket("control")
|
||||
@websocket_wrapper
|
||||
async def control(req, ws):
|
||||
while True:
|
||||
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
|
||||
await asyncio.to_thread(cmd)
|
||||
await asend(ws, StatusMsg(status="ack", req=cmd))
|
||||
cmd = msgspec.json.decode(await ws.recv(), type=ControlBase)
|
||||
await asyncio.to_thread(cmd)
|
||||
await asend(ws, StatusMsg(status="ack", req=cmd))
|
||||
|
||||
|
||||
@bp.websocket("watch")
|
||||
@websocket_wrapper
|
||||
async def watch(req, ws):
|
||||
await ws.send(
|
||||
msgspec.json.encode(
|
||||
{
|
||||
"server": {
|
||||
"name": config.config.name or config.config.path.name,
|
||||
"version": __version__,
|
||||
"public": config.config.public,
|
||||
},
|
||||
"user": {
|
||||
"username": req.ctx.username,
|
||||
"privileged": req.ctx.user.privileged,
|
||||
}
|
||||
if req.ctx.user
|
||||
else None,
|
||||
}
|
||||
).decode()
|
||||
)
|
||||
uuid = token_bytes(16)
|
||||
try:
|
||||
with watching.state.lock:
|
||||
q = watching.pubsub[uuid] = asyncio.Queue()
|
||||
with watching.tree_lock:
|
||||
q = watching.pubsub[ws] = asyncio.Queue()
|
||||
# Init with disk usage and full tree
|
||||
await ws.send(watching.format_space(watching.state.space))
|
||||
await ws.send(watching.format_root(watching.state.root))
|
||||
await ws.send(watching.format_du())
|
||||
await ws.send(watching.format_tree())
|
||||
# Send updates
|
||||
while True:
|
||||
await ws.send(await q.get())
|
||||
finally:
|
||||
del watching.pubsub[uuid]
|
||||
del watching.pubsub[ws]
|
||||
|
||||
217
cista/app.py
Normal file → Executable file
@@ -1,27 +1,15 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import mimetypes
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path, PurePath, PurePosixPath
|
||||
from stat import S_IFDIR, S_IFREG
|
||||
from importlib.resources import files
|
||||
from urllib.parse import unquote
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
import brotli
|
||||
import sanic.helpers
|
||||
from blake3 import blake3
|
||||
from sanic import Blueprint, Sanic, empty, raw
|
||||
from sanic.exceptions import Forbidden, NotFound, ServerError
|
||||
from sanic.log import logging
|
||||
from stream_zip import ZIP_AUTO, stream_zip
|
||||
from sanic import Blueprint, Sanic, raw
|
||||
from sanic.exceptions import Forbidden, NotFound
|
||||
|
||||
from cista import auth, config, session, watching
|
||||
from cista.api import bp
|
||||
from cista.util import filename
|
||||
from cista.util.apphelpers import handle_sanic_exception
|
||||
|
||||
# Workaround until Sanic PR #2824 is merged
|
||||
sanic.helpers._ENTITY_HEADERS = frozenset()
|
||||
|
||||
app = Sanic("cista", strict_slashes=True)
|
||||
app.blueprint(auth.bp)
|
||||
app.blueprint(bp)
|
||||
@@ -32,25 +20,19 @@ app.exception(Exception)(handle_sanic_exception)
|
||||
async def main_start(app, loop):
|
||||
config.load_config()
|
||||
await watching.start(app, loop)
|
||||
app.ctx.threadexec = ThreadPoolExecutor(
|
||||
max_workers=8, thread_name_prefix="cista-ioworker"
|
||||
)
|
||||
|
||||
|
||||
@app.after_server_stop
|
||||
async def main_stop(app, loop):
|
||||
await watching.stop(app, loop)
|
||||
app.ctx.threadexec.shutdown()
|
||||
|
||||
|
||||
@app.on_request
|
||||
async def use_session(req):
|
||||
req.ctx.session = session.get(req)
|
||||
try:
|
||||
req.ctx.username = req.ctx.session["username"] # type: ignore
|
||||
req.ctx.user = config.config.users[req.ctx.username]
|
||||
req.ctx.user = config.config.users[req.ctx.session["username"]] # type: ignore
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
req.ctx.username = None
|
||||
req.ctx.user = None
|
||||
# CSRF protection
|
||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||
@@ -76,188 +58,15 @@ def http_fileserver(app, _):
|
||||
app.blueprint(bp)
|
||||
|
||||
|
||||
www = {}
|
||||
|
||||
|
||||
def _load_wwwroot(www):
|
||||
wwwnew = {}
|
||||
base = Path(__file__).with_name("wwwroot")
|
||||
paths = [PurePath()]
|
||||
while paths:
|
||||
path = paths.pop(0)
|
||||
current = base / path
|
||||
for p in current.iterdir():
|
||||
if p.is_dir():
|
||||
paths.append(p.relative_to(base))
|
||||
continue
|
||||
name = p.relative_to(base).as_posix()
|
||||
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
mtime = p.stat().st_mtime
|
||||
data = p.read_bytes()
|
||||
etag = blake3(data).hexdigest(length=8)
|
||||
if name == "index.html":
|
||||
name = ""
|
||||
# Use old data if not changed
|
||||
if name in www and www[name][2]["etag"] == etag:
|
||||
wwwnew[name] = www[name]
|
||||
continue
|
||||
# Add charset definition
|
||||
if mime.startswith("text/"):
|
||||
mime = f"{mime}; charset=UTF-8"
|
||||
# Asset files names will change whenever the content changes
|
||||
cached = name.startswith("assets/")
|
||||
headers = {
|
||||
"etag": etag,
|
||||
"last-modified": format_date_time(mtime),
|
||||
"cache-control": "max-age=31536000, immutable"
|
||||
if cached
|
||||
else "no-cache",
|
||||
"content-type": mime,
|
||||
}
|
||||
# Precompress with Brotli
|
||||
br = brotli.compress(data)
|
||||
if len(br) >= len(data):
|
||||
br = False
|
||||
wwwnew[name] = data, br, headers
|
||||
if not wwwnew:
|
||||
raise ServerError(
|
||||
"Web frontend missing. Did you forget npm run build?",
|
||||
extra={"wwwroot": str(base)},
|
||||
quiet=True,
|
||||
)
|
||||
return wwwnew
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def start(app):
|
||||
await load_wwwroot(app)
|
||||
if app.debug:
|
||||
app.add_task(refresh_wwwroot())
|
||||
|
||||
|
||||
async def load_wwwroot(app):
|
||||
global www
|
||||
www = await asyncio.get_event_loop().run_in_executor(
|
||||
app.ctx.threadexec, _load_wwwroot, www
|
||||
)
|
||||
|
||||
|
||||
async def refresh_wwwroot():
|
||||
while True:
|
||||
await asyncio.sleep(0.5)
|
||||
try:
|
||||
wwwold = www
|
||||
await load_wwwroot(app)
|
||||
changes = ""
|
||||
for name in sorted(www):
|
||||
attr = www[name]
|
||||
if wwwold.get(name) == attr:
|
||||
continue
|
||||
headers = attr[2]
|
||||
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
|
||||
for name in sorted(set(wwwold) - set(www)):
|
||||
changes += f"Deleted /{name}\n"
|
||||
if changes:
|
||||
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
|
||||
except Exception as e:
|
||||
print("Error loading wwwroot", e)
|
||||
if not app.debug:
|
||||
return
|
||||
|
||||
|
||||
@app.route("/<path:path>", methods=["GET", "HEAD"])
|
||||
@app.get("/<path:path>", static=True)
|
||||
async def wwwroot(req, path=""):
|
||||
"""Frontend files only"""
|
||||
name = unquote(path)
|
||||
if name not in www:
|
||||
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
||||
data, br, headers = www[name]
|
||||
if req.headers.if_none_match == headers["etag"]:
|
||||
# The client has it cached, respond 304 Not Modified
|
||||
return empty(304, headers=headers)
|
||||
# Brotli compressed?
|
||||
if br and "br" in req.headers.accept_encoding.split(", "):
|
||||
headers = {**headers, "content-encoding": "br"}
|
||||
data = br
|
||||
return raw(data, headers=headers)
|
||||
|
||||
|
||||
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||
loc = PurePosixPath()
|
||||
idx = 0
|
||||
ret = []
|
||||
level: int | None = None
|
||||
parent: PurePosixPath | None = None
|
||||
with watching.state.lock:
|
||||
root = watching.state.root
|
||||
while idx < len(root):
|
||||
f = root[idx]
|
||||
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
|
||||
if parent is not None and f.level <= level:
|
||||
level = parent = None
|
||||
if f.key in wanted:
|
||||
level, parent = f.level, loc.parent
|
||||
if parent is not None:
|
||||
wanted.discard(f.key)
|
||||
ret.append((loc.relative_to(parent), watching.rootpath / loc))
|
||||
idx += 1
|
||||
return ret
|
||||
|
||||
|
||||
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
||||
async def zip_download(req, keys, zipfile, ext):
|
||||
"""Download a zip archive of the given keys"""
|
||||
|
||||
wanted = set(keys.split("+"))
|
||||
files = get_files(wanted)
|
||||
|
||||
if not files:
|
||||
name = filename.sanitize(unquote(path)) if path else "index.html"
|
||||
try:
|
||||
index = files("cista").joinpath("wwwroot", name).read_bytes()
|
||||
except OSError as e:
|
||||
raise NotFound(
|
||||
"No files found",
|
||||
context={"keys": keys, "zipfile": f"{zipfile}.{ext}", "wanted": wanted},
|
||||
f"File not found: /{path}", extra={"name": name, "exception": repr(e)}
|
||||
)
|
||||
if wanted:
|
||||
raise NotFound("Files not found", context={"missing": wanted})
|
||||
|
||||
def local_files(files):
|
||||
for rel, p in files:
|
||||
s = p.stat()
|
||||
size = s.st_size
|
||||
modified = datetime.datetime.fromtimestamp(s.st_mtime, datetime.UTC)
|
||||
name = rel.as_posix()
|
||||
if p.is_dir():
|
||||
yield f"{name}/", modified, S_IFDIR | 0o755, ZIP_AUTO(size), iter(b"")
|
||||
else:
|
||||
yield name, modified, S_IFREG | 0o644, ZIP_AUTO(size), contents(p, size)
|
||||
|
||||
def contents(name, size):
|
||||
with name.open("rb") as f:
|
||||
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
|
||||
size -= len(chunk)
|
||||
yield chunk
|
||||
assert size == 0
|
||||
|
||||
def worker():
|
||||
try:
|
||||
for chunk in stream_zip(local_files(files)):
|
||||
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
|
||||
except Exception:
|
||||
logging.exception("Error streaming ZIP")
|
||||
raise
|
||||
finally:
|
||||
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
||||
|
||||
# Don't block the event loop: run in a thread
|
||||
queue = asyncio.Queue(maxsize=1)
|
||||
loop = asyncio.get_event_loop()
|
||||
thread = loop.run_in_executor(app.ctx.threadexec, worker)
|
||||
|
||||
# Stream the response
|
||||
res = await req.respond(
|
||||
content_type="application/zip",
|
||||
headers={"cache-control": "no-store"},
|
||||
)
|
||||
while chunk := await queue.get():
|
||||
await res.send(chunk)
|
||||
|
||||
await thread # If it raises, the response will fail download
|
||||
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
return raw(index, content_type=mime)
|
||||
|
||||
17
cista/auth.py
Normal file → Executable file
@@ -25,7 +25,7 @@ def login(username: str, password: str):
|
||||
try:
|
||||
u = config.config.users[un.decode()]
|
||||
except KeyError:
|
||||
raise ValueError("Invalid username") from None
|
||||
raise ValueError("Invalid username")
|
||||
# Verify password
|
||||
need_rehash = False
|
||||
if not u.hash:
|
||||
@@ -41,7 +41,7 @@ def login(username: str, password: str):
|
||||
try:
|
||||
_argon.verify(u.hash, pw)
|
||||
except Exception:
|
||||
raise ValueError("Invalid password") from None
|
||||
raise ValueError("Invalid password")
|
||||
if _argon.check_needs_rehash(u.hash):
|
||||
need_rehash = True
|
||||
# Login successful
|
||||
@@ -62,16 +62,16 @@ class LoginResponse(msgspec.Struct):
|
||||
error: str = ""
|
||||
|
||||
|
||||
def verify(request, *, privileged=False):
|
||||
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
|
||||
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
|
||||
raise Forbidden("Access Forbidden: Only for privileged users")
|
||||
elif config.config.public or request.ctx.user:
|
||||
return
|
||||
raise Unauthorized("Login required", "cookie", quiet=True)
|
||||
raise Unauthorized("Login required", "cookie", context={"redirect": "/login"})
|
||||
|
||||
|
||||
bp = Blueprint("auth")
|
||||
@@ -130,14 +130,11 @@ async def login_post(request):
|
||||
if not username or not password:
|
||||
raise KeyError
|
||||
except KeyError:
|
||||
raise BadRequest(
|
||||
"Missing username or password",
|
||||
context={"redirect": "/login"},
|
||||
) from None
|
||||
raise BadRequest("Missing username or password", context={"redirect": "/login"})
|
||||
try:
|
||||
user = login(username, password)
|
||||
except ValueError as e:
|
||||
raise Forbidden(str(e), context={"redirect": "/login"}) from e
|
||||
raise Forbidden(str(e), context={"redirect": "/login"})
|
||||
|
||||
if "text/html" in request.headers.accept:
|
||||
res = redirect("/")
|
||||
|
||||
3
cista/config.py
Normal file → Executable file
@@ -14,7 +14,6 @@ class Config(msgspec.Struct):
|
||||
listen: str
|
||||
secret: str = secrets.token_hex(12)
|
||||
public: bool = False
|
||||
name: str = ""
|
||||
users: dict[str, User] = {}
|
||||
links: dict[str, Link] = {}
|
||||
|
||||
@@ -22,7 +21,7 @@ class Config(msgspec.Struct):
|
||||
class User(msgspec.Struct, omit_defaults=True):
|
||||
privileged: bool = False
|
||||
hash: str = ""
|
||||
lastSeen: int = 0 # noqa: N815
|
||||
lastSeen: int = 0
|
||||
|
||||
|
||||
class Link(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
6
cista/droppy.py
Normal file → Executable file
@@ -30,12 +30,10 @@ def _droppy_listeners(cf):
|
||||
host = listener["host"]
|
||||
if isinstance(host, list):
|
||||
host = host[0]
|
||||
except (KeyError, IndexError):
|
||||
continue
|
||||
else:
|
||||
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 "0.0.0.0:8989"
|
||||
|
||||
10
cista/fileio.py
Normal file → Executable file
@@ -34,11 +34,9 @@ class File:
|
||||
self.open_rw()
|
||||
assert self.fd is not None
|
||||
if file_size is not None:
|
||||
assert pos + len(buffer) <= file_size
|
||||
os.ftruncate(self.fd, file_size)
|
||||
if buffer:
|
||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||
os.write(self.fd, buffer)
|
||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||
os.write(self.fd, buffer)
|
||||
|
||||
def __getitem__(self, slice):
|
||||
if self.fd is None:
|
||||
@@ -64,9 +62,7 @@ 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,
|
||||
None, self.worker_thread, self.alink.to_sync
|
||||
)
|
||||
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
||||
|
||||
|
||||
86
cista/protocol.py
Normal file → Executable file
@@ -22,7 +22,7 @@ class MkDir(ControlBase):
|
||||
|
||||
def __call__(self):
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
path.mkdir(parents=True, exist_ok=False)
|
||||
path.mkdir(parents=False, exist_ok=False)
|
||||
|
||||
|
||||
class Rename(ControlBase):
|
||||
@@ -44,10 +44,7 @@ class Rm(ControlBase):
|
||||
root = config.config.path
|
||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||
for p in sel:
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p)
|
||||
else:
|
||||
p.unlink()
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
|
||||
|
||||
class Mv(ControlBase):
|
||||
@@ -75,19 +72,10 @@ class Cp(ControlBase):
|
||||
if not dst.is_dir():
|
||||
raise BadRequest("The destination must be a directory")
|
||||
for p in sel:
|
||||
if p.is_dir():
|
||||
# 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,
|
||||
)
|
||||
else:
|
||||
shutil.copy2(p, dst)
|
||||
|
||||
|
||||
ControlTypes = MkDir | Rename | Rm | Mv | Cp
|
||||
# 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
|
||||
)
|
||||
|
||||
|
||||
## File uploads and downloads
|
||||
@@ -112,43 +100,47 @@ class ErrorMsg(msgspec.Struct):
|
||||
## Directory listings
|
||||
|
||||
|
||||
class FileEntry(msgspec.Struct, array_like=True):
|
||||
level: int
|
||||
name: str
|
||||
key: str
|
||||
mtime: int
|
||||
class FileEntry(msgspec.Struct):
|
||||
id: str
|
||||
size: int
|
||||
isfile: int
|
||||
|
||||
def __repr__(self):
|
||||
return self.key or "FileEntry()"
|
||||
mtime: int
|
||||
|
||||
|
||||
class Update(msgspec.Struct, array_like=True):
|
||||
...
|
||||
class DirEntry(msgspec.Struct):
|
||||
id: str
|
||||
size: int
|
||||
mtime: int
|
||||
dir: DirList
|
||||
|
||||
def __getitem__(self, name):
|
||||
return self.dir[name]
|
||||
|
||||
def __setitem__(self, name, value):
|
||||
self.dir[name] = value
|
||||
|
||||
def __contains__(self, name):
|
||||
return name in self.dir
|
||||
|
||||
def __delitem__(self, name):
|
||||
del self.dir[name]
|
||||
|
||||
@property
|
||||
def props(self):
|
||||
return {k: v for k, v in self.__struct_fields__ if k != "dir"}
|
||||
|
||||
|
||||
class UpdKeep(Update, tag="k"):
|
||||
count: int
|
||||
DirList = dict[str, FileEntry | DirEntry]
|
||||
|
||||
|
||||
class UpdDel(Update, tag="d"):
|
||||
count: int
|
||||
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."""
|
||||
|
||||
|
||||
class UpdIns(Update, tag="i"):
|
||||
items: list[FileEntry]
|
||||
|
||||
|
||||
class UpdateMessage(msgspec.Struct):
|
||||
update: list[UpdKeep | UpdDel | UpdIns]
|
||||
|
||||
|
||||
class Space(msgspec.Struct):
|
||||
disk: int
|
||||
free: int
|
||||
usage: int
|
||||
storage: int
|
||||
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):
|
||||
|
||||
31
cista/serve.py
Normal file → Executable file
@@ -1,13 +1,13 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePath
|
||||
|
||||
from sanic import Sanic
|
||||
|
||||
from cista import config, server80
|
||||
|
||||
|
||||
def run(*, dev=False):
|
||||
def run(dev=False):
|
||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||
from .app import app
|
||||
|
||||
@@ -15,6 +15,7 @@ def run(*, dev=False):
|
||||
# Silence Sanic's warning about running in production rather than debug
|
||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||
confdir = config.conffile.parent
|
||||
wwwroot = PurePath(__file__).parent / "wwwroot"
|
||||
if opts.get("ssl"):
|
||||
# Run plain HTTP redirect/acme server on port 80
|
||||
server80.app.prepare(port=80, motd=False)
|
||||
@@ -26,13 +27,10 @@ def run(*, dev=False):
|
||||
motd=False,
|
||||
dev=dev,
|
||||
auto_reload=dev,
|
||||
reload_dir={confdir},
|
||||
reload_dir={confdir, wwwroot},
|
||||
access_log=True,
|
||||
) # type: ignore
|
||||
if dev:
|
||||
Sanic.serve()
|
||||
else:
|
||||
Sanic.serve_single()
|
||||
Sanic.serve()
|
||||
|
||||
|
||||
def check_cert(certdir, domain):
|
||||
@@ -40,7 +38,7 @@ def check_cert(certdir, domain):
|
||||
return
|
||||
# TODO: Use certbot to fetch a cert
|
||||
raise ValueError(
|
||||
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
|
||||
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}"
|
||||
)
|
||||
|
||||
|
||||
@@ -49,14 +47,15 @@ def parse_listen(listen):
|
||||
unix = Path(listen).resolve()
|
||||
if not unix.parent.exists():
|
||||
raise ValueError(
|
||||
f"Directory for unix socket does not exist: {unix.parent}/",
|
||||
f"Directory for unix socket does not exist: {unix.parent}/"
|
||||
)
|
||||
return "http://localhost", {"unix": unix}
|
||||
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
|
||||
elif re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
|
||||
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
|
||||
try:
|
||||
addr, _port = listen.split(":", 1)
|
||||
port = int(_port)
|
||||
except Exception:
|
||||
raise ValueError(f"Invalid listen address: {listen}") from None
|
||||
return f"http://localhost:{port}", {"host": addr, "port": port}
|
||||
else:
|
||||
try:
|
||||
addr, _port = listen.split(":", 1)
|
||||
port = int(_port)
|
||||
except Exception:
|
||||
raise ValueError(f"Invalid listen address: {listen}")
|
||||
return f"http://localhost:{port}", {"host": addr, "port": port}
|
||||
|
||||
0
cista/session.py
Normal file → Executable file
@@ -33,8 +33,7 @@ async def handle_sanic_exception(request, e):
|
||||
# Non-browsers get JSON errors
|
||||
if "text/html" not in request.headers.accept:
|
||||
return jres(
|
||||
ErrorMsg({"code": code, "message": message, **context}),
|
||||
status=code,
|
||||
ErrorMsg({"code": code, "message": message, **context}), status=code
|
||||
)
|
||||
# Redirections flash the error message via cookies
|
||||
if "redirect" in context:
|
||||
|
||||
3
cista/util/asynclink.py
Normal file → Executable file
@@ -80,9 +80,8 @@ class SyncRequest:
|
||||
if exc:
|
||||
self.set_exception(exc)
|
||||
return True
|
||||
if not self.done:
|
||||
elif not self.done:
|
||||
self.set_result(None)
|
||||
return None
|
||||
|
||||
def set_result(self, value):
|
||||
"""Set result value; mark as done."""
|
||||
|
||||
@@ -10,7 +10,4 @@ def sanitize(filename: str) -> str:
|
||||
filename = filename.replace("\\", "-")
|
||||
filename = sanitize_filepath(filename)
|
||||
filename = filename.strip("/")
|
||||
p = PurePosixPath(filename)
|
||||
if any(n.startswith(".") for n in p.parts):
|
||||
raise ValueError("Filenames starting with dot are not allowed")
|
||||
return p.as_posix()
|
||||
return PurePosixPath(filename).as_posix()
|
||||
|
||||
2
cista/util/lrucache.py
Normal file → Executable file
@@ -41,7 +41,7 @@ class LRUCache:
|
||||
The corresponding item's handle.
|
||||
"""
|
||||
# Take from cache or open a new one
|
||||
for i, (k, f, _ts) in enumerate(self.cache): # noqa: B007
|
||||
for i, (k, f, ts) in enumerate(self.cache):
|
||||
if k == key:
|
||||
self.cache.pop(i)
|
||||
break
|
||||
|
||||
393
cista/watching.py
Normal file → Executable file
@@ -1,137 +1,19 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from os import stat_result
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import inotify.adapters
|
||||
import msgspec
|
||||
from natsort import humansorted, natsort_keygen, ns
|
||||
from sanic.log import logging
|
||||
|
||||
from cista import config
|
||||
from cista.fileio import fuid
|
||||
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
||||
from cista.protocol import DirEntry, FileEntry, UpdateEntry
|
||||
|
||||
pubsub = {}
|
||||
sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self._space = Space(0, 0, 0, 0)
|
||||
self._listing: list[FileEntry] = []
|
||||
|
||||
@property
|
||||
def space(self):
|
||||
with self.lock:
|
||||
return self._space
|
||||
|
||||
@space.setter
|
||||
def space(self, space):
|
||||
with self.lock:
|
||||
self._space = space
|
||||
|
||||
@property
|
||||
def root(self) -> list[FileEntry]:
|
||||
with self.lock:
|
||||
return self._listing[:]
|
||||
|
||||
@root.setter
|
||||
def root(self, listing: list[FileEntry]):
|
||||
with self.lock:
|
||||
self._listing = listing
|
||||
|
||||
def _slice(self, idx: PurePosixPath | tuple[PurePosixPath, int]):
|
||||
relpath, relfile = idx if isinstance(idx, tuple) else (idx, 0)
|
||||
begin, end = 0, len(self._listing)
|
||||
level = 0
|
||||
isfile = 0
|
||||
|
||||
# Special case for root
|
||||
if not relpath.parts:
|
||||
return slice(begin, end)
|
||||
|
||||
begin += 1
|
||||
for part in relpath.parts:
|
||||
level += 1
|
||||
found = False
|
||||
|
||||
while begin < end:
|
||||
entry = self._listing[begin]
|
||||
|
||||
if entry.level < level:
|
||||
break
|
||||
|
||||
if entry.level == level:
|
||||
if entry.name == part:
|
||||
found = True
|
||||
if level == len(relpath.parts):
|
||||
isfile = relfile
|
||||
else:
|
||||
begin += 1
|
||||
break
|
||||
cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part)
|
||||
if cmp > 0:
|
||||
break
|
||||
|
||||
begin += 1
|
||||
|
||||
if not found:
|
||||
return slice(begin, begin)
|
||||
|
||||
# Found the starting point, now find the end of the slice
|
||||
for end in range(begin + 1, len(self._listing) + 1):
|
||||
if end == len(self._listing) or self._listing[end].level <= level:
|
||||
break
|
||||
return slice(begin, end)
|
||||
|
||||
def __getitem__(self, index: PurePosixPath | tuple[PurePosixPath, int]):
|
||||
with self.lock:
|
||||
return self._listing[self._slice(index)]
|
||||
|
||||
def __setitem__(
|
||||
self, index: tuple[PurePosixPath, int], value: list[FileEntry]
|
||||
) -> None:
|
||||
rel, isfile = index
|
||||
with self.lock:
|
||||
if rel.parts:
|
||||
parent = self._slice(rel.parent)
|
||||
if parent.start == parent.stop:
|
||||
raise ValueError(
|
||||
f"Parent folder {rel.as_posix()} is missing for {rel.name}"
|
||||
)
|
||||
self._listing[self._slice(index)] = value
|
||||
|
||||
def __delitem__(self, relpath: PurePosixPath):
|
||||
with self.lock:
|
||||
del self._listing[self._slice(relpath)]
|
||||
|
||||
def _index(self, rel: PurePosixPath):
|
||||
idx = 0
|
||||
ret = []
|
||||
|
||||
def _dir(self, idx: int):
|
||||
level = self._listing[idx].level + 1
|
||||
end = len(self._listing)
|
||||
idx += 1
|
||||
ret = []
|
||||
while idx < end and (r := self._listing[idx]).level >= level:
|
||||
if r.level == level:
|
||||
ret.append(idx)
|
||||
return ret, idx
|
||||
|
||||
def update(self, rel: PurePosixPath, value: FileEntry):
|
||||
begin = 0
|
||||
parents = []
|
||||
while self._listing[begin].level < len(rel.parts):
|
||||
parents.append(begin)
|
||||
|
||||
|
||||
state = State()
|
||||
tree = {"": None}
|
||||
tree_lock = threading.Lock()
|
||||
rootpath: Path = None # type: ignore
|
||||
quit = False
|
||||
modified_flags = (
|
||||
@@ -143,22 +25,22 @@ modified_flags = (
|
||||
"IN_MOVED_FROM",
|
||||
"IN_MOVED_TO",
|
||||
)
|
||||
disk_usage = None
|
||||
|
||||
|
||||
def watcher_thread(loop):
|
||||
global rootpath
|
||||
import inotify.adapters
|
||||
global disk_usage
|
||||
|
||||
while True:
|
||||
rootpath = config.config.path
|
||||
i = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||
# Initialize the tree from filesystem
|
||||
new = walk()
|
||||
with state.lock:
|
||||
old = state.root
|
||||
if old != new:
|
||||
state.root = new
|
||||
broadcast(format_update(old, new), loop)
|
||||
old = format_tree() if tree[""] else None
|
||||
with tree_lock:
|
||||
# Initialize the tree from filesystem
|
||||
tree[""] = walk(rootpath)
|
||||
msg = format_tree()
|
||||
if msg != old:
|
||||
asyncio.run_coroutine_threadsafe(broadcast(msg), loop)
|
||||
|
||||
# The watching is not entirely reliable, so do a full refresh every minute
|
||||
refreshdl = time.monotonic() + 60.0
|
||||
@@ -168,10 +50,9 @@ def watcher_thread(loop):
|
||||
return
|
||||
# Disk usage update
|
||||
du = shutil.disk_usage(rootpath)
|
||||
space = Space(*du, storage=state.root[0].size)
|
||||
if space != state.space:
|
||||
state.space = space
|
||||
broadcast(format_space(space), loop)
|
||||
if du != disk_usage:
|
||||
disk_usage = du
|
||||
asyncio.run_coroutine_threadsafe(broadcast(format_du()), loop)
|
||||
break
|
||||
# Do a full refresh?
|
||||
if time.monotonic() > refreshdl:
|
||||
@@ -186,162 +67,138 @@ def watcher_thread(loop):
|
||||
try:
|
||||
update(path.relative_to(rootpath), loop)
|
||||
except Exception as e:
|
||||
print("Watching error", e, path, rootpath)
|
||||
raise
|
||||
print("Watching error", e)
|
||||
break
|
||||
i = None # Free the inotify object
|
||||
|
||||
|
||||
def watcher_thread_poll(loop):
|
||||
global rootpath
|
||||
|
||||
while not quit:
|
||||
rootpath = config.config.path
|
||||
new = walk()
|
||||
with state.lock:
|
||||
old = state.root
|
||||
if old != new:
|
||||
state.root = new
|
||||
broadcast(format_update(old, new), loop)
|
||||
|
||||
# Disk usage update
|
||||
du = shutil.disk_usage(rootpath)
|
||||
space = Space(*du, storage=state.root[0].size)
|
||||
if space != state.space:
|
||||
state.space = space
|
||||
broadcast(format_space(space), loop)
|
||||
|
||||
time.sleep(2.0)
|
||||
def format_du():
|
||||
return msgspec.json.encode(
|
||||
{
|
||||
"space": {
|
||||
"disk": disk_usage.total,
|
||||
"used": disk_usage.used,
|
||||
"free": disk_usage.free,
|
||||
"storage": tree[""].size,
|
||||
}
|
||||
}
|
||||
).decode()
|
||||
|
||||
|
||||
def walk(rel=PurePosixPath()) -> list[FileEntry]: # noqa: B008
|
||||
path = rootpath / rel
|
||||
def format_tree():
|
||||
root = tree[""]
|
||||
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:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return []
|
||||
return _walk(rel, int(not stat.S_ISDIR(st.st_mode)), st)
|
||||
s = path.stat()
|
||||
id_ = fuid(s)
|
||||
mtime = int(s.st_mtime)
|
||||
if path.is_file():
|
||||
return FileEntry(id_, s.st_size, mtime)
|
||||
|
||||
|
||||
def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]:
|
||||
entry = FileEntry(
|
||||
level=len(rel.parts),
|
||||
name=rel.name,
|
||||
key=fuid(st),
|
||||
mtime=int(st.st_mtime),
|
||||
size=st.st_size if isfile else 0,
|
||||
isfile=isfile,
|
||||
)
|
||||
if isfile:
|
||||
return [entry]
|
||||
ret = [entry]
|
||||
path = rootpath / rel
|
||||
try:
|
||||
li = []
|
||||
for f in path.iterdir():
|
||||
if f.name.startswith("."):
|
||||
continue # No dotfiles
|
||||
s = f.stat()
|
||||
li.append((int(not stat.S_ISDIR(s.st_mode)), f.name, s))
|
||||
for [isfile, name, s] in humansorted(li):
|
||||
subtree = _walk(rel / name, isfile, s)
|
||||
child = subtree[0]
|
||||
entry.mtime = max(entry.mtime, child.mtime)
|
||||
entry.size += child.size
|
||||
ret.extend(subtree)
|
||||
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(id_, size, mtime, tree)
|
||||
except FileNotFoundError:
|
||||
pass # Things may be rapidly in motion
|
||||
return None
|
||||
except OSError as e:
|
||||
print("OS error walking path", path, e)
|
||||
return ret
|
||||
return None
|
||||
|
||||
|
||||
def update(relpath: PurePosixPath, loop):
|
||||
"""Called by inotify updates, check the filesystem and broadcast any changes."""
|
||||
if rootpath is None or relpath is None:
|
||||
print("ERROR", rootpath, relpath)
|
||||
new = walk(relpath)
|
||||
with state.lock:
|
||||
old = state[relpath]
|
||||
if old == new:
|
||||
return
|
||||
old = state.root
|
||||
if new:
|
||||
state[relpath, new[0].isfile] = new
|
||||
else:
|
||||
del state[relpath]
|
||||
broadcast(format_update(old, state.root), loop)
|
||||
new = walk(rootpath / relpath)
|
||||
with tree_lock:
|
||||
update = update_internal(relpath, new)
|
||||
if not update:
|
||||
return # No changes
|
||||
msg = msgspec.json.encode({"update": update}).decode()
|
||||
asyncio.run_coroutine_threadsafe(broadcast(msg), loop)
|
||||
|
||||
|
||||
def format_update(old, new):
|
||||
# Make keep/del/insert diff until one of the lists ends
|
||||
oidx, nidx = 0, 0
|
||||
def update_internal(
|
||||
relpath: PurePosixPath, new: DirEntry | FileEntry | None
|
||||
) -> list[UpdateEntry]:
|
||||
path = "", *relpath.parts
|
||||
old = tree
|
||||
elems = []
|
||||
for name in path:
|
||||
if name not in old:
|
||||
# File or folder created
|
||||
old = None
|
||||
elems.append((name, None))
|
||||
if len(elems) < len(path):
|
||||
# We got a notify for an item whose parent is not in tree
|
||||
print("Tree out of sync DEBUG", relpath)
|
||||
print(elems)
|
||||
print("Current tree:")
|
||||
print(tree[""])
|
||||
print("Walking all:")
|
||||
print(walk(rootpath))
|
||||
raise ValueError("Tree out of sync")
|
||||
break
|
||||
old = old[name]
|
||||
elems.append((name, old))
|
||||
if old == new:
|
||||
return []
|
||||
mt = new.mtime if new else 0
|
||||
szdiff = (new.size if new else 0) - (old.size if old else 0)
|
||||
# Update parents
|
||||
update = []
|
||||
keep_count = 0
|
||||
while oidx < len(old) and nidx < len(new):
|
||||
if old[oidx] == new[nidx]:
|
||||
keep_count += 1
|
||||
oidx += 1
|
||||
nidx += 1
|
||||
continue
|
||||
if keep_count > 0:
|
||||
update.append(UpdKeep(keep_count))
|
||||
keep_count = 0
|
||||
|
||||
del_count = 0
|
||||
rest = new[nidx:]
|
||||
while oidx < len(old) and old[oidx] not in rest:
|
||||
del_count += 1
|
||||
oidx += 1
|
||||
if del_count:
|
||||
update.append(UpdDel(del_count))
|
||||
continue
|
||||
|
||||
insert_items = []
|
||||
rest = old[oidx:]
|
||||
while nidx < len(new) and new[nidx] not in rest:
|
||||
insert_items.append(new[nidx])
|
||||
nidx += 1
|
||||
update.append(UpdIns(insert_items))
|
||||
|
||||
# Diff any remaining
|
||||
if keep_count > 0:
|
||||
update.append(UpdKeep(keep_count))
|
||||
if oidx < len(old):
|
||||
update.append(UpdDel(len(old) - oidx))
|
||||
elif nidx < len(new):
|
||||
update.append(UpdIns(new[nidx:]))
|
||||
|
||||
return msgspec.json.encode({"update": update}).decode()
|
||||
for name, entry in elems[:-1]:
|
||||
u = UpdateEntry(name)
|
||||
if szdiff:
|
||||
entry.size += szdiff
|
||||
u.size = entry.size
|
||||
if mt > entry.mtime:
|
||||
u.mtime = entry.mtime = mt
|
||||
update.append(u)
|
||||
# The last element is the one that changed
|
||||
name, entry = elems[-1]
|
||||
parent = elems[-2][1] if len(elems) > 1 else tree
|
||||
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 isinstance(new, DirEntry):
|
||||
if u.dir == new.dir:
|
||||
u.dir = new.dir
|
||||
else:
|
||||
del parent[name]
|
||||
u.deleted = True
|
||||
update.append(u)
|
||||
return update
|
||||
|
||||
|
||||
def format_space(usage):
|
||||
return msgspec.json.encode({"space": usage}).decode()
|
||||
|
||||
|
||||
def format_root(root):
|
||||
return msgspec.json.encode({"root": root}).decode()
|
||||
|
||||
|
||||
def broadcast(msg, loop):
|
||||
return asyncio.run_coroutine_threadsafe(abroadcast(msg), loop).result()
|
||||
|
||||
|
||||
async def abroadcast(msg):
|
||||
try:
|
||||
for queue in pubsub.values():
|
||||
queue.put_nowait(msg)
|
||||
except Exception:
|
||||
# Log because asyncio would silently eat the error
|
||||
logging.exception("Broadcast error")
|
||||
async def broadcast(msg):
|
||||
for queue in pubsub.values():
|
||||
await queue.put_nowait(msg)
|
||||
|
||||
|
||||
async def start(app, loop):
|
||||
config.load_config()
|
||||
use_inotify = False and sys.platform == "linux"
|
||||
app.ctx.watcher = threading.Thread(
|
||||
target=watcher_thread if use_inotify else watcher_thread_poll,
|
||||
args=[loop],
|
||||
)
|
||||
app.ctx.watcher = threading.Thread(target=watcher_thread, args=[loop])
|
||||
app.ctx.watcher.start()
|
||||
|
||||
|
||||
|
||||
1
cista/wwwroot/assets/index-213aec14.css
Normal file
512
cista/wwwroot/assets/index-25722397.js
Normal file
BIN
cista/wwwroot/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
15
cista/wwwroot/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<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>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
241
cista/wwwroot/old-index.html
Executable file
@@ -0,0 +1,241 @@
|
||||
<!DOCTYPE html>
|
||||
<title>Storage</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
max-width: 100ch;
|
||||
margin: 0 auto;
|
||||
padding: 1em;
|
||||
background-color: #333;
|
||||
color: #eee;
|
||||
}
|
||||
td {
|
||||
text-align: right;
|
||||
padding: .5em;
|
||||
}
|
||||
td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
<div>
|
||||
<h2>Quick file upload</h2>
|
||||
<p>Uses parallel WebSocket connections for increased bandwidth /api/upload</p>
|
||||
<input type=file id=fileInput>
|
||||
<progress id=progressBar value=0 max=1></progress>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Files</h2>
|
||||
<ul id=file_list></ul>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let files = {}
|
||||
let flatfiles = {}
|
||||
|
||||
function createWatchSocket() {
|
||||
const wsurl = new URL("/api/watch", location.href.replace(/^http/, 'ws'))
|
||||
const ws = new WebSocket(wsurl)
|
||||
ws.onmessage = event => {
|
||||
msg = JSON.parse(event.data)
|
||||
if (msg.update) {
|
||||
tree_update(msg.update)
|
||||
file_list(files)
|
||||
} else {
|
||||
console.log("Unkonwn message from watch socket", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createWatchSocket()
|
||||
|
||||
function tree_update(msg) {
|
||||
console.log("Tree update", msg)
|
||||
let node = files
|
||||
for (const elem of msg) {
|
||||
if (elem.deleted) {
|
||||
const p = node.dir[elem.name].path
|
||||
delete node.dir[elem.name]
|
||||
delete flatfiles[p]
|
||||
break
|
||||
}
|
||||
if (elem.name !== undefined) node = node.dir[elem.name] ||= {}
|
||||
if (elem.size !== undefined) node.size = elem.size
|
||||
if (elem.mtime !== undefined) node.mtime = elem.mtime
|
||||
if (elem.dir !== undefined) node.dir = elem.dir
|
||||
}
|
||||
// Update paths and flatfiles
|
||||
files.path = "/"
|
||||
const nodes = [files]
|
||||
flatfiles = {}
|
||||
while (node = nodes.pop()) {
|
||||
flatfiles[node.path] = node
|
||||
if (node.dir === undefined) continue
|
||||
for (const name of Object.keys(node.dir)) {
|
||||
const child = node.dir[name]
|
||||
child.path = node.path + name + (child.dir === undefined ? "" : "/")
|
||||
nodes.push(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||
|
||||
const compare_path = (a, b) => collator.compare(a.path, b.path)
|
||||
const compare_time = (a, b) => a.mtime > b.mtime
|
||||
|
||||
function file_list(files) {
|
||||
const table = document.getElementById("file_list")
|
||||
const sorted = Object.values(flatfiles).sort(compare_time)
|
||||
table.innerHTML = ""
|
||||
for (const f of sorted) {
|
||||
const {path, size, mtime} = f
|
||||
const tr = document.createElement("tr")
|
||||
const name_td = document.createElement("td")
|
||||
const size_td = document.createElement("td")
|
||||
const mtime_td = document.createElement("td")
|
||||
const a = document.createElement("a")
|
||||
table.appendChild(tr)
|
||||
tr.appendChild(name_td)
|
||||
tr.appendChild(size_td)
|
||||
tr.appendChild(mtime_td)
|
||||
name_td.appendChild(a)
|
||||
size_td.textContent = size
|
||||
mtime_td.textContent = formatUnixDate(mtime)
|
||||
a.textContent = path
|
||||
a.href = `/files${path}`
|
||||
/*a.onclick = event => {
|
||||
if (window.showSaveFilePicker) {
|
||||
event.preventDefault()
|
||||
download_ws(name, size)
|
||||
}
|
||||
}
|
||||
a.download = ""*/
|
||||
}
|
||||
}
|
||||
|
||||
function formatUnixDate(t) {
|
||||
const date = new Date(t * 1000)
|
||||
const now = new Date()
|
||||
const diff = date - now
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
async function download_ws(name, size) {
|
||||
const fh = await window.showSaveFilePicker({
|
||||
suggestedName: name,
|
||||
})
|
||||
const writer = await fh.createWritable()
|
||||
writer.truncate(size)
|
||||
const wsurl = new URL("/api/download", location.href.replace(/^http/, 'ws'))
|
||||
const ws = new WebSocket(wsurl)
|
||||
let pos = 0
|
||||
ws.onopen = () => {
|
||||
console.log("Downloading over WebSocket", name, size)
|
||||
ws.send(JSON.stringify({name, start: 0, end: size, size}))
|
||||
}
|
||||
ws.onmessage = event => {
|
||||
if (typeof event.data === 'string') {
|
||||
const msg = JSON.parse(event.data)
|
||||
console.log("Download finished", msg)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
console.log("Received chunk", name, pos, pos + event.data.size)
|
||||
pos += event.data.size
|
||||
writer.write(event.data)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (pos < size) {
|
||||
console.log("Download aborted", name, pos)
|
||||
writer.truncate(pos)
|
||||
}
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
|
||||
const fileInput = document.getElementById("fileInput")
|
||||
const progress = document.getElementById("progressBar")
|
||||
const numConnections = 2
|
||||
const chunkSize = 1<<20
|
||||
const wsConnections = new Set()
|
||||
|
||||
//for (let i = 0; i < numConnections; i++) createUploadWS()
|
||||
|
||||
function createUploadWS() {
|
||||
const wsurl = new URL("/api/upload", location.href.replace(/^http/, 'ws'))
|
||||
const ws = new WebSocket(wsurl)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
ws.onopen = () => {
|
||||
wsConnections.add(ws)
|
||||
console.log("Upload socket connected")
|
||||
}
|
||||
ws.onmessage = event => {
|
||||
msg = JSON.parse(event.data)
|
||||
if (msg.written) progress.value += +msg.written
|
||||
else console.log(`Error: ${msg.error}`)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
wsConnections.delete(ws)
|
||||
console.log("Upload socket disconnected, reconnecting...")
|
||||
setTimeout(createUploadWS, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async function load(file, start, end) {
|
||||
const reader = new FileReader()
|
||||
const load = new Promise(resolve => reader.onload = resolve)
|
||||
reader.readAsArrayBuffer(file.slice(start, end))
|
||||
const event = await load
|
||||
return event.target.result
|
||||
}
|
||||
|
||||
async function sendChunk(file, start, end, 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)
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", async function() {
|
||||
const file = this.files[0]
|
||||
const numChunks = Math.ceil(file.size / chunkSize)
|
||||
progress.value = 0
|
||||
progress.max = file.size
|
||||
|
||||
console.log(wsConnections)
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
const ws = Array.from(wsConnections)[i % wsConnections.size]
|
||||
const start = i * chunkSize
|
||||
const end = Math.min(file.size, start + chunkSize)
|
||||
const res = await sendChunk(file, start, end, ws)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=UTF-8>
|
||||
<title>Cista Storage</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="icon" href="/src/assets/logo.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<div id="app"></div>
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -1,127 +0,0 @@
|
||||
<template>
|
||||
<LoginModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
</HeaderMain>
|
||||
<BreadCrumb :path="path.pathList" tabindex="-1"/>
|
||||
</header>
|
||||
<main>
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import Router from '@/router/index'
|
||||
|
||||
interface Path {
|
||||
path: string
|
||||
pathList: string[]
|
||||
query: string
|
||||
}
|
||||
const store = useMainStore()
|
||||
const path: ComputedRef<Path> = computed(() => {
|
||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||
const pathList = p[0].split('/').filter(value => value !== '')
|
||||
const query = p.slice(1).join('//')
|
||||
return {
|
||||
path: p[0],
|
||||
pathList,
|
||||
query
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
|
||||
})
|
||||
onMounted(loadSession)
|
||||
onMounted(watchConnect)
|
||||
onUnmounted(watchDisconnect)
|
||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||
let vert = 0
|
||||
let timer: any = null
|
||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const fileExplorer = store.fileExplorer as any
|
||||
if (!fileExplorer) return
|
||||
const c = fileExplorer.isCursor()
|
||||
const keyup = event.type === 'keyup'
|
||||
if (event.repeat) {
|
||||
if (
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
(c && event.code === 'Space')
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
//console.log("key pressed", event)
|
||||
// For up/down implement custom fast repeat
|
||||
if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1
|
||||
else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1
|
||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Select all (toggle); keydown to prevent builtin
|
||||
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||
fileExplorer.toggleSelectAll()
|
||||
}
|
||||
// Keys 1-3 to sort columns
|
||||
else if (
|
||||
c &&
|
||||
keyup &&
|
||||
(event.key === '1' || event.key === '2' || event.key === '3')
|
||||
) {
|
||||
fileExplorer.toggleSortColumn(+event.key)
|
||||
}
|
||||
// Rename
|
||||
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
|
||||
fileExplorer.cursorRename()
|
||||
}
|
||||
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
|
||||
else if (c && event.code === 'Space') {
|
||||
if (keyup && !event.altKey && !event.ctrlKey)
|
||||
fileExplorer.cursorSelect()
|
||||
} else return
|
||||
event.preventDefault()
|
||||
if (!vert) {
|
||||
if (timer) {
|
||||
clearTimeout(timer) // Good for either timeout or interval
|
||||
timer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!timer) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const select = event.shiftKey
|
||||
fileExplorer.cursorMove(vert, select)
|
||||
const t0 = 200,
|
||||
tr = 30
|
||||
timer = setTimeout(
|
||||
() =>
|
||||
(timer = setInterval(() => {
|
||||
fileExplorer.cursorMove(vert, select)
|
||||
}, tr)),
|
||||
t0 - tr
|
||||
)
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', globalShortcutHandler)
|
||||
window.addEventListener('keyup', globalShortcutHandler)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', globalShortcutHandler)
|
||||
window.removeEventListener('keyup', globalShortcutHandler)
|
||||
})
|
||||
export type { Path }
|
||||
</script>
|
||||
@/stores/main
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f80" rx="64" ry="64"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg>
|
||||
|
Before Width: | Height: | Size: 258 B |
@@ -1,268 +0,0 @@
|
||||
@charset "UTF-8";
|
||||
|
||||
:root {
|
||||
--primary-color: #000;
|
||||
--primary-background: #ddd;
|
||||
--header-background: var(--soft-color);
|
||||
--header-color: #ccc;
|
||||
--input-background: #fff;
|
||||
--input-color: #000;
|
||||
--primary-color: #000;
|
||||
--soft-color: #146;
|
||||
--accent-color: #f80;
|
||||
--transition-time: 0.2s;
|
||||
/* The following are overridden by responsive layouts */
|
||||
--root-font-size: 1rem;
|
||||
--header-font-size: 1rem;
|
||||
--header-height: calc(6.5 * var(--header-font-size));
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--primary-color: #ddd;
|
||||
--primary-background: var(--soft-color);
|
||||
--header-background: #000;
|
||||
--header-color: #ccc;
|
||||
--input-background: var(--soft-color);
|
||||
--input-color: #ddd;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
.size,
|
||||
.modified,
|
||||
.summary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 1000px) {
|
||||
:root {
|
||||
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
||||
}
|
||||
header .buttons:has(input[type='search']) > div {
|
||||
display: none;
|
||||
}
|
||||
header .buttons > div:has(input[type='search']) {
|
||||
display: inherit;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 2000px) {
|
||||
:root {
|
||||
--root-font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
/* Low (landscape) screens: smaller header */
|
||||
@media screen and (max-height: 600px) {
|
||||
:root {
|
||||
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
|
||||
--root-font-size: 0.8rem;
|
||||
}
|
||||
header .breadcrumb > * {
|
||||
padding-top: calc(8 + 8 * 100vh / 600) !important;
|
||||
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-height: 300px) {
|
||||
:root {
|
||||
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
|
||||
--header-height: calc(1.75 * 16px);
|
||||
--root-font-size: 0.6rem;
|
||||
}
|
||||
header .breadcrumb > * {
|
||||
padding-top: 14px !important;
|
||||
padding-bottom: 14px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (orientation: landscape) and (min-width: 700px) {
|
||||
/* Breadcrumbs and buttons side by side */
|
||||
:root {
|
||||
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
}
|
||||
header .breadcrumb {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
header .breadcrumb > * {
|
||||
flex-shrink: 1;
|
||||
padding-top: 1rem !important;
|
||||
padding-bottom: 1rem !important;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
:root {
|
||||
--primary-color: black;
|
||||
--primary-background: none;
|
||||
--header-background: none;
|
||||
--header-color: black;
|
||||
}
|
||||
nav,
|
||||
.menu,
|
||||
.rename-button {
|
||||
display: none;
|
||||
}
|
||||
.breadcrumb > a {
|
||||
color: black !important;
|
||||
background: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
clip-path: none !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
.breadcrumb > a::after {
|
||||
content: '/';
|
||||
}
|
||||
.breadcrumb svg {
|
||||
fill: black !important;
|
||||
}
|
||||
main {
|
||||
height: auto !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
thead tr {
|
||||
position: static !important;
|
||||
background: none !important;
|
||||
border-bottom: 1pt solid black !important;
|
||||
}
|
||||
.selection {
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.selection input {
|
||||
display: none;
|
||||
}
|
||||
.selection input:checked {
|
||||
display: inherit;
|
||||
}
|
||||
tbody .selection input:checked {
|
||||
opacity: 1 !important;
|
||||
transform: scale(0.5);
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
html {
|
||||
font-size: var(--root-font-size);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Hide scrollbar for all browsers */
|
||||
main::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
main {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
body {
|
||||
background-color: var(--primary-background);
|
||||
font-size: 1rem;
|
||||
font-family: 'Roboto';
|
||||
color: var(--primary-color);
|
||||
margin: 0;
|
||||
}
|
||||
tbody .size,
|
||||
tbody .modified {
|
||||
font-family: 'Roboto Mono';
|
||||
}
|
||||
header {
|
||||
background-color: var(--header-background);
|
||||
color: var(--header-color);
|
||||
font-size: var(--header-font-size);
|
||||
}
|
||||
main {
|
||||
height: 100%;
|
||||
}
|
||||
::selection {
|
||||
color: #000;
|
||||
background: yellow !important;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
min-width: 1rem;
|
||||
min-height: 1rem;
|
||||
}
|
||||
input {
|
||||
margin: 0;
|
||||
}
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
a:link,
|
||||
a:visited,
|
||||
a:active,
|
||||
a:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
border: 0;
|
||||
gap: 0;
|
||||
}
|
||||
#app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header nav.headermain {
|
||||
/* Position so that tooltips can appear on top of other positioned elements */
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
main {
|
||||
height: calc(100svh - var(--header-height));
|
||||
padding-bottom: 3em; /* convenience space on the bottom */
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.spacer { flex-grow: 1 }
|
||||
.smallgap { flex-shrink: 1; width: 2em }
|
||||
|
||||
[data-tooltip]:hover:after {
|
||||
z-index: 101;
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
padding: .5rem 1rem;
|
||||
border-radius: 3rem 0 3rem 0;
|
||||
box-shadow: 0 0 1rem var(--accent-color);
|
||||
transform: translate(calc(1rem + -50%), 150%);
|
||||
background-color: var(--accent-color);
|
||||
color: var(--primary-color);
|
||||
white-space: pre;
|
||||
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
|
||||
}
|
||||
.modified [data-tooltip]:hover:after {
|
||||
transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%));
|
||||
}
|
||||
@keyframes appearbriefly {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0;
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.error-message {
|
||||
padding: .5em;
|
||||
font-weight: bold;
|
||||
background: var(--accent-color);
|
||||
color: #000;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM18.5 16v7.1h-5.3V16H8.7l7.1-7.1L23 16h-4.5z"/></svg>
|
||||
|
Before Width: | Height: | Size: 158 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm18.3 9.5V27h-5.6v-7.5H8l7.5-7.5 7.5 7.5h-4.7z"/></svg>
|
||||
|
Before Width: | Height: | Size: 168 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 -32 640 640"><path d="M495.46 365.98c-13.03-13.37-150.24-144.06-150.24-144.06A35.16 35.16 0 0 0 320 211.2a35.06 35.06 0 0 0-25.22 10.72s-137.2 130.7-150.27 144.06c-13 13.38-13.9 37.44 0 51.72 14 14.24 33.4 15.4 50.48 0L320 297.8l125.02 119.9c17.1 15.4 36.55 14.24 50.44 0 13.95-14.3 13.08-38.37 0-51.72z"/></svg>
|
||||
|
Before Width: | Height: | Size: 388 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-6 -2 44 36"><path d="M12 18H6v4l-6-6 6-6v4h6zm8-4h6v-4l6 6-6 6v-4h-6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -6 16 44"><path d="M8 20v6h4l-6 6-6-6h4v-6zm-4-8V6H0l6-6 6 6H8v6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 126 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="-48 0 512 512"><path d="M320 96L128 288l-64-64-64 64 128 128 256-256-64-64z"/></svg>
|
||||
|
Before Width: | Height: | Size: 158 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="-24 8 512 512"><path d="M304 96l-48 48 112 112-112 112 48 48 144-160L304 96zm-160 0L0 256l144 160 48-48L80 256l112-112-48-48z"/></svg>
|
||||
|
Before Width: | Height: | Size: 208 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M223.97 175A81 81 0 0 0 143 256c0 44.7 36.27 81.03 80.97 81.03 44.72 0 80.72-36.34 80.72-81.03 0-44.73-36-81-80.8-81zM386.3 302.53l-14.58 35.16 29.47 57.8-36.1 36.1-59.3-28-35.2 14.4-17.87 54.6-2.28 7.24h-51L177.4 418.2l-35.17-14.5-57.9 29.4-36.1-36.1 27.97-59.2-14.47-35.12L0 282.6v-51l61.7-22.1 14.5-35.1-25.96-51.23-3.43-6.72 36.1-36.03 59.3 27.92 35.1-14.5 17.9-54.6 2.3-7.24h51l22.1 61.73 35.07 14.52 58.04-29.4 36.06 36.03-27.96 59.2 14.42 35.17 61.8 20.13v50.97l-61.67 22.18z"/></svg>
|
||||
|
Before Width: | Height: | Size: 563 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 36 36"><path d="M26 8h-6V6l-6-6H0v24h12v8h20V14l-6-6zm0 2.83L29.17 14H26v-3.17zm-12-8L17.17 6H14V2.83zM2 2h10v6h6v14H2V2zm28 28H14v-6h6V10h4v6h6v14z"/></svg>
|
||||
|
Before Width: | Height: | Size: 212 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"/></svg>
|
||||
|
Before Width: | Height: | Size: 293 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm22.8 11.2c0 .3-.2.5-.5.5h-5.2v5.2c0 .3-.2.5-.5.5h-2.8c-.3 0-.5-.2-.5-.5v-5.2H8.1c-.3 0-.5-.2-.5-.5v-2.8c0-.3.2-.5.5-.5h5.2v-5.2c0-.3.2-.5.5-.5h2.8c.3 0 .5.2.5.5v5.2h5.2c.3 0 .5.2.5.5v2.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 310 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M25.3 8.56L17.88 16l7.44 7.44-1.86 1.87L16 17.9l-7.44 7.4-1.86-1.85L14.12 16 6.68 8.56 8.55 6.7 16 14.12l7.44-7.44z"/></svg>
|
||||
|
Before Width: | Height: | Size: 193 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M24.27 3.2H6.4a3.2 3.2 0 0 0-3.2 3.2v19.2a3.2 3.2 0 0 0 3.2 3.2h19.2a3.2 3.2 0 0 0 3.2-3.2V8.2l-4.53-5zm-1.87 9.6c0 .88-.72 1.6-1.6 1.6h-9.6a1.6 1.6 0 0 1-1.6-1.6v-8h12.8v8zm-1.6-6.4h-3.2v6.4h3.2V6.4z"/></svg>
|
||||
|
Before Width: | Height: | Size: 278 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M23 25.9c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3c-.3 0-.6.1-.8.3-.2.2-.3.5-.3.8s.1.6.3.8c.2.2.5.3.8.3.3 0 .6-.1.8-.3.2-.3.3-.5.3-.8zm4.6 0c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3c-.3 0-.6.1-.8.3-.2.2-.3.5-.3.8s.1.6.3.8c.2.2.5.3.8.3.3 0 .6-.1.8-.3.2-.3.3-.5.3-.8zm2.3-4v5.7c0 .5-.2.9-.5 1.2-.3.3-.7.5-1.2.5H1.9c-.5 0-.9-.2-1.2-.5s-.5-.7-.5-1.2v-5.7c0-.5.2-.9.5-1.2.3-.3.7-.5 1.2-.5h8.3l2.4 2.4c.7.7 1.5 1 2.4 1 .9 0 1.7-.3 2.4-1l2.4-2.4h8.3c.5 0 .9.2 1.2.5.4.3.6.7.6 1.2zm-5.8-10.2c.2.5.1.9-.3 1.3l-8 8c-.2.2-.5.3-.8.3-.3 0-.6-.1-.8-.3l-8-8c-.4-.3-.5-.8-.3-1.3S6.5 11 7 11h4.6V3c0-.3.1-.6.3-.8s.5-.3.8-.3h4.6c.3 0 .6.1.8.3s.3.5.3.8v8H23c.5 0 .8.2 1.1.7z"/></svg>
|
||||
|
Before Width: | Height: | Size: 711 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="448" height="448" viewBox="-136 0 448 448"><path d="M128 312v56q0 6.5-4.75 11.25T112 384H48q-6.5 0-11.25-4.75T32 368v-56q0-6.5 4.75-11.25T48 296h64q6.5 0 11.25 4.75T128 312zm7.5-264l-7 192q-.25 6.5-5.13 11.25T112 256H48q-6.5 0-11.38-4.75T31.5 240l-7-192q-.25-6.5 4.38-11.25T40 32h80q6.5 0 11.13 4.75T135.5 48z"/></svg>
|
||||
|
Before Width: | Height: | Size: 365 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 36 36"><path d="M29.715 16c-1.696-2.625-4.018-4.875-6.804-6.304 0.714 1.214 1.089 2.607 1.089 4.018 0 4.411-3.589 8-8 8s-8-3.589-8-8c0-1.411 0.375-2.804 1.089-4.018-2.786 1.429-5.107 3.679-6.804 6.304 3.054 4.714 7.982 8 13.714 8s10.661-3.286 13.714-8zM16.858 9.143c0-0.464-0.393-0.857-0.857-0.857-2.982 0-5.429 2.446-5.429 5.429 0 0.464 0.393 0.857 0.857 0.857s0.857-0.393 0.857-0.857c0-2.036 1.679-3.714 3.714-3.714 0.464 0 0.857-0.393 0.857-0.857zM32 16c0 0.446-0.143 0.857-0.357 1.232-3.286 5.411-9.304 9.054-15.643 9.054s-12.357-3.661-15.643-9.054c-0.214-0.375-0.357-0.786-0.357-1.232s0.143-0.857 0.357-1.232c3.286-5.393 9.304-9.054 15.643-9.054s12.357 3.661 15.643 9.054c0.214 0.375 0.357 0.786 0.357 1.232z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 783 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-12 -12 512 512"><path d="M480 416L355.44 291.44C373.22 262.4 384 228.58 384 192 384 85.98 298 0 192 0 85.98 0 0 85.98 0 192c0 106 85.98 192 192 192 36.58 0 70.4-10.78 99.44-28.5L416 480c8.75 8.75 23.25 8.7 32 0l32-32a22.8 22.8 0 0 0 0-32zm-288-96c-70.7 0-128-57.3-128-128S121.3 64 192 64s128 57.3 128 128-57.3 128-128 128z"/></svg>
|
||||
|
Before Width: | Height: | Size: 382 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M18.7 6.7h6.6v6.6h-2.6v-4h-4V6.7zm4 16v-4h2.6v6.6h-6.6v-2.6h4zm-16-9.4V6.7h6.6v2.6h-4v4H6.7zm2.6 5.4v4h4v2.6H6.7v-6.6h2.6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 200 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M256 6.3C114.6 6.3 0 121 0 262.3c0 113 73.4 209 175 243 13 2.3 17.6-5.6 17.6-12.4l-.4-48C121 460.5 106 415 106 415c-11.7-29.5-28.5-37.4-28.5-37.4-23.2-16 1.8-15.6 1.8-15.6 25.7 1.8 39.2 26.4 39.2 26.4 23 39.2 60 27.8 74.5 21.3 2.3-16.5 9-27.8 16.3-34.2C152.3 369 92.6 347 92.6 249c0-28 10-50.8 26.4-68.8-2.6-6.4-11.4-32.5 2.5-67.7 0 0 21.5-7 70.4 26.2 20-5.6 42-8.5 64-8.6 21.3.7 43.2 3 64 9 49-33 70-26 70-26 14 35.3 5 61.4 2.4 67.8 16.3 18 26.2 40.8 26.2 68.7 0 98.4-60 120-117 126.4 9.2 8 17.4 23.4 17.4 47.3l-.2 70.2c0 6.6 4.7 14.6 17.7 12 101.7-34 175-129.7 175-243C512 121 397.5 6 256 6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 698 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M32 18.45L16 6.03 0 18.45V13.4L16 .96 32 13.4zM28 18v12h-8v-8h-8v8H4V18l12-9z"/></svg>
|
||||
|
Before Width: | Height: | Size: 156 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 34 34"><path d="M16 .6C7.5.6.6 7.6.6 16c0 8.5 7 15.4 15.4 15.4 8.5 0 15.4-7 15.4-15.4C31.4 7.5 24.4.6 16 .6zm1.4 5.6c1.5 0 2 1 2 1.8 0 1.3-1 2.4-2.7 2.4-1.4 0-2-.7-2-2 0-.8.8-2.2 2.7-2.2zm-3.8 19c-1 0-1.8-.6-1-3.4l1-4.8c.3-.8.4-1 0-1-.2 0-1.5.5-2.3 1l-.5-1c2.5-2 5.3-3 6.6-3 1 0 1.2 1 .6 3l-1.3 5c-.2 1 0 1.2 0 1.2.4 0 1.4-.3 2.4-1l1 .7c-2.4 2-5 3-6 3z"/></svg>
|
||||
|
Before Width: | Height: | Size: 416 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg>
|
||||
|
Before Width: | Height: | Size: 517 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#26b" rx="64" ry="64"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg>
|
||||
|
Before Width: | Height: | Size: 257 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 36 36"><path d="M23.53 8.44l3.13-3.13v9.4h-9.38l4.3-4.3C20.18 8.94 18.18 8 16 8c-4.45 0-8 3.56-8 8s3.55 8 8 8c3.5 0 6.5-2.2 7.55-5.3h2.75c-1.2 4.6-5.3 8-10.3 8-5.9 0-10.64-4.83-10.64-10.7S10.1 5.3 15.96 5.3c2.95 0 5.63 1.2 7.57 3.14z"/></svg>
|
||||
|
Before Width: | Height: | Size: 297 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 0 24 32"><path d="M16 21.3c1.4 0 2.7 1.3 2.7 2.7s-1.3 2.7-2.7 2.7-2.7-1.3-2.7-2.7 1.3-2.7 2.7-2.7zm0-8c1.4 0 2.7 1.3 2.7 2.7s-1.3 2.7-2.7 2.7-2.7-1.3-2.7-2.7 1.3-2.7 2.7-2.7zm0-2.6c-1.4 0-2.7-1.3-2.7-2.7s1.3-2.7 2.7-2.7 2.7 1.3 2.7 2.7-1.3 2.7-2.7 2.7z"/></svg>
|
||||
|
Before Width: | Height: | Size: 312 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="2 0 32 32"><path d="M24 4v24h-4V17L10 27V5l10 10V4z"/></svg>
|
||||
|
Before Width: | Height: | Size: 109 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640"><path d="M576 32H64C28.8 32 0 60.8 0 96v384c0 35.2 28.8 63.36 64 63.36h127.36v-62.72h-128V185.6h513.28v295.04h-128v62.75H576c35.23 0 64-28.2 64-63.4V96c0-35.2-28.77-64-64-64zM83.23 138.56c-13.28 0-24-10.46-24-23.36s10.72-23.36 24-23.36c13.25 0 24 10.46 24 23.36s-10.75 23.36-24 23.36zm64 0c-13.28 0-24-10.46-24-23.36s10.72-23.36 24-23.36c13.25 0 24 10.46 24 23.36s-10.75 23.36-24 23.36zm429.44-3.52h-385.3V95.36h385.27v39.68zM318.34 261.57l-155.27 154.3h96V608H377.6V415.87h96l-155.26-154.3z"/></svg>
|
||||
|
Before Width: | Height: | Size: 587 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26 10V5a1 1 0 0 0-1-1h-7V2a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2H3a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h9v6h14l6-6V10h-6zM12 2h4v2h-4V2zM6 8V6h16v2H6zm20 21.17V26h3.17L26 29.17zM30 24h-6v6H14V12h16v12z"/></svg>
|
||||
|
Before Width: | Height: | Size: 269 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M4 4h10v24H4zm14 0h10v24H18z"/></svg>
|
||||
|
Before Width: | Height: | Size: 106 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2.5 0 32 32"><path d="M6.5 27.4l1.6-1.6-4.2-4.2-1.6 1.6v1.9h2.3v2.3h1.9zm9.3-16.5c0-.3-.1-.4-.4-.4-.1 0-.2 0-.3.1l-9.7 9.7c-.1.1-.1.2-.1.3 0 .3.1.4.4.4.1 0 .2 0 .3-.1l9.7-9.7c.1-.1.1-.2.1-.3zm-.9-3.5l7.4 7.4L7.4 29.7H0v-7.4L14.9 7.4zm12.2 1.7a2 2 0 0 1-.7 1.6l-3 3L16 6.3l3-2.9a2 2 0 0 1 1.6-.7 2 2 0 0 1 1.6.7l4.2 4.2c.4.4.7.9.7 1.5z"/></svg>
|
||||
|
Before Width: | Height: | Size: 393 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M6 4l20 12L6 28z"/></svg>
|
||||
|
Before Width: | Height: | Size: 94 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M31 12H20V1a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v11H1a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h11v11a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V20h11a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1z"/></svg>
|
||||
|
Before Width: | Height: | Size: 229 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M8 28V4h4v11L22 5v22L12 17v11z"/></svg>
|
||||
|
Before Width: | Height: | Size: 108 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M24.48 14.8c.37 2.55-.4 5.24-2.4 7.2-2.94 2.9-7.48 3.26-10.82 1.08l2.34-2.28L5 19.6 6.2 28l2.62-2.52c4.72 3.48 11.4 3.15 15.7-1.08 2.48-2.45 3.6-5.7 3.47-8.9l-3.53-.7zM9.92 10c2.94-2.9 7.48-3.26 10.82-1.08L18.4 11.2l8.6 1.2L25.8 4l-2.63 2.52C18.47 3.04 11.77 3.37 7.5 7.6 5 10.05 3.86 13.3 4 16.5l3.52.7c-.37-2.55.4-5.24 2.4-7.2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 407 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26.67 4V1.33h-8V4h2.66v24h-2.66v2.67h8V28H24V4zm-8 3.12c0-.14-.26-.3-.43-.42a5.8 5.8 0 0 0-2.45-1.07c-.9-.17-2-.25-3.2-.25-.9 0-1.8.14-2.7.42-.9.28-1.7.62-2.4 1.03a5.7 5.7 0 0 0-1.8 1.62c-.5.6-.7 1.24-.7 1.9 0 .64.1 1.2.5 1.72s.8.77 1.6.77 1.4-.2 1.9-.63c.4-.4.7-.8.7-1.3s-.1-1-.2-1.5c-.2-.5-.2-1-.2-1.3.2-.2.6-.5 1.2-.7.5-.2 1.2-.3 1.8-.3.9 0 1.7.2 2.2.6.5.4.9.9 1.2 1.4.2.5.2 1.6.2 1.6v3.2c0 .36-1.8.9-3.8 1.54s-3.2 1-3.8 1.27c-.5.2-1 .48-1.6.8a5.54 5.54 0 0 0-2.4 2.9c-.2.65-.3 1.36-.3 2.2 0 1.58.5 2.87 1.5 3.85S7.82 27.9 9.4 27.9c1.5 0 2.8-.6 3.8-1.13 1.1-.5 2.1-1.3 3-2.7h.1c.2 1.4.6 2.1 1.26 2.7l.87.07V7.1zm-2.32 15.85a7.96 7.96 0 0 1-1.97 1.76 4.9 4.9 0 0 1-2.7.75c-.97 0-1.76-.28-2.4-.85-.62-.56-.93-1.44-.93-2.64 0-1 .2-1.8.63-2.4.4-.7 1-1.3 1.7-1.8.8-.5 1.65-1 2.58-1.3.92-.4 1.86-.7 3.1-1.1v7.4z"/></svg>
|
||||
|
Before Width: | Height: | Size: 887 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M27.84 22.16A7.15 7.15 0 0 0 22.9 20H22l-2-2 7.98-8c2-2 2-6 0-8L16 14 4.02 2c-2 2-2 6 0 8l8 8-2 2H9.1c-1.7 0-3.5.74-4.94 2.16-2.55 2.55-2.9 6.33-.77 8.45.9 1 2.2 1.4 3.5 1.4a7 7 0 0 0 4.9-2.1 6.86 6.86 0 0 0 2.1-5.8L16 22l2.04 2.05a6.87 6.87 0 0 0 2.1 5.8A7.2 7.2 0 0 0 25.07 32c1.34 0 2.6-.46 3.53-1.4 2.13-2.1 1.8-5.9-.77-8.44zm-16.8 4.26A4.95 4.95 0 0 1 8.44 29c-.5.22-1.02.33-1.5.33-.5 0-1.16-.1-1.67-.6a2.3 2.3 0 0 1-.6-1.64A4 4 0 0 1 5 25.5a3.9 3.9 0 0 1 1-1.53c.44-.46 1-.8 1.52-1.05.5-.23 1.03-.34 1.52-.34.46 0 1.13.1 1.64.6.5.5.6 1.2.6 1.65 0 .5-.1 1.02-.3 1.52zm4.96-5.6a2.83 2.83 0 1 1 0-5.67 2.83 2.83 0 0 1 0 5.68zm10.73 7.9c-.5.5-1.18.6-1.66.6-.5 0-1-.1-1.52-.32a4.92 4.92 0 0 1-2.9-4.08c0-.47.1-1.13.6-1.64.5-.5 1.2-.6 1.65-.6.5 0 1.02.1 1.52.32a5.08 5.08 0 0 1 2.6 2.58c.25.5.37 1.02.37 1.5 0 .47-.1 1.13-.6 1.64z"/></svg>
|
||||
|
Before Width: | Height: | Size: 908 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M32 8l-8-8v6c-4.1 0-7.2.97-9.55 2.98l-.48.43A29.5 29.5 0 0 1 16.1 13c1.5-1.8 3.63-3 7.9-3v12c-6.8 0-8.3-3-10.2-6.9-1.1-2.1-2.2-4.3-4.3-6.1C7.2 7 4.1 6 0 6v4c6.76 0 8.28 3.04 10.2 6.9 1.08 2.14 2.2 4.36 4.25 6.12C16.8 25.02 19.9 26 24 26v6l8-8-8-8 8-8zM0 22v4c4.1 0 7.2-.97 9.55-2.98l.48-.43C9.17 21.4 8.5 20.1 7.9 19c-1.5 1.8-3.67 3-7.9 3z"/></svg>
|
||||
|
Before Width: | Height: | Size: 417 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 2 28 28"><path d="M21.1 16c0 .3-.1.6-.3.8l-9.7 9.7c-.2.2-.5.3-.8.3s-.6-.1-.8-.3c-.2-.2-.3-.5-.3-.8v-5.1h-8c-.3 0-.6-.1-.8-.3-.3-.3-.4-.6-.4-.9v-6.9c0-.3.1-.6.3-.8s.5-.3.8-.3h8V6.3c0-.3.1-.6.3-.8s.5-.3.8-.3.6.1.8.3l9.7 9.7c.3.2.4.5.4.8zm6.3-6.3v12.6c0 1.4-.5 2.6-1.5 3.6s-2.2 1.5-3.6 1.5h-5.7c-.2 0-.3-.1-.4-.2s-.2-.2-.2-.3V26l.1-.4.2-.3.4-.1h5.7c.8 0 1.5-.3 2-.8.6-.6.8-1.2.8-2V9.7c0-.8-.3-1.5-.8-2-.6-.6-1.2-.8-2-.8h-5.8l-.2-.1-.1-.1-.3-.2V5l.2-.3.4-.1h5.7c1.4 0 2.6.5 3.6 1.5s1.5 2.2 1.5 3.6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 554 B |