frontend first steps

master
adb-sh 2 years ago
parent 65a67ae19b
commit 01a6481d62

@ -1 +0,0 @@
VUE_APP_ROOT_WEBDAV="http://127.0.0.1:4918"

@ -0,0 +1 @@
VUE_APP_ROOT_WEBDAV="http://127.0.0.1:8080"

@ -9,11 +9,15 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"bootstrap": "^5.2.0",
"bootstrap-darkmode": "^5.0.1",
"bootstrap-icons": "^1.9.1",
"core-js": "^3.6.5",
"pinia": "^2.0.13",
"register-service-worker": "^1.7.1",
"typescript-is": "^0.19.0",
"vue": "^3.0.0",
"vue-router": "^4.0.0-0",
"pinia": "^2.0.13",
"webdav": "^4.9.0"
},
"devDependencies": {

@ -1,30 +1,24 @@
<template>
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view />
<header
class="d-flex justify-content-between p-3 bg-darkmode-dark bg-light shadow"
>
<div>
<b class="mx-2">vuedav</b>
<router-link class="mx-2" to="/files">Files</router-link>
</div>
<div class="d-flex">
<DarkModeToggle class="mx-2" v-slot="{ state }">
<i v-if="state" class="bi-moon"></i>
<i v-else class="bi-sun"></i>
</DarkModeToggle>
</div>
</header>
<main class="container my-5">
<router-view />
</main>
<footer></footer>
</template>
<style lang="scss">
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
#nav {
padding: 30px;
a {
font-weight: bold;
color: #2c3e50;
&.router-link-exact-active {
color: #42b983;
}
}
}
</style>
<script setup lang="ts">
import DarkModeToggle from '@/components/DarkmodeToggle.vue';
</script>

@ -0,0 +1,23 @@
<template>
<div class="form-check form-switch">
<label class="custom-control-label" for="darkSwitch">
<slot :state="sliderState" />
</label>
<input v-model="sliderState" type="checkbox" class="form-check-input" />
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { themeConfig } from '@/main';
const sliderState = ref(themeConfig.getTheme() === 'dark');
watch(sliderState, (state) => {
themeConfig.setTheme(state ? 'dark' : 'light');
});
themeConfig.themeChangeHandlers.push((newTheme) => {
sliderState.value = newTheme === 'dark';
});
</script>

@ -0,0 +1,19 @@
<template>
<div class="card sideMenu sticky-top">
<div class="card-header">Menu</div>
<div class="card-body">
<router-link class="py-2" to="/files">
<i class="bi-archive"></i> My Files
</router-link>
</div>
</div>
</template>
<style scoped lang="scss">
.sideMenu {
a {
color: inherit;
text-decoration: none;
}
}
</style>

