UBERF-7008 Show files and folders as a grid (#5845)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2024-06-18 16:31:08 +07:00 committed by GitHub
parent ece0504d3c
commit bb6f9d7645
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 476 additions and 25 deletions

View File

@ -16,6 +16,7 @@
import core, {
type Blob,
type Domain,
type FindOptions,
type Role,
type RolesAssignment,
type Type,
@ -84,6 +85,11 @@ export class TResource extends TDoc implements Resource {
@ReadOnly()
file?: Ref<Blob>
@Prop(TypeRef(core.class.Blob), drive.string.Preview)
@ReadOnly()
@Hidden()
preview?: Ref<Blob>
@Prop(TypeRef(drive.class.Resource), drive.string.Parent)
@Index(IndexKind.Indexed)
@ReadOnly()
@ -107,6 +113,7 @@ export class TFolder extends TResource implements Folder {
declare path: Ref<Folder>[]
declare file: undefined
declare preview: undefined
}
@Model(drive.class.File, drive.class.Resource, DOMAIN_DRIVE)
@ -274,33 +281,86 @@ function defineResource (builder: Builder): void {
},
'createdBy'
],
/* eslint-disable @typescript-eslint/consistent-type-assertions */
options: {
lookup: {
file: core.class.Blob,
preview: core.class.Blob
},
sort: {
_class: SortingOrder.Descending
}
},
} as FindOptions<Resource>,
configOptions: {
hiddenKeys: ['name', 'file', 'parent', 'path'],
hiddenKeys: ['name', 'file', 'parent', 'path', 'type'],
sortable: true
}
},
drive.viewlet.FileTable
)
// builder.createDoc<Viewlet>(
// view.class.Viewlet,
// core.space.Model,
// {
// attachTo: drive.class.Resource,
// descriptor: drive.viewlet.Grid,
// config: ['', 'type', 'size', 'lastModified', 'createdBy'],
// configOptions: {
// hiddenKeys: ['name', 'file', 'parent', 'path'],
// sortable: true
// }
// },
// drive.viewlet.FileGrid
// )
builder.createDoc(
view.class.ViewletDescriptor,
core.space.Model,
{
label: drive.string.Grid,
icon: drive.icon.Grid,
component: drive.component.GridView
},
drive.viewlet.Grid
)
builder.createDoc<Viewlet>(
view.class.Viewlet,
core.space.Model,
{
attachTo: drive.class.Resource,
descriptor: drive.viewlet.Grid,
viewOptions: {
groupBy: [],
orderBy: [
['name', SortingOrder.Ascending],
['$lookup.file.size', SortingOrder.Ascending],
['$lookup.file.modifiedOn', SortingOrder.Descending]
],
other: []
},
config: [
{
key: '',
presenter: drive.component.ResourcePresenter,
label: drive.string.Name,
sortingKey: 'name'
},
{
key: '$lookup.file.size',
presenter: drive.component.FileSizePresenter,
label: drive.string.Size,
sortingKey: '$lookup.file.size'
},
{
key: '$lookup.file.modifiedOn',
label: core.string.ModifiedDate
},
'createdBy'
],
configOptions: {
hiddenKeys: ['name', 'file', 'parent', 'path'],
sortable: true
},
/* eslint-disable @typescript-eslint/consistent-type-assertions */
options: {
lookup: {
file: core.class.Blob,
preview: core.class.Blob
},
sort: {
_class: SortingOrder.Descending
}
} as FindOptions<Resource>
},
drive.viewlet.FileGrid
)
}
function defineFolder (builder: Builder): void {

View File

@ -75,6 +75,7 @@ export default mergeIds(driveId, drive, {
RenameFolder: '' as ViewAction
},
string: {
Grid: '' as IntlString,
Name: '' as IntlString,
Description: '' as IntlString,
Size: '' as IntlString,
@ -84,6 +85,7 @@ export default mergeIds(driveId, drive, {
Parent: '' as IntlString,
Path: '' as IntlString,
Drives: '' as IntlString,
Download: '' as IntlString
Download: '' as IntlString,
Preview: '' as IntlString
}
})

View File

@ -60,6 +60,7 @@
"UpdateSpaceDescription": "Grants users ability to update the space",
"ArchiveSpaceDescription": "Grants users ability to archive the space",
"AutoJoin": "Auto join",
"AutoJoinDescr": "Automatically join new employees to this space"
"AutoJoinDescr": "Automatically join new employees to this space",
"BlobSize": "Size"
}
}

