frontend first steps
parent
65a67ae19b
commit
01a6481d62
@ -0,0 +1 @@
|
||||
VUE_APP_ROOT_WEBDAV="http://127.0.0.1:8080"
|
@ -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();
|
||||
};
|
@ -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,13 +0,0 @@
|
||||
import {defineStore} from "pinia";
|
||||
|
||||
export const useAuthStorage = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: '',
|
||||
password: ''
|
||||
}),
|
||||
actions: {
|
||||
login(){
|
||||
|
||||
}
|
||||
}
|
||||
});
|
@ -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>
|
||||
|
Loading…
Reference in New Issue