feat: rewrite file list

Signed-off-by: Vitor Mattos <vitor@php.rio>
This commit is contained in:
Vitor Mattos 2024-09-20 18:41:00 -03:00
parent 4e359acd9c
commit 70d463d715
13 changed files with 752 additions and 1 deletions

8
package-lock.json generated
View file

@ -12,6 +12,8 @@
"@fontsource/dancing-script": "^5.1.0", "@fontsource/dancing-script": "^5.1.0",
"@libresign/vue-pdf-editor": "^1.3.7", "@libresign/vue-pdf-editor": "^1.3.7",
"@marionebl/option": "^1.0.8", "@marionebl/option": "^1.0.8",
"@mdi/js": "^7.4.47",
"@mdi/svg": "^7.4.47",
"@nextcloud/auth": "^2.4.0", "@nextcloud/auth": "^2.4.0",
"@nextcloud/axios": "^2.5.1", "@nextcloud/axios": "^2.5.1",
"@nextcloud/dialogs": "^6.0.1", "@nextcloud/dialogs": "^6.0.1",
@ -2783,6 +2785,12 @@
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==", "integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@mdi/svg": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/svg/-/svg-7.4.47.tgz",
"integrity": "sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw==",
"license": "Apache-2.0"
},
"node_modules/@nextcloud/auth": { "node_modules/@nextcloud/auth": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-2.4.0.tgz", "resolved": "https://registry.npmjs.org/@nextcloud/auth/-/auth-2.4.0.tgz",

View file

@ -23,6 +23,8 @@
"@fontsource/dancing-script": "^5.1.0", "@fontsource/dancing-script": "^5.1.0",
"@libresign/vue-pdf-editor": "^1.3.7", "@libresign/vue-pdf-editor": "^1.3.7",
"@marionebl/option": "^1.0.8", "@marionebl/option": "^1.0.8",
"@mdi/js": "^7.4.47",
"@mdi/svg": "^7.4.47",
"@nextcloud/auth": "^2.4.0", "@nextcloud/auth": "^2.4.0",
"@nextcloud/axios": "^2.5.1", "@nextcloud/axios": "^2.5.1",
"@nextcloud/dialogs": "^6.0.1", "@nextcloud/dialogs": "^6.0.1",

11
src/helpers/logger.js Normal file
View file

@ -0,0 +1,11 @@
/**
* SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getLoggerBuilder } from '@nextcloud/logger'
export default getLoggerBuilder()
.setApp('libresign')
.detectUser()
.build()

View file

@ -141,6 +141,11 @@ const router = new Router({
name: 'timeline', name: 'timeline',
component: () => import('../views/Timeline/Timeline.vue'), component: () => import('../views/Timeline/Timeline.vue'),
}, },
{
path: '/f/filelist/sign',
name: 'fileslist',
component: () => import('../views/FilesList/FilesList.vue'),
},
{ {
path: '/f/request', path: '/f/request',
name: 'requestFiles', name: 'requestFiles',

27
src/store/filters.js Normal file
View file

@ -0,0 +1,27 @@
/**
* SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { defineStore } from 'pinia'
import logger from '../helpers/logger.js'
export const useFiltersStore = defineStore('filter', {
state: () => ({
chips: {},
}),
getters: {
activeChips(state) {
return Object.values(state.chips).flat()
},
},
actions: {
onFilterUpdateChips(event) {
this.chips = { ...this.chips, [event.id]: [...event.detail] }
logger.debug('File list filter chips updated', { chips: event.detail })
},
},
})

View file

@ -0,0 +1,58 @@
<template>
<NcActions force-menu
:type="isActive ? 'secondary' : 'tertiary'"
:menu-name="filterName">
<template #icon>
<slot name="icon" />
</template>
<slot />
<template v-if="isActive">
<NcActionSeparator />
<NcActionButton class="files-list-filter__clear-button"
close-after-click
@click="$emit('reset-filter')">
{{ t('files', 'Clear filter') }}
</NcActionButton>
</template>
</NcActions>
</template>
<script>
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
export default {
name: 'FileListFilter',
components: {
NcActions,
NcActionButton,
NcActionSeparator,
},
props: {
isActive: {
type: Boolean,
required: true,
},
filterName: {
type: String,
required: true,
},
},
}
</script>
<style scoped lang="scss">
.files-list-filter__clear-button :deep(.action-button__text) {
color: var(--color-error-text);
}
:deep(.button-vue) {
font-weight: normal !important;
* {
font-weight: normal !important;
}
}
</style>

View file

@ -0,0 +1,128 @@
<template>
<FileListFilter :is-active="isActive"
:filter-name="t('libresign', 'Modified')"
@reset-filter="resetFilter">
<template #icon>
<NcIconSvgWrapper :path="mdiCalendarRange" />
</template>
<NcActionButton v-for="preset of timePresets"
:key="preset.id"
type="radio"
close-after-click
:model-value.sync="selectedOption"
:value="preset.id">
{{ preset.label }}
</NcActionButton>
</FileListFilter>
</template>
<script>
import { mdiCalendarRange } from '@mdi/js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import calendarSvg from '@mdi/svg/svg/calendar.svg?raw'
import FileListFilter from './FileListFilter.vue'
import { useFiltersStore } from '../../../store/filters.js'
const startOfToday = () => (new Date()).setHours(0, 0, 0, 0)
export default {
name: 'FileListFilterModified',
components: {
FileListFilter,
NcActionButton,
NcIconSvgWrapper,
},
setup() {
const filtersStore = useFiltersStore()
return {
// icons used in template
mdiCalendarRange,
filtersStore,
}
},
data() {
return {
selectedOption: null,
timeRangeEnd: null,
timeRangeStart: null,
timePresets: [
{
id: 'today',
label: t('libresign', 'Today'),
filter: (time) => time > startOfToday(),
},
{
id: 'last-7',
label: t('libresign', 'Last 7 days'),
filter: (time) => time > (startOfToday() - (7 * 24 * 60 * 60 * 1000)),
},
{
id: 'last-30',
label: t('libresign', 'Last 30 days'),
filter: (time) => time > (startOfToday() - (30 * 24 * 60 * 60 * 1000)),
},
{
id: 'this-year',
label: t('libresign', 'This year ({year})', { year: (new Date()).getFullYear() }),
filter: (time) => time > (new Date(startOfToday())).setMonth(0, 1),
},
{
id: 'last-year',
label: t('libresign', 'Last year ({year})', { year: (new Date()).getFullYear() - 1 }),
filter: (time) => (time > (new Date(startOfToday())).setFullYear((new Date()).getFullYear() - 1, 0, 1)) && (time < (new Date(startOfToday())).setMonth(0, 1)),
},
],
}
},
computed: {
isActive() {
return this.selectedOption !== null
},
currentPreset() {
return this.timePresets.find(({ id }) => id === this.selectedOption) ?? null
},
},
watch: {
selectedOption() {
if (this.selectedOption === null) {
this.selectedOption = null
this.setPreset()
} else {
this.setPreset(this.currentPreset)
}
},
},
methods: {
setPreset(preset) {
const chips = []
if (preset) {
chips.push({
icon: calendarSvg,
text: preset.label,
onclick: () => this.setPreset(),
})
} else {
this.resetFilter()
}
this.filtersStore.onFilterUpdateChips({ detail: chips, id: 'modified' })
},
resetFilter() {
if (this.selectedOption !== null) {
this.selectedOption = null
this.timeRangeEnd = null
this.timeRangeStart = null
}
},
},
}
</script>
<style scoped lang="scss">
.files-list-filter-time {
&__clear-button :deep(.action-button__text) {
color: var(--color-error-text);
}
}
</style>

View file

@ -0,0 +1,136 @@
<template>
<FileListFilter class="file-list-filter-status"
:is-active="isActive"
:filter-name="t('libresign', 'Status')"
@reset-filter="resetFilter">
<template #icon>
<NcIconSvgWrapper :path="mdiListStatus" />
</template>
<NcActionButton v-for="status of statusPresets"
:key="status.id"
type="checkbox"
:model-value="selectedOptions.includes(status)"
@click="toggleOption(status)">
<template #icon>
<NcIconSvgWrapper :svg="status.icon" />
</template>
{{ status.label }}
</NcActionButton>
</FileListFilter>
</template>
<script>
import { mdiListStatus } from '@mdi/js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import svgFile from '@mdi/svg/svg/file.svg?raw'
import svgSignature from '@mdi/svg/svg/signature.svg?raw'
import svgFractionOneHalf from '@mdi/svg/svg/fraction-one-half.svg?raw'
import svgSignatureFreehand from '@mdi/svg/svg/signature-freehand.svg?raw'
import svgDelete from '@mdi/svg/svg/delete.svg?raw'
import FileListFilter from './FileListFilter.vue'
import { useFiltersStore } from '../../../store/filters.js'
const colorize = (svg, color) => {
return svg.replace('<path ', `<path fill="${color}" `)
}
export default {
name: 'FileListFilterStatus',
components: {
FileListFilter,
NcActionButton,
NcIconSvgWrapper,
},
setup() {
const filtersStore = useFiltersStore()
return {
mdiListStatus,
filtersStore,
}
},
data() {
return {
selectedOptions: [],
statusPresets: [
{
id: 0,
icon: colorize(svgFile, '#E0E0E0'),
label: t('libresign', 'draft'),
},
{
id: 1,
icon: colorize(svgSignature, '#B2E0B2'),
label: t('libresign', 'available for signature'),
},
{
id: 2,
icon: colorize(svgFractionOneHalf, '#F0E68C'),
label: t('libresign', 'partially signed'),
},
{
id: 3,
icon: colorize(svgSignatureFreehand, '#A0C4FF'),
label: t('libresign', 'signed'),
},
{
id: 4,
icon: colorize(svgDelete, '#FFB2B2'),
label: t('libresign', 'deleted'),
},
],
}
},
computed: {
isActive() {
return this.selectedOptions.length > 0
},
},
watch: {
selectedOptions(newValue, oldValue) {
if (newValue.length === 0) {
this.setPreset()
} else {
this.setPreset(newValue)
}
},
},
methods: {
setPreset(presets) {
const chips = []
if (presets && presets.length > 0) {
for (const preset of presets) {
chips.push({
icon: preset.icon,
text: preset.label,
onclick: () => this.setPreset(presets.filter(({ id }) => id !== preset.id)),
})
}
} else {
this.resetFilter()
}
this.filtersStore.onFilterUpdateChips({ detail: chips, id: 'status' })
},
resetFilter() {
if (this.selectedOptions.length > 0) {
this.selectedOptions = []
}
},
toggleOption(option) {
const idx = this.selectedOptions.indexOf(option)
if (idx !== -1) {
this.selectedOptions.splice(idx, 1)
} else {
this.selectedOptions.push(option)
}
},
},
}
</script>
<style>
.file-list-filter-status {
max-width: 220px;
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<div class="file-list-filters">
<div class="file-list-filters__filter">
<div class="file-list-filters__filter">
<FileListFilterModified />
<FileListFilterStatus />
</div>
</div>
<ul v-if="filtersStore.activeChips.length > 0" class="file-list-filters__active" :aria-label="t('libresign', 'Active filters')">
<li v-for="(chip, index) of filtersStore.activeChips" :key="index">
<NcChip :aria-label-close="t('libresign', 'Remove filter')"
:icon-svg="chip.icon"
:text="chip.text"
@close="chip.onclick">
<template v-if="chip.user" #icon>
<NcAvatar disable-menu
:show-user-status="false"
:size="24"
:user="chip.user" />
</template>
</NcChip>
</li>
</ul>
</div>
</template>
<script>
import NcChip from '@nextcloud/vue/dist/Components/NcChip.js'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import FileListFilterModified from './FileListFilter/FileListFilterModified.vue'
import FileListFilterStatus from './FileListFilter/FileListFilterStatus.vue'
import { useFiltersStore } from '../../store/filters.js'
export default {
name: 'FileListFilters',
components: {
NcChip,
NcAvatar,
FileListFilterModified,
FileListFilterStatus,
},
setup() {
const filtersStore = useFiltersStore()
return { filtersStore }
},
}
</script>
<style scoped lang="scss">
.file-list-filters {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
height: 100%;
width: 100%;
&__filter {
display: flex;
align-items: start;
justify-content: start;
gap: calc(var(--default-grid-baseline, 4px) * 2);
> * {
flex: 0 1 fit-content;
}
}
&__active {
display: flex;
flex-direction: row;
gap: calc(var(--default-grid-baseline, 4px) * 2);
}
}
</style>

View file

@ -0,0 +1,232 @@
<template>
<NcAppContent :page-heading="t('libresign', 'Files')">
<div class="files-list__header">
<NcBreadcrumbs class="files-list__breadcrumbs">
<NcBreadcrumb :name="t('libresign', 'Files')"
:title="t('libresign', 'Files')"
:exact="true"
:force-icon-text="true"
:to="{ name: 'fileslist' }"
:aria-description="t('libresign', 'Files')"
:disable-drop="true"
@click.native="refresh()">
<template #icon>
<NcIconSvgWrapper :size="20"
:svg="viewIcon" />
</template>
</NcBreadcrumb>
<template #actions>
<NcActions :menu-name="t('libresign', 'Request')">
<template #icon>
<PlusIcon :size="20" />
</template>
<NcActionButton>
<template #icon>
<LinkIcon :size="20" />
</template>
{{ t('libresign', 'Upload from URL') }}
</NcActionButton>
<NcActionButton>
<template #icon>
<FolderIcon :size="20" />
</template>
{{ t('libresign', 'Choose from Files') }}
</NcActionButton>
<NcActionButton>
<template #icon>
<NcLoadingIcon v-if="isUploading" :size="20" />
<UploadIcon v-else :size="20" />
</template>
{{ t('libresign', 'Upload') }}
</NcActionButton>
</NcActions>
</template>
</NcBreadcrumbs>
<NcLoadingIcon v-if="isRefreshing" class="files-list__refresh-icon" />
<NcButton :aria-label="gridViewButtonLabel"
:title="gridViewButtonLabel"
class="files-list__header-grid-button"
type="tertiary"
@click="toggleGridView">
<template #icon>
<ListViewIcon v-if="userConfig.grid_view" />
<ViewGridIcon v-else />
</template>
</NcButton>
</div>
<NcLoadingIcon v-if="loading && !isRefreshing"
class="files-list__loading-icon"
:size="38"
:name="t('libresign', 'Loading …')" />
<NcEmptyContent v-else-if="!loading && isEmptyDir"
:name="t('libresign', 'There are no documents')"
:description="t('libresign', 'Choose the file to request signatures.')">
<template #icon>
<FolderIcon />
</template>
</NcEmptyContent>
<FilesListVirtual v-else
:nodes="dirContentsSorted" />
</NcAppContent>
</template>
<script>
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import PlusIcon from 'vue-material-design-icons/Plus.vue'
import HomeSvg from '@mdi/svg/svg/home.svg?raw'
import ListViewIcon from 'vue-material-design-icons/FormatListBulletedSquare.vue'
import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import UploadIcon from 'vue-material-design-icons/Upload.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import NcBreadcrumb from '@nextcloud/vue/dist/Components/NcBreadcrumb.js'
import NcBreadcrumbs from '@nextcloud/vue/dist/Components/NcBreadcrumbs.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import FilesListVirtual from './FilesListVirtual.vue'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import { useFilesStore } from '../../store/files.js'
export default {
name: 'FilesList',
components: {
NcAppContent,
NcButton,
PlusIcon,
ListViewIcon,
ViewGridIcon,
NcLoadingIcon,
FolderIcon,
UploadIcon,
LinkIcon,
NcBreadcrumb,
NcBreadcrumbs,
NcActions,
NcActionButton,
NcIconSvgWrapper,
FilesListVirtual,
NcEmptyContent,
},
setup() {
const filesStore = useFilesStore()
return { filesStore }
},
data() {
return {
isUploading: false,
loading: false,
userConfig: {
grid_view: false,
},
dirContentsFiltered: [],
}
},
computed: {
viewIcon() {
return HomeSvg
},
gridViewButtonLabel() {
return this.userConfig.grid_view
? t('libresign', 'Switch to list view')
: t('libresign', 'Switch to grid view')
},
dirContentsSorted() {
if (!this.isEmptyDir) {
return []
}
return this.dirContentsFiltered
},
isEmptyDir() {
return this.filesStore.files.length === 0
},
isRefreshing() {
return !this.isEmptyDir
&& this.loading
},
},
async created() {
await this.filesStore.getAllFiles()
},
methods: {
refresh() {
console.log('Need to implement refresh')
},
toggleGridView() {
this.userConfig.grid_view = !this.userConfig.grid_view
console.log('Need to implement toggle')
// this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
},
filterDirContent() {
const nodes = this.filesStore.files
console.log('Implement filter here')
this.dirContentsFiltered = nodes
},
},
}
</script>
<style scoped lang="scss">
.app-content {
// Virtual list needs to be full height and is scrollable
display: flex;
overflow: hidden;
flex-direction: column;
max-height: 100%;
position: relative !important;
}
.files-list__breadcrumbs {
// Take as much space as possible
flex: 1 1 100% !important;
width: 100%;
height: 100%;
margin-block: 0;
margin-inline: 10px;
min-width: 0;
:deep() {
a {
cursor: pointer !important;
}
}
&--with-progress {
flex-direction: column !important;
align-items: flex-start !important;
}
}
.files-list {
&__header {
display: flex;
align-items: center;
// Do not grow or shrink (vertically)
flex: 0 0;
max-width: 100%;
// Align with the navigation toggle icon
margin-block: var(--app-navigation-padding, 4px);
margin-inline: calc(var(--default-clickable-area, 44px) + 2 * var(--app-navigation-padding, 4px)) var(--app-navigation-padding, 4px);
>* {
// Do not grow or shrink (horizontally)
flex: 0 0;
}
}
&__refresh-icon {
flex: 0 0 var(--default-clickable-area);
width: var(--default-clickable-area);
height: var(--default-clickable-area);
}
&__loading-icon {
margin: auto;
}
}
</style>

View file

@ -0,0 +1,57 @@
<template>
<VirtualList>
<template #filters>
<FileListFilters />
</template>
</VirtualList>
</template>
<script>
import VirtualList from './VirtualList.vue'
import FileListFilters from './FileListFilters.vue'
export default {
name: 'FilesListVirtual',
components: {
VirtualList,
FileListFilters,
},
props: {
nodes: {
type: Array,
required: true,
},
},
}
</script>
<style scoped lang="scss">
.files-list {
--row-height: 55px;
--cell-margin: 14px;
--checkbox-padding: calc((var(--row-height) - var(--checkbox-size)) / 2);
--checkbox-size: 24px;
--clickable-area: var(--default-clickable-area);
--icon-preview-size: 32px;
--fixed-block-start-position: var(--default-clickable-area);
overflow: auto;
height: 100%;
will-change: scroll-position;
& :deep() {
.files-list__filters {
// Pinned on top when scrolling above table header
position: sticky;
top: 0;
// ensure there is a background to hide the file list on scroll
background-color: var(--color-main-background);
z-index: 10;
// fixed the size
padding-inline: var(--row-height) var(--default-grid-baseline, 4px);
height: var(--fixed-block-start-position);
width: 100%;
}
}
}
</style>

View file

@ -0,0 +1,13 @@
<template>
<div class="files-list">
<div class="files-list__filters">
<slot name="filters" />
</div>
</div>
</template>
<script>
export default {
name: 'VirtualList',
}
</script>

View file

@ -12,4 +12,4 @@
"vueCompilerOptions": { "vueCompilerOptions": {
"target": 2.7, "target": 2.7,
} }
} }