View File

@ -53,6 +53,7 @@
"UpdateSpaceDescription": "Concede a los usuarios la capacidad de actualizar el espacio",
"ArchiveSpaceDescription": "Concede a los usuarios la capacidad de archivar el espacio",
"AutoJoin": "Auto unirse",
"AutoJoinDescr": "Unirse automáticamente a los nuevos empleados a este espacio"
"AutoJoinDescr": "Unirse automáticamente a los nuevos empleados a este espacio",
"BlobSize": "Tamaño"
}
}

View File

@ -53,6 +53,7 @@
"UpdateSpaceDescription": "Concede aos usuários a capacidade de atualizar o espaço",
"ArchiveSpaceDescription": "Concede aos usuários a capacidade de arquivar o espaço",
"AutoJoin": "Auto adesão",
"AutoJoinDescr": "Adesão automática de novos funcionários a este espaço"
"AutoJoinDescr": "Adesão automática de novos funcionários a este espaço",
"BlobSize": "Tamanho"
}
}

View File

@ -60,6 +60,7 @@
"UpdateSpaceDescription": "Дает пользователям разрешение обновлять пространство",
"ArchiveSpaceDescription": "Дает пользователям разрешение архивировать пространство",
"AutoJoin": "Автоприсоединение",
"AutoJoinDescr": "Автоматически присоединять новых сотрудников к этому пространству"
"AutoJoinDescr": "Автоматически присоединять новых сотрудников к этому пространству",
"BlobSize": "Размер"
}
}

View File

@ -25,9 +25,12 @@
export let value: Account
export let avatarSize: IconSize = 'x-small'
export let shouldShowAvatar: boolean = true
export let shouldShowName: boolean = true
export let disabled: boolean = false
export let inline: boolean = false
export let accent: boolean = false
export let noUnderline: boolean = false
export let compact = false
$: employee = $employeeByIdStore.get((value as PersonAccount)?.person as Ref<Employee>)
@ -39,9 +42,31 @@
{#if value}
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if employee}
<EmployeePresenter value={employee} {disabled} {inline} {accent} {avatarSize} {compact} on:accent-color />
<EmployeePresenter
value={employee}
{shouldShowAvatar}
{shouldShowName}
{disabled}
{inline}
{accent}
{avatarSize}
{noUnderline}
{compact}
on:accent-color
/>
{:else if person}
<PersonPresenter value={person} {disabled} {inline} {accent} {avatarSize} {compact} on:accent-color />
<PersonPresenter
value={person}
{shouldShowAvatar}
{shouldShowName}
{disabled}
{inline}
{accent}
{avatarSize}
{noUnderline}
{compact}
on:accent-color
/>
{:else}
<div class="flex-row-center">
<Avatar size={avatarSize} />

View File