@ -0,0 +1,100 @@
<template>
<div class="px-0">
<div class="files card">
<div class="card-header sticky-top bg-light bg-darkmode-dark">
<div class="row">
<i
v-if="path && path !== '/'"
@click="navigateUp"
class="col-1 px-0 btn btn-default bi-arrow-90deg-left"
></i>
<PathSegments
class="col my-2"
:path="path"
@newPath="(newPath) => emit('newPath', newPath)"
/>
<div class="col-auto px-0">
<FileUpload :path="path" @finished="fetchLocation" />
</div>
</div>
</div>
<div class="card-body">
<div v-if="loading">loading...</div>
<div v-else>
<table class="table table-hover">
<thead>
<tr>
<th></th>
<th>Name</th>
<th class="d-none d-md-table-cell">Modified</th>
<th class="d-none d-sm-table-cell">Size</th>
</tr>
</thead>
<tbody>
<component
v-for="file in files"
:key="file"
:is="file.type === 'file' ? FileListElement : FolderListElement"
:file="file"
:client="client"
/>
</tbody>
</table>
</div>
{{ error }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import FileListElement from './FileListElement.vue';
import FolderListElement from './FolderListElement.vue';
import FileUpload from '@/components/helpers/FileUpload.vue';
import PathSegments from '@/components/files/PathSegments.vue';
import { useWebdavStore } from '@/store/webdav';
import { ref, defineProps, watch, defineEmits } from 'vue';
import type { File } from '@/models';
const props = defineProps({
path: String,
});
const emit = defineEmits(['newPath']);
const files = ref([] as Array<File>);
const loading = ref(true as boolean);
const error = ref('' as string);
const store = useWebdavStore();
const client = store.currentSession?.client;
const fetchLocation = async () => {
try {
loading.value = true;
files.value = (await client?.getDirectoryContents(
props.path ?? '/'
)) as Array<File>;
loading.value = false;
error.value = '';
} catch (e) {
files.value = [];
loading.value = false;
error.value = e as string;
}
};
const navigateUp = () => {
const newPath = (props.path ?? '/').split('/');
newPath.pop();
emit('newPath', newPath.join('/') || '/');
};
watch(
() => props.path,
() => {
fetchLocation();
}
);
fetchLocation();
</script>

@ -0,0 +1,32 @@
<template>
<tr>
<td><i class="icon col-1" :class="`bi-${getIconFromFile(file)}`"></i></td>
<td>{{ file.basename }}</td>
<td class="d-none d-md-table-cell">
{{ new Date(file.lastmod).toLocaleDateString() }}
</td>
<td class="d-none d-sm-table-cell"><ByteCalc :bytes="file.size" /></td>
</tr>
</template>
<script setup lang="ts">
import ByteCalc from '@/components/helpers/ByteCalc.vue';
import { defineProps } from 'vue';
import { fileExtensions, defaultIcon } from '@/lib/fileTypeToIconMappings';
import type { File } from '@/models';
const props = defineProps({
file: Object,
client: Object,
});
const getIconFromFile = (file: File): string => {
const segments = file.basename.split('.');
if (segments.length < 2) return defaultIcon;
return fileExtensions.get(segments.pop() as string) ?? defaultIcon;
};
const downloadFile = async (file: File) => {
await props.client?.getFileContents(file.filename);
};
</script>

@ -0,0 +1,27 @@
<template>
<tr @click="$router.push(`#${file.filename}`)" class="file">
<td><i class="icon bi-folder"></i></td>
<td>{{ file.basename }}</td>
<td class="d-none d-md-table-cell">{{ new Date(file.lastmod).toLocaleDateString() }}</td>
<td class="d-none d-sm-table-cell"></td>
</tr>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
defineProps({
file: Object,
});
</script>
<style lang="scss">
.file {
color: inherit !important;
text-decoration: inherit !important;
cursor: pointer;
&:hover{
color: inherit !important;
}
}
</style>

@ -0,0 +1,46 @@
<template>
<div>
<span v-for="(segment, index) of getSegments().slice(0, -1)" :key="segment">
<span
class="segment p-1"
@click="
emit(
'newPath',
`/${getSegments()
.slice(1, index + 1)
.join('/')}`
)
"
>{{ segment }}</span
><i class="bi-caret-right mx-1"></i>
</span>
<span class="p-1">
<b>{{ getSegments()?.pop() }}</b>
</span>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
const props = defineProps({
path: String,
});
const emit = defineEmits(['newPath']);
const getSegments = (): Array<string> => {
const segments = `My Files${props.path ?? ''}`.split('/');
if (segments[segments.length - 1] === '') segments?.pop();
return segments;
};
</script>
<style scoped lang="scss">
.segment {
border-radius: .2rem;
cursor: pointer;
&:hover {
background-color: #aaa6;
}
}
</style>

@ -0,0 +1,23 @@
<template>
<span>{{ getByteString(bytes) }}</span>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
const props = defineProps({
bytes: Number,
});
const getByteString = (bytes: number): string => {
const unit = ['B', 'kB', 'MB', 'GB', 'TB', 'PB']
.map((symbol, index) => ({
symbol,
index,
breakpoint: 10 ** ((index + 1) * 3),
}))
.find((unit) => bytes < unit.breakpoint);
if (!unit) return 'wtf';
return `${(bytes / 10 ** (unit.index * 3)).toFixed(2)} ${unit.symbol}`;
};
</script>

@ -0,0 +1,61 @@
<template>
<button class="btn btn-secondary" @click.prevent="$refs.fileInput.click()">
<slot><i class="bi-plus"></i></slot>
</button>
<input
type="file"
ref="fileInput"
class="d-none"
@change="setFile($refs.fileInput.files[0])"
/>
<div v-if="info">{{ info }}</div>
<div v-if="progressRef">
<ByteCalc :bytes="progressRef.loaded" /><span> of </span
><ByteCalc :bytes="progressRef.total" />
</div>
</template>
<script setup lang="ts">
import ByteCalc from '@/components/helpers/ByteCalc.vue';
import { defineEmits, defineProps, ref } from 'vue';
import { readFileBuffer } from '@/lib/readFileBlob';
import { useWebdavStore } from '@/store/webdav';
import type { ProgressEvent } from 'webdav';
const props = defineProps({ path: String });
const emit = defineEmits(['started', 'finished', 'failed', 'progress']);
const store = useWebdavStore();
const progressRef = ref(null as null | ProgressEvent);
const info = ref('');
const setFile = async (file: File) => {
emit('started');
const buffer = await readFileBuffer(file);
try {
await store.currentSession?.client.putFileContents(
`${props.path}/${file.name}`,
buffer,
{
onUploadProgress: (progress) => {
progressRef.value = progress;
emit('progress', progress);
console.log(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
},
}
);
info.value = 'upload completed';
emit('finished');
} catch (e) {
info.value = 'upload failed';
emit('failed', e);
console.error(e);
}
setTimeout(() => {
progressRef.value = null;
info.value = '';
}, 2000);
};
</script>
<style scoped></style>

@ -0,0 +1,41 @@
export const defaultIcon = 'file-earmark';
export const fileExtensions = new Map([
// Images
['png', 'file-earmark-image'],
['jpg', 'file-earmark-image'],
['tiff', 'file-earmark-image'],
// Music
['mp3', 'file-earmark-music'],
['m4a', 'file-earmark-music'],
['aac', 'file-earmark-music'],
['aiff', 'file-earmark-music'],
['wav', 'file-earmark-music'],
['wma', 'file-earmark-music'],
// Code
['html', 'file-earmark-code'],
['htm', 'file-earmark-code'],
['xml', 'file-earmark-code'],
['js', 'file-earmark-code'],
['mjs', 'file-earmark-code'],
['py', 'file-earmark-code'],
['sh', 'file-earmark-code'],
['ts', 'file-earmark-code'],
['go', 'file-earmark-code'],
['rs', 'file-earmark-code'],
['java', 'file-earmark-code'],
// Binaries
['jar', 'file-earmark-binary'],
['exe', 'file-earmark-binary'],
['iso', 'file-earmark-binary'],
// etc...
['pdf', 'file-earmark-pdf'],
['txt', 'filetype-txt'],
['zip', 'file-earmark-zip'],
['gz', 'file-earmark-zip'],
['xz', 'file-earmark-zip'],
]);
export const mimeTypes = new Map([
['application/pdf', 'file-earmark-pdf'],
]);

@ -0,0 +1,41 @@
import { is } from 'typescript-is';
export const readFileAs = <T>(
file: File,
getReaderMethod = (reader: FileReader) => reader.readAsDataURL
): Promise<T> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = async (event) => {
const buffer = event.target?.result;
if (!buffer) reject('failed to read file');
if (!is<T>(buffer)) reject('wrong type');
else resolve(buffer as T);
};
getReaderMethod(reader as FileReader)(file);
});
};
export const readFileBuffer = async (file: File): Promise<ArrayBuffer> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = async (event) => {
const buffer = event.target?.result;
if (buffer === null || buffer === undefined || typeof buffer === 'string')
reject('failed to read file');
else resolve(buffer);
};
reader.readAsArrayBuffer(file);
});
/*return readFileAs<ArrayBuffer>(
file,
(reader: FileReader) => reader.readAsArrayBuffer
);*/
};
export const readFileBlob = async (file: File): Promise<Blob> => {
const data = await readFileAs<string>(file);
return await (await fetch(data)).blob();
};

