Rewrote Frontend
This commit is contained in:
31
frontend/src/components/AsyncImage.vue
Normal file
31
frontend/src/components/AsyncImage.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup async lang="ts">
|
||||
import type { TokenInjectType } from '@/api';
|
||||
import { inject, ref } from 'vue';
|
||||
import { NImage } from 'naive-ui';
|
||||
import { check_token, FS, isErrorResponse } from '@/api';
|
||||
|
||||
const props = defineProps<{
|
||||
alt: string;
|
||||
id: number;
|
||||
}>();
|
||||
|
||||
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
|
||||
|
||||
const success = ref(false);
|
||||
const data = ref('');
|
||||
|
||||
const token = await check_token(jwt);
|
||||
if (token) {
|
||||
const resp = await FS.download_preview(jwt.jwt.value ?? '', props.id);
|
||||
if (!isErrorResponse(resp)) {
|
||||
data.value = resp.data;
|
||||
success.value = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NImage v-if="success" :alt="alt" :src="data" />
|
||||
</template>
|
||||
|
||||
<style lang="scss"></style>
|
||||
90
frontend/src/components/DirViewer/CreateZipDialog.tsx
Normal file
90
frontend/src/components/DirViewer/CreateZipDialog.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { TokenInjectType } from '@/api';
|
||||
import { ref } from 'vue';
|
||||
import { NProgress, NButton, NIcon } from 'naive-ui';
|
||||
import filesize from 'filesize';
|
||||
import { Archive, Download } from '@vicons/carbon';
|
||||
import { FS, check_token, isErrorResponse } from '@/api';
|
||||
import type { DialogApiInjection } from 'naive-ui/es/dialog/src/DialogProvider';
|
||||
|
||||
export default function createZipDialog(
|
||||
nodes: number[],
|
||||
dialog: DialogApiInjection,
|
||||
jwt: TokenInjectType
|
||||
) {
|
||||
const progress = ref(0);
|
||||
const total = ref(1);
|
||||
const percentage = ref(0);
|
||||
const done = ref(false);
|
||||
const dia = dialog.create({
|
||||
title: 'Create Archive...',
|
||||
closable: false,
|
||||
closeOnEsc: false,
|
||||
maskClosable: false,
|
||||
icon: () => <Archive />,
|
||||
content: () => (
|
||||
<NProgress
|
||||
type="line"
|
||||
percentage={percentage.value}
|
||||
height={20}
|
||||
status="info"
|
||||
showIndicator={false}
|
||||
/>
|
||||
),
|
||||
action: () =>
|
||||
done.value ? (
|
||||
<NButton
|
||||
onClick={async () => {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
if (nodes.length == 1)
|
||||
FS.download_file(token, nodes[0]);
|
||||
else FS.download_multi_file(token, nodes);
|
||||
dia.destroy();
|
||||
}}
|
||||
>
|
||||
{{
|
||||
icon: () => (
|
||||
<NIcon>
|
||||
<Download />
|
||||
</NIcon>
|
||||
),
|
||||
default: () => 'Download archive'
|
||||
}}
|
||||
</NButton>
|
||||
) : (
|
||||
<div>
|
||||
{filesize(progress.value, {
|
||||
base: 2,
|
||||
standard: 'jedec'
|
||||
})}
|
||||
/
|
||||
{filesize(total.value, {
|
||||
base: 2,
|
||||
standard: 'jedec'
|
||||
})}
|
||||
- {Math.floor(percentage.value * 1000) / 1000}%
|
||||
</div>
|
||||
)
|
||||
});
|
||||
let updateRunning = false;
|
||||
const updateInterval = setInterval(async () => {
|
||||
if (updateRunning) return;
|
||||
updateRunning = true;
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
const resp = await FS.create_zip(token, nodes);
|
||||
if (isErrorResponse(resp)) return;
|
||||
if (resp.done) {
|
||||
percentage.value = 100;
|
||||
clearInterval(updateInterval);
|
||||
done.value = true;
|
||||
} else {
|
||||
progress.value = resp.progress ?? 0;
|
||||
total.value = resp.total ?? 1;
|
||||
if (total.value == 0) total.value = 1;
|
||||
percentage.value = (progress.value / total.value) * 100;
|
||||
}
|
||||
updateRunning = false;
|
||||
}, 500);
|
||||
return dia;
|
||||
}
|
||||
74
frontend/src/components/DirViewer/DeleteModal.vue
Normal file
74
frontend/src/components/DirViewer/DeleteModal.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { TokenInjectType } from '@/api';
|
||||
import type { LogInst } from 'naive-ui';
|
||||
import { ref, inject } from 'vue';
|
||||
import { check_token } from '@/api';
|
||||
import { NCard, NLog } from 'naive-ui';
|
||||
|
||||
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
|
||||
const log = ref('');
|
||||
const logInst = ref<LogInst>();
|
||||
|
||||
function getLogWriter() {
|
||||
const decoder = new TextDecoder();
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
log.value += decoder.decode(chunk, { stream: true });
|
||||
logInst.value?.scrollTo({ position: 'top' });
|
||||
},
|
||||
close() {
|
||||
log.value += decoder.decode(new Uint8Array(0), { stream: false });
|
||||
logInst.value?.scrollTo({ position: 'top' });
|
||||
},
|
||||
abort(err) {
|
||||
log.value += `Error: ${err}\n`;
|
||||
logInst.value?.scrollTo({ position: 'top' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: number[];
|
||||
}>();
|
||||
|
||||
async function startDelete() {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
for (const node of props.nodes) {
|
||||
try {
|
||||
const logWriter = getLogWriter();
|
||||
const resp = await fetch(`/api/fs/delete/${node}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + token
|
||||
}
|
||||
});
|
||||
if (!resp.ok) continue;
|
||||
if (!resp.body) continue;
|
||||
await resp.body.pipeTo(logWriter);
|
||||
} catch (err) {
|
||||
log.value += `Error: ${err}\n`;
|
||||
logInst.value?.scrollTo({ position: 'top' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
startDelete
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card title="Deleting..." style="margin: 20px">
|
||||
<n-log ref="logInst" class="log-code" :log="log" :rows="50"></n-log>
|
||||
<!--<n-code class="log-code">
|
||||
</n-code>-->
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.log-code {
|
||||
margin: 8px;
|
||||
background-color: rgb(250, 250, 252);
|
||||
}
|
||||
</style>
|
||||
124
frontend/src/components/DirViewer/DirViewer.vue
Normal file
124
frontend/src/components/DirViewer/DirViewer.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="tsx">
|
||||
import type { TokenInjectType, Responses } from '@/api';
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { inject, ref, watch } from 'vue';
|
||||
import {
|
||||
useMessage,
|
||||
useDialog,
|
||||
NSwitch,
|
||||
NGrid,
|
||||
NGi,
|
||||
NButton,
|
||||
NIcon,
|
||||
NInput
|
||||
} from 'naive-ui';
|
||||
import { FolderAdd } from '@vicons/carbon';
|
||||
import { FS, check_token } from '@/api';
|
||||
import DirViewerTable from '@/components/DirViewer/DirViewerTable.vue';
|
||||
import { loadingMsgWrapper } from '@/utils';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const props = defineProps<{
|
||||
node: Responses.GetNode;
|
||||
}>();
|
||||
|
||||
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'reloadNode'): void;
|
||||
}>();
|
||||
|
||||
const showPreview = ref(false);
|
||||
const nodes = ref<Responses.GetNodeEntry[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
async (to) => {
|
||||
nodes.value = [];
|
||||
if (to.parent != null)
|
||||
nodes.value.push({
|
||||
id: to.parent,
|
||||
isFile: false,
|
||||
parent: null,
|
||||
name: '..',
|
||||
preview: false
|
||||
});
|
||||
if (to.children) nodes.value.push(...to.children);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const newFolder = loadingMsgWrapper(message, async (name: string) => {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
await FS.create_folder(token, props.node.id, name);
|
||||
emit('reloadNode');
|
||||
});
|
||||
|
||||
function previewSwitchRailStyle(state: { focused: boolean; checked: boolean }) {
|
||||
const style: CSSProperties = {};
|
||||
style.background = state.checked ? '#0b0' : '#d00';
|
||||
if (state.focused)
|
||||
style.boxShadow = `0 0 0 2px ${
|
||||
state.checked ? '#00bb0040' : '#dd000040'
|
||||
}`;
|
||||
return style;
|
||||
}
|
||||
|
||||
function createNewFolderDialog() {
|
||||
let newFolderName = '';
|
||||
const dia = dialog.create({
|
||||
title: 'New Folder',
|
||||
icon: () => <FolderAdd />,
|
||||
content: () => (
|
||||
<NInput
|
||||
type="text"
|
||||
placeholder="Folder name"
|
||||
onInput={(e) => (newFolderName = e)}
|
||||
onKeyup={(e) => {
|
||||
if (e.key === 'Enter')
|
||||
newFolder(newFolderName).then(() => dia.destroy());
|
||||
}}
|
||||
/>
|
||||
),
|
||||
negativeText: 'Cancel',
|
||||
positiveText: 'Create',
|
||||
positiveButtonProps: { type: 'success' },
|
||||
onPositiveClick: () => newFolder(newFolderName)
|
||||
});
|
||||
return dia;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-grid cols="2" x-gap="16" y-gap="16">
|
||||
<n-gi>
|
||||
<n-button @click="createNewFolderDialog">
|
||||
<template #icon>
|
||||
<n-icon><FolderAdd /></n-icon>
|
||||
</template>
|
||||
Create folder
|
||||
</n-button>
|
||||
</n-gi>
|
||||
<n-gi style="text-align: right">
|
||||
<n-switch
|
||||
:rail-style="previewSwitchRailStyle"
|
||||
v-model:value="showPreview"
|
||||
>
|
||||
<template #checked>Show preview</template>
|
||||
<template #unchecked>Hide preview</template>
|
||||
</n-switch>
|
||||
</n-gi>
|
||||
<n-gi span="2">
|
||||
<DirViewerTable
|
||||
:nodes="nodes"
|
||||
:show-preview="showPreview"
|
||||
@reloadNode="emit('reloadNode')"
|
||||
/>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
379
frontend/src/components/DirViewer/DirViewerTable.vue
Normal file
379
frontend/src/components/DirViewer/DirViewerTable.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<script setup lang="tsx">
|
||||
import type { TokenInjectType, Responses } from '@/api';
|
||||
import type {
|
||||
DropdownOption,
|
||||
DropdownGroupOption,
|
||||
DropdownDividerOption,
|
||||
DropdownRenderOption,
|
||||
DataTableColumn
|
||||
} from 'naive-ui';
|
||||
import type { SummaryCell } from 'naive-ui/es/data-table/src/interface';
|
||||
import { inject, ref, nextTick, Suspense } from 'vue';
|
||||
import filesize from 'filesize';
|
||||
import { check_token, FS } from '@/api';
|
||||
import { loadingMsgWrapper } from '@/utils';
|
||||
import {
|
||||
useMessage,
|
||||
useDialog,
|
||||
NDataTable,
|
||||
NText,
|
||||
NIcon,
|
||||
NDropdown,
|
||||
NPopover,
|
||||
NSpin,
|
||||
NImageGroup,
|
||||
NButtonGroup,
|
||||
NButton,
|
||||
NModal
|
||||
} from 'naive-ui';
|
||||
import {
|
||||
Folder,
|
||||
FolderParent,
|
||||
DocumentBlank,
|
||||
Delete,
|
||||
Download
|
||||
} from '@vicons/carbon';
|
||||
import NLink from '@/components/NLink.vue';
|
||||
import AsyncImage from '@/components/AsyncImage.vue';
|
||||
import createZipDialog from '@/components/DirViewer/CreateZipDialog';
|
||||
import DeleteModal from '@/components/DirViewer/DeleteModal.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
|
||||
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'reloadNode'): void;
|
||||
}>();
|
||||
|
||||
type DropdownOptionsType = Array<
|
||||
| DropdownOption
|
||||
| DropdownGroupOption
|
||||
| DropdownDividerOption
|
||||
| DropdownRenderOption
|
||||
>;
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: Responses.GetNodeEntry[];
|
||||
showPreview: boolean;
|
||||
}>();
|
||||
|
||||
const checkedRows = ref<number[]>([]);
|
||||
const deleteNodes = ref<number[]>([]);
|
||||
const deleteDialog = ref();
|
||||
const deleteDialogShow = ref(false);
|
||||
|
||||
const dropdownX = ref(0);
|
||||
const dropdownY = ref(0);
|
||||
const dropdownShow = ref(false);
|
||||
let dropdownCurrentNode: Responses.GetNodeEntry | null = null;
|
||||
|
||||
const dropdownOptions = ref<DropdownOptionsType>();
|
||||
const dropdownOptionsFolder: DropdownOptionsType = [
|
||||
{
|
||||
label: () => <NText>Download</NText>,
|
||||
key: 'download',
|
||||
icon: () => (
|
||||
<NIcon>
|
||||
<Download />
|
||||
</NIcon>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: () => <NText type="error">Delete</NText>,
|
||||
key: 'delete',
|
||||
icon: () => (
|
||||
<NIcon>
|
||||
<Delete />
|
||||
</NIcon>
|
||||
)
|
||||
}
|
||||
];
|
||||
const dropdownOptionsFile: DropdownOptionsType = [
|
||||
{
|
||||
label: () => <NText>Download</NText>,
|
||||
key: 'download',
|
||||
icon: () => (
|
||||
<NIcon>
|
||||
<Download />
|
||||
</NIcon>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: () => <NText type="error">Delete</NText>,
|
||||
key: 'delete',
|
||||
icon: () => (
|
||||
<NIcon>
|
||||
<Delete />
|
||||
</NIcon>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const dropdownSelect = loadingMsgWrapper(message, async (key: string) => {
|
||||
dropdownShow.value = false;
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
if (!dropdownCurrentNode) return;
|
||||
switch (key) {
|
||||
case 'download':
|
||||
if (dropdownCurrentNode.isFile)
|
||||
await FS.download_file(token, dropdownCurrentNode.id);
|
||||
else createZipDialog([dropdownCurrentNode.id], dialog, jwt);
|
||||
break;
|
||||
case 'delete':
|
||||
dialog.warning({
|
||||
title: 'Really delete?',
|
||||
content: `Are you sure you want to delete "${dropdownCurrentNode.name}"`,
|
||||
positiveText: 'Yes',
|
||||
negativeText: 'No',
|
||||
onPositiveClick: () => {
|
||||
if (!dropdownCurrentNode) return;
|
||||
deleteNodes.value = [dropdownCurrentNode.id];
|
||||
showDeleteDialog();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const columns: DataTableColumn<Responses.GetNodeEntry>[] = [
|
||||
{
|
||||
type: 'selection',
|
||||
options: [
|
||||
{
|
||||
label: 'Select all folders',
|
||||
key: 'folders',
|
||||
onSelect(data) {
|
||||
checkedRows.value = data
|
||||
.filter((node) => !node.isFile)
|
||||
.map((node) => node.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Select all files',
|
||||
key: 'files',
|
||||
onSelect(data) {
|
||||
checkedRows.value = data
|
||||
.filter((node) => node.isFile)
|
||||
.map((node) => node.id);
|
||||
}
|
||||
}
|
||||
],
|
||||
disabled(node) {
|
||||
return node.parent == null;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
minWidth: 720,
|
||||
render(node) {
|
||||
return (
|
||||
<NLink to={`/fs/${node.id}`}>
|
||||
<div>
|
||||
<NIcon
|
||||
size="1.2em"
|
||||
color="#111"
|
||||
component={
|
||||
node.isFile
|
||||
? DocumentBlank
|
||||
: node.name == '..'
|
||||
? FolderParent
|
||||
: Folder
|
||||
}
|
||||
style="top: 0.25em; margin-right: 0.5em"
|
||||
/>
|
||||
{node.name}
|
||||
</div>
|
||||
</NLink>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Size',
|
||||
key: 'size',
|
||||
minWidth: 100,
|
||||
render(node) {
|
||||
return !node.isFile ? (
|
||||
''
|
||||
) : (
|
||||
<NPopover trigger="hover">
|
||||
{{
|
||||
default: () => `${node.size?.toLocaleString()} bytes`,
|
||||
trigger: () =>
|
||||
filesize(node.size ?? 0, {
|
||||
base: 2,
|
||||
standard: 'jedec'
|
||||
})
|
||||
}}
|
||||
</NPopover>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
const previewColumns: DataTableColumn<Responses.GetNodeEntry>[] = [
|
||||
columns[0],
|
||||
{
|
||||
title: 'Preview',
|
||||
key: 'preview',
|
||||
render(node) {
|
||||
return node.preview ? (
|
||||
<Suspense>
|
||||
{{
|
||||
default: () => (
|
||||
<AsyncImage alt={node.name} id={node.id} />
|
||||
),
|
||||
fallback: () => <NSpin size="small" />
|
||||
}}
|
||||
</Suspense>
|
||||
) : (
|
||||
''
|
||||
);
|
||||
}
|
||||
},
|
||||
...columns.slice(1)
|
||||
];
|
||||
|
||||
const massDownload = loadingMsgWrapper(message, async () => {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
const nodes = checkedRows.value;
|
||||
if (nodes.length == 1) {
|
||||
const node = props.nodes.find((n) => n.id == nodes[0]);
|
||||
if (!node) return;
|
||||
if (node.isFile) await FS.download_file(token, nodes[0]);
|
||||
else createZipDialog(nodes, dialog, jwt);
|
||||
} else createZipDialog(nodes, dialog, jwt);
|
||||
checkedRows.value = [];
|
||||
});
|
||||
|
||||
const massDelete = loadingMsgWrapper(message, async () => {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
dialog.warning({
|
||||
title: 'Really delete?',
|
||||
content: `Are you sure you want to delete "${checkedRows.value.length} folders/files"`,
|
||||
positiveText: 'Yes',
|
||||
negativeText: 'No',
|
||||
onPositiveClick: loadingMsgWrapper(message, async () => {
|
||||
deleteNodes.value = checkedRows.value;
|
||||
showDeleteDialog();
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const selectionCell = (): SummaryCell => {
|
||||
return {
|
||||
value:
|
||||
checkedRows.value.length != 0 ? (
|
||||
<NButtonGroup>
|
||||
<NButton onClick={massDownload}>Download</NButton>
|
||||
<NButton onClick={massDelete} type="error">
|
||||
Delete
|
||||
</NButton>
|
||||
</NButtonGroup>
|
||||
) : (
|
||||
''
|
||||
),
|
||||
colSpan: props.showPreview ? 2 : 1
|
||||
};
|
||||
};
|
||||
|
||||
const sizeCell = (data: Responses.GetNodeEntry[]): SummaryCell => {
|
||||
return {
|
||||
value: (
|
||||
<span>
|
||||
{filesize(
|
||||
data.reduce((cur, node) => cur + (node.size ?? 0), 0),
|
||||
{
|
||||
base: 2,
|
||||
standard: 'jedec'
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
function createPreviewSummary(data: Responses.GetNodeEntry[]) {
|
||||
return {
|
||||
preview: selectionCell(),
|
||||
size: sizeCell(data)
|
||||
};
|
||||
}
|
||||
|
||||
function createSummary(data: Responses.GetNodeEntry[]) {
|
||||
return {
|
||||
name: selectionCell(),
|
||||
size: sizeCell(data)
|
||||
};
|
||||
}
|
||||
|
||||
function rowProps(node: Responses.GetNodeEntry) {
|
||||
if (!('isFile' in node)) return {};
|
||||
return {
|
||||
onContextmenu: (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dropdownShow.value = false;
|
||||
dropdownCurrentNode = node;
|
||||
dropdownOptions.value = node.isFile
|
||||
? dropdownOptionsFile
|
||||
: dropdownOptionsFolder;
|
||||
nextTick().then(() => {
|
||||
dropdownShow.value = true;
|
||||
dropdownX.value = e.clientX;
|
||||
dropdownY.value = e.clientY;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const rowKey = (node: Responses.GetNodeEntry): number => node.id;
|
||||
|
||||
function showDeleteDialog() {
|
||||
if (deleteNodes.value.length == 0) return;
|
||||
deleteDialogShow.value = true;
|
||||
}
|
||||
|
||||
async function onShowDeleteDialog() {
|
||||
await deleteDialog.value?.startDelete();
|
||||
deleteDialogShow.value = false;
|
||||
emit('reloadNode');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-image-group>
|
||||
<n-data-table
|
||||
:columns="showPreview ? previewColumns : columns"
|
||||
:data="nodes"
|
||||
:row-key="rowKey"
|
||||
:row-props="rowProps"
|
||||
:summary="showPreview ? createPreviewSummary : createSummary"
|
||||
v-model:checked-row-keys="checkedRows"
|
||||
/>
|
||||
</n-image-group>
|
||||
<n-dropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="dropdownX"
|
||||
:y="dropdownY"
|
||||
:show="dropdownShow"
|
||||
:show-arrow="true"
|
||||
:options="dropdownOptions"
|
||||
:on-clickoutside="() => (dropdownShow = false)"
|
||||
@select="dropdownSelect"
|
||||
/>
|
||||
<n-modal
|
||||
v-model:show="deleteDialogShow"
|
||||
:close-on-esc="false"
|
||||
:mask-closable="false"
|
||||
:on-after-enter="onShowDeleteDialog"
|
||||
>
|
||||
<DeleteModal ref="deleteDialog" :nodes="deleteNodes" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TokenInjectType } from "@/api";
|
||||
import { defineEmits, defineProps, inject } from "vue";
|
||||
import { check_token, FS, Responses } from "@/api";
|
||||
|
||||
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
|
||||
const props = defineProps<{
|
||||
node: Responses.FS.GetNodeResponse;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "reloadNode"): void;
|
||||
}>();
|
||||
|
||||
async function del() {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
await FS.delete_node(token, props.node.id);
|
||||
emit("reloadNode");
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
FS.download_file(token, props.node.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td>
|
||||
<router-link :to="'/fs/' + props.node.id">{{ node.name }}</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<a href="#" @click="download()" v-if="props.node.isFile">Download</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="#" @click="del()" v-if="props.node.name !== '..'">delete</a>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TokenInjectType } from "@/api";
|
||||
import { defineEmits, defineProps, inject, reactive, ref, watch } from "vue";
|
||||
import { FS, Responses, check_token } from "@/api";
|
||||
import DirEntry from "@/components/FSView/DirEntry.vue";
|
||||
import UploadFileDialog from "@/components/UploadDialog/UploadFileDialog.vue";
|
||||
import { NModal } from "naive-ui";
|
||||
|
||||
const props = defineProps<{
|
||||
node: Responses.FS.GetNodeResponse;
|
||||
}>();
|
||||
|
||||
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "reloadNode"): void;
|
||||
(e: "gotoRoot"): void;
|
||||
}>();
|
||||
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const uploadDialog = ref();
|
||||
const uploadDialogShow = ref(false);
|
||||
|
||||
const new_folder_name = ref("");
|
||||
const files = ref<File[]>([]);
|
||||
const nodes = ref<Responses.FS.GetNodeResponse[]>([]);
|
||||
const hasParent = ref(false);
|
||||
const parentNode = reactive<Responses.FS.GetNodeResponse>({
|
||||
id: 0,
|
||||
statusCode: 200,
|
||||
isFile: false,
|
||||
parent: null,
|
||||
name: "..",
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
async (to) => {
|
||||
parentNode.id = to.parent ?? 0;
|
||||
hasParent.value = to.parent != null;
|
||||
nodes.value = [];
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
await Promise.all(
|
||||
to.children?.map(async (child) => {
|
||||
nodes.value.push(
|
||||
(await FS.get_node(token, child)) as Responses.FS.GetNodeResponse
|
||||
);
|
||||
}) ?? []
|
||||
);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function newFolder() {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
await FS.create_folder(token, props.node.id, new_folder_name.value);
|
||||
emit("reloadNode");
|
||||
}
|
||||
|
||||
async function uploadFiles() {
|
||||
files.value = Array.from(fileInput.value?.files ?? []);
|
||||
if (files.value.length == 0) return;
|
||||
uploadDialogShow.value = true;
|
||||
}
|
||||
async function uploadFilesDialogOpen() {
|
||||
await uploadDialog.value?.startUpload(props.node.id);
|
||||
uploadDialogShow.value = false;
|
||||
if (fileInput.value) fileInput.value.value = "";
|
||||
emit("reloadNode");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<input type="text" placeholder="Folder name" v-model="new_folder_name" />
|
||||
<a href="#" @click="newFolder()">create folder</a>
|
||||
</div>
|
||||
<div>
|
||||
<input type="file" ref="fileInput" multiple />
|
||||
<a href="#" @click="uploadFiles()">upload files</a>
|
||||
</div>
|
||||
<table>
|
||||
<tr v-if="hasParent">
|
||||
<DirEntry :node="parentNode" @reloadNode="emit('reloadNode')" />
|
||||
</tr>
|
||||
<tr v-for="n in nodes" :key="n.id">
|
||||
<DirEntry :node="n" @reloadNode="emit('reloadNode')" />
|
||||
</tr>
|
||||
</table>
|
||||
<n-modal
|
||||
v-model:show="uploadDialogShow"
|
||||
:close-on-esc="false"
|
||||
:mask-closable="false"
|
||||
:on-after-enter="uploadFilesDialogOpen"
|
||||
>
|
||||
<UploadFileDialog ref="uploadDialog" :files="files" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TokenInjectType } from "@/api";
|
||||
import { defineProps, inject } from "vue";
|
||||
import { check_token, FS, Responses } from "@/api";
|
||||
|
||||
const props = defineProps<{
|
||||
node: Responses.FS.GetNodeResponse;
|
||||
}>();
|
||||
|
||||
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
|
||||
|
||||
async function del() {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
await FS.delete_node(token, props.node.id);
|
||||
}
|
||||
|
||||
async function download() {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
FS.download_file(token, props.node.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<router-link :to="'/fs/' + props.node.parent ?? 0">..</router-link>
|
||||
</div>
|
||||
<div>
|
||||
<a href="#" @click="download()" v-if="props.node.isFile">Download</a>
|
||||
</div>
|
||||
<div>
|
||||
<router-link :to="'/fs/' + props.node.parent ?? 0" @click="del()">
|
||||
delete
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
45
frontend/src/components/FileViewer/AudioVideoDownload.tsx
Normal file
45
frontend/src/components/FileViewer/AudioVideoDownload.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ref } from 'vue';
|
||||
import { NProgress } from 'naive-ui';
|
||||
import filesize from 'filesize';
|
||||
import { Music, Video } from '@vicons/carbon';
|
||||
import type { DialogApiInjection } from 'naive-ui/es/dialog/src/DialogProvider';
|
||||
|
||||
export default function createAudioVideoDialog(
|
||||
dialog: DialogApiInjection,
|
||||
video: boolean
|
||||
) {
|
||||
const progress = ref(0);
|
||||
const total = ref(1);
|
||||
const percentage = ref(0);
|
||||
const dia = dialog.create({
|
||||
title: video ? 'Loading video...' : 'Loading audio...',
|
||||
closable: false,
|
||||
closeOnEsc: false,
|
||||
maskClosable: false,
|
||||
icon: () => (video ? <Video /> : <Music />),
|
||||
content: () => (
|
||||
<NProgress
|
||||
type="line"
|
||||
percentage={percentage.value}
|
||||
height={20}
|
||||
status="info"
|
||||
showIndicator={false}
|
||||
/>
|
||||
),
|
||||
action: () => (
|
||||
<div>
|
||||
{filesize(progress.value, {
|
||||
base: 2,
|
||||
standard: 'jedec'
|
||||
})}
|
||||
/
|
||||
{filesize(total.value, {
|
||||
base: 2,
|
||||
standard: 'jedec'
|
||||
})}
|
||||
- {Math.floor(percentage.value * 1000) / 1000}%
|
||||
</div>
|
||||
)
|
||||
});
|
||||
return { progress, total, percentage, dia };
|
||||
}
|
||||
129
frontend/src/components/FileViewer/FileViewer.vue
Normal file
129
frontend/src/components/FileViewer/FileViewer.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import type { TokenInjectType, Responses } from '@/api';
|
||||
import { inject, ref, watch } from 'vue';
|
||||
import { Download, Play } from '@vicons/carbon';
|
||||
import { useDialog, NGrid, NGi, NButton, NImage, NSpin, NIcon } from 'naive-ui';
|
||||
import { check_token, FS, isErrorResponse } from '@/api';
|
||||
import createAudioVideoDialog from '@/components/FileViewer/AudioVideoDownload';
|
||||
import axios from 'axios';
|
||||
|
||||
const props = defineProps<{
|
||||
node: Responses.GetNode;
|
||||
}>();
|
||||
|
||||
const dialog = useDialog();
|
||||
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
|
||||
|
||||
enum fileTypes {
|
||||
UNKNOWN,
|
||||
LOADING,
|
||||
IMAGE,
|
||||
AUDIO,
|
||||
VIDEO
|
||||
}
|
||||
|
||||
const fileType = ref<fileTypes>(fileTypes.UNKNOWN);
|
||||
const src = ref('');
|
||||
|
||||
async function download() {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
FS.download_file(token, props.node.id);
|
||||
}
|
||||
|
||||
async function loadAudioOrVideo() {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
const { progress, total, percentage, dia } = createAudioVideoDialog(
|
||||
dialog,
|
||||
fileType.value === fileTypes.VIDEO
|
||||
);
|
||||
total.value = props.node.size ?? 1;
|
||||
const params = new URLSearchParams();
|
||||
params.append('jwtToken', token);
|
||||
params.append('id', props.node.id.toString());
|
||||
const resp = await axios.post('/api/fs/download', params, {
|
||||
responseType: 'blob',
|
||||
onDownloadProgress: (e: ProgressEvent) => {
|
||||
progress.value = e.loaded;
|
||||
percentage.value = (e.loaded / e.total) * 100;
|
||||
}
|
||||
});
|
||||
dia.destroy();
|
||||
if (resp.status != 200) return;
|
||||
src.value = URL.createObjectURL(resp.data as Blob);
|
||||
}
|
||||
|
||||
async function getType(node: Responses.GetNode) {
|
||||
fileType.value = fileTypes.LOADING;
|
||||
if (src.value.startsWith('blob')) URL.revokeObjectURL(src.value);
|
||||
src.value = '';
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
const resp = await FS.get_type(token, node.id);
|
||||
if (isErrorResponse(resp)) return;
|
||||
if (resp.type.startsWith('image')) {
|
||||
const dataResp = await FS.download_base64(token, node.id);
|
||||
if (isErrorResponse(dataResp)) return;
|
||||
src.value = dataResp.data;
|
||||
fileType.value = fileTypes.IMAGE;
|
||||
}
|
||||
if (resp.type.startsWith('audio')) fileType.value = fileTypes.AUDIO;
|
||||
if (resp.type.startsWith('video')) fileType.value = fileTypes.VIDEO;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
async (to) => {
|
||||
await getType(to);
|
||||
if (fileType.value === fileTypes.LOADING)
|
||||
fileType.value = fileTypes.UNKNOWN;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-grid cols="1" x-gap="16" y-gap="16">
|
||||
<n-gi style="text-align: right">
|
||||
<n-button @click="download()">
|
||||
<template #icon>
|
||||
<n-icon><Download /></n-icon>
|
||||
</template>
|
||||
Download
|
||||
</n-button>
|
||||
</n-gi>
|
||||
<n-gi style="text-align: center">
|
||||
<n-spin v-if="fileType === fileTypes.LOADING" size="large" />
|
||||
<n-image
|
||||
v-else-if="fileType === fileTypes.IMAGE"
|
||||
:src="src"
|
||||
:alt="node.name"
|
||||
/>
|
||||
<template
|
||||
v-else-if="
|
||||
fileType === fileTypes.VIDEO || fileType === fileTypes.AUDIO
|
||||
"
|
||||
>
|
||||
<video
|
||||
v-if="fileType === fileTypes.VIDEO && src !== ''"
|
||||
:src="src"
|
||||
controls
|
||||
/>
|
||||
<audio
|
||||
v-else-if="fileType === fileTypes.AUDIO && src !== ''"
|
||||
:src="src"
|
||||
controls
|
||||
/>
|
||||
<n-button v-else @click="loadAudioOrVideo">
|
||||
<template #icon>
|
||||
<n-icon><Play /></n-icon>
|
||||
</template>
|
||||
Load and play
|
||||
</n-button>
|
||||
</template>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<div class="hello">
|
||||
<h1>{{ msg }}</h1>
|
||||
<p>
|
||||
For a guide and recipes on how to configure / customize this project,<br />
|
||||
check out the
|
||||
<a href="https://cli.vuejs.org" target="_blank" rel="noopener"
|
||||
>vue-cli documentation</a
|
||||
>.
|
||||
</p>
|
||||
<h3>Installed CLI Plugins</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>babel</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>router</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-vuex"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vuex</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>eslint</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>typescript</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Essential Links</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://forum.vuejs.org" target="_blank" rel="noopener"
|
||||
>Forum</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener"
|
||||
>Community Chat</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener"
|
||||
>Twitter</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Ecosystem</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://router.vuejs.org" target="_blank" rel="noopener"
|
||||
>vue-router</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/vue-devtools#vue-devtools"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>vue-devtools</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener"
|
||||
>vue-loader</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/vuejs/awesome-vue"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>awesome-vue</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "HelloWorld",
|
||||
props: {
|
||||
msg: String,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
h3 {
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
||||
13
frontend/src/components/NLink.vue
Normal file
13
frontend/src/components/NLink.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { NA } from 'naive-ui';
|
||||
|
||||
defineProps<{
|
||||
to: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="to" #="{ navigate, href }" custom>
|
||||
<n-a :href="href" @click="navigate"><slot /></n-a>
|
||||
</router-link>
|
||||
</template>
|
||||
@@ -1,52 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { Status } from "naive-ui/es/progress/src/interface";
|
||||
import { defineProps, defineExpose, ref } from "vue";
|
||||
import { isErrorResponse, FS } from "@/api";
|
||||
import { NProgress } from "naive-ui";
|
||||
import filesize from "filesize";
|
||||
import type { Status } from 'naive-ui/es/progress/src/interface';
|
||||
import type { UploadFile } from '@/api';
|
||||
import { ref } from 'vue';
|
||||
import { isErrorResponse, FS } from '@/api';
|
||||
import { NProgress } from 'naive-ui';
|
||||
import filesize from 'filesize';
|
||||
|
||||
const props = defineProps<{
|
||||
file: File;
|
||||
file: UploadFile;
|
||||
}>();
|
||||
|
||||
const progress = ref(0);
|
||||
const percentage = ref(0);
|
||||
const err = ref("");
|
||||
const status = ref<Status>("info");
|
||||
const err = ref('');
|
||||
const status = ref<Status>('info');
|
||||
const shown = ref(true);
|
||||
|
||||
async function startUpload(parent: number, token: string) {
|
||||
const resp = await FS.upload_file(token, parent, props.file, (e) => {
|
||||
progress.value = e.loaded;
|
||||
percentage.value = (e.loaded / e.total) * 100;
|
||||
});
|
||||
percentage.value = 100;
|
||||
if (isErrorResponse(resp)) {
|
||||
err.value = resp.message ?? "Error";
|
||||
status.value = "error";
|
||||
} else status.value = "success";
|
||||
async function startUpload(token: string, done: () => void) {
|
||||
const resp = await FS.upload_file(token, props.file, (e) => {
|
||||
progress.value = e.loaded;
|
||||
percentage.value = (e.loaded / e.total) * 100;
|
||||
if (e.loaded == e.total) done();
|
||||
});
|
||||
percentage.value = 100;
|
||||
if (isErrorResponse(resp)) {
|
||||
err.value = resp.message ?? 'Error';
|
||||
status.value = 'error';
|
||||
} else {
|
||||
status.value = 'success';
|
||||
shown.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
startUpload,
|
||||
startUpload
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="percentage < 100">
|
||||
{{ file.name }} - {{ filesize(progress) }} / {{ filesize(file.size) }} -
|
||||
{{ Math.floor(percentage * 1000) / 1000 }}%
|
||||
</div>
|
||||
<div v-else-if="err !== ''">{{ file.name }} - Error: {{ err }}</div>
|
||||
<div v-else>{{ file.name }} - Completed</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="percentage"
|
||||
:height="20"
|
||||
:status="status"
|
||||
border-radius="10px 0"
|
||||
fill-border-radius="10px 0"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<Transition name="slide-up">
|
||||
<div class="container" v-show="shown">
|
||||
<div v-if="percentage < 100">
|
||||
{{ file.fullName }} -
|
||||
{{
|
||||
filesize(progress, {
|
||||
base: 2,
|
||||
standard: 'jedec'
|
||||
})
|
||||
}}
|
||||
/
|
||||
{{
|
||||
filesize(file.file.size, {
|
||||
base: 2,
|
||||
standard: 'jedec'
|
||||
})
|
||||
}}
|
||||
- {{ Math.floor(percentage * 1000) / 1000 }}%
|
||||
</div>
|
||||
<div v-else-if="err !== ''">
|
||||
{{ file.fullName }} - Error: {{ err }}
|
||||
</div>
|
||||
<div v-else>{{ file.fullName }} - Completed</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="percentage"
|
||||
:height="20"
|
||||
:status="status"
|
||||
border-radius="10px 0"
|
||||
fill-border-radius="10px 0"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
height: 60px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.slide-up-leave-active {
|
||||
transition: all 2s ease-out;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
height: 0;
|
||||
padding: 0 8px;
|
||||
opacity: 0;
|
||||
transform: translateY(-60px);
|
||||
}
|
||||
</style>
|
||||
|
||||
203
frontend/src/components/UploadDialog/UploadField.vue
Normal file
203
frontend/src/components/UploadDialog/UploadField.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import type { TokenInjectType, Responses, UploadFile } from '@/api';
|
||||
import { inject, ref } from 'vue';
|
||||
import { useMessage, NModal, NText, NIcon } from 'naive-ui';
|
||||
import { CloudUpload } from '@vicons/carbon';
|
||||
import { FS, check_token, isErrorResponse } from '@/api';
|
||||
import UploadFileDialog from '@/components/UploadDialog/UploadFileDialog.vue';
|
||||
import { loadingMsgWrapper } from '@/utils';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const props = defineProps<{
|
||||
node: Responses.GetNode;
|
||||
}>();
|
||||
|
||||
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'reloadNode'): void;
|
||||
}>();
|
||||
|
||||
const uploadArea = ref<HTMLDivElement>();
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const uploadDialog = ref();
|
||||
const uploadDialogShow = ref(false);
|
||||
|
||||
const files = ref<UploadFile[]>([]);
|
||||
|
||||
function startDrag() {
|
||||
uploadArea.value?.classList.add('uploadActive');
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
uploadArea.value?.classList.remove('uploadActive');
|
||||
}
|
||||
|
||||
function openBrowser() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function browserChanged(event: InputEvent) {
|
||||
files.value = Array.from(
|
||||
(event.target as HTMLInputElement).files ?? []
|
||||
).map((file) => {
|
||||
return {
|
||||
parent: props.node.id,
|
||||
fullName: file.name,
|
||||
file
|
||||
};
|
||||
});
|
||||
uploadFiles();
|
||||
}
|
||||
|
||||
interface FileSystemDirectoryReader {
|
||||
readEntries(
|
||||
successCallback: (entries: FileSystemEntry[]) => void,
|
||||
errorCallback?: (err: DOMException) => void
|
||||
): void;
|
||||
}
|
||||
|
||||
interface FileSystemEntry {
|
||||
readonly fullPath: string;
|
||||
readonly isDirectory: boolean;
|
||||
readonly isFile: boolean;
|
||||
readonly name: string;
|
||||
file(
|
||||
successCallback: (file: File) => void,
|
||||
errorCallback?: (err: DOMException) => void
|
||||
): void;
|
||||
createReader(): FileSystemDirectoryReader;
|
||||
}
|
||||
|
||||
const asyncReadEntries = async (
|
||||
reader: FileSystemDirectoryReader
|
||||
): Promise<FileSystemEntry[]> =>
|
||||
new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
|
||||
const getFile = async (entry: FileSystemEntry): Promise<File> =>
|
||||
new Promise((resolve, reject) => entry.file(resolve, reject));
|
||||
|
||||
async function processDirOrFile(
|
||||
entry: FileSystemEntry,
|
||||
parent: number,
|
||||
token: string
|
||||
) {
|
||||
if (entry.isDirectory) {
|
||||
const resp = await FS.create_folder(token, parent, entry.name);
|
||||
if (isErrorResponse(resp)) return;
|
||||
if ('exists' in resp && resp.isFile) return;
|
||||
const reader = entry.createReader();
|
||||
let entries = [];
|
||||
do {
|
||||
try {
|
||||
entries = await asyncReadEntries(reader);
|
||||
entries.forEach((e) => processDirOrFile(e, resp.id, token));
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
} while (entries.length != 0);
|
||||
} else
|
||||
files.value.push({
|
||||
parent: parent,
|
||||
fullName: entry.fullPath.slice(1),
|
||||
file: await getFile(entry)
|
||||
});
|
||||
}
|
||||
|
||||
const filesDropped = loadingMsgWrapper(message, async (event: DragEvent) => {
|
||||
stopDrag();
|
||||
if (!event.dataTransfer) return;
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
files.value = [];
|
||||
for (const file of event.dataTransfer.items) {
|
||||
const entry = file.webkitGetAsEntry();
|
||||
if (entry)
|
||||
await processDirOrFile(
|
||||
entry as unknown as FileSystemEntry,
|
||||
props.node.id,
|
||||
token
|
||||
);
|
||||
}
|
||||
uploadFiles();
|
||||
});
|
||||
|
||||
function uploadFiles() {
|
||||
if (files.value.length == 0) return;
|
||||
uploadDialogShow.value = true;
|
||||
}
|
||||
|
||||
async function uploadFilesDialogOpen() {
|
||||
await uploadDialog.value?.startUpload();
|
||||
uploadDialogShow.value = false;
|
||||
if (fileInput.value) fileInput.value.value = '';
|
||||
emit('reloadNode');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="uploadArea"
|
||||
ref="uploadArea"
|
||||
@drop.prevent
|
||||
@dragenter.prevent
|
||||
@dragover.prevent
|
||||
@dragleave.prevent
|
||||
@dragend.prevent
|
||||
@click="openBrowser"
|
||||
@drop="filesDropped"
|
||||
@dragenter="startDrag"
|
||||
@dragover="startDrag"
|
||||
@dragleave="stopDrag"
|
||||
@dragend="stopDrag"
|
||||
>
|
||||
<input type="file" ref="fileInput" multiple @input="browserChanged" />
|
||||
<div>
|
||||
<n-icon size="2em">
|
||||
<CloudUpload />
|
||||
</n-icon>
|
||||
</div>
|
||||
<n-text>
|
||||
Click or drag here to upload files
|
||||
</n-text>
|
||||
</div>
|
||||
<n-modal
|
||||
v-model:show="uploadDialogShow"
|
||||
:close-on-esc="false"
|
||||
:mask-closable="false"
|
||||
:on-after-enter="uploadFilesDialogOpen"
|
||||
>
|
||||
<UploadFileDialog ref="uploadDialog" :files="files" />
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.uploadArea {
|
||||
border: 1px dashed #ddd;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
background-color: rgb(250, 250, 252);
|
||||
|
||||
text-align: center;
|
||||
|
||||
transition: border-color 250ms ease-out, background-color 250ms ease-out;
|
||||
|
||||
padding: 20px;
|
||||
|
||||
input {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadArea:hover {
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.uploadActive {
|
||||
background-color: rgb(240, 252, 240);
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { TokenInjectType } from "@/api";
|
||||
import { defineProps, defineExpose, ref, inject } from "vue";
|
||||
import { check_token } from "@/api";
|
||||
import UploadEntry from "@/components/UploadDialog/UploadEntry.vue";
|
||||
import { NCard } from "naive-ui";
|
||||
import type { TokenInjectType, UploadFile } from '@/api';
|
||||
import { ref, inject } from 'vue';
|
||||
import { check_token } from '@/api';
|
||||
import UploadEntry from '@/components/UploadDialog/UploadEntry.vue';
|
||||
import { NCard } from 'naive-ui';
|
||||
|
||||
const jwt = inject<TokenInjectType>("jwt") as TokenInjectType;
|
||||
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
|
||||
|
||||
const entries = ref<typeof UploadEntry[]>([]);
|
||||
const done = ref(false);
|
||||
let canCloseResolve: (value: unknown) => void = () => null;
|
||||
const canClose = new Promise((r) => (canCloseResolve = r));
|
||||
|
||||
async function startUpload(parent: number) {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
await Promise.all(
|
||||
entries.value.map((entry) => entry.startUpload(parent, token))
|
||||
);
|
||||
done.value = true;
|
||||
await canClose;
|
||||
async function startUpload() {
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
const ents: typeof UploadEntry[] = entries.value;
|
||||
const allProms: Promise<void>[] = [];
|
||||
for (const entry of ents) {
|
||||
await new Promise<void>((resolve) =>
|
||||
allProms.push(entry.startUpload(token, resolve))
|
||||
);
|
||||
}
|
||||
await Promise.all(allProms);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
startUpload,
|
||||
startUpload
|
||||
});
|
||||
defineProps<{
|
||||
files: File[];
|
||||
files: UploadFile[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card title="Upload Files">
|
||||
<div>
|
||||
<UploadEntry v-for="f in files" :key="f.name" ref="entries" :file="f" />
|
||||
</div>
|
||||
<div>
|
||||
<button v-if="done" @click="canCloseResolve(null)">Close</button>
|
||||
</div>
|
||||
</n-card>
|
||||
<n-card title="Uploading files" style="margin: 20px">
|
||||
<UploadEntry
|
||||
v-for="f in files"
|
||||
:key="f.file.name"
|
||||
ref="entries"
|
||||
:file="f"
|
||||
/>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
73
frontend/src/components/UserChangePw.vue
Normal file
73
frontend/src/components/UserChangePw.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import type { TokenInjectType } from '@/api';
|
||||
import { inject, ref } from 'vue';
|
||||
import { Auth, check_token, isErrorResponse } from '@/api';
|
||||
import { useMessage, NInput, NGrid, NGi, NButton, NCard } from 'naive-ui';
|
||||
import { loadingMsgWrapper } from '@/utils';
|
||||
|
||||
const message = useMessage();
|
||||
|
||||
const oldPw = ref('');
|
||||
const newPw = ref('');
|
||||
const newPw2 = ref('');
|
||||
const jwt = inject<TokenInjectType>('jwt') as TokenInjectType;
|
||||
|
||||
const changePw = loadingMsgWrapper(message, async () => {
|
||||
if (oldPw.value === '' || newPw.value === '' || newPw2.value === '') {
|
||||
message.error('Password missing');
|
||||
return;
|
||||
}
|
||||
if (newPw.value !== newPw2.value) {
|
||||
message.error("Passwords don't match");
|
||||
return;
|
||||
}
|
||||
const token = await check_token(jwt);
|
||||
if (!token) return;
|
||||
const res = await Auth.change_password(oldPw.value, newPw.value, token);
|
||||
if (isErrorResponse(res))
|
||||
message.error(`Password change failed: ${res.message}`);
|
||||
else jwt.logout();
|
||||
});
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key == 'Enter') changePw();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card title="Change password" embedded>
|
||||
<n-grid cols="1" x-gap="16" y-gap="16">
|
||||
<n-gi>
|
||||
<n-input
|
||||
type="password"
|
||||
placeholder="Old password"
|
||||
v-model:value="oldPw"
|
||||
@keyup="onKey"
|
||||
/>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
v-model:value="newPw"
|
||||
@keyup="onKey"
|
||||
/>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-input
|
||||
type="password"
|
||||
placeholder="Repeat new password"
|
||||
v-model:value="newPw2"
|
||||
@keyup="onKey"
|
||||
/>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-button type="info" @click="changePw">
|
||||
Change password
|
||||
</n-button>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user