feat: filebrowser with encfs
27
frontend/.eslintrc.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended",
|
||||
"@vue/eslint-config-typescript",
|
||||
"@vue/eslint-config-prettier"
|
||||
],
|
||||
"rules": {
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-mutating-props": [
|
||||
"error",
|
||||
{
|
||||
"shallowOnly": true
|
||||
}
|
||||
]
|
||||
// no-undef is already included in
|
||||
// @vue/eslint-config-typescript
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
}
|
||||
}
|
||||
2
frontend/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
# Ignore artifacts:
|
||||
dist
|
||||
3
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
13
frontend/assets.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !dev
|
||||
// +build !dev
|
||||
|
||||
package frontend
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed dist/*
|
||||
var assets embed.FS
|
||||
|
||||
func Assets() embed.FS {
|
||||
return assets
|
||||
}
|
||||
15
frontend/assets_dev.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build dev
|
||||
// +build dev
|
||||
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
var assets fs.FS = os.DirFS("frontend")
|
||||
|
||||
func Assets() fs.FS {
|
||||
return assets
|
||||
}
|
||||
0
frontend/dist/.gitkeep
vendored
Normal file
192
frontend/index.html
Normal file
@@ -0,0 +1,192 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, user-scalable=no"
|
||||
/>
|
||||
|
||||
<title>File Browser</title>
|
||||
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/img/icons/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/img/icons/favicon-16x16.png"
|
||||
/>
|
||||
|
||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||
<link
|
||||
rel="manifest"
|
||||
id="manifestPlaceholder"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<meta name="theme-color" content="#2979ff" />
|
||||
|
||||
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="assets" />
|
||||
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png" />
|
||||
|
||||
<!-- Add to home screen for Windows -->
|
||||
<meta
|
||||
name="msapplication-TileImage"
|
||||
content="/img/icons/mstile-144x144.png"
|
||||
/>
|
||||
<meta name="msapplication-TileColor" content="#2979ff" />
|
||||
|
||||
<!-- Inject Some Variables and generate the manifest json -->
|
||||
<script>
|
||||
// We can assign JSON directly
|
||||
window.FileBrowser = {
|
||||
AuthMethod: "json",
|
||||
BaseURL: "",
|
||||
CSS: false,
|
||||
Color: "",
|
||||
DisableExternal: false,
|
||||
DisableUsedPercentage: false,
|
||||
EnableExec: true,
|
||||
EnableThumbs: true,
|
||||
LoginPage: true,
|
||||
Name: "",
|
||||
NoAuth: false,
|
||||
ReCaptcha: false,
|
||||
ResizePreview: true,
|
||||
Signup: false,
|
||||
StaticURL: "",
|
||||
Theme: "",
|
||||
TusSettings: { chunkSize: 10485760, retryCount: 5 },
|
||||
Version: "(untracked)",
|
||||
};
|
||||
// Global function to prepend static url
|
||||
window.__prependStaticUrl = (url) => {
|
||||
return `${window.FileBrowser.StaticURL}/${url.replace(/^\/+/, "")}`;
|
||||
};
|
||||
var dynamicManifest = {
|
||||
name: window.FileBrowser.Name || "File Browser",
|
||||
short_name: window.FileBrowser.Name || "File Browser",
|
||||
icons: [
|
||||
{
|
||||
src: window.__prependStaticUrl(
|
||||
"/img/icons/android-chrome-192x192.png"
|
||||
),
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: window.__prependStaticUrl(
|
||||
"/img/icons/android-chrome-512x512.png"
|
||||
),
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
start_url: window.location.origin + window.FileBrowser.BaseURL,
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: window.FileBrowser.Color || "#455a64",
|
||||
};
|
||||
|
||||
const stringManifest = JSON.stringify(dynamicManifest);
|
||||
const blob = new Blob([stringManifest], { type: "application/json" });
|
||||
const manifestURL = URL.createObjectURL(blob);
|
||||
document
|
||||
.querySelector("#manifestPlaceholder")
|
||||
.setAttribute("href", manifestURL);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
z-index: 9999;
|
||||
transition: 0.1s ease opacity;
|
||||
-webkit-transition: 0.1s ease opacity;
|
||||
}
|
||||
|
||||
#loading.done {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#loading .spinner {
|
||||
width: 70px;
|
||||
text-align: center;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
#loading .spinner > div {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: #333;
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
-webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
#loading .spinner .bounce1 {
|
||||
-webkit-animation-delay: -0.32s;
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
#loading .spinner .bounce2 {
|
||||
-webkit-animation-delay: -0.16s;
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes sk-bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<div id="loading">
|
||||
<div class="spinner">
|
||||
<div class="bounce1"></div>
|
||||
<div class="bounce2"></div>
|
||||
<div class="bounce3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
frontend/jsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
7962
frontend/package-lock.json
generated
Normal file
73
frontend/package.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "filebrowser-frontend",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"npm": ">=7.0.0",
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"clean": "find ./dist -maxdepth 1 -mindepth 1 ! -name '.gitkeep' -exec rm -r {} +",
|
||||
"typecheck": "vue-tsc -p ./tsconfig.json --noEmit",
|
||||
"lint": "npm run typecheck && eslint --ext .vue,.ts src/",
|
||||
"lint:fix": "eslint --ext .vue,.ts --fix src/",
|
||||
"format": "prettier --write .",
|
||||
"test": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenfengyuan/vue-number-input": "^2.0.1",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"@vueuse/integrations": "^10.9.0",
|
||||
"ace-builds": "^1.32.9",
|
||||
"core-js": "^3.36.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"filesize": "^10.1.1",
|
||||
"js-base64": "^3.7.7",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"material-icons": "^1.13.12",
|
||||
"marked": "^14.1.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"qrcode.vue": "^3.4.1",
|
||||
"tus-js-client": "^4.1.0",
|
||||
"utif": "^3.1.0",
|
||||
"video.js": "^8.10.0",
|
||||
"videojs-hotkeys": "^0.2.28",
|
||||
"videojs-mobile-ui": "^1.1.1",
|
||||
"vue": "^3.4.21",
|
||||
"vue-final-modal": "^4.5.4",
|
||||
"vue-i18n": "^9.10.2",
|
||||
"vue-lazyload": "^3.0.0",
|
||||
"vue-reader": "^1.2.14",
|
||||
"vue-router": "^4.3.0",
|
||||
"vue-toastification": "^2.0.0-rc.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^20.12.2",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@vitejs/plugin-legacy": "^5.3.2",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-vue": "^9.24.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"terser": "^5.30.0",
|
||||
"vite": "^5.2.7",
|
||||
"vite-plugin-compression2": "^1.0.0",
|
||||
"vue-tsc": "^2.0.7"
|
||||
}
|
||||
}
|
||||
80
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://127.0.0.1:5173",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
||||
/* Set default locale to English (US) */
|
||||
locale: "en-US",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
// {
|
||||
// name: "webkit",
|
||||
// use: { ...devices["Desktop Safari"] },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: "http://127.0.0.1:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
5
frontend/postcss.config.cjs
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
0
frontend/public/.gitkeep
Normal file
BIN
frontend/public/img/icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
frontend/public/img/icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/img/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
9
frontend/public/img/icons/browserconfig.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#455a64</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
BIN
frontend/public/img/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 843 B |
BIN
frontend/public/img/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/public/img/icons/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/img/icons/mstile-144x144.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
frontend/public/img/icons/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
frontend/public/img/icons/mstile-310x150.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
frontend/public/img/icons/mstile-310x310.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/img/icons/mstile-70x70.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
42
frontend/public/img/icons/safari-pinned-tab.svg
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M3245 6989 c-522 -39 -1042 -197 -1480 -449 -849 -488 -1459 -1308
|
||||
-1673 -2250 -177 -776 -89 -1582 250 -2301 368 -778 1052 -1418 1857 -1739
|
||||
903 -359 1927 -325 2812 92 778 368 1418 1052 1739 1857 359 903 325 1927 -92
|
||||
2812 -296 627 -806 1175 -1423 1529 -587 338 -1308 500 -1990 449z m555 -580
|
||||
c519 -51 1018 -245 1446 -565 788 -588 1229 -1526 1174 -2496 -16 -277 -58
|
||||
-500 -145 -763 -144 -440 -378 -819 -710 -1150 -452 -452 -1005 -730 -1655
|
||||
-832 -91 -14 -175 -18 -405 -18 -304 0 -369 6 -595 51 -1105 223 -1999 1092
|
||||
-2259 2197 -52 221 -73 412 -73 667 0 397 64 732 204 1080 304 752 886 1334
|
||||
1638 1638 431 174 895 238 1380 191z"/>
|
||||
<path d="M2670 5215 c0 -13 -44 -15 -335 -15 -352 0 -383 -3 -399 -45 -3 -9
|
||||
-6 -758 -6 -1663 0 -1168 -3 -1643 -11 -1632 -8 11 -9 8 -4 -15 3 -16 17 -41
|
||||
31 -55 l24 -25 1530 0 1530 0 24 25 c14 14 26 36 27 50 1 14 1 711 1 1550 l-2
|
||||
1526 -228 142 -229 142 -136 0 -137 0 0 -600 0 -600 -705 0 -705 0 0 615 0
|
||||
615 -135 0 c-113 0 -135 -2 -135 -15z m-264 -190 c57 -29 89 -71 103 -137 35
|
||||
-154 -98 -282 -258 -247 -55 12 -122 62 -148 113 -36 69 -12 186 49 243 62 58
|
||||
170 70 254 28z m2316 -1702 c17 -15 18 -49 18 -670 l0 -653 -1245 0 -1245 0 0
|
||||
654 c0 582 2 656 16 670 14 14 139 16 1226 16 1113 0 1213 -1 1230 -17z
|
||||
m-2602 -1363 c40 -40 13 -100 -43 -100 -60 0 -88 59 -47 100 11 11 31 20 45
|
||||
20 14 0 34 -9 45 -20z m2840 0 c41 -41 11 -100 -52 -100 -35 0 -58 24 -58 60
|
||||
0 54 71 79 110 40z"/>
|
||||
<path d="M2431 3091 c-7 -13 -7 -23 2 -35 11 -15 97 -16 1067 -14 l1055 3 0
|
||||
30 0 30 -1057 3 c-1023 2 -1058 1 -1067 -17z"/>
|
||||
<path d="M2436 2675 c-19 -19 -11 -41 17 -49 41 -11 2067 -7 2088 4 23 13 25
|
||||
46 3 54 -9 3 -483 6 -1054 6 -919 0 -1040 -2 -1054 -15z"/>
|
||||
<path d="M2447 2273 c-14 -4 -17 -13 -15 -36 l3 -32 1049 -3 c767 -1 1052 1
|
||||
1062 9 20 16 17 47 -5 59 -20 10 -2055 13 -2094 3z"/>
|
||||
<path d="M3822 5027 c-21 -23 -22 -30 -22 -293 0 -258 1 -271 20 -292 27 -29
|
||||
56 -35 140 -30 56 3 75 8 93 26 22 22 22 26 22 298 l0 276 -24 19 c-19 16 -40
|
||||
19 -115 19 -84 0 -95 -2 -114 -23z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
147
frontend/public/img/logo.svg
Normal file
@@ -0,0 +1,147 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xml:space="preserve"
|
||||
width="560"
|
||||
height="560"
|
||||
version="1.1"
|
||||
style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
|
||||
viewBox="0 0 560 560"
|
||||
id="svg44"
|
||||
sodipodi:docname="icon_raw.svg"
|
||||
inkscape:version="0.92.3 (2405546, 2018-03-11)"
|
||||
inkscape:export-filename="/home/umarcor/filebrowser/logo/icon_raw.svg.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><metadata
|
||||
id="metadata48"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1366"
|
||||
inkscape:window-height="711"
|
||||
id="namedview46"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.33714286"
|
||||
inkscape:cx="-172.33051"
|
||||
inkscape:cy="280"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="20"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg44" />
|
||||
<defs
|
||||
id="defs4">
|
||||
<style
|
||||
type="text/css"
|
||||
id="style2">
|
||||
<![CDATA[
|
||||
.fil1 {fill:#FEFEFE}
|
||||
.fil6 {fill:#006498}
|
||||
.fil7 {fill:#0EA5EB}
|
||||
.fil8 {fill:#2979FF}
|
||||
.fil3 {fill:#2BBCFF}
|
||||
.fil0 {fill:#455A64}
|
||||
.fil4 {fill:#53C6FC}
|
||||
.fil5 {fill:#BDEAFF}
|
||||
.fil2 {fill:#332C2B;fill-opacity:0.149020}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g
|
||||
id="g85"
|
||||
transform="translate(-70,-70)"><path
|
||||
class="fil1"
|
||||
d="M 350,71 C 504,71 629,196 629,350 629,504 504,629 350,629 196,629 71,504 71,350 71,196 196,71 350,71 Z"
|
||||
id="path9"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#fefefe" /><path
|
||||
class="fil2"
|
||||
d="M 475,236 593,387 C 596,503 444,639 301,585 L 225,486 339,330 c 0,0 138,-95 136,-94 z"
|
||||
id="path11"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#332c2b;fill-opacity:0.14902003" /><path
|
||||
class="fil3"
|
||||
d="m 231,211 h 208 l 38,24 v 246 c 0,5 -3,8 -8,8 H 231 c -5,0 -8,-3 -8,-8 V 219 c 0,-5 3,-8 8,-8 z"
|
||||
id="path13"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#2bbcff" /><path
|
||||
class="fil4"
|
||||
d="m 231,211 h 208 l 38,24 v 2 L 440,214 H 231 c -4,0 -7,3 -7,7 v 263 c -1,-1 -1,-2 -1,-3 V 219 c 0,-5 3,-8 8,-8 z"
|
||||
id="path15"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#53c6fc" /><polygon
|
||||
class="fil5"
|
||||
points="305,212 418,212 418,310 305,310 "
|
||||
id="polygon17"
|
||||
style="fill:#bdeaff" /><path
|
||||
class="fil5"
|
||||
d="m 255,363 h 189 c 3,0 5,2 5,4 V 483 H 250 V 367 c 0,-2 2,-4 5,-4 z"
|
||||
id="path19"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#bdeaff" /><polygon
|
||||
class="fil6"
|
||||
points="250,470 449,470 449,483 250,483 "
|
||||
id="polygon21"
|
||||
style="fill:#006498" /><path
|
||||
class="fil6"
|
||||
d="m 380,226 h 10 c 3,0 6,2 6,5 v 40 c 0,3 -3,6 -6,6 h -10 c -3,0 -6,-3 -6,-6 v -40 c 0,-3 3,-5 6,-5 z"
|
||||
id="path23"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#006498" /><path
|
||||
class="fil1"
|
||||
d="m 254,226 c 10,0 17,7 17,17 0,9 -7,16 -17,16 -9,0 -17,-7 -17,-16 0,-10 8,-17 17,-17 z"
|
||||
id="path25"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#fefefe" /><path
|
||||
class="fil6"
|
||||
d="m 267,448 h 165 c 2,0 3,1 3,3 v 0 c 0,1 -1,3 -3,3 H 267 c -2,0 -3,-2 -3,-3 v 0 c 0,-2 1,-3 3,-3 z"
|
||||
id="path27"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#006498" /><path
|
||||
class="fil6"
|
||||
d="m 267,415 h 165 c 2,0 3,1 3,3 v 0 c 0,1 -1,2 -3,2 H 267 c -2,0 -3,-1 -3,-2 v 0 c 0,-2 1,-3 3,-3 z"
|
||||
id="path29"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#006498" /><path
|
||||
class="fil6"
|
||||
d="m 267,381 h 165 c 2,0 3,2 3,3 v 0 c 0,2 -1,3 -3,3 H 267 c -2,0 -3,-1 -3,-3 v 0 c 0,-1 1,-3 3,-3 z"
|
||||
id="path31"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#006498" /><path
|
||||
class="fil1"
|
||||
d="m 236,472 c 3,0 5,2 5,5 0,2 -2,4 -5,4 -3,0 -5,-2 -5,-4 0,-3 2,-5 5,-5 z"
|
||||
id="path33"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#fefefe" /><path
|
||||
class="fil1"
|
||||
d="m 463,472 c 3,0 5,2 5,5 0,2 -2,4 -5,4 -3,0 -5,-2 -5,-4 0,-3 2,-5 5,-5 z"
|
||||
id="path35"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#fefefe" /><polygon
|
||||
class="fil6"
|
||||
points="305,212 284,212 284,310 305,310 "
|
||||
id="polygon37"
|
||||
style="fill:#006498" /><path
|
||||
class="fil7"
|
||||
d="m 477,479 v 2 c 0,5 -3,8 -8,8 H 231 c -5,0 -8,-3 -8,-8 v -2 c 0,4 3,8 8,8 h 238 c 5,0 8,-4 8,-8 z"
|
||||
id="path39"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#0ea5eb" /><path
|
||||
class="fil8"
|
||||
d="M 350,70 C 505,70 630,195 630,350 630,505 505,630 350,630 195,630 70,505 70,350 70,195 195,70 350,70 Z m 0,46 C 479,116 584,221 584,350 584,479 479,584 350,584 221,584 116,479 116,350 116,221 221,116 350,116 Z"
|
||||
id="path41"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#2979ff" /></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
190
frontend/public/index.html
Normal file
@@ -0,0 +1,190 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, user-scalable=no"
|
||||
/>
|
||||
|
||||
[{[ if .ReCaptcha -]}]
|
||||
<script src="[{[ .ReCaptchaHost ]}]/recaptcha/api.js?render=explicit"></script>
|
||||
[{[ end ]}]
|
||||
|
||||
<title>
|
||||
[{[ if .Name -]}][{[ .Name ]}][{[ else ]}]File Browser[{[ end ]}]
|
||||
</title>
|
||||
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="[{[ .StaticURL ]}]/img/icons/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="[{[ .StaticURL ]}]/img/icons/favicon-16x16.png"
|
||||
/>
|
||||
|
||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||
<link
|
||||
rel="manifest"
|
||||
id="manifestPlaceholder"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]"
|
||||
/>
|
||||
|
||||
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="assets" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png"
|
||||
/>
|
||||
|
||||
<!-- Add to home screen for Windows -->
|
||||
<meta
|
||||
name="msapplication-TileImage"
|
||||
content="[{[ .StaticURL ]}]/img/icons/mstile-144x144.png"
|
||||
/>
|
||||
<meta
|
||||
name="msapplication-TileColor"
|
||||
content="[{[ if .Color -]}][{[ .Color ]}][{[ else ]}]#2979ff[{[ end ]}]"
|
||||
/>
|
||||
|
||||
<!-- Inject Some Variables and generate the manifest json -->
|
||||
<script>
|
||||
// We can assign JSON directly
|
||||
window.FileBrowser = [{[ .Json ]}];
|
||||
// Global function to prepend static url
|
||||
window.__prependStaticUrl = (url) => {
|
||||
return `${window.FileBrowser.StaticURL}/${url.replace(/^\/+/, "")}`;
|
||||
};
|
||||
var dynamicManifest = {
|
||||
name: window.FileBrowser.Name || "File Browser",
|
||||
short_name: window.FileBrowser.Name || "File Browser",
|
||||
icons: [
|
||||
{
|
||||
src: window.__prependStaticUrl("/img/icons/android-chrome-192x192.png"),
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: window.__prependStaticUrl("/img/icons/android-chrome-512x512.png"),
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
start_url: window.location.origin + window.FileBrowser.BaseURL,
|
||||
display: "standalone",
|
||||
background_color: "#ffffff",
|
||||
theme_color: window.FileBrowser.Color || "#455a64",
|
||||
};
|
||||
|
||||
const stringManifest = JSON.stringify(dynamicManifest);
|
||||
const blob = new Blob([stringManifest], { type: "application/json" });
|
||||
const manifestURL = URL.createObjectURL(blob);
|
||||
document
|
||||
.querySelector("#manifestPlaceholder")
|
||||
.setAttribute("href", manifestURL);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
z-index: 9999;
|
||||
transition: 0.1s ease opacity;
|
||||
-webkit-transition: 0.1s ease opacity;
|
||||
}
|
||||
|
||||
#loading.done {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#loading .spinner {
|
||||
width: 70px;
|
||||
text-align: center;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-webkit-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
#loading .spinner > div {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: #333;
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
-webkit-animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
animation: sk-bouncedelay 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
#loading .spinner .bounce1 {
|
||||
-webkit-animation-delay: -0.32s;
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
#loading .spinner .bounce2 {
|
||||
-webkit-animation-delay: -0.16s;
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes sk-bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-bouncedelay {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<div id="loading">
|
||||
<div class="spinner">
|
||||
<div class="bounce1"></div>
|
||||
<div class="bounce2"></div>
|
||||
<div class="bounce3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
[{[ if .CSS -]}]
|
||||
<link rel="stylesheet" href="[{[ .StaticURL ]}]/custom.css" />
|
||||
[{[ end ]}]
|
||||
</body>
|
||||
</html>
|
||||
20
frontend/public/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "File Browser",
|
||||
"short_name": "File Browser",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./img/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./static/img/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#455a64"
|
||||
}
|
||||
33
frontend/src/App.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div>
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { setHtmlLocale } from "./i18n";
|
||||
import { getMediaPreference, getTheme, setTheme } from "./utils/theme";
|
||||
|
||||
const { locale } = useI18n();
|
||||
|
||||
const userTheme = ref<UserTheme>(getTheme() || getMediaPreference());
|
||||
|
||||
onMounted(() => {
|
||||
setTheme(userTheme.value);
|
||||
setHtmlLocale(locale.value);
|
||||
// this might be null during HMR
|
||||
const loading = document.getElementById("loading");
|
||||
loading?.classList.add("done");
|
||||
|
||||
setTimeout(function () {
|
||||
loading?.parentNode?.removeChild(loading);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// handles ltr/rtl changes
|
||||
watch(locale, (newValue) => {
|
||||
newValue && setHtmlLocale(newValue);
|
||||
});
|
||||
</script>
|
||||
23
frontend/src/api/commands.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { removePrefix } from "./utils";
|
||||
import { baseURL } from "@/utils/constants";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const ssl = window.location.protocol === "https:";
|
||||
const protocol = ssl ? "wss:" : "ws:";
|
||||
|
||||
export default function command(
|
||||
url: string,
|
||||
command: string,
|
||||
onmessage: WebSocket["onmessage"],
|
||||
onclose: WebSocket["onclose"]
|
||||
) {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
url = removePrefix(url);
|
||||
url = `${protocol}//${window.location.host}${baseURL}/api/command${url}?auth=${authStore.jwt}`;
|
||||
|
||||
const conn = new window.WebSocket(url);
|
||||
conn.onopen = () => conn.send(command);
|
||||
conn.onmessage = onmessage;
|
||||
conn.onclose = onclose;
|
||||
}
|
||||
217
frontend/src/api/files.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { createURL, fetchURL, removePrefix } from "./utils";
|
||||
import { baseURL } from "@/utils/constants";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { upload as postTus, useTus } from "./tus";
|
||||
|
||||
export async function fetch(url: string) {
|
||||
url = removePrefix(url);
|
||||
|
||||
const res = await fetchURL(`/api/resources${url}`, {});
|
||||
|
||||
const data = (await res.json()) as Resource;
|
||||
data.url = `/files${url}`;
|
||||
|
||||
if (data.isDir) {
|
||||
if (!data.url.endsWith("/")) data.url += "/";
|
||||
// Perhaps change the any
|
||||
data.items = data.items.map((item: any, index: any) => {
|
||||
item.index = index;
|
||||
item.url = `${data.url}${encodeURIComponent(item.name)}`;
|
||||
|
||||
if (item.isDir) {
|
||||
item.url += "/";
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function resourceAction(url: string, method: ApiMethod, content?: any) {
|
||||
url = removePrefix(url);
|
||||
|
||||
const opts: ApiOpts = {
|
||||
method,
|
||||
};
|
||||
|
||||
if (content) {
|
||||
opts.body = content;
|
||||
}
|
||||
|
||||
const res = await fetchURL(`/api/resources${url}`, opts);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function remove(url: string) {
|
||||
return resourceAction(url, "DELETE");
|
||||
}
|
||||
|
||||
export async function put(url: string, content = "") {
|
||||
return resourceAction(url, "PUT", content);
|
||||
}
|
||||
|
||||
export function download(format: any, ...files: string[]) {
|
||||
let url = `${baseURL}/api/raw`;
|
||||
|
||||
if (files.length === 1) {
|
||||
url += removePrefix(files[0]) + "?";
|
||||
} else {
|
||||
let arg = "";
|
||||
|
||||
for (const file of files) {
|
||||
arg += removePrefix(file) + ",";
|
||||
}
|
||||
|
||||
arg = arg.substring(0, arg.length - 1);
|
||||
arg = encodeURIComponent(arg);
|
||||
url += `/?files=${arg}&`;
|
||||
}
|
||||
|
||||
if (format) {
|
||||
url += `algo=${format}&`;
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
if (authStore.jwt) {
|
||||
url += `auth=${authStore.jwt}&`;
|
||||
}
|
||||
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
export async function post(
|
||||
url: string,
|
||||
content: ApiContent = "",
|
||||
overwrite = false,
|
||||
onupload: any = () => {}
|
||||
) {
|
||||
// Use the pre-existing API if:
|
||||
const useResourcesApi =
|
||||
// a folder is being created
|
||||
url.endsWith("/") ||
|
||||
// We're not using http(s)
|
||||
(content instanceof Blob &&
|
||||
!["http:", "https:"].includes(window.location.protocol)) ||
|
||||
// Tus is disabled / not applicable
|
||||
!(await useTus(content));
|
||||
return useResourcesApi
|
||||
? postResources(url, content, overwrite, onupload)
|
||||
: postTus(url, content, overwrite, onupload);
|
||||
}
|
||||
|
||||
async function postResources(
|
||||
url: string,
|
||||
content: ApiContent = "",
|
||||
overwrite = false,
|
||||
onupload: any
|
||||
) {
|
||||
url = removePrefix(url);
|
||||
|
||||
let bufferContent: ArrayBuffer;
|
||||
if (
|
||||
content instanceof Blob &&
|
||||
!["http:", "https:"].includes(window.location.protocol)
|
||||
) {
|
||||
bufferContent = await new Response(content).arrayBuffer();
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open(
|
||||
"POST",
|
||||
`${baseURL}/api/resources${url}?override=${overwrite}`,
|
||||
true
|
||||
);
|
||||
request.setRequestHeader("X-Auth", authStore.jwt);
|
||||
|
||||
if (typeof onupload === "function") {
|
||||
request.upload.onprogress = onupload;
|
||||
}
|
||||
|
||||
request.onload = () => {
|
||||
if (request.status === 200) {
|
||||
resolve(request.responseText);
|
||||
} else if (request.status === 409) {
|
||||
reject(request.status);
|
||||
} else {
|
||||
reject(request.responseText);
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error("001 Connection aborted"));
|
||||
};
|
||||
|
||||
request.send(bufferContent || content);
|
||||
});
|
||||
}
|
||||
|
||||
function moveCopy(
|
||||
items: any[],
|
||||
copy = false,
|
||||
overwrite = false,
|
||||
rename = false
|
||||
) {
|
||||
const promises = [];
|
||||
|
||||
for (const item of items) {
|
||||
const from = item.from;
|
||||
const to = encodeURIComponent(removePrefix(item.to ?? ""));
|
||||
const url = `${from}?action=${
|
||||
copy ? "copy" : "rename"
|
||||
}&destination=${to}&override=${overwrite}&rename=${rename}`;
|
||||
promises.push(resourceAction(url, "PATCH"));
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
export function move(items: any[], overwrite = false, rename = false) {
|
||||
return moveCopy(items, false, overwrite, rename);
|
||||
}
|
||||
|
||||
export function copy(items: any[], overwrite = false, rename = false) {
|
||||
return moveCopy(items, true, overwrite, rename);
|
||||
}
|
||||
|
||||
export async function checksum(url: string, algo: ChecksumAlg) {
|
||||
const data = await resourceAction(`${url}?checksum=${algo}`, "GET");
|
||||
return (await data.json()).checksums[algo];
|
||||
}
|
||||
|
||||
export function getDownloadURL(file: ResourceItem, inline: any) {
|
||||
const params = {
|
||||
...(inline && { inline: "true" }),
|
||||
};
|
||||
|
||||
return createURL("api/raw" + file.path, params);
|
||||
}
|
||||
|
||||
export function getPreviewURL(file: ResourceItem, size: string) {
|
||||
const params = {
|
||||
inline: "true",
|
||||
key: Date.parse(file.modified),
|
||||
};
|
||||
|
||||
return createURL("api/preview/" + size + file.path, params);
|
||||
}
|
||||
|
||||
export function getSubtitlesURL(file: ResourceItem) {
|
||||
const params = {
|
||||
inline: "true",
|
||||
};
|
||||
|
||||
return file.subtitles?.map((d) => createURL("api/subtitle" + d, params));
|
||||
}
|
||||
|
||||
export async function usage(url: string) {
|
||||
url = removePrefix(url);
|
||||
|
||||
const res = await fetchURL(`/api/usage${url}`, {});
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
9
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as files from "./files";
|
||||
import * as share from "./share";
|
||||
import * as users from "./users";
|
||||
import * as settings from "./settings";
|
||||
import * as pub from "./pub";
|
||||
import search from "./search";
|
||||
import commands from "./commands";
|
||||
|
||||
export { files, share, users, settings, pub, commands, search };
|
||||
75
frontend/src/api/pub.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { fetchURL, removePrefix, createURL } from "./utils";
|
||||
import { baseURL } from "@/utils/constants";
|
||||
|
||||
export async function fetch(url: string, password: string = "") {
|
||||
url = removePrefix(url);
|
||||
|
||||
const res = await fetchURL(
|
||||
`/api/public/share${url}`,
|
||||
{
|
||||
headers: { "X-SHARE-PASSWORD": encodeURIComponent(password) },
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
const data = (await res.json()) as Resource;
|
||||
data.url = `/share${url}`;
|
||||
|
||||
if (data.isDir) {
|
||||
if (!data.url.endsWith("/")) data.url += "/";
|
||||
data.items = data.items.map((item: any, index: any) => {
|
||||
item.index = index;
|
||||
item.url = `${data.url}${encodeURIComponent(item.name)}`;
|
||||
|
||||
if (item.isDir) {
|
||||
item.url += "/";
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function download(
|
||||
format: DownloadFormat,
|
||||
hash: string,
|
||||
token: string,
|
||||
...files: string[]
|
||||
) {
|
||||
let url = `${baseURL}/api/public/dl/${hash}`;
|
||||
|
||||
if (files.length === 1) {
|
||||
url += encodeURIComponent(files[0]) + "?";
|
||||
} else {
|
||||
let arg = "";
|
||||
|
||||
for (const file of files) {
|
||||
arg += encodeURIComponent(file) + ",";
|
||||
}
|
||||
|
||||
arg = arg.substring(0, arg.length - 1);
|
||||
arg = encodeURIComponent(arg);
|
||||
url += `/?files=${arg}&`;
|
||||
}
|
||||
|
||||
if (format) {
|
||||
url += `algo=${format}&`;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
url += `token=${token}&`;
|
||||
}
|
||||
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
export function getDownloadURL(res: Resource, inline = false) {
|
||||
const params = {
|
||||
...(inline && { inline: "true" }),
|
||||
...(res.token && { token: res.token }),
|
||||
};
|
||||
|
||||
return createURL("api/public/dl/" + res.hash + res.path, params, false);
|
||||
}
|
||||
27
frontend/src/api/search.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { fetchURL, removePrefix } from "./utils";
|
||||
import url from "../utils/url";
|
||||
|
||||
export default async function search(base: string, query: string) {
|
||||
base = removePrefix(base);
|
||||
query = encodeURIComponent(query);
|
||||
|
||||
if (!base.endsWith("/")) {
|
||||
base += "/";
|
||||
}
|
||||
|
||||
const res = await fetchURL(`/api/search${base}?query=${query}`, {});
|
||||
|
||||
let data = await res.json();
|
||||
|
||||
data = data.map((item: UploadItem) => {
|
||||
item.url = `/files${base}` + url.encodePath(item.path);
|
||||
|
||||
if (item.dir) {
|
||||
item.url += "/";
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
12
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { fetchURL, fetchJSON } from "./utils";
|
||||
|
||||
export function get() {
|
||||
return fetchJSON<ISettings>(`/api/settings`, {});
|
||||
}
|
||||
|
||||
export async function update(settings: ISettings) {
|
||||
await fetchURL(`/api/settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
45
frontend/src/api/share.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { fetchURL, fetchJSON, removePrefix, createURL } from "./utils";
|
||||
|
||||
export async function list() {
|
||||
return fetchJSON<Share[]>("/api/shares");
|
||||
}
|
||||
|
||||
export async function get(url: string) {
|
||||
url = removePrefix(url);
|
||||
return fetchJSON<Share>(`/api/share${url}`);
|
||||
}
|
||||
|
||||
export async function remove(hash: string) {
|
||||
await fetchURL(`/api/share/${hash}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function create(
|
||||
url: string,
|
||||
password = "",
|
||||
expires = "",
|
||||
unit = "hours"
|
||||
) {
|
||||
url = removePrefix(url);
|
||||
url = `/api/share${url}`;
|
||||
if (expires !== "") {
|
||||
url += `?expires=${expires}&unit=${unit}`;
|
||||
}
|
||||
let body = "{}";
|
||||
if (password != "" || expires !== "" || unit !== "hours") {
|
||||
body = JSON.stringify({
|
||||
password: password,
|
||||
expires: expires.toString(), // backend expects string not number
|
||||
unit: unit,
|
||||
});
|
||||
}
|
||||
return fetchJSON(url, {
|
||||
method: "POST",
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
|
||||
export function getShareURL(share: Share) {
|
||||
return createURL("share/" + share.hash, {}, false);
|
||||
}
|
||||
213
frontend/src/api/tus.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import * as tus from "tus-js-client";
|
||||
import { baseURL, tusEndpoint, tusSettings } from "@/utils/constants";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useUploadStore } from "@/stores/upload";
|
||||
import { removePrefix } from "@/api/utils";
|
||||
import { fetchURL } from "./utils";
|
||||
|
||||
const RETRY_BASE_DELAY = 1000;
|
||||
const RETRY_MAX_DELAY = 20000;
|
||||
const SPEED_UPDATE_INTERVAL = 1000;
|
||||
const ALPHA = 0.2;
|
||||
const ONE_MINUS_ALPHA = 1 - ALPHA;
|
||||
const RECENT_SPEEDS_LIMIT = 5;
|
||||
const MB_DIVISOR = 1024 * 1024;
|
||||
const CURRENT_UPLOAD_LIST: CurrentUploadList = {};
|
||||
|
||||
export async function upload(
|
||||
filePath: string,
|
||||
content: ApiContent = "",
|
||||
overwrite = false,
|
||||
onupload: any
|
||||
) {
|
||||
if (!tusSettings) {
|
||||
// Shouldn't happen as we check for tus support before calling this function
|
||||
throw new Error("Tus.io settings are not defined");
|
||||
}
|
||||
|
||||
filePath = removePrefix(filePath);
|
||||
const resourcePath = `${tusEndpoint}${filePath}?override=${overwrite}`;
|
||||
|
||||
await createUpload(resourcePath);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Exit early because of typescript, tus content can't be a string
|
||||
if (content === "") {
|
||||
return false;
|
||||
}
|
||||
return new Promise<void | string>((resolve, reject) => {
|
||||
const upload = new tus.Upload(content, {
|
||||
uploadUrl: `${baseURL}${resourcePath}`,
|
||||
chunkSize: tusSettings.chunkSize,
|
||||
retryDelays: computeRetryDelays(tusSettings),
|
||||
parallelUploads: 1,
|
||||
storeFingerprintForResuming: false,
|
||||
headers: {
|
||||
"X-Auth": authStore.jwt,
|
||||
},
|
||||
onError: function (error) {
|
||||
if (CURRENT_UPLOAD_LIST[filePath].interval) {
|
||||
clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
|
||||
}
|
||||
delete CURRENT_UPLOAD_LIST[filePath];
|
||||
reject(new Error(`Upload failed: ${error.message}`));
|
||||
},
|
||||
onProgress: function (bytesUploaded) {
|
||||
const fileData = CURRENT_UPLOAD_LIST[filePath];
|
||||
fileData.currentBytesUploaded = bytesUploaded;
|
||||
|
||||
if (!fileData.hasStarted) {
|
||||
fileData.hasStarted = true;
|
||||
fileData.lastProgressTimestamp = Date.now();
|
||||
|
||||
fileData.interval = window.setInterval(() => {
|
||||
calcProgress(filePath);
|
||||
}, SPEED_UPDATE_INTERVAL);
|
||||
}
|
||||
if (typeof onupload === "function") {
|
||||
onupload({ loaded: bytesUploaded });
|
||||
}
|
||||
},
|
||||
onSuccess: function () {
|
||||
if (CURRENT_UPLOAD_LIST[filePath].interval) {
|
||||
clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
|
||||
}
|
||||
delete CURRENT_UPLOAD_LIST[filePath];
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
CURRENT_UPLOAD_LIST[filePath] = {
|
||||
upload: upload,
|
||||
recentSpeeds: [],
|
||||
initialBytesUploaded: 0,
|
||||
currentBytesUploaded: 0,
|
||||
currentAverageSpeed: 0,
|
||||
lastProgressTimestamp: null,
|
||||
sumOfRecentSpeeds: 0,
|
||||
hasStarted: false,
|
||||
interval: undefined,
|
||||
};
|
||||
upload.start();
|
||||
});
|
||||
}
|
||||
|
||||
async function createUpload(resourcePath: string) {
|
||||
const headResp = await fetchURL(resourcePath, {
|
||||
method: "POST",
|
||||
});
|
||||
if (headResp.status !== 201) {
|
||||
throw new Error(
|
||||
`Failed to create an upload: ${headResp.status} ${headResp.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function computeRetryDelays(tusSettings: TusSettings): number[] | undefined {
|
||||
if (!tusSettings.retryCount || tusSettings.retryCount < 1) {
|
||||
// Disable retries altogether
|
||||
return undefined;
|
||||
}
|
||||
// The tus client expects our retries as an array with computed backoffs
|
||||
// E.g.: [0, 3000, 5000, 10000, 20000]
|
||||
const retryDelays = [];
|
||||
let delay = 0;
|
||||
|
||||
for (let i = 0; i < tusSettings.retryCount; i++) {
|
||||
retryDelays.push(Math.min(delay, RETRY_MAX_DELAY));
|
||||
delay =
|
||||
delay === 0 ? RETRY_BASE_DELAY : Math.min(delay * 2, RETRY_MAX_DELAY);
|
||||
}
|
||||
|
||||
return retryDelays;
|
||||
}
|
||||
|
||||
export async function useTus(content: ApiContent) {
|
||||
return isTusSupported() && content instanceof Blob;
|
||||
}
|
||||
|
||||
function isTusSupported() {
|
||||
return tus.isSupported === true;
|
||||
}
|
||||
|
||||
function computeETA(state: ETAState, speed?: number) {
|
||||
if (state.speedMbyte === 0) {
|
||||
return Infinity;
|
||||
}
|
||||
const totalSize = state.sizes.reduce(
|
||||
(acc: number, size: number) => acc + size,
|
||||
0
|
||||
);
|
||||
const uploadedSize = state.progress.reduce(
|
||||
(acc: number, progress: Progress) => {
|
||||
if (typeof progress === "number") {
|
||||
return acc + progress;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
0
|
||||
);
|
||||
const remainingSize = totalSize - uploadedSize;
|
||||
const speedBytesPerSecond = (speed ?? state.speedMbyte) * 1024 * 1024;
|
||||
return remainingSize / speedBytesPerSecond;
|
||||
}
|
||||
|
||||
function computeGlobalSpeedAndETA() {
|
||||
const uploadStore = useUploadStore();
|
||||
let totalSpeed = 0;
|
||||
let totalCount = 0;
|
||||
|
||||
for (const filePath in CURRENT_UPLOAD_LIST) {
|
||||
totalSpeed += CURRENT_UPLOAD_LIST[filePath].currentAverageSpeed;
|
||||
totalCount++;
|
||||
}
|
||||
|
||||
if (totalCount === 0) return { speed: 0, eta: Infinity };
|
||||
|
||||
const averageSpeed = totalSpeed / totalCount;
|
||||
const averageETA = computeETA(uploadStore, averageSpeed);
|
||||
|
||||
return { speed: averageSpeed, eta: averageETA };
|
||||
}
|
||||
|
||||
function calcProgress(filePath: string) {
|
||||
const uploadStore = useUploadStore();
|
||||
const fileData = CURRENT_UPLOAD_LIST[filePath];
|
||||
|
||||
const elapsedTime =
|
||||
(Date.now() - (fileData.lastProgressTimestamp ?? 0)) / 1000;
|
||||
const bytesSinceLastUpdate =
|
||||
fileData.currentBytesUploaded - fileData.initialBytesUploaded;
|
||||
const currentSpeed = bytesSinceLastUpdate / MB_DIVISOR / elapsedTime;
|
||||
|
||||
if (fileData.recentSpeeds.length >= RECENT_SPEEDS_LIMIT) {
|
||||
fileData.sumOfRecentSpeeds -= fileData.recentSpeeds.shift() ?? 0;
|
||||
}
|
||||
|
||||
fileData.recentSpeeds.push(currentSpeed);
|
||||
fileData.sumOfRecentSpeeds += currentSpeed;
|
||||
|
||||
const avgRecentSpeed =
|
||||
fileData.sumOfRecentSpeeds / fileData.recentSpeeds.length;
|
||||
fileData.currentAverageSpeed =
|
||||
ALPHA * avgRecentSpeed + ONE_MINUS_ALPHA * fileData.currentAverageSpeed;
|
||||
|
||||
const { speed, eta } = computeGlobalSpeedAndETA();
|
||||
uploadStore.setUploadSpeed(speed);
|
||||
uploadStore.setETA(eta);
|
||||
|
||||
fileData.initialBytesUploaded = fileData.currentBytesUploaded;
|
||||
fileData.lastProgressTimestamp = Date.now();
|
||||
}
|
||||
|
||||
export function abortAllUploads() {
|
||||
for (const filePath in CURRENT_UPLOAD_LIST) {
|
||||
if (CURRENT_UPLOAD_LIST[filePath].interval) {
|
||||
clearInterval(CURRENT_UPLOAD_LIST[filePath].interval);
|
||||
}
|
||||
if (CURRENT_UPLOAD_LIST[filePath].upload) {
|
||||
CURRENT_UPLOAD_LIST[filePath].upload.abort(true);
|
||||
}
|
||||
delete CURRENT_UPLOAD_LIST[filePath];
|
||||
}
|
||||
}
|
||||
43
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { fetchURL, fetchJSON, StatusError } from "./utils";
|
||||
|
||||
export async function getAll() {
|
||||
return fetchJSON<IUser[]>(`/api/users`, {});
|
||||
}
|
||||
|
||||
export async function get(id: number) {
|
||||
return fetchJSON<IUser>(`/api/users/${id}`, {});
|
||||
}
|
||||
|
||||
export async function create(user: IUser) {
|
||||
const res = await fetchURL(`/api/users`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
what: "user",
|
||||
which: [],
|
||||
data: user,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 201) {
|
||||
return res.headers.get("Location");
|
||||
}
|
||||
|
||||
throw new StatusError(await res.text(), res.status);
|
||||
}
|
||||
|
||||
export async function update(user: IUser, which = ["all"]) {
|
||||
await fetchURL(`/api/users/${user.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
what: "user",
|
||||
which: which,
|
||||
data: user,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function remove(id: number) {
|
||||
await fetchURL(`/api/users/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
98
frontend/src/api/utils.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { renew, logout } from "@/utils/auth";
|
||||
import { baseURL } from "@/utils/constants";
|
||||
import { encodePath } from "@/utils/url";
|
||||
|
||||
export class StatusError extends Error {
|
||||
constructor(
|
||||
message: any,
|
||||
public status?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "StatusError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchURL(
|
||||
url: string,
|
||||
opts: ApiOpts,
|
||||
auth = true
|
||||
): Promise<Response> {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
opts = opts || {};
|
||||
opts.headers = opts.headers || {};
|
||||
|
||||
const { headers, ...rest } = opts;
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${baseURL}${url}`, {
|
||||
headers: {
|
||||
"X-Auth": authStore.jwt,
|
||||
...headers,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
} catch {
|
||||
throw new StatusError("000 No connection", 0);
|
||||
}
|
||||
|
||||
if (auth && res.headers.get("X-Renew-Token") === "true") {
|
||||
await renew(authStore.jwt);
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status > 299) {
|
||||
const body = await res.text();
|
||||
const error = new StatusError(
|
||||
body || `${res.status} ${res.statusText}`,
|
||||
res.status
|
||||
);
|
||||
|
||||
if (auth && res.status == 401) {
|
||||
logout();
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function fetchJSON<T>(url: string, opts?: any): Promise<T> {
|
||||
const res = await fetchURL(url, opts);
|
||||
|
||||
if (res.status === 200) {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
throw new StatusError(`${res.status} ${res.statusText}`, res.status);
|
||||
}
|
||||
|
||||
export function removePrefix(url: string): string {
|
||||
url = url.split("/").splice(2).join("/");
|
||||
|
||||
if (url === "") url = "/";
|
||||
if (url[0] !== "/") url = "/" + url;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function createURL(endpoint: string, params = {}, auth = true): string {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
let prefix = baseURL;
|
||||
if (!prefix.endsWith("/")) {
|
||||
prefix = prefix + "/";
|
||||
}
|
||||
const url = new URL(prefix + encodePath(endpoint), origin);
|
||||
|
||||
const searchParams: SearchParams = {
|
||||
...(auth && { auth: authStore.jwt }),
|
||||
...params,
|
||||
};
|
||||
|
||||
for (const key in searchParams) {
|
||||
url.searchParams.set(key, searchParams[key]);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
BIN
frontend/src/assets/fonts/roboto/bold-cyrillic-ext.woff2
Executable file
BIN
frontend/src/assets/fonts/roboto/bold-cyrillic.woff2
Executable file
BIN
frontend/src/assets/fonts/roboto/bold-greek-ext.woff2
Executable file
BIN
frontend/src/assets/fonts/roboto/bold-greek.woff2
Executable file
BIN
frontend/src/assets/fonts/roboto/bold-latin-ext.woff2
Executable file
BIN
frontend/src/assets/fonts/roboto/bold-latin.woff2
Executable file
BIN
frontend/src/assets/fonts/roboto/bold-vietnamese.woff2
Executable file
BIN
frontend/src/assets/fonts/roboto/medium-cyrillic-ext.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/medium-cyrillic.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/medium-greek-ext.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/medium-greek.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/medium-latin-ext.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/medium-latin.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/medium-vietnamese.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/normal-cyrillic-ext.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/normal-cyrillic.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/normal-greek-ext.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/normal-greek.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/normal-latin-ext.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/normal-latin.woff2
Normal file
BIN
frontend/src/assets/fonts/roboto/normal-vietnamese.woff2
Normal file
83
frontend/src/components/Breadcrumbs.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="breadcrumbs">
|
||||
<component
|
||||
:is="element"
|
||||
:to="base || ''"
|
||||
:aria-label="t('files.home')"
|
||||
:title="t('files.home')"
|
||||
>
|
||||
<i class="material-icons">home</i>
|
||||
</component>
|
||||
|
||||
<span v-for="(link, index) in items" :key="index">
|
||||
<span class="chevron"
|
||||
><i class="material-icons">keyboard_arrow_right</i></span
|
||||
>
|
||||
<component :is="element" :to="link.url">{{ link.name }}</component>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const props = defineProps<{
|
||||
base: string;
|
||||
noLink?: boolean;
|
||||
}>();
|
||||
|
||||
const items = computed(() => {
|
||||
const relativePath = route.path.replace(props.base, "");
|
||||
let parts = relativePath.split("/");
|
||||
|
||||
if (parts[0] === "") {
|
||||
parts.shift();
|
||||
}
|
||||
|
||||
if (parts[parts.length - 1] === "") {
|
||||
parts.pop();
|
||||
}
|
||||
|
||||
let breadcrumbs: BreadCrumb[] = [];
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (i === 0) {
|
||||
breadcrumbs.push({
|
||||
name: decodeURIComponent(parts[i]),
|
||||
url: props.base + "/" + parts[i] + "/",
|
||||
});
|
||||
} else {
|
||||
breadcrumbs.push({
|
||||
name: decodeURIComponent(parts[i]),
|
||||
url: breadcrumbs[i - 1].url + parts[i] + "/",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (breadcrumbs.length > 3) {
|
||||
while (breadcrumbs.length !== 4) {
|
||||
breadcrumbs.shift();
|
||||
}
|
||||
|
||||
breadcrumbs[0].name = "...";
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
});
|
||||
|
||||
const element = computed(() => {
|
||||
if (props.noLink) {
|
||||
return "span";
|
||||
}
|
||||
|
||||
return "router-link";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
45
frontend/src/components/CustomToast.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="t-container">
|
||||
<span>{{ message }}</span>
|
||||
<button v-if="isReport" class="action" @click.stop="clicked">
|
||||
{{ reportText }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message: string;
|
||||
reportText?: string;
|
||||
isReport?: boolean;
|
||||
}>();
|
||||
|
||||
const clicked = () => {
|
||||
window.open("https://github.com/filebrowser/filebrowser/issues/new/choose");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.t-container {
|
||||
width: 100%;
|
||||
padding: 5px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.action {
|
||||
text-align: center;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
margin-left: 20px;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border: thin solid currentColor;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .action {
|
||||
margin-left: initial;
|
||||
margin-right: 20px;
|
||||
}
|
||||
</style>
|
||||
224
frontend/src/components/ProgressBar.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<!-- This component taken directly from vue-simple-progress
|
||||
since it didnt support Vue 3 but the component itself does
|
||||
https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/components/Progress.vue -->
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="vue-simple-progress-text"
|
||||
:style="text_style"
|
||||
v-if="text.length > 0 && textPosition == 'top'"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="vue-simple-progress" :style="progress_style">
|
||||
<div
|
||||
class="vue-simple-progress-text"
|
||||
:style="text_style"
|
||||
v-if="text.length > 0 && textPosition == 'middle'"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
<div
|
||||
style="position: relative; left: -9999px"
|
||||
:style="text_style"
|
||||
v-if="text.length > 0 && textPosition == 'inside'"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="vue-simple-progress-bar" :style="bar_style">
|
||||
<div
|
||||
:style="text_style"
|
||||
v-if="text.length > 0 && textPosition == 'inside'"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="vue-simple-progress-text"
|
||||
:style="text_style"
|
||||
v-if="text.length > 0 && textPosition == 'bottom'"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// We're leaving this untouched as you can read in the beginning
|
||||
var isNumber = function (n) {
|
||||
return !isNaN(parseFloat(n)) && isFinite(n);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "progress-bar",
|
||||
props: {
|
||||
val: {
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
default: 100,
|
||||
},
|
||||
size: {
|
||||
// either a number (pixel width/height) or 'tiny', 'small',
|
||||
// 'medium', 'large', 'huge', 'massive' for common sizes
|
||||
default: 3,
|
||||
},
|
||||
"bg-color": {
|
||||
type: String,
|
||||
default: "#eee",
|
||||
},
|
||||
"bar-color": {
|
||||
type: String,
|
||||
default: "#2196f3", // match .blue color to Material Design's 'Blue 500' color
|
||||
},
|
||||
"bar-transition": {
|
||||
type: String,
|
||||
default: "all 0.5s ease",
|
||||
},
|
||||
"bar-border-radius": {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
spacing: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
"text-align": {
|
||||
type: String,
|
||||
default: "center", // 'left', 'right'
|
||||
},
|
||||
"text-position": {
|
||||
type: String,
|
||||
default: "bottom", // 'bottom', 'top', 'middle', 'inside'
|
||||
},
|
||||
"font-size": {
|
||||
type: Number,
|
||||
default: 13,
|
||||
},
|
||||
"text-fg-color": {
|
||||
type: String,
|
||||
default: "#222",
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
pct() {
|
||||
var pct = (this.val / this.max) * 100;
|
||||
pct = pct.toFixed(2);
|
||||
return Math.min(pct, this.max);
|
||||
},
|
||||
size_px() {
|
||||
switch (this.size) {
|
||||
case "tiny":
|
||||
return 2;
|
||||
case "small":
|
||||
return 4;
|
||||
case "medium":
|
||||
return 8;
|
||||
case "large":
|
||||
return 12;
|
||||
case "big":
|
||||
return 16;
|
||||
case "huge":
|
||||
return 32;
|
||||
case "massive":
|
||||
return 64;
|
||||
}
|
||||
|
||||
return isNumber(this.size) ? this.size : 32;
|
||||
},
|
||||
text_padding() {
|
||||
switch (this.size) {
|
||||
case "tiny":
|
||||
case "small":
|
||||
case "medium":
|
||||
case "large":
|
||||
case "big":
|
||||
case "huge":
|
||||
case "massive":
|
||||
return Math.min(Math.max(Math.ceil(this.size_px / 8), 3), 12);
|
||||
}
|
||||
|
||||
return isNumber(this.spacing) ? this.spacing : 4;
|
||||
},
|
||||
text_font_size() {
|
||||
switch (this.size) {
|
||||
case "tiny":
|
||||
case "small":
|
||||
case "medium":
|
||||
case "large":
|
||||
case "big":
|
||||
case "huge":
|
||||
case "massive":
|
||||
return Math.min(Math.max(Math.ceil(this.size_px * 1.4), 11), 32);
|
||||
}
|
||||
|
||||
return isNumber(this.fontSize) ? this.fontSize : 13;
|
||||
},
|
||||
progress_style() {
|
||||
var style = {
|
||||
background: this.bgColor,
|
||||
};
|
||||
|
||||
if (this.textPosition == "middle" || this.textPosition == "inside") {
|
||||
style["position"] = "relative";
|
||||
style["min-height"] = this.size_px + "px";
|
||||
style["z-index"] = "-2";
|
||||
}
|
||||
|
||||
if (this.barBorderRadius > 0) {
|
||||
style["border-radius"] = this.barBorderRadius + "px";
|
||||
}
|
||||
|
||||
return style;
|
||||
},
|
||||
bar_style() {
|
||||
var style = {
|
||||
background: this.barColor,
|
||||
width: this.pct + "%",
|
||||
height: this.size_px + "px",
|
||||
transition: this.barTransition,
|
||||
};
|
||||
|
||||
if (this.barBorderRadius > 0) {
|
||||
style["border-radius"] = this.barBorderRadius + "px";
|
||||
}
|
||||
|
||||
if (this.textPosition == "middle" || this.textPosition == "inside") {
|
||||
style["position"] = "absolute";
|
||||
style["top"] = "0";
|
||||
style["height"] = "100%";
|
||||
(style["min-height"] = this.size_px + "px"), (style["z-index"] = "-1");
|
||||
}
|
||||
|
||||
return style;
|
||||
},
|
||||
text_style() {
|
||||
var style = {
|
||||
color: this.textFgColor,
|
||||
"font-size": this.text_font_size + "px",
|
||||
"text-align": this.textAlign,
|
||||
};
|
||||
|
||||
if (
|
||||
this.textPosition == "top" ||
|
||||
this.textPosition == "middle" ||
|
||||
this.textPosition == "inside"
|
||||
)
|
||||
style["padding-bottom"] = this.text_padding + "px";
|
||||
if (
|
||||
this.textPosition == "bottom" ||
|
||||
this.textPosition == "middle" ||
|
||||
this.textPosition == "inside"
|
||||
)
|
||||
style["padding-top"] = this.text_padding + "px";
|
||||
|
||||
return style;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
218
frontend/src/components/Search.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div id="search" @click="open" v-bind:class="{ active, ongoing }">
|
||||
<div id="input">
|
||||
<button
|
||||
v-if="active"
|
||||
class="action"
|
||||
@click="close"
|
||||
:aria-label="$t('buttons.close')"
|
||||
:title="$t('buttons.close')"
|
||||
>
|
||||
<i class="material-icons">arrow_back</i>
|
||||
</button>
|
||||
<i v-else class="material-icons">search</i>
|
||||
<input
|
||||
type="text"
|
||||
@keyup.exact="keyup"
|
||||
@keyup.enter="submit"
|
||||
ref="input"
|
||||
:autofocus="active"
|
||||
v-model.trim="prompt"
|
||||
:aria-label="$t('search.search')"
|
||||
:placeholder="$t('search.search')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="result" ref="result">
|
||||
<div>
|
||||
<template v-if="isEmpty">
|
||||
<p>{{ text }}</p>
|
||||
|
||||
<template v-if="prompt.length === 0">
|
||||
<div class="boxes">
|
||||
<h3>{{ $t("search.types") }}</h3>
|
||||
<div>
|
||||
<div
|
||||
tabindex="0"
|
||||
v-for="(v, k) in boxes"
|
||||
:key="k"
|
||||
role="button"
|
||||
@click="init('type:' + k)"
|
||||
:aria-label="$t('search.' + v.label)"
|
||||
>
|
||||
<i class="material-icons">{{ v.icon }}</i>
|
||||
<p>{{ $t("search." + v.label) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<ul v-show="results.length > 0">
|
||||
<li v-for="(s, k) in filteredResults" :key="k">
|
||||
<router-link v-on:click="close" :to="s.url">
|
||||
<i v-if="s.dir" class="material-icons">folder</i>
|
||||
<i v-else class="material-icons">insert_drive_file</i>
|
||||
<span>./{{ s.path }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p id="renew">
|
||||
<i class="material-icons spin">autorenew</i>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import url from "@/utils/url";
|
||||
import { search } from "@/api";
|
||||
import { computed, inject, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const boxes = {
|
||||
image: { label: "images", icon: "insert_photo" },
|
||||
audio: { label: "music", icon: "volume_up" },
|
||||
video: { label: "video", icon: "movie" },
|
||||
pdf: { label: "pdf", icon: "picture_as_pdf" },
|
||||
};
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const fileStore = useFileStore();
|
||||
|
||||
const { currentPromptName } = storeToRefs(layoutStore);
|
||||
|
||||
const prompt = ref<string>("");
|
||||
const active = ref<boolean>(false);
|
||||
const ongoing = ref<boolean>(false);
|
||||
const results = ref<any[]>([]);
|
||||
const reload = ref<boolean>(false);
|
||||
const resultsCount = ref<number>(50);
|
||||
|
||||
const $showError = inject<IToastError>("$showError")!;
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
const result = ref<HTMLElement | null>(null);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
watch(currentPromptName, (newVal, oldVal) => {
|
||||
active.value = newVal === "search";
|
||||
|
||||
if (oldVal === "search" && !active.value) {
|
||||
if (reload.value) {
|
||||
fileStore.reload = true;
|
||||
}
|
||||
|
||||
document.body.style.overflow = "auto";
|
||||
reset();
|
||||
prompt.value = "";
|
||||
active.value = false;
|
||||
input.value?.blur();
|
||||
} else if (active.value) {
|
||||
reload.value = false;
|
||||
input.value?.focus();
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
});
|
||||
|
||||
watch(prompt, () => {
|
||||
if (results.value.length) {
|
||||
reset();
|
||||
}
|
||||
});
|
||||
|
||||
// ...mapState(useFileStore, ["isListing"]),
|
||||
// ...mapState(useLayoutStore, ["show"]),
|
||||
// ...mapWritableState(useFileStore, { sReload: "reload" }),
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
return results.value.length === 0;
|
||||
});
|
||||
const text = computed(() => {
|
||||
if (ongoing.value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return prompt.value === ""
|
||||
? t("search.typeToSearch")
|
||||
: t("search.pressToSearch");
|
||||
});
|
||||
const filteredResults = computed(() => {
|
||||
return results.value.slice(0, resultsCount.value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (result.value === null) {
|
||||
return;
|
||||
}
|
||||
result.value.addEventListener("scroll", (event: Event) => {
|
||||
if (
|
||||
(event.target as HTMLElement).offsetHeight +
|
||||
(event.target as HTMLElement).scrollTop >=
|
||||
(event.target as HTMLElement).scrollHeight - 100
|
||||
) {
|
||||
resultsCount.value += 50;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const open = () => {
|
||||
!active.value && layoutStore.showHover("search");
|
||||
};
|
||||
|
||||
const close = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
layoutStore.closeHovers();
|
||||
};
|
||||
|
||||
const keyup = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
close(event);
|
||||
return;
|
||||
}
|
||||
results.value.length = 0;
|
||||
};
|
||||
|
||||
const init = (string: string) => {
|
||||
prompt.value = `${string} `;
|
||||
input.value !== null ? input.value.focus() : "";
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
ongoing.value = false;
|
||||
resultsCount.value = 50;
|
||||
results.value = [];
|
||||
};
|
||||
|
||||
const submit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (prompt.value === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
let path = route.path;
|
||||
if (!fileStore.isListing) {
|
||||
path = url.removeLastDir(path) + "/";
|
||||
}
|
||||
|
||||
ongoing.value = true;
|
||||
|
||||
try {
|
||||
results.value = await search(path, prompt.value);
|
||||
} catch (error: any) {
|
||||
$showError(error);
|
||||
}
|
||||
|
||||
ongoing.value = false;
|
||||
};
|
||||
</script>
|
||||
194
frontend/src/components/Shell.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div
|
||||
class="shell"
|
||||
:class="{ ['shell--hidden']: !showShell }"
|
||||
:style="{ height: `${this.shellHeight}em`, direction: 'ltr' }"
|
||||
>
|
||||
<div
|
||||
@pointerdown="startDrag()"
|
||||
@pointerup="stopDrag()"
|
||||
class="shell__divider"
|
||||
:style="this.shellDrag ? { background: `${checkTheme()}` } : ''"
|
||||
></div>
|
||||
<div @click="focus" class="shell__content" ref="scrollable">
|
||||
<div v-for="(c, index) in content" :key="index" class="shell__result">
|
||||
<div class="shell__prompt">
|
||||
<i class="material-icons">chevron_right</i>
|
||||
</div>
|
||||
<pre class="shell__text">{{ c.text }}</pre>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="shell__result"
|
||||
:class="{ 'shell__result--hidden': !canInput }"
|
||||
>
|
||||
<div class="shell__prompt">
|
||||
<i class="material-icons">chevron_right</i>
|
||||
</div>
|
||||
<pre
|
||||
tabindex="0"
|
||||
ref="input"
|
||||
class="shell__text"
|
||||
:contenteditable="true"
|
||||
@keydown.prevent.arrow-up="historyUp"
|
||||
@keydown.prevent.arrow-down="historyDown"
|
||||
@keypress.prevent.enter="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@pointerup="stopDrag()"
|
||||
class="shell__overlay"
|
||||
v-show="this.shellDrag"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapActions } from "pinia";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import { commands } from "@/api";
|
||||
import { throttle } from "lodash";
|
||||
import { theme } from "@/utils/constants";
|
||||
|
||||
export default {
|
||||
name: "shell",
|
||||
computed: {
|
||||
...mapState(useLayoutStore, ["showShell"]),
|
||||
...mapState(useFileStore, ["isFiles"]),
|
||||
path: function () {
|
||||
if (this.isFiles) {
|
||||
return this.$route.path;
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
content: [],
|
||||
history: [],
|
||||
historyPos: 0,
|
||||
canInput: true,
|
||||
shellDrag: false,
|
||||
shellHeight: 25,
|
||||
fontsize: parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||
}),
|
||||
mounted() {
|
||||
window.addEventListener("resize", this.resize);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener("resize", this.resize);
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["toggleShell"]),
|
||||
checkTheme() {
|
||||
if (theme == "dark") {
|
||||
return "rgba(255, 255, 255, 0.4)";
|
||||
}
|
||||
return "rgba(127, 127, 127, 0.4)";
|
||||
},
|
||||
startDrag() {
|
||||
document.addEventListener("pointermove", this.handleDrag);
|
||||
this.shellDrag = true;
|
||||
},
|
||||
stopDrag() {
|
||||
document.removeEventListener("pointermove", this.handleDrag);
|
||||
this.shellDrag = false;
|
||||
},
|
||||
handleDrag: throttle(function (event) {
|
||||
const top = window.innerHeight / this.fontsize - 4;
|
||||
const userPos = (window.innerHeight - event.clientY) / this.fontsize;
|
||||
const bottom =
|
||||
2.25 +
|
||||
document.querySelector(".shell__divider").offsetHeight / this.fontsize;
|
||||
|
||||
if (userPos <= top && userPos >= bottom) {
|
||||
this.shellHeight = userPos.toFixed(2);
|
||||
}
|
||||
}, 32),
|
||||
resize: throttle(function () {
|
||||
const top = window.innerHeight / this.fontsize - 4;
|
||||
const bottom =
|
||||
2.25 +
|
||||
document.querySelector(".shell__divider").offsetHeight / this.fontsize;
|
||||
|
||||
if (this.shellHeight > top) {
|
||||
this.shellHeight = top;
|
||||
} else if (this.shellHeight < bottom) {
|
||||
this.shellHeight = bottom;
|
||||
}
|
||||
}, 32),
|
||||
scroll: function () {
|
||||
this.$refs.scrollable.scrollTop = this.$refs.scrollable.scrollHeight;
|
||||
},
|
||||
focus: function () {
|
||||
this.$refs.input.focus();
|
||||
},
|
||||
historyUp() {
|
||||
if (this.historyPos > 0) {
|
||||
this.$refs.input.innerText = this.history[--this.historyPos];
|
||||
this.focus();
|
||||
}
|
||||
},
|
||||
historyDown() {
|
||||
if (this.historyPos >= 0 && this.historyPos < this.history.length - 1) {
|
||||
this.$refs.input.innerText = this.history[++this.historyPos];
|
||||
this.focus();
|
||||
} else {
|
||||
this.historyPos = this.history.length;
|
||||
this.$refs.input.innerText = "";
|
||||
}
|
||||
},
|
||||
submit: function (event) {
|
||||
const cmd = event.target.innerText.trim();
|
||||
|
||||
if (cmd === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "clear") {
|
||||
this.content = [];
|
||||
event.target.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "exit") {
|
||||
event.target.innerHTML = "";
|
||||
this.toggleShell();
|
||||
return;
|
||||
}
|
||||
|
||||
this.canInput = false;
|
||||
event.target.innerHTML = "";
|
||||
|
||||
let results = {
|
||||
text: `${cmd}\n\n`,
|
||||
};
|
||||
|
||||
this.history.push(cmd);
|
||||
this.historyPos = this.history.length;
|
||||
this.content.push(results);
|
||||
|
||||
commands(
|
||||
this.path,
|
||||
cmd,
|
||||
(event) => {
|
||||
results.text += `${event.data}\n`;
|
||||
this.scroll();
|
||||
},
|
||||
() => {
|
||||
results.text = results.text
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\u001b\[[0-9;]+m/g, "") // Filter ANSI color for now
|
||||
.trimEnd();
|
||||
this.canInput = true;
|
||||
this.$refs.input.focus();
|
||||
this.scroll();
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
199
frontend/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div v-show="active" @click="closeHovers" class="overlay"></div>
|
||||
<nav :class="{ active }">
|
||||
<template v-if="isLoggedIn">
|
||||
<button
|
||||
class="action"
|
||||
@click="toRoot"
|
||||
:aria-label="$t('sidebar.myFiles')"
|
||||
:title="$t('sidebar.myFiles')"
|
||||
>
|
||||
<i class="material-icons">folder</i>
|
||||
<span>{{ $t("sidebar.myFiles") }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="user.perm.create">
|
||||
<button
|
||||
@click="showHover('newDir')"
|
||||
class="action"
|
||||
:aria-label="$t('sidebar.newFolder')"
|
||||
:title="$t('sidebar.newFolder')"
|
||||
>
|
||||
<i class="material-icons">create_new_folder</i>
|
||||
<span>{{ $t("sidebar.newFolder") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="showHover('newFile')"
|
||||
class="action"
|
||||
:aria-label="$t('sidebar.newFile')"
|
||||
:title="$t('sidebar.newFile')"
|
||||
>
|
||||
<i class="material-icons">note_add</i>
|
||||
<span>{{ $t("sidebar.newFile") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="action"
|
||||
@click="toSettings"
|
||||
:aria-label="$t('sidebar.settings')"
|
||||
:title="$t('sidebar.settings')"
|
||||
>
|
||||
<i class="material-icons">settings_applications</i>
|
||||
<span>{{ $t("sidebar.settings") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canLogout"
|
||||
@click="logout"
|
||||
class="action"
|
||||
id="logout"
|
||||
:aria-label="$t('sidebar.logout')"
|
||||
:title="$t('sidebar.logout')"
|
||||
>
|
||||
<i class="material-icons">exit_to_app</i>
|
||||
<span>{{ $t("sidebar.logout") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link
|
||||
class="action"
|
||||
to="/login"
|
||||
:aria-label="$t('sidebar.login')"
|
||||
:title="$t('sidebar.login')"
|
||||
>
|
||||
<i class="material-icons">exit_to_app</i>
|
||||
<span>{{ $t("sidebar.login") }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="signup"
|
||||
class="action"
|
||||
to="/login"
|
||||
:aria-label="$t('sidebar.signup')"
|
||||
:title="$t('sidebar.signup')"
|
||||
>
|
||||
<i class="material-icons">person_add</i>
|
||||
<span>{{ $t("sidebar.signup") }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="credits"
|
||||
v-if="isFiles && !disableUsedPercentage"
|
||||
style="width: 90%; margin: 2em 2.5em 3em 2.5em"
|
||||
>
|
||||
<progress-bar :val="usage.usedPercentage" size="small"></progress-bar>
|
||||
<br />
|
||||
{{ usage.used }} of {{ usage.total }} used
|
||||
</div>
|
||||
|
||||
<p class="credits">
|
||||
<span>
|
||||
<span v-if="disableExternal">File Browser</span>
|
||||
<a
|
||||
v-else
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="https://github.com/filebrowser/filebrowser"
|
||||
>File Browser</a
|
||||
>
|
||||
<span> {{ version }}</span>
|
||||
</span>
|
||||
<span>
|
||||
<a @click="help">{{ $t("sidebar.help") }}</a>
|
||||
</span>
|
||||
</p>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { reactive } from "vue";
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import * as auth from "@/utils/auth";
|
||||
import {
|
||||
version,
|
||||
signup,
|
||||
disableExternal,
|
||||
disableUsedPercentage,
|
||||
noAuth,
|
||||
loginPage,
|
||||
} from "@/utils/constants";
|
||||
import { files as api } from "@/api";
|
||||
import ProgressBar from "@/components/ProgressBar.vue";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
|
||||
const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 };
|
||||
|
||||
export default {
|
||||
name: "sidebar",
|
||||
setup() {
|
||||
const usage = reactive(USAGE_DEFAULT);
|
||||
return { usage };
|
||||
},
|
||||
components: {
|
||||
ProgressBar,
|
||||
},
|
||||
inject: ["$showError"],
|
||||
computed: {
|
||||
...mapState(useAuthStore, ["user", "isLoggedIn"]),
|
||||
...mapState(useFileStore, ["isFiles", "reload"]),
|
||||
...mapState(useLayoutStore, ["currentPromptName"]),
|
||||
active() {
|
||||
return this.currentPromptName === "sidebar";
|
||||
},
|
||||
signup: () => signup,
|
||||
version: () => version,
|
||||
disableExternal: () => disableExternal,
|
||||
disableUsedPercentage: () => disableUsedPercentage,
|
||||
canLogout: () => !noAuth && loginPage,
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
|
||||
async fetchUsage() {
|
||||
let path = this.$route.path.endsWith("/")
|
||||
? this.$route.path
|
||||
: this.$route.path + "/";
|
||||
let usageStats = USAGE_DEFAULT;
|
||||
if (this.disableUsedPercentage) {
|
||||
return Object.assign(this.usage, usageStats);
|
||||
}
|
||||
try {
|
||||
let usage = await api.usage(path);
|
||||
usageStats = {
|
||||
used: prettyBytes(usage.used, { binary: true }),
|
||||
total: prettyBytes(usage.total, { binary: true }),
|
||||
usedPercentage: Math.round((usage.used / usage.total) * 100),
|
||||
};
|
||||
} catch (error) {
|
||||
this.$showError(error);
|
||||
}
|
||||
return Object.assign(this.usage, usageStats);
|
||||
},
|
||||
toRoot() {
|
||||
this.$router.push({ path: "/files" });
|
||||
this.closeHovers();
|
||||
},
|
||||
toSettings() {
|
||||
this.$router.push({ path: "/settings" });
|
||||
this.closeHovers();
|
||||
},
|
||||
help() {
|
||||
this.showHover("help");
|
||||
},
|
||||
logout: auth.logout,
|
||||
},
|
||||
watch: {
|
||||
isFiles(newValue) {
|
||||
newValue && this.fetchUsage();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
326
frontend/src/components/files/ExtendedImage.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div
|
||||
class="image-ex-container"
|
||||
ref="container"
|
||||
@touchstart="touchStart"
|
||||
@touchmove="touchMove"
|
||||
@dblclick="zoomAuto"
|
||||
@mousedown="mousedownStart"
|
||||
@mousemove="mouseMove"
|
||||
@mouseup="mouseUp"
|
||||
@wheel="wheelMove"
|
||||
>
|
||||
<img class="image-ex-img image-ex-img-center" ref="imgex" @load="onLoad" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import throttle from "lodash/throttle";
|
||||
import UTIF from "utif";
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
interface IProps {
|
||||
src: string;
|
||||
moveDisabledTime: number;
|
||||
classList: any[];
|
||||
zoomStep: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
moveDisabledTime: () => 200,
|
||||
classList: () => [],
|
||||
zoomStep: () => 0.25,
|
||||
});
|
||||
|
||||
const scale = ref<number>(1);
|
||||
const lastX = ref<number | null>(null);
|
||||
const lastY = ref<number | null>(null);
|
||||
const inDrag = ref<boolean>(false);
|
||||
const touches = ref<number>(0);
|
||||
const lastTouchDistance = ref<number | null>(0);
|
||||
const moveDisabled = ref<boolean>(false);
|
||||
const disabledTimer = ref<number | null>(null);
|
||||
const imageLoaded = ref<boolean>(false);
|
||||
const position = ref<{
|
||||
center: { x: number; y: number };
|
||||
relative: { x: number; y: number };
|
||||
}>({
|
||||
center: { x: 0, y: 0 },
|
||||
relative: { x: 0, y: 0 },
|
||||
});
|
||||
const maxScale = ref<number>(4);
|
||||
const minScale = ref<number>(0.25);
|
||||
|
||||
// Refs
|
||||
const imgex = ref<HTMLImageElement | null>(null);
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
if (!decodeUTIF() && imgex.value !== null) {
|
||||
imgex.value.src = props.src;
|
||||
}
|
||||
|
||||
props.classList.forEach((className) =>
|
||||
container.value !== null ? container.value.classList.add(className) : ""
|
||||
);
|
||||
|
||||
if (container.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// set width and height if they are zero
|
||||
if (getComputedStyle(container.value).width === "0px") {
|
||||
container.value.style.width = "100%";
|
||||
}
|
||||
if (getComputedStyle(container.value).height === "0px") {
|
||||
container.value.style.height = "100%";
|
||||
}
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
if (!decodeUTIF() && imgex.value !== null) {
|
||||
imgex.value.src = props.src;
|
||||
}
|
||||
|
||||
scale.value = 1;
|
||||
setZoom();
|
||||
setCenter();
|
||||
}
|
||||
);
|
||||
|
||||
// Modified from UTIF.replaceIMG
|
||||
const decodeUTIF = () => {
|
||||
const sufs = ["tif", "tiff", "dng", "cr2", "nef"];
|
||||
if (document?.location?.pathname === undefined) {
|
||||
return;
|
||||
}
|
||||
let suff = document.location.pathname.split(".")?.pop()?.toLowerCase() ?? "";
|
||||
|
||||
if (sufs.indexOf(suff) == -1) return false;
|
||||
let xhr = new XMLHttpRequest();
|
||||
UTIF._xhrs.push(xhr);
|
||||
UTIF._imgs.push(imgex.value);
|
||||
xhr.open("GET", props.src);
|
||||
xhr.responseType = "arraybuffer";
|
||||
xhr.onload = UTIF._imgLoaded;
|
||||
xhr.send();
|
||||
return true;
|
||||
};
|
||||
|
||||
const onLoad = () => {
|
||||
imageLoaded.value = true;
|
||||
|
||||
if (imgex.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
imgex.value.classList.remove("image-ex-img-center");
|
||||
setCenter();
|
||||
imgex.value.classList.add("image-ex-img-ready");
|
||||
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
let realSize = imgex.value.naturalWidth;
|
||||
let displaySize = imgex.value.offsetWidth;
|
||||
|
||||
// Image is in portrait orientation
|
||||
if (imgex.value.naturalHeight > imgex.value.naturalWidth) {
|
||||
realSize = imgex.value.naturalHeight;
|
||||
displaySize = imgex.value.offsetHeight;
|
||||
}
|
||||
|
||||
// Scale needed to display the image on full size
|
||||
const fullScale = realSize / displaySize;
|
||||
|
||||
// Full size plus additional zoom
|
||||
maxScale.value = fullScale + 4;
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
inDrag.value = false;
|
||||
};
|
||||
|
||||
const onResize = throttle(function () {
|
||||
if (imageLoaded.value) {
|
||||
setCenter();
|
||||
doMove(position.value.relative.x, position.value.relative.y);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
const setCenter = () => {
|
||||
if (container.value === null || imgex.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
position.value.center.x = Math.floor(
|
||||
(container.value.clientWidth - imgex.value.clientWidth) / 2
|
||||
);
|
||||
position.value.center.y = Math.floor(
|
||||
(container.value.clientHeight - imgex.value.clientHeight) / 2
|
||||
);
|
||||
|
||||
imgex.value.style.left = position.value.center.x + "px";
|
||||
imgex.value.style.top = position.value.center.y + "px";
|
||||
};
|
||||
|
||||
const mousedownStart = (event: Event) => {
|
||||
lastX.value = null;
|
||||
lastY.value = null;
|
||||
inDrag.value = true;
|
||||
event.preventDefault();
|
||||
};
|
||||
const mouseMove = (event: MouseEvent) => {
|
||||
if (!inDrag.value) return;
|
||||
doMove(event.movementX, event.movementY);
|
||||
event.preventDefault();
|
||||
};
|
||||
const mouseUp = (event: Event) => {
|
||||
inDrag.value = false;
|
||||
event.preventDefault();
|
||||
};
|
||||
const touchStart = (event: TouchEvent) => {
|
||||
lastX.value = null;
|
||||
lastY.value = null;
|
||||
lastTouchDistance.value = null;
|
||||
if (event.targetTouches.length < 2) {
|
||||
setTimeout(() => {
|
||||
touches.value = 0;
|
||||
}, 300);
|
||||
touches.value++;
|
||||
if (touches.value > 1) {
|
||||
zoomAuto(event);
|
||||
}
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const zoomAuto = (event: Event) => {
|
||||
switch (scale.value) {
|
||||
case 1:
|
||||
scale.value = 2;
|
||||
break;
|
||||
case 2:
|
||||
scale.value = 4;
|
||||
break;
|
||||
default:
|
||||
case 4:
|
||||
scale.value = 1;
|
||||
setCenter();
|
||||
break;
|
||||
}
|
||||
setZoom();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const touchMove = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
if (lastX.value === null) {
|
||||
lastX.value = event.targetTouches[0].pageX;
|
||||
lastY.value = event.targetTouches[0].pageY;
|
||||
return;
|
||||
}
|
||||
if (imgex.value === null) {
|
||||
return;
|
||||
}
|
||||
let step = imgex.value.width / 5;
|
||||
if (event.targetTouches.length === 2) {
|
||||
moveDisabled.value = true;
|
||||
if (disabledTimer.value) clearTimeout(disabledTimer.value);
|
||||
disabledTimer.value = window.setTimeout(
|
||||
() => (moveDisabled.value = false),
|
||||
props.moveDisabledTime
|
||||
);
|
||||
|
||||
let p1 = event.targetTouches[0];
|
||||
let p2 = event.targetTouches[1];
|
||||
let touchDistance = Math.sqrt(
|
||||
Math.pow(p2.pageX - p1.pageX, 2) + Math.pow(p2.pageY - p1.pageY, 2)
|
||||
);
|
||||
if (!lastTouchDistance.value) {
|
||||
lastTouchDistance.value = touchDistance;
|
||||
return;
|
||||
}
|
||||
scale.value += (touchDistance - lastTouchDistance.value) / step;
|
||||
lastTouchDistance.value = touchDistance;
|
||||
setZoom();
|
||||
} else if (event.targetTouches.length === 1) {
|
||||
if (moveDisabled.value) return;
|
||||
let x = event.targetTouches[0].pageX - (lastX.value ?? 0);
|
||||
let y = event.targetTouches[0].pageY - (lastY.value ?? 0);
|
||||
if (Math.abs(x) >= step && Math.abs(y) >= step) return;
|
||||
lastX.value = event.targetTouches[0].pageX;
|
||||
lastY.value = event.targetTouches[0].pageY;
|
||||
doMove(x, y);
|
||||
}
|
||||
};
|
||||
|
||||
const doMove = (x: number, y: number) => {
|
||||
if (imgex.value === null) {
|
||||
return;
|
||||
}
|
||||
const style = imgex.value.style;
|
||||
|
||||
let posX = pxStringToNumber(style.left) + x;
|
||||
let posY = pxStringToNumber(style.top) + y;
|
||||
|
||||
style.left = posX + "px";
|
||||
style.top = posY + "px";
|
||||
|
||||
position.value.relative.x = Math.abs(position.value.center.x - posX);
|
||||
position.value.relative.y = Math.abs(position.value.center.y - posY);
|
||||
|
||||
if (posX < position.value.center.x) {
|
||||
position.value.relative.x = position.value.relative.x * -1;
|
||||
}
|
||||
|
||||
if (posY < position.value.center.y) {
|
||||
position.value.relative.y = position.value.relative.y * -1;
|
||||
}
|
||||
};
|
||||
const wheelMove = (event: WheelEvent) => {
|
||||
scale.value += -Math.sign(event.deltaY) * props.zoomStep;
|
||||
setZoom();
|
||||
};
|
||||
const setZoom = () => {
|
||||
scale.value = scale.value < minScale.value ? minScale.value : scale.value;
|
||||
scale.value = scale.value > maxScale.value ? maxScale.value : scale.value;
|
||||
if (imgex.value !== null)
|
||||
imgex.value.style.transform = `scale(${scale.value})`;
|
||||
};
|
||||
const pxStringToNumber = (style: string) => {
|
||||
return +style.replace("px", "");
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
.image-ex-container {
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-ex-img {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.image-ex-img-center {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
position: absolute;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.image-ex-img-ready {
|
||||
left: 0;
|
||||
top: 0;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
</style>
|
||||
284
frontend/src/components/files/ListingItem.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div
|
||||
class="item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:draggable="isDraggable"
|
||||
@dragstart="dragStart"
|
||||
@dragover="dragOver"
|
||||
@drop="drop"
|
||||
@click="itemClick"
|
||||
:data-dir="isDir"
|
||||
:data-type="type"
|
||||
:aria-label="name"
|
||||
:aria-selected="isSelected"
|
||||
:data-ext="getExtension(name).toLowerCase()"
|
||||
>
|
||||
<div>
|
||||
<img
|
||||
v-if="!readOnly && type === 'image' && isThumbsEnabled"
|
||||
v-lazy="thumbnailUrl"
|
||||
/>
|
||||
<i v-else class="material-icons"></i>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="name">{{ name }}</p>
|
||||
|
||||
<p v-if="isDir" class="size" data-order="-1">—</p>
|
||||
<p v-else class="size" :data-order="humanSize()">{{ humanSize() }}</p>
|
||||
|
||||
<p class="modified">
|
||||
<time :datetime="modified">{{ humanTime() }}</time>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import { enableThumbs } from "@/utils/constants";
|
||||
import { filesize } from "@/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { files as api } from "@/api";
|
||||
import * as upload from "@/utils/upload";
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const touches = ref<number>(0);
|
||||
|
||||
const $showError = inject<IToastError>("$showError")!;
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
url: string;
|
||||
type: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
index: number;
|
||||
readOnly?: boolean;
|
||||
path?: string;
|
||||
}>();
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const fileStore = useFileStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
const singleClick = computed(
|
||||
() => !props.readOnly && authStore.user?.singleClick
|
||||
);
|
||||
const isSelected = computed(
|
||||
() => fileStore.selected.indexOf(props.index) !== -1
|
||||
);
|
||||
const isDraggable = computed(
|
||||
() => !props.readOnly && authStore.user?.perm.rename
|
||||
);
|
||||
|
||||
const canDrop = computed(() => {
|
||||
if (!props.isDir || props.readOnly) return false;
|
||||
|
||||
for (let i of fileStore.selected) {
|
||||
if (fileStore.req?.items[i].url === props.url) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const thumbnailUrl = computed(() => {
|
||||
const file = {
|
||||
path: props.path,
|
||||
modified: props.modified,
|
||||
};
|
||||
|
||||
return api.getPreviewURL(file as Resource, "thumb");
|
||||
});
|
||||
|
||||
const isThumbsEnabled = computed(() => {
|
||||
return enableThumbs;
|
||||
});
|
||||
|
||||
const humanSize = () => {
|
||||
return props.type == "invalid_link" ? "invalid link" : filesize(props.size);
|
||||
};
|
||||
|
||||
const humanTime = () => {
|
||||
if (!props.readOnly && authStore.user?.dateFormat) {
|
||||
return dayjs(props.modified).format("L LT");
|
||||
}
|
||||
return dayjs(props.modified).fromNow();
|
||||
};
|
||||
|
||||
const dragStart = () => {
|
||||
if (fileStore.selectedCount === 0) {
|
||||
fileStore.selected.push(props.index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSelected.value) {
|
||||
fileStore.selected = [];
|
||||
fileStore.selected.push(props.index);
|
||||
}
|
||||
};
|
||||
|
||||
const dragOver = (event: Event) => {
|
||||
if (!canDrop.value) return;
|
||||
|
||||
event.preventDefault();
|
||||
let el = event.target as HTMLElement | null;
|
||||
if (el !== null) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!el?.classList.contains("item")) {
|
||||
el = el?.parentElement ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (el !== null) el.style.opacity = "1";
|
||||
}
|
||||
};
|
||||
|
||||
const drop = async (event: Event) => {
|
||||
if (!canDrop.value) return;
|
||||
event.preventDefault();
|
||||
|
||||
if (fileStore.selectedCount === 0) return;
|
||||
|
||||
let el = event.target as HTMLElement | null;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (el !== null && !el.classList.contains("item")) {
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
let items: any[] = [];
|
||||
|
||||
for (let i of fileStore.selected) {
|
||||
if (fileStore.req) {
|
||||
items.push({
|
||||
from: fileStore.req?.items[i].url,
|
||||
to: props.url + encodeURIComponent(fileStore.req?.items[i].name),
|
||||
name: fileStore.req?.items[i].name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get url from ListingItem instance
|
||||
if (el === null) {
|
||||
return;
|
||||
}
|
||||
let path = el.__vue__.url;
|
||||
let baseItems = (await api.fetch(path)).items;
|
||||
|
||||
let action = (overwrite: boolean, rename: boolean) => {
|
||||
api
|
||||
.move(items, overwrite, rename)
|
||||
.then(() => {
|
||||
fileStore.reload = true;
|
||||
})
|
||||
.catch($showError);
|
||||
};
|
||||
|
||||
let conflict = upload.checkConflict(items, baseItems);
|
||||
|
||||
let overwrite = false;
|
||||
let rename = false;
|
||||
|
||||
if (conflict) {
|
||||
layoutStore.showHover({
|
||||
prompt: "replace-rename",
|
||||
confirm: (event: Event, option: any) => {
|
||||
overwrite = option == "overwrite";
|
||||
rename = option == "rename";
|
||||
|
||||
event.preventDefault();
|
||||
layoutStore.closeHovers();
|
||||
action(overwrite, rename);
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
action(overwrite, rename);
|
||||
};
|
||||
|
||||
const itemClick = (event: Event | KeyboardEvent) => {
|
||||
if (
|
||||
singleClick.value &&
|
||||
!(event as KeyboardEvent).ctrlKey &&
|
||||
!(event as KeyboardEvent).metaKey &&
|
||||
!(event as KeyboardEvent).shiftKey &&
|
||||
!fileStore.multiple
|
||||
)
|
||||
open();
|
||||
else click(event);
|
||||
};
|
||||
|
||||
const click = (event: Event | KeyboardEvent) => {
|
||||
if (!singleClick.value && fileStore.selectedCount !== 0)
|
||||
event.preventDefault();
|
||||
|
||||
setTimeout(() => {
|
||||
touches.value = 0;
|
||||
}, 300);
|
||||
|
||||
touches.value++;
|
||||
if (touches.value > 1) {
|
||||
open();
|
||||
}
|
||||
|
||||
if (fileStore.selected.indexOf(props.index) !== -1) {
|
||||
fileStore.removeSelected(props.index);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event as KeyboardEvent).shiftKey && fileStore.selected.length > 0) {
|
||||
let fi = 0;
|
||||
let la = 0;
|
||||
|
||||
if (props.index > fileStore.selected[0]) {
|
||||
fi = fileStore.selected[0] + 1;
|
||||
la = props.index;
|
||||
} else {
|
||||
fi = props.index;
|
||||
la = fileStore.selected[0] - 1;
|
||||
}
|
||||
|
||||
for (; fi <= la; fi++) {
|
||||
if (fileStore.selected.indexOf(fi) == -1) {
|
||||
fileStore.selected.push(fi);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!singleClick.value &&
|
||||
!(event as KeyboardEvent).ctrlKey &&
|
||||
!(event as KeyboardEvent).metaKey &&
|
||||
!fileStore.multiple
|
||||
) {
|
||||
fileStore.selected = [];
|
||||
}
|
||||
fileStore.selected.push(props.index);
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
router.push({ path: props.url });
|
||||
};
|
||||
|
||||
const getExtension = (fileName: string): string => {
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
if (lastDotIndex === -1) {
|
||||
return fileName;
|
||||
}
|
||||
return fileName.substring(lastDotIndex);
|
||||
};
|
||||
</script>
|
||||
173
frontend/src/components/files/VideoPlayer.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<video ref="videoPlayer" class="video-max video-js" controls preload="auto">
|
||||
<source />
|
||||
<track
|
||||
kind="subtitles"
|
||||
v-for="(sub, index) in subtitles"
|
||||
:key="index"
|
||||
:src="sub"
|
||||
:label="subLabel(sub)"
|
||||
:default="index === 0"
|
||||
/>
|
||||
<p class="vjs-no-js">
|
||||
Sorry, your browser doesn't support embedded videos, but don't worry, you
|
||||
can <a :href="source">download it</a>
|
||||
and watch it with your favorite video player!
|
||||
</p>
|
||||
</video>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import videojs from "video.js";
|
||||
import type Player from "video.js/dist/types/player";
|
||||
import "videojs-mobile-ui";
|
||||
import "videojs-hotkeys";
|
||||
import "video.js/dist/video-js.min.css";
|
||||
import "videojs-mobile-ui/dist/videojs-mobile-ui.css";
|
||||
|
||||
const videoPlayer = ref<HTMLElement | null>(null);
|
||||
const player = ref<Player | null>(null);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
source: string;
|
||||
subtitles?: string[];
|
||||
options?: any;
|
||||
}>(),
|
||||
{
|
||||
options: {},
|
||||
}
|
||||
);
|
||||
|
||||
const source = ref(props.source);
|
||||
const sourceType = ref("");
|
||||
|
||||
nextTick(() => {
|
||||
initVideoPlayer();
|
||||
});
|
||||
|
||||
onMounted(() => {});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (player.value) {
|
||||
player.value.dispose();
|
||||
player.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
const initVideoPlayer = async () => {
|
||||
try {
|
||||
const lang = document.documentElement.lang;
|
||||
const languagePack = await (
|
||||
languageImports[lang] || languageImports.en
|
||||
)?.();
|
||||
videojs.addLanguage("videoPlayerLocal", languagePack.default);
|
||||
sourceType.value = "";
|
||||
|
||||
//
|
||||
sourceType.value = getSourceType(source.value);
|
||||
|
||||
const srcOpt = { sources: { src: props.source, type: sourceType.value } };
|
||||
//Supporting localized language display.
|
||||
const langOpt = { language: "videoPlayerLocal" };
|
||||
// support for playback at different speeds.
|
||||
const playbackRatesOpt = { playbackRates: [0.5, 1, 1.5, 2, 2.5, 3] };
|
||||
let options = getOptions(props.options, langOpt, srcOpt, playbackRatesOpt);
|
||||
player.value = videojs(videoPlayer.value!, options, () => {});
|
||||
|
||||
// TODO: need to test on mobile
|
||||
// @ts-ignore
|
||||
player.value!.mobileUi();
|
||||
} catch (error) {
|
||||
console.error("Error initializing video player:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getOptions = (...srcOpt: any[]) => {
|
||||
const options = {
|
||||
controlBar: {
|
||||
skipButtons: {
|
||||
forward: 5,
|
||||
backward: 5,
|
||||
},
|
||||
},
|
||||
html5: {
|
||||
nativeTextTracks: false,
|
||||
},
|
||||
plugins: {
|
||||
hotkeys: {
|
||||
volumeStep: 0.1,
|
||||
seekStep: 10,
|
||||
enableModifiersForNumbers: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return videojs.obj.merge(options, ...srcOpt);
|
||||
};
|
||||
|
||||
// Attempting to fix the issue of being unable to play .MKV format video files
|
||||
const getSourceType = (source: string) => {
|
||||
const fileExtension = source ? source.split("?")[0].split(".").pop() : "";
|
||||
if (fileExtension?.toLowerCase() === "mkv") {
|
||||
return "video/mp4";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const subLabel = (subUrl: string) => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(subUrl);
|
||||
} catch (_) {
|
||||
// treat it as a relative url
|
||||
// we only need this for filename
|
||||
url = new URL(subUrl, window.location.origin);
|
||||
}
|
||||
|
||||
const label = decodeURIComponent(
|
||||
url.pathname
|
||||
.split("/")
|
||||
.pop()!
|
||||
.replace(/\.[^/.]+$/, "")
|
||||
);
|
||||
|
||||
return label;
|
||||
};
|
||||
|
||||
interface LanguageImports {
|
||||
[key: string]: () => Promise<any>;
|
||||
}
|
||||
|
||||
const languageImports: LanguageImports = {
|
||||
he: () => import("video.js/dist/lang/he.json"),
|
||||
hu: () => import("video.js/dist/lang/hu.json"),
|
||||
ar: () => import("video.js/dist/lang/ar.json"),
|
||||
de: () => import("video.js/dist/lang/de.json"),
|
||||
el: () => import("video.js/dist/lang/el.json"),
|
||||
en: () => import("video.js/dist/lang/en.json"),
|
||||
es: () => import("video.js/dist/lang/es.json"),
|
||||
fr: () => import("video.js/dist/lang/fr.json"),
|
||||
it: () => import("video.js/dist/lang/it.json"),
|
||||
ja: () => import("video.js/dist/lang/ja.json"),
|
||||
ko: () => import("video.js/dist/lang/ko.json"),
|
||||
"nl-be": () => import("video.js/dist/lang/nl.json"),
|
||||
pl: () => import("video.js/dist/lang/pl.json"),
|
||||
"pt-br": () => import("video.js/dist/lang/pt-BR.json"),
|
||||
pt: () => import("video.js/dist/lang/pt-PT.json"),
|
||||
ro: () => import("video.js/dist/lang/ro.json"),
|
||||
ru: () => import("video.js/dist/lang/ru.json"),
|
||||
sk: () => import("video.js/dist/lang/sk.json"),
|
||||
tr: () => import("video.js/dist/lang/tr.json"),
|
||||
uk: () => import("video.js/dist/lang/uk.json"),
|
||||
"zh-cn": () => import("video.js/dist/lang/zh-CN.json"),
|
||||
"zh-tw": () => import("video.js/dist/lang/zh-TW.json"),
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.video-max {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
32
frontend/src/components/header/Action.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<button @click="action" :aria-label="label" :title="label" class="action">
|
||||
<i class="material-icons">{{ icon }}</i>
|
||||
<span>{{ label }}</span>
|
||||
<span v-if="counter && counter > 0" class="counter">{{ counter }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
const props = defineProps<{
|
||||
icon?: string;
|
||||
label?: string;
|
||||
counter?: number;
|
||||
show?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action"): any;
|
||||
}>();
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
const action = () => {
|
||||
if (props.show) {
|
||||
layoutStore.showHover(props.show);
|
||||
}
|
||||
|
||||
emit("action");
|
||||
};
|
||||
</script>
|
||||
59
frontend/src/components/header/HeaderBar.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<header>
|
||||
<img v-if="showLogo" :src="logoURL" />
|
||||
<Action
|
||||
v-if="showMenu"
|
||||
class="menu-button"
|
||||
icon="menu"
|
||||
:label="t('buttons.toggleSidebar')"
|
||||
@action="layoutStore.showHover('sidebar')"
|
||||
/>
|
||||
|
||||
<slot />
|
||||
|
||||
<div
|
||||
id="dropdown"
|
||||
:class="{ active: layoutStore.currentPromptName === 'more' }"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<Action
|
||||
v-if="ifActionsSlot"
|
||||
id="more"
|
||||
icon="more_vert"
|
||||
:label="t('buttons.more')"
|
||||
@action="layoutStore.showHover('more')"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="overlay"
|
||||
v-show="layoutStore.currentPromptName == 'more'"
|
||||
@click="layoutStore.closeHovers"
|
||||
/>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import { logoURL } from "@/utils/constants";
|
||||
|
||||
import Action from "@/components/header/Action.vue";
|
||||
import { computed, useSlots } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
defineProps<{
|
||||
showLogo?: boolean;
|
||||
showMenu?: boolean;
|
||||
}>();
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
const slots = useSlots();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const ifActionsSlot = computed(() => (slots.actions ? true : false));
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
21
frontend/src/components/prompts/BaseModal.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<VueFinalModal
|
||||
class="vfm-modal"
|
||||
overlay-transition="vfm-fade"
|
||||
content-transition="vfm-fade"
|
||||
@closed="layoutStore.closeHovers"
|
||||
:focus-trap="{
|
||||
initialFocus: '#focus-prompt',
|
||||
fallbackFocus: 'div.vfm__content',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</VueFinalModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VueFinalModal } from "vue-final-modal";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
</script>
|
||||
151
frontend/src/components/prompts/Copy.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("prompts.copy") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ $t("prompts.copyMessage") }}</p>
|
||||
<file-list
|
||||
ref="fileList"
|
||||
@update:selected="(val) => (dest = val)"
|
||||
tabindex="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-action"
|
||||
style="display: flex; align-items: center; justify-content: space-between"
|
||||
>
|
||||
<template v-if="user.perm.create">
|
||||
<button
|
||||
class="button button--flat"
|
||||
@click="$refs.fileList.createDir()"
|
||||
:aria-label="$t('sidebar.newFolder')"
|
||||
:title="$t('sidebar.newFolder')"
|
||||
style="justify-self: left"
|
||||
>
|
||||
<span>{{ $t("sidebar.newFolder") }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<div>
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="closeHovers"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="3"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
class="button button--flat"
|
||||
@click="copy"
|
||||
:aria-label="$t('buttons.copy')"
|
||||
:title="$t('buttons.copy')"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ $t("buttons.copy") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState, mapWritableState } from "pinia";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import FileList from "./FileList.vue";
|
||||
import { files as api } from "@/api";
|
||||
import buttons from "@/utils/buttons";
|
||||
import * as upload from "@/utils/upload";
|
||||
|
||||
export default {
|
||||
name: "copy",
|
||||
components: { FileList },
|
||||
data: function () {
|
||||
return {
|
||||
current: window.location.pathname,
|
||||
dest: null,
|
||||
};
|
||||
},
|
||||
inject: ["$showError"],
|
||||
computed: {
|
||||
...mapState(useFileStore, ["req", "selected"]),
|
||||
...mapState(useAuthStore, ["user"]),
|
||||
...mapWritableState(useFileStore, ["reload"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["showHover", "closeHovers"]),
|
||||
copy: async function (event) {
|
||||
event.preventDefault();
|
||||
let items = [];
|
||||
|
||||
// Create a new promise for each file.
|
||||
for (let item of this.selected) {
|
||||
items.push({
|
||||
from: this.req.items[item].url,
|
||||
to: this.dest + encodeURIComponent(this.req.items[item].name),
|
||||
name: this.req.items[item].name,
|
||||
});
|
||||
}
|
||||
|
||||
let action = async (overwrite, rename) => {
|
||||
buttons.loading("copy");
|
||||
|
||||
await api
|
||||
.copy(items, overwrite, rename)
|
||||
.then(() => {
|
||||
buttons.success("copy");
|
||||
|
||||
if (this.$route.path === this.dest) {
|
||||
this.reload = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router.push({ path: this.dest });
|
||||
})
|
||||
.catch((e) => {
|
||||
buttons.done("copy");
|
||||
this.$showError(e);
|
||||
});
|
||||
};
|
||||
|
||||
if (this.$route.path === this.dest) {
|
||||
this.closeHovers();
|
||||
action(false, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let dstItems = (await api.fetch(this.dest)).items;
|
||||
let conflict = upload.checkConflict(items, dstItems);
|
||||
|
||||
let overwrite = false;
|
||||
let rename = false;
|
||||
|
||||
if (conflict) {
|
||||
this.showHover({
|
||||
prompt: "replace-rename",
|
||||
confirm: (event, option) => {
|
||||
overwrite = option == "overwrite";
|
||||
rename = option == "rename";
|
||||
|
||||
event.preventDefault();
|
||||
this.closeHovers();
|
||||
action(overwrite, rename);
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
action(overwrite, rename);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
93
frontend/src/components/prompts/Delete.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-content">
|
||||
<p v-if="!this.isListing || selectedCount === 1">
|
||||
{{ $t("prompts.deleteMessageSingle") }}
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ $t("prompts.deleteMessageMultiple", { count: selectedCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<button
|
||||
@click="closeHovers"
|
||||
class="button button--flat button--grey"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
@click="submit"
|
||||
class="button button--flat button--red"
|
||||
:aria-label="$t('buttons.delete')"
|
||||
:title="$t('buttons.delete')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ $t("buttons.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState, mapWritableState } from "pinia";
|
||||
import { files as api } from "@/api";
|
||||
import buttons from "@/utils/buttons";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
export default {
|
||||
name: "delete",
|
||||
inject: ["$showError"],
|
||||
computed: {
|
||||
...mapState(useFileStore, [
|
||||
"isListing",
|
||||
"selectedCount",
|
||||
"req",
|
||||
"selected",
|
||||
"currentPrompt",
|
||||
]),
|
||||
...mapWritableState(useFileStore, ["reload"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
submit: async function () {
|
||||
buttons.loading("delete");
|
||||
|
||||
window.sessionStorage.setItem("modified", "true");
|
||||
try {
|
||||
if (!this.isListing) {
|
||||
await api.remove(this.$route.path);
|
||||
buttons.success("delete");
|
||||
|
||||
this.currentPrompt?.confirm();
|
||||
this.closeHovers();
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeHovers();
|
||||
|
||||
if (this.selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let promises = [];
|
||||
for (let index of this.selected) {
|
||||
promises.push(api.remove(this.req.items[index].url));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
buttons.success("delete");
|
||||
this.reload = true;
|
||||
} catch (e) {
|
||||
buttons.done("delete");
|
||||
this.$showError(e);
|
||||
if (this.isListing) this.reload = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
40
frontend/src/components/prompts/DeleteUser.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-content">
|
||||
<p>{{ t("prompts.deleteUser") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
id="focus-prompt"
|
||||
class="button button--flat button--grey"
|
||||
@click="layoutStore.closeHovers"
|
||||
:aria-label="t('buttons.cancel')"
|
||||
:title="t('buttons.cancel')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button button--flat"
|
||||
@click="layoutStore.currentPrompt?.confirm"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ t("buttons.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// const emit = defineEmits<{
|
||||
// (e: "confirm"): void;
|
||||
// }>();
|
||||
</script>
|
||||
51
frontend/src/components/prompts/DiscardEditorChanges.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-content">
|
||||
<p>
|
||||
{{ $t("prompts.discardEditorChanges") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="closeHovers"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
@click="submit"
|
||||
class="button button--flat button--red"
|
||||
:aria-label="$t('buttons.discardChanges')"
|
||||
:title="$t('buttons.discardChanges')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ $t("buttons.discardChanges") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from "pinia";
|
||||
import url from "@/utils/url";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
|
||||
export default {
|
||||
name: "discardEditorChanges",
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
...mapActions(useFileStore, ["updateRequest"]),
|
||||
submit: async function () {
|
||||
this.updateRequest(null);
|
||||
|
||||
let uri = url.removeLastDir(this.$route.path) + "/";
|
||||
this.$router.push({ path: uri });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
40
frontend/src/components/prompts/Download.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="card floating" id="download">
|
||||
<div class="card-title">
|
||||
<h2>{{ t("prompts.download") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ t("prompts.downloadMessage") }}</p>
|
||||
|
||||
<button
|
||||
id="focus-prompt"
|
||||
v-for="(ext, format) in formats"
|
||||
:key="format"
|
||||
class="button button--block"
|
||||
@click="layoutStore.currentPrompt?.confirm(format)"
|
||||
>
|
||||
{{ ext }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formats = {
|
||||
zip: "zip",
|
||||
tar: "tar",
|
||||
targz: "tar.gz",
|
||||
tarbz2: "tar.bz2",
|
||||
tarxz: "tar.xz",
|
||||
tarlz4: "tar.lz4",
|
||||
tarsz: "tar.sz",
|
||||
};
|
||||
</script>
|
||||
154
frontend/src/components/prompts/FileList.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div>
|
||||
<ul class="file-list">
|
||||
<li
|
||||
@click="itemClick"
|
||||
@touchstart="touchstart"
|
||||
@dblclick="next"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="item.name"
|
||||
:aria-selected="selected == item.url"
|
||||
:key="item.name"
|
||||
v-for="item in items"
|
||||
:data-url="item.url"
|
||||
>
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
{{ $t("prompts.currentlyNavigating") }} <code>{{ nav }}</code
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from "pinia";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
|
||||
import url from "@/utils/url";
|
||||
import { files } from "@/api";
|
||||
|
||||
export default {
|
||||
name: "file-list",
|
||||
data: function () {
|
||||
return {
|
||||
items: [],
|
||||
touches: {
|
||||
id: "",
|
||||
count: 0,
|
||||
},
|
||||
selected: null,
|
||||
current: window.location.pathname,
|
||||
};
|
||||
},
|
||||
inject: ["$showError"],
|
||||
computed: {
|
||||
...mapState(useAuthStore, ["user"]),
|
||||
...mapState(useFileStore, ["req"]),
|
||||
nav() {
|
||||
return decodeURIComponent(this.current);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fillOptions(this.req);
|
||||
},
|
||||
methods: {
|
||||
fillOptions(req) {
|
||||
// Sets the current path and resets
|
||||
// the current items.
|
||||
this.current = req.url;
|
||||
this.items = [];
|
||||
|
||||
this.$emit("update:selected", this.current);
|
||||
|
||||
// If the path isn't the root path,
|
||||
// show a button to navigate to the previous
|
||||
// directory.
|
||||
if (req.url !== "/files/") {
|
||||
this.items.push({
|
||||
name: "..",
|
||||
url: url.removeLastDir(req.url) + "/",
|
||||
});
|
||||
}
|
||||
|
||||
// If this folder is empty, finish here.
|
||||
if (req.items === null) return;
|
||||
|
||||
// Otherwise we add every directory to the
|
||||
// move options.
|
||||
for (let item of req.items) {
|
||||
if (!item.isDir) continue;
|
||||
|
||||
this.items.push({
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
});
|
||||
}
|
||||
},
|
||||
next: function (event) {
|
||||
// Retrieves the URL of the directory the user
|
||||
// just clicked in and fill the options with its
|
||||
// content.
|
||||
let uri = event.currentTarget.dataset.url;
|
||||
|
||||
files.fetch(uri).then(this.fillOptions).catch(this.$showError);
|
||||
},
|
||||
touchstart(event) {
|
||||
let url = event.currentTarget.dataset.url;
|
||||
|
||||
// In 300 milliseconds, we shall reset the count.
|
||||
setTimeout(() => {
|
||||
this.touches.count = 0;
|
||||
}, 300);
|
||||
|
||||
// If the element the user is touching
|
||||
// is different from the last one he touched,
|
||||
// reset the count.
|
||||
if (this.touches.id !== url) {
|
||||
this.touches.id = url;
|
||||
this.touches.count = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
this.touches.count++;
|
||||
|
||||
// If there is more than one touch already,
|
||||
// open the next screen.
|
||||
if (this.touches.count > 1) {
|
||||
this.next(event);
|
||||
}
|
||||
},
|
||||
itemClick: function (event) {
|
||||
if (this.user.singleClick) this.next(event);
|
||||
else this.select(event);
|
||||
},
|
||||
select: function (event) {
|
||||
// If the element is already selected, unselect it.
|
||||
if (this.selected === event.currentTarget.dataset.url) {
|
||||
this.selected = null;
|
||||
this.$emit("update:selected", this.current);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise select the element.
|
||||
this.selected = event.currentTarget.dataset.url;
|
||||
this.$emit("update:selected", this.selected);
|
||||
},
|
||||
createDir: async function () {
|
||||
this.$store.commit("showHover", {
|
||||
prompt: "newDir",
|
||||
action: null,
|
||||
confirm: null,
|
||||
props: {
|
||||
redirect: false,
|
||||
base: this.current === this.$route.path ? null : this.current,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
47
frontend/src/components/prompts/Help.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="card floating help">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("help.help") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<ul>
|
||||
<li><strong>F1</strong> - {{ $t("help.f1") }}</li>
|
||||
<li><strong>F2</strong> - {{ $t("help.f2") }}</li>
|
||||
<li><strong>DEL</strong> - {{ $t("help.del") }}</li>
|
||||
<li><strong>ESC</strong> - {{ $t("help.esc") }}</li>
|
||||
<li><strong>CTRL + S</strong> - {{ $t("help.ctrl.s") }}</li>
|
||||
<li><strong>CTRL + F</strong> - {{ $t("help.ctrl.f") }}</li>
|
||||
<li><strong>CTRL + Click</strong> - {{ $t("help.ctrl.click") }}</li>
|
||||
<li><strong>Click</strong> - {{ $t("help.click") }}</li>
|
||||
<li><strong>Double click</strong> - {{ $t("help.doubleClick") }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
id="focus-prompt"
|
||||
type="submit"
|
||||
@click="closeHovers"
|
||||
class="button button--flat"
|
||||
:aria-label="$t('buttons.ok')"
|
||||
:title="$t('buttons.ok')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ $t("buttons.ok") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from "pinia";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
export default {
|
||||
name: "help",
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
196
frontend/src/components/prompts/Info.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("prompts.fileInfo") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p v-if="selected.length > 1">
|
||||
{{ $t("prompts.filesSelected", { count: selected.length }) }}
|
||||
</p>
|
||||
|
||||
<p class="break-word" v-if="selected.length < 2">
|
||||
<strong>{{ $t("prompts.displayName") }}</strong> {{ name }}
|
||||
</p>
|
||||
|
||||
<p v-if="!dir || selected.length > 1">
|
||||
<strong>{{ $t("prompts.size") }}:</strong>
|
||||
<span id="content_length"></span> {{ humanSize }}
|
||||
</p>
|
||||
|
||||
<div v-if="resolution">
|
||||
<strong>{{ $t("prompts.resolution") }}:</strong>
|
||||
{{ resolution.width }} x {{ resolution.height }}
|
||||
</div>
|
||||
|
||||
<p v-if="selected.length < 2" :title="modTime">
|
||||
<strong>{{ $t("prompts.lastModified") }}:</strong> {{ humanTime }}
|
||||
</p>
|
||||
|
||||
<template v-if="dir && selected.length === 0">
|
||||
<p>
|
||||
<strong>{{ $t("prompts.numberFiles") }}:</strong> {{ req.numFiles }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{{ $t("prompts.numberDirs") }}:</strong> {{ req.numDirs }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-if="!dir">
|
||||
<p>
|
||||
<strong>MD5: </strong
|
||||
><code
|
||||
><a
|
||||
@click="checksum($event, 'md5')"
|
||||
@keypress.enter="checksum($event, 'md5')"
|
||||
tabindex="2"
|
||||
>{{ $t("prompts.show") }}</a
|
||||
></code
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>SHA1: </strong
|
||||
><code
|
||||
><a
|
||||
@click="checksum($event, 'sha1')"
|
||||
@keypress.enter="checksum($event, 'sha1')"
|
||||
tabindex="3"
|
||||
>{{ $t("prompts.show") }}</a
|
||||
></code
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>SHA256: </strong
|
||||
><code
|
||||
><a
|
||||
@click="checksum($event, 'sha256')"
|
||||
@keypress.enter="checksum($event, 'sha256')"
|
||||
tabindex="4"
|
||||
>{{ $t("prompts.show") }}</a
|
||||
></code
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
<strong>SHA512: </strong
|
||||
><code
|
||||
><a
|
||||
@click="checksum($event, 'sha512')"
|
||||
@keypress.enter="checksum($event, 'sha512')"
|
||||
tabindex="5"
|
||||
>{{ $t("prompts.show") }}</a
|
||||
></code
|
||||
>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
id="focus-prompt"
|
||||
type="submit"
|
||||
@click="closeHovers"
|
||||
class="button button--flat"
|
||||
:aria-label="$t('buttons.ok')"
|
||||
:title="$t('buttons.ok')"
|
||||
>
|
||||
{{ $t("buttons.ok") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { filesize } from "@/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { files as api } from "@/api";
|
||||
|
||||
export default {
|
||||
name: "info",
|
||||
inject: ["$showError"],
|
||||
computed: {
|
||||
...mapState(useFileStore, [
|
||||
"req",
|
||||
"selected",
|
||||
"selectedCount",
|
||||
"isListing",
|
||||
]),
|
||||
humanSize: function () {
|
||||
if (this.selectedCount === 0 || !this.isListing) {
|
||||
return filesize(this.req.size);
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
|
||||
for (let selected of this.selected) {
|
||||
sum += this.req.items[selected].size;
|
||||
}
|
||||
|
||||
return filesize(sum);
|
||||
},
|
||||
humanTime: function () {
|
||||
if (this.selectedCount === 0) {
|
||||
return dayjs(this.req.modified).fromNow();
|
||||
}
|
||||
|
||||
return dayjs(this.req.items[this.selected[0]].modified).fromNow();
|
||||
},
|
||||
modTime: function () {
|
||||
if (this.selectedCount === 0) {
|
||||
return new Date(Date.parse(this.req.modified)).toLocaleString();
|
||||
}
|
||||
|
||||
return new Date(
|
||||
Date.parse(this.req.items[this.selected[0]].modified)
|
||||
).toLocaleString();
|
||||
},
|
||||
name: function () {
|
||||
return this.selectedCount === 0
|
||||
? this.req.name
|
||||
: this.req.items[this.selected[0]].name;
|
||||
},
|
||||
dir: function () {
|
||||
return (
|
||||
this.selectedCount > 1 ||
|
||||
(this.selectedCount === 0
|
||||
? this.req.isDir
|
||||
: this.req.items[this.selected[0]].isDir)
|
||||
);
|
||||
},
|
||||
resolution: function () {
|
||||
if (this.selectedCount === 1) {
|
||||
const selectedItem = this.req.items[this.selected[0]];
|
||||
if (selectedItem && selectedItem.type === "image") {
|
||||
return selectedItem.resolution;
|
||||
}
|
||||
} else if (this.req && this.req.type === "image") {
|
||||
return this.req.resolution;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
checksum: async function (event, algo) {
|
||||
event.preventDefault();
|
||||
|
||||
let link;
|
||||
|
||||
if (this.selectedCount) {
|
||||
link = this.req.items[this.selected[0]].url;
|
||||
} else {
|
||||
link = this.$route.path;
|
||||
}
|
||||
|
||||
try {
|
||||
const hash = await api.checksum(link, algo);
|
||||
event.target.textContent = hash;
|
||||
} catch (e) {
|
||||
this.$showError(e);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
135
frontend/src/components/prompts/Move.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("prompts.move") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<file-list
|
||||
ref="fileList"
|
||||
@update:selected="(val) => (dest = val)"
|
||||
tabindex="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-action"
|
||||
style="display: flex; align-items: center; justify-content: space-between"
|
||||
>
|
||||
<template v-if="user.perm.create">
|
||||
<button
|
||||
class="button button--flat"
|
||||
@click="$refs.fileList.createDir()"
|
||||
:aria-label="$t('sidebar.newFolder')"
|
||||
:title="$t('sidebar.newFolder')"
|
||||
style="justify-self: left"
|
||||
>
|
||||
<span>{{ $t("sidebar.newFolder") }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<div>
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="closeHovers"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="3"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
class="button button--flat"
|
||||
@click="move"
|
||||
:disabled="$route.path === dest"
|
||||
:aria-label="$t('buttons.move')"
|
||||
:title="$t('buttons.move')"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ $t("buttons.move") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import FileList from "./FileList.vue";
|
||||
import { files as api } from "@/api";
|
||||
import buttons from "@/utils/buttons";
|
||||
import * as upload from "@/utils/upload";
|
||||
|
||||
export default {
|
||||
name: "move",
|
||||
components: { FileList },
|
||||
data: function () {
|
||||
return {
|
||||
current: window.location.pathname,
|
||||
dest: null,
|
||||
};
|
||||
},
|
||||
inject: ["$showError"],
|
||||
computed: {
|
||||
...mapState(useFileStore, ["req", "selected"]),
|
||||
...mapState(useAuthStore, ["user"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["showHover", "closeHovers"]),
|
||||
move: async function (event) {
|
||||
event.preventDefault();
|
||||
let items = [];
|
||||
|
||||
for (let item of this.selected) {
|
||||
items.push({
|
||||
from: this.req.items[item].url,
|
||||
to: this.dest + encodeURIComponent(this.req.items[item].name),
|
||||
name: this.req.items[item].name,
|
||||
});
|
||||
}
|
||||
|
||||
let action = async (overwrite, rename) => {
|
||||
buttons.loading("move");
|
||||
|
||||
await api
|
||||
.move(items, overwrite, rename)
|
||||
.then(() => {
|
||||
buttons.success("move");
|
||||
this.$router.push({ path: this.dest });
|
||||
})
|
||||
.catch((e) => {
|
||||
buttons.done("move");
|
||||
this.$showError(e);
|
||||
});
|
||||
};
|
||||
|
||||
let dstItems = (await api.fetch(this.dest)).items;
|
||||
let conflict = upload.checkConflict(items, dstItems);
|
||||
|
||||
let overwrite = false;
|
||||
let rename = false;
|
||||
|
||||
if (conflict) {
|
||||
this.showHover({
|
||||
prompt: "replace-rename",
|
||||
confirm: (event, option) => {
|
||||
overwrite = option == "overwrite";
|
||||
rename = option == "rename";
|
||||
|
||||
event.preventDefault();
|
||||
this.closeHovers();
|
||||
action(overwrite, rename);
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
action(overwrite, rename);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
104
frontend/src/components/prompts/NewDir.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ t("prompts.newDir") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ t("prompts.newDirMessage") }}</p>
|
||||
<input
|
||||
id="focus-prompt"
|
||||
class="input input--block"
|
||||
type="text"
|
||||
@keyup.enter="submit"
|
||||
v-model.trim="name"
|
||||
tabindex="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="layoutStore.closeHovers"
|
||||
:aria-label="t('buttons.cancel')"
|
||||
:title="t('buttons.cancel')"
|
||||
tabindex="3"
|
||||
>
|
||||
{{ t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button button--flat"
|
||||
:aria-label="$t('buttons.create')"
|
||||
:title="t('buttons.create')"
|
||||
@click="submit"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ t("buttons.create") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from "vue";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import { files as api } from "@/api";
|
||||
import url from "@/utils/url";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const $showError = inject<IToastError>("$showError")!;
|
||||
|
||||
const props = defineProps({
|
||||
base: String,
|
||||
redirect: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const fileStore = useFileStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const name = ref<string>("");
|
||||
|
||||
const submit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (name.value === "") return;
|
||||
|
||||
// Build the path of the new directory.
|
||||
let uri: string;
|
||||
if (props.base) uri = props.base;
|
||||
else if (fileStore.isFiles) uri = route.path + "/";
|
||||
else uri = "/";
|
||||
|
||||
if (!fileStore.isListing) {
|
||||
uri = url.removeLastDir(uri) + "/";
|
||||
}
|
||||
|
||||
uri += encodeURIComponent(name.value) + "/";
|
||||
uri = uri.replace("//", "/");
|
||||
|
||||
try {
|
||||
await api.post(uri);
|
||||
if (props.redirect) {
|
||||
router.push({ path: uri });
|
||||
} else if (!props.base) {
|
||||
const res = await api.fetch(url.removeLastDir(uri) + "/");
|
||||
fileStore.updateRequest(res);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
$showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
layoutStore.closeHovers();
|
||||
};
|
||||
</script>
|
||||
85
frontend/src/components/prompts/NewFile.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ t("prompts.newFile") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ t("prompts.newFileMessage") }}</p>
|
||||
<input
|
||||
id="focus-prompt"
|
||||
class="input input--block"
|
||||
type="text"
|
||||
@keyup.enter="submit"
|
||||
v-model.trim="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="layoutStore.closeHovers"
|
||||
:aria-label="t('buttons.cancel')"
|
||||
:title="t('buttons.cancel')"
|
||||
>
|
||||
{{ t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button button--flat"
|
||||
@click="submit"
|
||||
:aria-label="t('buttons.create')"
|
||||
:title="t('buttons.create')"
|
||||
>
|
||||
{{ t("buttons.create") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import { files as api } from "@/api";
|
||||
import url from "@/utils/url";
|
||||
|
||||
const $showError = inject<IToastError>("$showError")!;
|
||||
|
||||
const fileStore = useFileStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const name = ref<string>("");
|
||||
|
||||
const submit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (name.value === "") return;
|
||||
|
||||
// Build the path of the new directory.
|
||||
let uri = fileStore.isFiles ? route.path + "/" : "/";
|
||||
|
||||
if (!fileStore.isListing) {
|
||||
uri = url.removeLastDir(uri) + "/";
|
||||
}
|
||||
|
||||
uri += encodeURIComponent(name.value);
|
||||
uri = uri.replace("//", "/");
|
||||
|
||||
try {
|
||||
await api.post(uri);
|
||||
router.push({ path: uri });
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
$showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
layoutStore.closeHovers();
|
||||
};
|
||||
</script>
|
||||
82
frontend/src/components/prompts/Prompts.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<ModalsContainer />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { ModalsContainer, useModal } from "vue-final-modal";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import BaseModal from "./BaseModal.vue";
|
||||
import Help from "./Help.vue";
|
||||
import Info from "./Info.vue";
|
||||
import Delete from "./Delete.vue";
|
||||
import DeleteUser from "./DeleteUser.vue";
|
||||
import Download from "./Download.vue";
|
||||
import Rename from "./Rename.vue";
|
||||
import Move from "./Move.vue";
|
||||
import Copy from "./Copy.vue";
|
||||
import NewFile from "./NewFile.vue";
|
||||
import NewDir from "./NewDir.vue";
|
||||
import Replace from "./Replace.vue";
|
||||
import ReplaceRename from "./ReplaceRename.vue";
|
||||
import Share from "./Share.vue";
|
||||
import ShareDelete from "./ShareDelete.vue";
|
||||
import Upload from "./Upload.vue";
|
||||
import DiscardEditorChanges from "./DiscardEditorChanges.vue";
|
||||
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
const { currentPromptName } = storeToRefs(layoutStore);
|
||||
|
||||
const closeModal = ref<() => Promise<string>>();
|
||||
|
||||
const components = new Map<string, any>([
|
||||
["info", Info],
|
||||
["help", Help],
|
||||
["delete", Delete],
|
||||
["rename", Rename],
|
||||
["move", Move],
|
||||
["copy", Copy],
|
||||
["newFile", NewFile],
|
||||
["newDir", NewDir],
|
||||
["download", Download],
|
||||
["replace", Replace],
|
||||
["replace-rename", ReplaceRename],
|
||||
["share", Share],
|
||||
["upload", Upload],
|
||||
["share-delete", ShareDelete],
|
||||
["deleteUser", DeleteUser],
|
||||
["discardEditorChanges", DiscardEditorChanges],
|
||||
]);
|
||||
|
||||
watch(currentPromptName, (newValue) => {
|
||||
if (closeModal.value) {
|
||||
closeModal.value();
|
||||
closeModal.value = undefined;
|
||||
}
|
||||
|
||||
const modal = components.get(newValue!);
|
||||
if (!modal) return;
|
||||
|
||||
const { open, close } = useModal({
|
||||
component: BaseModal,
|
||||
slots: {
|
||||
default: modal,
|
||||
},
|
||||
});
|
||||
|
||||
closeModal.value = close;
|
||||
open();
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!layoutStore.currentPrompt) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.stopImmediatePropagation();
|
||||
layoutStore.closeHovers();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
117
frontend/src/components/prompts/Rename.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("prompts.rename") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>
|
||||
{{ $t("prompts.renameMessage") }} <code>{{ oldName() }}</code
|
||||
>:
|
||||
</p>
|
||||
<input
|
||||
id="focus-prompt"
|
||||
class="input input--block"
|
||||
type="text"
|
||||
@keyup.enter="submit"
|
||||
v-model.trim="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="closeHovers"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
@click="submit"
|
||||
class="button button--flat"
|
||||
type="submit"
|
||||
:aria-label="$t('buttons.rename')"
|
||||
:title="$t('buttons.rename')"
|
||||
>
|
||||
{{ $t("buttons.rename") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState, mapWritableState } from "pinia";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import url from "@/utils/url";
|
||||
import { files as api } from "@/api";
|
||||
|
||||
export default {
|
||||
name: "rename",
|
||||
data: function () {
|
||||
return {
|
||||
name: "",
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.name = this.oldName();
|
||||
},
|
||||
inject: ["$showError"],
|
||||
computed: {
|
||||
...mapState(useFileStore, [
|
||||
"req",
|
||||
"selected",
|
||||
"selectedCount",
|
||||
"isListing",
|
||||
]),
|
||||
...mapWritableState(useFileStore, ["reload"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
cancel: function () {
|
||||
this.closeHovers();
|
||||
},
|
||||
oldName: function () {
|
||||
if (!this.isListing) {
|
||||
return this.req.name;
|
||||
}
|
||||
|
||||
if (this.selectedCount === 0 || this.selectedCount > 1) {
|
||||
// This shouldn't happen.
|
||||
return;
|
||||
}
|
||||
|
||||
return this.req.items[this.selected[0]].name;
|
||||
},
|
||||
submit: async function () {
|
||||
let oldLink = "";
|
||||
let newLink = "";
|
||||
|
||||
if (!this.isListing) {
|
||||
oldLink = this.req.url;
|
||||
} else {
|
||||
oldLink = this.req.items[this.selected[0]].url;
|
||||
}
|
||||
|
||||
newLink =
|
||||
url.removeLastDir(oldLink) + "/" + encodeURIComponent(this.name);
|
||||
|
||||
window.sessionStorage.setItem("modified", "true");
|
||||
try {
|
||||
await api.move([{ from: oldLink, to: newLink }]);
|
||||
if (!this.isListing) {
|
||||
this.$router.push({ path: newLink });
|
||||
return;
|
||||
}
|
||||
|
||||
this.reload = true;
|
||||
} catch (e) {
|
||||
this.$showError(e);
|
||||
}
|
||||
|
||||
this.closeHovers();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
57
frontend/src/components/prompts/Replace.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("prompts.replace") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ $t("prompts.replaceMessage") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="closeHovers"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="3"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button button--flat button--blue"
|
||||
@click="currentPrompt.action"
|
||||
:aria-label="$t('buttons.continue')"
|
||||
:title="$t('buttons.continue')"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ $t("buttons.continue") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
class="button button--flat button--red"
|
||||
@click="currentPrompt.confirm"
|
||||
:aria-label="$t('buttons.replace')"
|
||||
:title="$t('buttons.replace')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ $t("buttons.replace") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
export default {
|
||||
name: "replace",
|
||||
computed: {
|
||||
...mapState(useLayoutStore, ["currentPrompt"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
57
frontend/src/components/prompts/ReplaceRename.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("prompts.replace") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ $t("prompts.replaceMessage") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="closeHovers"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="3"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="button button--flat button--blue"
|
||||
@click="(event) => currentPrompt.confirm(event, 'rename')"
|
||||
:aria-label="$t('buttons.rename')"
|
||||
:title="$t('buttons.rename')"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ $t("buttons.rename") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
class="button button--flat button--red"
|
||||
@click="(event) => currentPrompt.confirm(event, 'overwrite')"
|
||||
:aria-label="$t('buttons.replace')"
|
||||
:title="$t('buttons.replace')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ $t("buttons.replace") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
export default {
|
||||
name: "replace-rename",
|
||||
computed: {
|
||||
...mapState(useLayoutStore, ["currentPrompt"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
274
frontend/src/components/prompts/Share.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="card floating" id="share">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("buttons.share") }}</h2>
|
||||
</div>
|
||||
|
||||
<template v-if="listing">
|
||||
<div class="card-content">
|
||||
<table>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ $t("settings.shareDuration") }}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
|
||||
<tr v-for="link in links" :key="link.hash">
|
||||
<td>{{ link.hash }}</td>
|
||||
<td>
|
||||
<template v-if="link.expire !== 0">{{
|
||||
humanTime(link.expire)
|
||||
}}</template>
|
||||
<template v-else>{{ $t("permanent") }}</template>
|
||||
</td>
|
||||
<td class="small">
|
||||
<button
|
||||
class="action copy-clipboard"
|
||||
:aria-label="$t('buttons.copyToClipboard')"
|
||||
:title="$t('buttons.copyToClipboard')"
|
||||
@click="copyToClipboard(buildLink(link))"
|
||||
>
|
||||
<i class="material-icons">content_paste</i>
|
||||
</button>
|
||||
</td>
|
||||
<td class="small" v-if="hasDownloadLink()">
|
||||
<button
|
||||
class="action copy-clipboard"
|
||||
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
|
||||
:title="$t('buttons.copyDownloadLinkToClipboard')"
|
||||
@click="copyToClipboard(buildDownloadLink(link))"
|
||||
>
|
||||
<i class="material-icons">content_paste_go</i>
|
||||
</button>
|
||||
</td>
|
||||
<td class="small">
|
||||
<button
|
||||
class="action"
|
||||
@click="deleteLink($event, link)"
|
||||
:aria-label="$t('buttons.delete')"
|
||||
:title="$t('buttons.delete')"
|
||||
>
|
||||
<i class="material-icons">delete</i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="closeHovers"
|
||||
:aria-label="$t('buttons.close')"
|
||||
:title="$t('buttons.close')"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ $t("buttons.close") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
class="button button--flat button--blue"
|
||||
@click="() => switchListing()"
|
||||
:aria-label="$t('buttons.new')"
|
||||
:title="$t('buttons.new')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ $t("buttons.new") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="card-content">
|
||||
<p>{{ $t("settings.shareDuration") }}</p>
|
||||
<div class="input-group input">
|
||||
<vue-number-input
|
||||
center
|
||||
controls
|
||||
size="small"
|
||||
:max="2147483647"
|
||||
:min="0"
|
||||
@keyup.enter="submit"
|
||||
v-model="time"
|
||||
tabindex="1"
|
||||
/>
|
||||
<select
|
||||
class="right"
|
||||
v-model="unit"
|
||||
:aria-label="$t('time.unit')"
|
||||
tabindex="2"
|
||||
>
|
||||
<option value="seconds">{{ $t("time.seconds") }}</option>
|
||||
<option value="minutes">{{ $t("time.minutes") }}</option>
|
||||
<option value="hours">{{ $t("time.hours") }}</option>
|
||||
<option value="days">{{ $t("time.days") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p>{{ $t("prompts.optionalPassword") }}</p>
|
||||
<input
|
||||
class="input input--block"
|
||||
type="password"
|
||||
v-model.trim="password"
|
||||
tabindex="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<button
|
||||
class="button button--flat button--grey"
|
||||
@click="() => switchListing()"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="5"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
class="button button--flat button--blue"
|
||||
@click="submit"
|
||||
:aria-label="$t('buttons.share')"
|
||||
:title="$t('buttons.share')"
|
||||
tabindex="4"
|
||||
>
|
||||
{{ $t("buttons.share") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { share as api, pub as pub_api } from "@/api";
|
||||
import dayjs from "dayjs";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
import { copy } from "@/utils/clipboard";
|
||||
|
||||
export default {
|
||||
name: "share",
|
||||
data: function () {
|
||||
return {
|
||||
time: 0,
|
||||
unit: "hours",
|
||||
links: [],
|
||||
clip: null,
|
||||
password: "",
|
||||
listing: true,
|
||||
};
|
||||
},
|
||||
inject: ["$showError", "$showSuccess"],
|
||||
computed: {
|
||||
...mapState(useFileStore, [
|
||||
"req",
|
||||
"selected",
|
||||
"selectedCount",
|
||||
"isListing",
|
||||
]),
|
||||
url() {
|
||||
if (!this.isListing) {
|
||||
return this.$route.path;
|
||||
}
|
||||
|
||||
if (this.selectedCount === 0 || this.selectedCount > 1) {
|
||||
// This shouldn't happen.
|
||||
return;
|
||||
}
|
||||
|
||||
return this.req.items[this.selected[0]].url;
|
||||
},
|
||||
},
|
||||
async beforeMount() {
|
||||
try {
|
||||
const links = await api.get(this.url);
|
||||
this.links = links;
|
||||
this.sort();
|
||||
|
||||
if (this.links.length == 0) {
|
||||
this.listing = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.$showError(e);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
copyToClipboard: function (text) {
|
||||
copy(text).then(
|
||||
() => {
|
||||
// clipboard successfully set
|
||||
this.$showSuccess(this.$t("success.linkCopied"));
|
||||
},
|
||||
() => {
|
||||
// clipboard write failed
|
||||
}
|
||||
);
|
||||
},
|
||||
submit: async function () {
|
||||
try {
|
||||
let res = null;
|
||||
|
||||
if (!this.time) {
|
||||
res = await api.create(this.url, this.password);
|
||||
} else {
|
||||
res = await api.create(this.url, this.password, this.time, this.unit);
|
||||
}
|
||||
|
||||
this.links.push(res);
|
||||
this.sort();
|
||||
|
||||
this.time = 0;
|
||||
this.unit = "hours";
|
||||
this.password = "";
|
||||
|
||||
this.listing = true;
|
||||
} catch (e) {
|
||||
this.$showError(e);
|
||||
}
|
||||
},
|
||||
deleteLink: async function (event, link) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await api.remove(link.hash);
|
||||
this.links = this.links.filter((item) => item.hash !== link.hash);
|
||||
|
||||
if (this.links.length == 0) {
|
||||
this.listing = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.$showError(e);
|
||||
}
|
||||
},
|
||||
humanTime(time) {
|
||||
return dayjs(time * 1000).fromNow();
|
||||
},
|
||||
buildLink(share) {
|
||||
return api.getShareURL(share);
|
||||
},
|
||||
hasDownloadLink() {
|
||||
return (
|
||||
this.selected.length === 1 && !this.req.items[this.selected[0]].isDir
|
||||
);
|
||||
},
|
||||
buildDownloadLink(share) {
|
||||
return pub_api.getDownloadURL(share);
|
||||
},
|
||||
sort() {
|
||||
this.links = this.links.sort((a, b) => {
|
||||
if (a.expire === 0) return -1;
|
||||
if (b.expire === 0) return 1;
|
||||
return new Date(a.expire) - new Date(b.expire);
|
||||
});
|
||||
},
|
||||
switchListing() {
|
||||
if (this.links.length == 0 && !this.listing) {
|
||||
this.closeHovers();
|
||||
}
|
||||
|
||||
this.listing = !this.listing;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
46
frontend/src/components/prompts/ShareDelete.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-content">
|
||||
<p>{{ $t("prompts.deleteMessageShare", { path: "" }) }}</p>
|
||||
</div>
|
||||
<div class="card-action">
|
||||
<button
|
||||
@click="closeHovers"
|
||||
class="button button--flat button--grey"
|
||||
:aria-label="$t('buttons.cancel')"
|
||||
:title="$t('buttons.cancel')"
|
||||
tabindex="2"
|
||||
>
|
||||
{{ $t("buttons.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
id="focus-prompt"
|
||||
@click="submit"
|
||||
class="button button--flat button--red"
|
||||
:aria-label="$t('buttons.delete')"
|
||||
:title="$t('buttons.delete')"
|
||||
tabindex="1"
|
||||
>
|
||||
{{ $t("buttons.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapState } from "pinia";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
export default {
|
||||
name: "share-delete",
|
||||
computed: {
|
||||
...mapState(useLayoutStore, ["currentPrompt"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useLayoutStore, ["closeHovers"]),
|
||||
submit: function () {
|
||||
this.currentPrompt?.confirm();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
111
frontend/src/components/prompts/Upload.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ t("prompts.upload") }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<p>{{ t("prompts.uploadMessage") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-action full">
|
||||
<div
|
||||
@click="uploadFile"
|
||||
@keypress.enter="uploadFile"
|
||||
class="action"
|
||||
id="focus-prompt"
|
||||
tabindex="1"
|
||||
>
|
||||
<i class="material-icons">insert_drive_file</i>
|
||||
<div class="title">{{ t("buttons.file") }}</div>
|
||||
</div>
|
||||
<div
|
||||
@click="uploadFolder"
|
||||
@keypress.enter="uploadFolder"
|
||||
class="action"
|
||||
tabindex="2"
|
||||
>
|
||||
<i class="material-icons">folder</i>
|
||||
<div class="title">{{ t("buttons.folder") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { useLayoutStore } from "@/stores/layout";
|
||||
|
||||
import * as upload from "@/utils/upload";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const fileStore = useFileStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
// TODO: this is a copy of the same function in FileListing.vue
|
||||
const uploadInput = (event: Event) => {
|
||||
layoutStore.closeHovers();
|
||||
|
||||
let files = (event.currentTarget as HTMLInputElement)?.files;
|
||||
if (files === null) return;
|
||||
|
||||
let folder_upload = !!files[0].webkitRelativePath;
|
||||
|
||||
const uploadFiles: UploadList = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const fullPath = folder_upload ? file.webkitRelativePath : undefined;
|
||||
uploadFiles.push({
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
isDir: false,
|
||||
fullPath,
|
||||
});
|
||||
}
|
||||
|
||||
let path = route.path.endsWith("/") ? route.path : route.path + "/";
|
||||
let conflict = upload.checkConflict(uploadFiles, fileStore.req!.items);
|
||||
|
||||
if (conflict) {
|
||||
layoutStore.showHover({
|
||||
prompt: "replace",
|
||||
action: (event: Event) => {
|
||||
event.preventDefault();
|
||||
layoutStore.closeHovers();
|
||||
upload.handleFiles(uploadFiles, path, false);
|
||||
},
|
||||
confirm: (event: Event) => {
|
||||
event.preventDefault();
|
||||
layoutStore.closeHovers();
|
||||
upload.handleFiles(uploadFiles, path, true);
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
upload.handleFiles(uploadFiles, path);
|
||||
};
|
||||
|
||||
const openUpload = (isFolder: boolean) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.webkitdirectory = isFolder;
|
||||
// TODO: call the function in FileListing.vue instead
|
||||
input.onchange = uploadInput;
|
||||
input.click();
|
||||
};
|
||||
|
||||
const uploadFile = () => {
|
||||
openUpload(false);
|
||||
};
|
||||
const uploadFolder = () => {
|
||||
openUpload(true);
|
||||
};
|
||||
</script>
|
||||
127
frontend/src/components/prompts/UploadFiles.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="filesInUploadCount > 0"
|
||||
class="upload-files"
|
||||
v-bind:class="{ closed: !open }"
|
||||
>
|
||||
<div class="card floating">
|
||||
<div class="card-title">
|
||||
<h2>{{ $t("prompts.uploadFiles", { files: filesInUploadCount }) }}</h2>
|
||||
<div class="upload-info">
|
||||
<div class="upload-speed">{{ uploadSpeed.toFixed(2) }} MB/s</div>
|
||||
<div class="upload-eta">{{ formattedETA }} remaining</div>
|
||||
<div class="upload-percentage">
|
||||
{{ getProgressDecimal }}% Completed
|
||||
</div>
|
||||
<div class="upload-fraction">
|
||||
{{ getTotalProgressBytes }} / {{ getTotalSize }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="action"
|
||||
@click="abortAll"
|
||||
aria-label="Abort upload"
|
||||
title="Abort upload"
|
||||
>
|
||||
<i class="material-icons">{{ "cancel" }}</i>
|
||||
</button>
|
||||
<button
|
||||
class="action"
|
||||
@click="toggle"
|
||||
aria-label="Toggle file upload list"
|
||||
title="Toggle file upload list"
|
||||
>
|
||||
<i class="material-icons">{{
|
||||
open ? "keyboard_arrow_down" : "keyboard_arrow_up"
|
||||
}}</i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-content file-icons">
|
||||
<div
|
||||
class="file"
|
||||
v-for="file in filesInUpload"
|
||||
:key="file.id"
|
||||
:data-dir="file.isDir"
|
||||
:data-type="file.type"
|
||||
:aria-label="file.name"
|
||||
>
|
||||
<div class="file-name">
|
||||
<i class="material-icons"></i> {{ file.name }}
|
||||
</div>
|
||||
<div class="file-progress">
|
||||
<div v-bind:style="{ width: file.progress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapWritableState, mapActions } from "pinia";
|
||||
import { useUploadStore } from "@/stores/upload";
|
||||
import { useFileStore } from "@/stores/file";
|
||||
import { abortAllUploads } from "@/api/tus";
|
||||
import buttons from "@/utils/buttons";
|
||||
|
||||
export default {
|
||||
name: "uploadFiles",
|
||||
data: function () {
|
||||
return {
|
||||
open: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(useUploadStore, [
|
||||
"filesInUpload",
|
||||
"filesInUploadCount",
|
||||
"uploadSpeed",
|
||||
"getETA",
|
||||
"getProgress",
|
||||
"getProgressDecimal",
|
||||
"getTotalProgressBytes",
|
||||
"getTotalSize",
|
||||
]),
|
||||
...mapWritableState(useFileStore, ["reload"]),
|
||||
formattedETA() {
|
||||
if (!this.getETA || this.getETA === Infinity) {
|
||||
return "--:--:--";
|
||||
}
|
||||
|
||||
let totalSeconds = this.getETA;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
totalSeconds %= 3600;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = Math.round(totalSeconds % 60);
|
||||
|
||||
return `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useUploadStore, ["reset"]), // Mapping reset action from upload store
|
||||
toggle: function () {
|
||||
this.open = !this.open;
|
||||
},
|
||||
abortAll() {
|
||||
if (confirm(this.$t("upload.abortUpload"))) {
|
||||
abortAllUploads();
|
||||
buttons.done("upload");
|
||||
this.open = false;
|
||||
this.reset(); // Resetting the upload store state
|
||||
this.reload = true; // Trigger reload in the file store
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-info {
|
||||
min-width: 19ch;
|
||||
width: auto;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
30
frontend/src/components/settings/Commands.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>{{ $t("settings.userCommands") }}</h3>
|
||||
<p class="small">
|
||||
{{ $t("settings.userCommandsHelp") }} <i>git svn hg</i>.
|
||||
</p>
|
||||
<input class="input input--block" type="text" v-model.trim="raw" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "permissions",
|
||||
props: ["commands"],
|
||||
computed: {
|
||||
raw: {
|
||||
get() {
|
||||
return this.commands.join(" ");
|
||||
},
|
||||
set(value) {
|
||||
if (value !== "") {
|
||||
this.$emit("update:commands", value.split(" "));
|
||||
} else {
|
||||
this.$emit("update:commands", []);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
62
frontend/src/components/settings/Languages.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<select name="selectLanguage" v-on:change="change" :value="locale">
|
||||
<option v-for="(language, value) in locales" :key="value" :value="value">
|
||||
{{ language }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { markRaw } from "vue";
|
||||
|
||||
export default {
|
||||
name: "languages",
|
||||
props: ["locale"],
|
||||
data() {
|
||||
let dataObj = {};
|
||||
const locales = {
|
||||
he: "עברית",
|
||||
hu: "Magyar",
|
||||
ar: "العربية",
|
||||
ca: "Català",
|
||||
de: "Deutsch",
|
||||
el: "Ελληνικά",
|
||||
en: "English",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
is: "Icelandic",
|
||||
it: "Italiano",
|
||||
ja: "日本語",
|
||||
ko: "한국어",
|
||||
"nl-be": "Dutch (Belgium)",
|
||||
pl: "Polski",
|
||||
"pt-br": "Português",
|
||||
pt: "Português (Brasil)",
|
||||
ro: "Romanian",
|
||||
ru: "Русский",
|
||||
sk: "Slovenčina",
|
||||
"sv-se": "Swedish (Sweden)",
|
||||
tr: "Türkçe",
|
||||
uk: "Українська",
|
||||
"zh-cn": "中文 (简体)",
|
||||
"zh-tw": "中文 (繁體)",
|
||||
};
|
||||
|
||||
// Vue3 reactivity breaks with this configuration
|
||||
// so we need to use markRaw as a workaround
|
||||
// https://github.com/vuejs/core/issues/3024
|
||||
Object.defineProperty(dataObj, "locales", {
|
||||
value: markRaw(locales),
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
return dataObj;
|
||||
},
|
||||
methods: {
|
||||
change(event) {
|
||||
this.$emit("update:locale", event.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
65
frontend/src/components/settings/Permissions.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>{{ $t("settings.permissions") }}</h3>
|
||||
<p class="small">{{ $t("settings.permissionsHelp") }}</p>
|
||||
|
||||
<p>
|
||||
<input type="checkbox" v-model="admin" />
|
||||
{{ $t("settings.administrator") }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<input type="checkbox" :disabled="admin" v-model="perm.create" />
|
||||
{{ $t("settings.perm.create") }}
|
||||
</p>
|
||||
<p>
|
||||
<input type="checkbox" :disabled="admin" v-model="perm.delete" />
|
||||
{{ $t("settings.perm.delete") }}
|
||||
</p>
|
||||
<p>
|
||||
<input type="checkbox" :disabled="admin" v-model="perm.download" />
|
||||
{{ $t("settings.perm.download") }}
|
||||
</p>
|
||||
<p>
|
||||
<input type="checkbox" :disabled="admin" v-model="perm.modify" />
|
||||
{{ $t("settings.perm.modify") }}
|
||||
</p>
|
||||
<p v-if="isExecEnabled">
|
||||
<input type="checkbox" :disabled="admin" v-model="perm.execute" />
|
||||
{{ $t("settings.perm.execute") }}
|
||||
</p>
|
||||
<p>
|
||||
<input type="checkbox" :disabled="admin" v-model="perm.rename" />
|
||||
{{ $t("settings.perm.rename") }}
|
||||
</p>
|
||||
<p>
|
||||
<input type="checkbox" :disabled="admin" v-model="perm.share" />
|
||||
{{ $t("settings.perm.share") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { enableExec } from "@/utils/constants";
|
||||
export default {
|
||||
name: "permissions",
|
||||
props: ["perm"],
|
||||
computed: {
|
||||
admin: {
|
||||
get() {
|
||||
return this.perm.admin;
|
||||
},
|
||||
set(value) {
|
||||
if (value) {
|
||||
for (const key in this.perm) {
|
||||
this.perm[key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.perm.admin = value;
|
||||
},
|
||||
},
|
||||
isExecEnabled: () => enableExec,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
63
frontend/src/components/settings/Rules.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<form class="rules small">
|
||||
<div v-for="(rule, index) in rules" :key="index">
|
||||
<input type="checkbox" v-model="rule.regex" /><label>Regex</label>
|
||||
<input type="checkbox" v-model="rule.allow" /><label>Allow</label>
|
||||
|
||||
<input
|
||||
@keypress.enter.prevent
|
||||
type="text"
|
||||
v-if="rule.regex"
|
||||
v-model="rule.regexp.raw"
|
||||
:placeholder="$t('settings.insertRegex')"
|
||||
/>
|
||||
<input
|
||||
@keypress.enter.prevent
|
||||
type="text"
|
||||
v-else
|
||||
v-model="rule.path"
|
||||
:placeholder="$t('settings.insertPath')"
|
||||
/>
|
||||
|
||||
<button class="button button--red" @click="remove($event, index)">
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button class="button" @click="create" default="false">
|
||||
{{ $t("buttons.new") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "rules-textarea",
|
||||
props: ["rules"],
|
||||
methods: {
|
||||
remove(event, index) {
|
||||
event.preventDefault();
|
||||
let rules = [...this.rules];
|
||||
rules.splice(index, 1);
|
||||
this.$emit("update:rules", [...rules]);
|
||||
},
|
||||
create(event) {
|
||||
event.preventDefault();
|
||||
|
||||
this.$emit("update:rules", [
|
||||
...this.rules,
|
||||
{
|
||||
allow: true,
|
||||
path: "",
|
||||
regex: false,
|
||||
regexp: {
|
||||
raw: "",
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
27
frontend/src/components/settings/Themes.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<select v-on:change="change" :value="theme">
|
||||
<option value="">{{ t("settings.themes.default") }}</option>
|
||||
<option value="light">{{ t("settings.themes.light") }}</option>
|
||||
<option value="dark">{{ t("settings.themes.dark") }}</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SelectHTMLAttributes } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps<{
|
||||
theme: UserTheme;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(e: "update:theme", val: string | null): void;
|
||||
}>();
|
||||
|
||||
const change = (event: Event) => {
|
||||
emit("update:theme", (event.target as SelectHTMLAttributes)?.value);
|
||||
};
|
||||
</script>
|
||||
122
frontend/src/components/settings/UserForm.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div>
|
||||
<p v-if="!isDefault && props.user !== null">
|
||||
<label for="username">{{ t("settings.username") }}</label>
|
||||
<input
|
||||
class="input input--block"
|
||||
type="text"
|
||||
v-model="user.username"
|
||||
id="username"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<p v-if="!isDefault">
|
||||
<label for="password">{{ t("settings.password") }}</label>
|
||||
<input
|
||||
class="input input--block"
|
||||
type="password"
|
||||
:placeholder="passwordPlaceholder"
|
||||
v-model="user.password"
|
||||
id="password"
|
||||
/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label for="scope">{{ t("settings.scope") }}</label>
|
||||
<input
|
||||
:disabled="createUserDirData ?? false"
|
||||
:placeholder="scopePlaceholder"
|
||||
class="input input--block"
|
||||
type="text"
|
||||
v-model="user.scope"
|
||||
id="scope"
|
||||
/>
|
||||
</p>
|
||||
<p class="small" v-if="displayHomeDirectoryCheckbox">
|
||||
<input type="checkbox" v-model="createUserDirData" />
|
||||
{{ t("settings.createUserHomeDirectory") }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<label for="locale">{{ t("settings.language") }}</label>
|
||||
<languages
|
||||
class="input input--block"
|
||||
id="locale"
|
||||
v-model:locale="user.locale"
|
||||
></languages>
|
||||
</p>
|
||||
|
||||
<p v-if="!isDefault && user.perm">
|
||||
<input
|
||||
type="checkbox"
|
||||
:disabled="user.perm.admin"
|
||||
v-model="user.lockPassword"
|
||||
/>
|
||||
{{ t("settings.lockPassword") }}
|
||||
</p>
|
||||
|
||||
<permissions v-model:perm="user.perm" />
|
||||
<commands v-if="enableExec" v-model:commands="user.commands" />
|
||||
|
||||
<div v-if="!isDefault">
|
||||
<h3>{{ t("settings.rules") }}</h3>
|
||||
<p class="small">{{ t("settings.rulesHelp") }}</p>
|
||||
<rules v-model:rules="user.rules" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Languages from "./Languages.vue";
|
||||
import Rules from "./Rules.vue";
|
||||
import Permissions from "./Permissions.vue";
|
||||
import Commands from "./Commands.vue";
|
||||
import { enableExec } from "@/utils/constants";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const createUserDirData = ref<boolean | null>(null);
|
||||
const originalUserScope = ref<string | null>(null);
|
||||
|
||||
const props = defineProps<{
|
||||
user: IUserForm;
|
||||
isNew: boolean;
|
||||
isDefault: boolean;
|
||||
createUserDir?: boolean;
|
||||
}>();
|
||||
|
||||
onMounted(() => {
|
||||
if (props.user.scope) {
|
||||
originalUserScope.value = props.user.scope;
|
||||
createUserDirData.value = props.createUserDir;
|
||||
}
|
||||
});
|
||||
|
||||
const passwordPlaceholder = computed(() =>
|
||||
props.isNew ? "" : t("settings.avoidChanges")
|
||||
);
|
||||
const scopePlaceholder = computed(() =>
|
||||
createUserDirData.value ? t("settings.userScopeGenerationPlaceholder") : ""
|
||||
);
|
||||
const displayHomeDirectoryCheckbox = computed(
|
||||
() => props.isNew && createUserDirData.value
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.user,
|
||||
() => {
|
||||
if (!props.user?.perm?.admin) return;
|
||||
props.user.lockPassword = false;
|
||||
}
|
||||
);
|
||||
|
||||
watch(createUserDirData, () => {
|
||||
if (props.user?.scope) {
|
||||
props.user.scope = createUserDirData.value
|
||||
? ""
|
||||
: originalUserScope.value ?? "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
55
frontend/src/css/_buttons.css
Normal file
@@ -0,0 +1,55 @@
|
||||
.button {
|
||||
outline: 0;
|
||||
border: 0;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 0.1em;
|
||||
cursor: pointer;
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
border: 1px solid var(--divider);
|
||||
box-shadow: 0 0 5px var(--divider);
|
||||
transition: 0.1s ease all;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: var(--dark-blue);
|
||||
}
|
||||
|
||||
.button--block {
|
||||
margin: 0 0 0.5em;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button--red {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.button--blue {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.button--flat {
|
||||
color: var(--dark-blue);
|
||||
background: transparent;
|
||||
box-shadow: 0 0 0;
|
||||
border: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.button--flat:hover {
|
||||
background: var(--surfaceSecondary);
|
||||
}
|
||||
|
||||
.button--flat.button--red {
|
||||
color: var(--dark-red);
|
||||
}
|
||||
|
||||
.button--flat.button--grey {
|
||||
color: #6f6f6f;
|
||||
}
|
||||
|
||||
.button[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
35
frontend/src/css/_inputs.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.input {
|
||||
background: var(--surfacePrimary);
|
||||
color: var(--textSecondary);
|
||||
border: 1px solid var(--borderPrimary);
|
||||
border-radius: 0.1em;
|
||||
padding: 0.5em 1em;
|
||||
transition: 0.2s ease all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.input:hover,
|
||||
.input:focus {
|
||||
border-color: var(--borderSecondary);
|
||||
}
|
||||
|
||||
.input--block {
|
||||
margin-bottom: 0.5em;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input--textarea {
|
||||
line-height: 1.15;
|
||||
font-family: monospace;
|
||||
min-height: 10em;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input--red {
|
||||
background: var(--input-red) !important;
|
||||
}
|
||||
|
||||
.input--green {
|
||||
background: var(--input-green) !important;
|
||||
}
|
||||