@ -1,29 +1,29 @@
import { Context } from '@/middleware/Context';
type RunMiddleware = (context: Context) => void;
type Middleware = (context: Context) => void;
function nextFactory(
context: Context,
middlewares: Array<RunMiddleware>,
middlewares: Array<Middleware>,
index: number
) {
const subsequentMiddleware = middlewares[index];
if (!subsequentMiddleware) return context.next;
return (...args: any[]) => {
return (...args: Array<any>) => {
// @ts-ignore
context.next(...args);
context.next(...args as Array<any>);
subsequentMiddleware({
...context,
next: nextFactory(context, middlewares, index++),
});
};
}
export function runMiddleware(context: Context) {
export function runMiddleware(context: Context): void {
const { to } = context;
const middlewares = [
...((Array.isArray(to.meta.middleware)
? to.meta.middleware
: [to.meta.middleware]) as Array<RunMiddleware>),
: [to.meta.middleware]) as Array<Middleware>),
];
middlewares[0]({ ...context, next: nextFactory(context, middlewares, 1) });

@ -0,0 +1,16 @@
// Your variable overrides
//$body-bg: #222;
//$body-color: #222;
$dark-body-bg: #222;
// Bootstrap and its default variables
@import "~bootstrap/scss/bootstrap";
@import "~bootstrap-darkmode/css/darktheme";
html, body {
}
#app{
font-family: monospace;
//color: #fff;
}