@ -22,14 +22,28 @@
export let value: Ref<PersonAccount>
export let avatarSize: IconSize = 'x-small'
export let shouldShowAvatar: boolean = true
export let shouldShowName: boolean = true
export let disabled: boolean = false
export let inline: boolean = false
export let accent: boolean = false
export let noUnderline: boolean = false
export let compact = false
$: account = $personAccountByIdStore.get(value)
</script>
{#if account}
<PersonAccountPresenter value={account} {disabled} {inline} {avatarSize} {accent} {compact} on:accent-color />
<PersonAccountPresenter
value={account}
{shouldShowAvatar}
{shouldShowName}
{disabled}
{inline}
{avatarSize}
{accent}
{noUnderline}
{compact}
on:accent-color
/>
{/if}

View File

@ -2,6 +2,10 @@
<symbol id="drive" viewBox="0 0 32 32">
<path d="M16 3C10.7021 3 5 4.252 5 7V25C5 27.748 10.7021 29 16 29C21.2979 29 27 27.748 27 25V7C27 4.252 21.2979 3 16 3ZM16 5C21.7976 5 24.7949 6.4341 24.9968 7C24.7949 7.5659 21.7976 9 16 9C10.1587 9 7.1606 7.5444 7 7.0176V7.0127C7.1606 6.4556 10.1587 5 16 5ZM7 9.4277C9.1279 10.4951 12.6426 11 16 11C19.3574 11 22.8721 10.4951 25 9.4277V12.9873C24.8394 13.5444 21.8413 15 16 15C10.1499 15 7.1509 13.54 7 13V9.4277ZM7 15.4277C9.1279 16.4951 12.6426 17 16 17C19.3574 17 22.8721 16.4951 25 15.4277V18.9873C24.8394 19.5444 21.8413 21 16 21C10.1499 21 7.1509 19.54 7 19V15.4277ZM16 27C10.1499 27 7.1509 25.54 7 25V21.4277C9.1279 22.4951 12.6426 23 16 23C19.3574 23 22.8721 22.4951 25 21.4277V24.9873C24.8394 25.5444 21.8413 27 16 27Z" fill="currentColor"/>
</symbol>
<symbol id="grid" viewBox="0 0 20 20">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.5 12.5H3.75L3.75 16.25H7.5V12.5ZM7.5 3.75H3.75L3.75 7.5H7.5V3.75ZM16.25 12.5H12.5V16.25H16.25V12.5ZM16.25 3.75H12.5V7.5H16.25V3.75ZM3.75 2.5C3.05964 2.5 2.5 3.05964 2.5 3.75V7.5C2.5 8.19036 3.05964 8.75 3.75 8.75H7.5C8.19036 8.75 8.75 8.19036 8.75 7.5V3.75C8.75 3.05964 8.19036 2.5 7.5 2.5H3.75ZM3.75 11.25C3.05964 11.25 2.5 11.8096 2.5 12.5V16.25C2.5 16.9404 3.05964 17.5 3.75 17.5H7.5C8.19036 17.5 8.75 16.9404 8.75 16.25V12.5C8.75 11.8096 8.19036 11.25 7.5 11.25H3.75ZM11.25 12.5C11.25 11.8096 11.8096 11.25 12.5 11.25H16.25C16.9404 11.25 17.5 11.8096 17.5 12.5V16.25C17.5 16.9404 16.9404 17.5 16.25 17.5H12.5C11.8096 17.5 11.25 16.9404 11.25 16.25V12.5ZM12.5 2.5C11.8096 2.5 11.25 3.05964 11.25 3.75V7.5C11.25 8.19036 11.8096 8.75 12.5 8.75H16.25C16.9404 8.75 17.5 8.19036 17.5 7.5V3.75C17.5 3.05964 16.9404 2.5 16.25 2.5H12.5Z" />
</symbol>
<symbol id="file" viewBox="0 0 32 32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 4C7.89543 4 7 4.89543 7 6V26C7 27.1046 7.89543 28 9 28H23C24.1046 28 25 27.1046 25 26V12H21C18.7909 12 17 10.2091 17 8V4H9ZM19 4.41421V8C19 9.10457 19.8954 10 21 10H24.5858L19 4.41421ZM5 6C5 3.79086 6.79086 2 9 2H18.5858C19.1162 2 19.6249 2.21071 20 2.58579L26.4142 9C26.7893 9.37507 27 9.88378 27 10.4142V26C27 28.2091 25.2091 30 23 30H9C6.79086 30 5 28.2091 5 26V6Z" fill="currentColor"/>
</symbol>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -2,6 +2,7 @@
"string": {
"Drive": "Drive",
"Drives": "Drives",
"Grid": "Grid",
"File": "File",
"Folder": "Folder",
"Resource": "Resource",

View File

@ -2,6 +2,7 @@
"string": {
"Drive": "Unidad",
"Drives": "Unidades",
"Grid": "Red",
"File": "Archivo",
"Folder": "Carpeta",
"Resource": "Recurso",

View File

@ -2,6 +2,7 @@
"string": {
"Drive": "Unidade",
"Drives": "Unidades",
"Grid": "Grade",
"File": "Ficheiro",
"Folder": "Pasta",
"Resource": "Recurso",

View File

@ -2,6 +2,7 @@
"string": {
"Drive": "Диск",
"Drives": "Диски",
"Grid": "Сетка",
"File": "Файл",
"Folder": "Папка",
"Resource": "Ресурс",

View File

@ -19,6 +19,7 @@ import drive from '@hcengineering/drive'
const icons = require('../assets/icons.svg') as string // eslint-disable-line
loadMetadata(drive.icon, {
Drive: `${icons}#drive`,
Grid: `${icons}#grid`,
File: `${icons}#file`,
Folder: `${icons}#folder`,
FolderOpen: `${icons}#folder-open`,

View File

@ -0,0 +1,149 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import core, { type WithLookup } from '@hcengineering/core'
import { type Resource } from '@hcengineering/drive'
import { getClient } from '@hcengineering/presentation'
import { Button, IconMoreH } from '@hcengineering/ui'
import { ObjectPresenter, TimestampPresenter, openDoc, showMenu } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import FileSizePresenter from './FileSizePresenter.svelte'
import ResourcePresenter from './ResourcePresenter.svelte'
import Thumbnail from './Thumbnail.svelte'
export let object: WithLookup<Resource>
export let selected: boolean = false
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
let hovered = false
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="card-container"
class:selected
class:hovered
on:mouseover={() => dispatch('obj-focus', object)}
on:mouseenter={() => dispatch('obj-focus', object)}
on:focus={() => {}}
on:contextmenu={(evt) => {
showMenu(evt, { object })
}}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="card"
on:click={() => {
void openDoc(hierarchy, object)
}}
>
<div class="card-content">
<Thumbnail {object} size={'x-large'} />
</div>
<div class="header flex-col p-2 pt-1">
<div class="flex-row-center flex-gap-2 h-8">
<div class="title overflow-label flex-grow">
<ResourcePresenter value={object} shouldShowAvatar={false} accent />
</div>
<div class="tools">
<Button
icon={IconMoreH}
kind="ghost"
size="medium"
on:click={(evt) => {
hovered = true
showMenu(evt, { object }, () => {
hovered = false
})
}}
/>
</div>
</div>
<div class="flex-between flex-gap-2 h-4">
<div class="flex-row-center flex-gap-2 font-regular-12">
<span class="flex-no-shrink">
<ObjectPresenter
_class={core.class.Account}
objectId={object.createdBy}
noUnderline
props={{ avatarSize: 'tiny' }}
/>
</span>
<span></span>
<TimestampPresenter value={object.$lookup?.file?.modifiedOn ?? object.createdOn ?? object.modifiedOn} />
</div>
<div class="flex-no-shrink font-regular-12">
<FileSizePresenter value={object.$lookup?.file?.size} />
</div>
</div>
</div>
</div>
</div>
<style lang="scss">
.card-container {
position: relative;
border-radius: 0.5rem;
border: 1px solid var(--theme-divider-color);
background-color: var(--theme-kanban-card-bg-color);
&.selected {
outline: 2px solid var(--global-focus-BorderColor);
outline-offset: 2px;
background-color: var(--highlight-hover);
}
&:hover,
&.hovered {
.tools {
display: block;
}
}
}
.card {
border-radius: 0.5rem;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
}
.tools {
display: none;
}
.card-content {
display: flex;
justify-content: center;
align-items: center;
height: 10rem;
}
.header {
border-top: 1px solid var(--theme-divider-color);
padding: 0.25rem 0.5rem 0.5rem;
}
</style>

View File

@ -13,6 +13,85 @@
// limitations under the License.
-->
<script lang="ts">
import type { Class, Doc, DocumentQuery, FindOptions, Ref, WithLookup } from '@hcengineering/core'
import { type Resource } from '@hcengineering/drive'
import { ActionContext, createQuery, getClient } from '@hcengineering/presentation'
import { BuildModelKey, ViewOptions } from '@hcengineering/view'
import { ListSelectionProvider, SelectDirection, buildConfigLookup, focusStore } from '@hcengineering/view-resources'
import GridItem from './GridItem.svelte'
export let _class: Ref<Class<Resource>>
export let query: DocumentQuery<Resource>
export let config: Array<BuildModelKey | string>
export let options: FindOptions<Resource> | undefined = undefined
export let viewOptions: ViewOptions
const client = getClient()
const hierarchy = client.getHierarchy()
const q = createQuery()
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') {
let pos = objects.findIndex((p) => p._id === of?._id)
pos += offset
if (pos < 0) {
pos = 0
}
if (pos >= objects.length) {
pos = objects.length - 1
}
listProvider.updateFocus(objects[pos])
}
})
let objects: WithLookup<Resource>[] = []
$: orderBy = viewOptions.orderBy
$: lookup = buildConfigLookup(hierarchy, _class, config, options?.lookup)
$: q.query(
_class,
query,
(result) => {
objects = result
},
{
...options,
sort: {
...(options != null ? options.sort : {}),
...(orderBy != null ? { [orderBy[0]]: orderBy[1] } : {})
},
lookup
}
)
$: listProvider.update(objects)
$: selection = listProvider.current($focusStore)
</script>
<div>Here will be grid view</div>
<ActionContext context={{ mode: 'browser' }} />
<div class="grid-container">
{#each objects as object, i}
{@const selected = selection === i}
<div class="grid-cell">
<GridItem
{object}
{selected}
on:obj-focus={(evt) => {
listProvider.updateFocus(evt.detail)
}}
/>
</div>
{/each}
</div>
<style lang="scss">
.grid-container {
display: grid;
margin: 0.5rem 1rem;
padding-bottom: 0.5rem;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 1rem;
}
</style>

View File

@ -0,0 +1,82 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { type WithLookup } from '@hcengineering/core'
import drive, { type Resource } from '@hcengineering/drive'
import { getBlobRef, getClient, sizeToWidth } from '@hcengineering/presentation'
import { Icon, IconSize } from '@hcengineering/ui'
import IconFolderThumbnail from './icons/FolderThumbnail.svelte'
export let object: WithLookup<Resource>
export let size: IconSize = 'x-large'
const client = getClient()
const hierarchy = client.getHierarchy()
function extensionIconLabel (name: string): string {
const parts = name.split('.')
const ext = parts[parts.length - 1]
return ext.substring(0, 4).toUpperCase()
}
let isImage = false
let isError = false
$: previewBlob = object.$lookup?.preview ?? object.$lookup?.file
$: previewRef = object.$lookup?.preview !== undefined ? object.preview : object.file
$: isImage = previewBlob?.contentType?.startsWith('image/') ?? false
$: isFolder = hierarchy.isDerived(object._class, drive.class.Folder)
</script>
{#if isFolder}
<Icon icon={IconFolderThumbnail} size={'full'} fill={'var(--theme-trans-color)'} />
{:else if previewBlob != null && previewRef != null && isImage && !isError}
{#await getBlobRef(previewBlob, previewRef, object.name, sizeToWidth(size)) then blobSrc}
<img
class="img-fit"
src={blobSrc.src}
srcset={blobSrc.srcset}
alt={object.name}
on:error={() => {
isError = true
}}
/>
{/await}
{:else}
<div class="flex-center ext-icon">
{extensionIconLabel(object.name)}
</div>
{/if}
<style lang="scss">
.ext-icon {
flex-shrink: 0;
width: 2rem;
height: 2rem;
font-weight: 500;
font-size: 0.625rem;
color: var(--primary-button-color);
background-color: var(--primary-button-default);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
}
.img-fit {
object-fit: cover;
height: 100%;
width: 100%;
}
</style>

View File

@ -0,0 +1,25 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
export let fill: string = 'currentColor'
export let size: 'small' | 'medium' | 'large' = 'small'
</script>
<svg class="svg-{size}" {fill} width="32" height="32" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
d="M426.666667 170.666667H170.666667c-47.36 0-85.333333 37.973333-85.333334 85.333333v512a85.333333 85.333333 0 0 0 85.333334 85.333333h682.666666a85.333333 85.333333 0 0 0 85.333334-85.333333V341.333333a85.333333 85.333333 0 0 0-85.333334-85.333333h-341.333333l-85.333333-85.333333z"
/>
</svg>

View File

@ -39,6 +39,7 @@ export const drivePlugin = plugin(driveId, {
},
icon: {
Drive: '' as Asset,
Grid: '' as Asset,
File: '' as Asset,
Folder: '' as Asset,
FolderOpen: '' as Asset,

View File

@ -25,6 +25,7 @@ export interface Drive extends TypedSpace {}
export interface Resource extends Doc<Drive> {
name: string
file?: Ref<Blob>
preview?: Ref<Blob>
parent: Ref<Resource>
path: Ref<Resource>[]