@ -1,7 +1,13 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./registerServiceWorker";
import router from "./router";
import store from "./store";
import { createApp } from 'vue';
import App from './App.vue';
import './registerServiceWorker';
import router from './router';
import store from './store';
import 'bootstrap-icons/font/bootstrap-icons.scss';
import './main.scss';
import { ThemeConfig, writeDarkSwitch } from 'bootstrap-darkmode';
createApp(App).use(store).use(router).mount("#app");
export const themeConfig = new ThemeConfig();
themeConfig.initTheme();
createApp(App).use(store).use(router).mount('#app');

@ -1,8 +1,8 @@
import { useWebdavStorage } from '@/store/webdav';
import { useWebdavStore } from '@/store/webdav';
import { Context } from '@/middleware/Context';
export default function auth({ next, router }: Context) {
console.log('auth');
if (!useWebdavStorage().currentSession?.isActive) return router.push({ name: 'Login' });
export const auth = ({ next }: Context) => {
if (!useWebdavStore().currentSession?.isActive)
return next({ name: 'Login' });
return next();
}
};

@ -0,0 +1,9 @@
export type File = {
basename: string;
etag: string;
filename: string;
lastmod: string;
mime: string;
size: number;
type: string;
};

@ -1,7 +1,6 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import Home from '../views/Home.vue';
import auth from '@/middleware/auth';
import log from '@/middleware/log';
import { auth } from '@/middleware/auth';
import { runMiddleware } from '@/lib/runMiddleware';
const routes: Array<RouteRecordRaw> = [
@ -22,9 +21,6 @@ const routes: Array<RouteRecordRaw> = [
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: {
middleware: [log],
},
},
];

@ -1,13 +0,0 @@
import {defineStore} from "pinia";
export const useAuthStorage = defineStore('auth', {
state: () => ({
user: '',
password: ''
}),
actions: {
login(){
}
}
});

@ -11,31 +11,29 @@ export type Session = {
isActive: boolean;
};
export const useWebdavStorage = defineStore('auth', {
export const useWebdavStore = defineStore('auth', {
state: () => ({
sessions: [] as Array<Session>,
currentSession: null as null | Session,
}),
actions: {
login({ user, pass }: Auth): Promise<Session> {
return new Promise((resolve, reject) => {
try {
const client = createClient(
process.env.VUE_APP_ROOT_WEBDAV as string,
{
authType: AuthType.Digest,
username: user as string,
password: pass as string,
}
) as WebDAVClient;
const session = { client, isActive: true } as Session;
this.sessions.push(session);
this.currentSession = session;
resolve(session);
} catch (e) {
reject(e);
}
});
async login({ user, pass }: Auth): Promise<Session> {
try {
const client = createClient(
`${process.env.VUE_APP_ROOT_WEBDAV ?? ''}/api/dav/files/` as string,
{
authType: AuthType.Password,
username: user as string,
password: pass as string,
}
) as WebDAVClient;
const session = { client, isActive: true } as Session;
this.sessions.push(session);
this.currentSession = session;
return session;
} catch (e) {
throw 'login failed';
}
},
},
});

@ -1,11 +1,20 @@
<template>
<h1>Files</h1>
<div class="row mx-1 justify-content-center">
<div class="d-none d-lg-block col-3">
<SideMenu />
</div>
<FileList
class="col"
:path="$route.hash.slice(1)"
@newPath="(path) => $router.push(`#${path}`)"
/>
</div>
</template>
<script>
export default {
name: 'Files',
};
</script>
<script setup lang="ts">
import FileList from '@/components/files/FileList.vue';
import SideMenu from '@/components/SideMenu.vue';
import { useWebdavStore } from '@/store/webdav';
<style scoped></style>
const store = useWebdavStore();
</script>

@ -2,22 +2,32 @@
<h1>Login</h1>
user: <input type="text" v-model="user" /><br />
pass: <input type="password" v-model="pass" /><br />
<button @click="login">Login</button>
<button class="btn btn-primary" @click="login">Login</button><br />
<span class="error">{{ error }}</span>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useWebdavStorage } from '@/store/webdav';
import { useWebdavStore } from '@/store/webdav';
import { useRouter } from 'vue-router';
const user = ref('');
const pass = ref('');
const error = ref('');
const router = useRouter();
const store = useWebdavStore();
const login = async () => {
await useWebdavStorage()
.login({ user, pass })
.then(async session => {
console.log(await session.client.getDirectoryContents('/'));
});
try {
await store.login({ user: user.value, pass: pass.value });
error.value = '';
await router.push({ name: 'Files' });
} catch (e) {
console.error(e);
error.value = e as string;
}
await store.currentSession?.client.getDirectoryContents(`/`);
};
</script>

Loading…
Cancel
Save