Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
<!--Start Foot Content-->
<div class="app-foot-menu">
<span>{{ spaces.length }} {{ 'spaces' | translate:locale.language }}</span>
<div class="ms-auto"><span l10nTranslate>Total</span>&nbsp;{{ storageUsage | toBytes }}</div>
<div class="ms-auto">{{ storageUsage | toBytes }}</div>
</div>
<!--End Foot Content-->
<!--Start Context Menu -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
<!--Start Foot Content-->
<div class="app-foot-menu">
<div>{{ users.length }} {{ (guestsView ? 'guests' : 'users') | translate:locale.language }}</div>
<div class="ms-auto"><span l10nTranslate>Total</span>&nbsp;{{ storageUsage | toBytes }}</div>
<div class="ms-auto">{{ storageUsage | toBytes }}</div>
</div>
<!--End Foot Content-->
<!--Start Context Menu -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@
@for (f of files; track f.id) {
<div class="sidebar-item-container">
<div class="sidebar-item-content" [tooltip]="f.path">
<img [src]="f.mimeUrl" alt="" class="me-1" height="28" width="auto" (error)="f.fallBackMimeUrl()">
<img [src]="f.mimeUrl" alt="" height="28" width="28" (error)="f.fallBackMimeUrl()">
<div class="text-truncate fs-sm">{{ f.name }}</div>
<button (click)="remove(f)" class="btn btn-xs btn-danger ms-auto">
<button (click)="remove(f)" class="btn btn-xs btn-default ms-auto">
<fa-icon [icon]="icons.faTimes"></fa-icon>
</button>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,51 @@
@if (files().length) {
@if (multiple()) {
<div class="fs-sm">
<div class="d-flex justify-content-center mt-3">
<button (click)="addToClipboard()" class="btn btn-sm btn-primary me-1" type="button" l10nTranslate>
<fa-icon [icon]="icons.faClipboardCheck"></fa-icon>
Send to Clipboard
</button>
@if (files().length > 1) {
@let stats = selectionStats();
@let totalSize = selectionSize | async;
<div class="p-2 fs-sm">
<div class="app-small-card rounded d-flex justify-content-center align-items-center text-nowrap py-2 mb-3">
<span><span class="text-bold">{{ stats.directories }}</span> {{ (stats.directories === 1 ? 'directory' : 'directories') | translate:locale.language }}</span>
<span class="mx-1 lh-1">•</span>
<span><span class="text-bold">{{ stats.files }}</span> {{ (stats.files === 1 ? 'file' : 'files') | translate:locale.language }}</span>
@if (totalSize !== null) {
<span class="mx-1 lh-1">•</span>
<span>
@if (totalSize.pendingDirectories || totalSize.hasError) {
≥&nbsp;
}
{{ totalSize.size | toBytes:0:true }}
</span>
}
</div>
<div class="d-flex">
<select [(ngModel)]="selectedAction" class="form-select form-select-sm">
<option value="clipboard" l10nTranslate>Clipboard</option>
<option value="copyMove" l10nTranslate>Copy · Move</option>
<option value="download" l10nTranslate>Download</option>
<option value="compress" l10nTranslate>Compress</option>
</select>
<button (click)="doAction()" class="btn btn-sm btn-primary ms-1" type="button" l10nTranslate>Confirm</button>
</div>
</div>
<hr class="mx-0 mt-3 mb-1">
}
<div appAutoResize class="fs-sm" [resizeOffset]=resizeOffset()>
@if (multiple()) {
<div class="flex-column p-2 w-100">
@for (f of files(); track f.id) {
<div class="d-inline-flex w-100 align-content-center align-items-center mx-1">
<img [src]="f.mimeUrl" alt="" class="me-1" height="28" width="auto" (error)="f.fallBackMimeUrl()">
<span class="text-truncate">{{ f.name }}</span>
<hr class="mx-0 my-1">
<div appAutoResize class="sidebar-item" [resizeOffset]="200">
@for (f of files(); track f.id) {
<div class="sidebar-item-container">
<div class="sidebar-item-content">
<img [src]="f.mimeUrl" alt="" height="28" width="28" (error)="f.fallBackMimeUrl()">
<div class="text-truncate fs-sm">{{ f.name }}</div>
<button (click)="removeFromSelection(f)"
class="btn btn-xs btn-default ms-auto"
type="button"
[attr.aria-label]="'Remove' | translate:locale.language">
<fa-icon [icon]="icons.faTimes"></fa-icon>
</button>
</div>
}
</div>
} @else {
</div>
}
</div>
} @else {
<div appAutoResize class="fs-sm">
@for (f of files(); track f.id) {
<div class="card px-1 pt-1">
<div class="card-body align-self-center p-0 my-1">
Expand All @@ -40,19 +65,18 @@
</div>
</div>
<hr class="my-1" />
<div class="fs-sm p-2">
<div class="p-2">
<div class="d-flex mb-1">
<div class="d-flex col-5 col-sm-5 text-bold" l10nTranslate>Name</div>
<div class="word-break-all">{{ f.name }}</div>
</div>
<div class="d-flex mb-1">
<div class="d-flex col-5 col-sm-5 text-bold" l10nTranslate>Size</div>
@let size = (getSizeLazy(f) | async);
@if (size === null) {
<fa-icon [icon]="icons.faSpinner" animation="spin-pulse"></fa-icon>
} @else {
{{ size }}
}
@if (f.isDir && (getSizeLazy(f) | async) === null) {
<fa-icon [icon]="icons.faSpinner" animation="spin-pulse"></fa-icon>
} @else {
{{ f.hSize }}
}
</div>
<div class="d-flex mb-1">
<div class="d-flex col-5 col-sm-5 text-bold" l10nTranslate>Created</div>
Expand Down Expand Up @@ -137,7 +161,7 @@
}
@if (f.lock || f.spaces.length || f.shares.length || f.links.length || f.syncs.length) {
<hr class="mt-1 mb-2" />
<div class="d-flex flex-wrap align-items-center p-2 fs-sm">
<div class="d-flex flex-wrap align-items-center p-2">
@if (f.lock) {
<div (click)="openLockDialog(f)"
class="badge white-space-normal bg-maroon cursor-pointer mb-2">
Expand Down Expand Up @@ -172,6 +196,6 @@
</div>
}
}
}
</div>
</div>
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, computed, inject, input, InputSignal, Signal } from '@angular/core'
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'
import { toObservable } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import { FaIconComponent } from '@fortawesome/angular-fontawesome'
import { faArrowsAlt, faClipboardCheck, faLock, faSpinner, faUnlock } from '@fortawesome/free-solid-svg-icons'
import { faLock, faSpinner, faTimes, faUnlock } from '@fortawesome/free-solid-svg-icons'
import { TAR_EXTENSION } from '@sync-in-server/backend/src/applications/files/constants/compress'
import type { CompressFileDto } from '@sync-in-server/backend/src/applications/files/dto/file-operations.dto'
import { L10N_LOCALE, L10nLocale, L10nTranslateDirective, L10nTranslatePipe } from 'angular-l10n'
import { catchError, of, shareReplay } from 'rxjs'
import { map } from 'rxjs/operators'
import { from, of, shareReplay } from 'rxjs'
import { catchError, concatMap, scan, startWith, switchMap, tap } from 'rxjs/operators'
import { BadgePermissionsComponent } from '../../../../common/components/badge-permissions.component'
import { AutoResizeDirective } from '../../../../common/directives/auto-resize.directive'
import { TimeDateFormatPipe } from '../../../../common/pipes/time-date-format.pipe'
import { convertBytesToText } from '../../../../common/utils/functions'
import { defaultCardImageSize, defaultResizeOffset } from '../../../../layout/layout.constants'
import { ToBytesPipe } from '../../../../common/pipes/to-bytes.pipe'
import { defaultCardImageSize } from '../../../../layout/layout.constants'
import { TAB_MENU } from '../../../../layout/layout.interfaces'
import { LayoutService } from '../../../../layout/layout.service'
import { SPACES_ICON, SPACES_PATH } from '../../../spaces/spaces.constants'
import { SYNC_ICON } from '../../../sync/sync.constants'
import { UserAvatarComponent } from '../../../users/components/utils/user-avatar.component'
import { USER_PATH } from '../../../users/user.constants'
import type { SelectionAction, SelectionSize } from '../../interfaces/file-selection.interface'
import { FileModel } from '../../models/file.model'
import { FilesService } from '../../services/files.service'
import { FilesCompressionDialogComponent } from '../dialogs/files-compression-dialog.component'
import { FileLockFormatPipe } from '../utils/file-lock.utils'
import { FilesViewerMediaComponent } from '../viewers/files-viewer-media.component'

Expand All @@ -36,30 +42,57 @@ import { FilesViewerMediaComponent } from '../viewers/files-viewer-media.compone
UserAvatarComponent,
BadgePermissionsComponent,
AsyncPipe,
FileLockFormatPipe
FileLockFormatPipe,
FormsModule,
ToBytesPipe
],
styles: ['.card {width: 100%; background: transparent; border: none}']
})
export class FilesSelectionComponent {
files: InputSignal<FileModel[]> = input.required<FileModel[]>()
files = input.required<FileModel[]>()
private readonly router = inject(Router)
private readonly layout = inject(LayoutService)
private readonly filesService = inject(FilesService)
protected readonly locale = inject<L10nLocale>(L10N_LOCALE)
protected multiple: Signal<boolean> = computed(() => this.files().length > 1)
protected resizeOffset: Signal<number> = computed(() => defaultResizeOffset + (this.multiple() ? 40 : 0))
protected readonly cardImageSize = defaultCardImageSize
protected selectedAction: SelectionAction = 'clipboard'
protected readonly selectionStats = computed(() => {
const files = this.files()
const directories = files.filter((file) => file.isDir).length
return { directories, files: files.length - directories }
})
protected readonly selectionSize = toObservable(this.files).pipe(
switchMap((files) => {
const directories = files.filter((file) => file.isDir)
const initial: SelectionSize = {
size: files.reduce((size, file) => size + (file.isDir ? 0 : file.size), 0),
pendingDirectories: directories.length,
hasError: false
}
return from(directories).pipe(
concatMap((directory) => this.getSizeLazy(directory)),
scan(
(total: SelectionSize, size: number | undefined): SelectionSize => ({
size: total.size + (size ?? 0),
pendingDirectories: total.pendingDirectories - 1,
hasError: total.hasError || size === undefined
}),
initial
),
startWith(initial)
)
})
)
protected readonly icons = {
SPACES: SPACES_ICON.SPACES,
SHARES: SPACES_ICON.SHARES,
LINKS: SPACES_ICON.LINKS,
SYNC: SYNC_ICON.SYNC,
faLock,
faUnlock,
faClipboardCheck,
faArrowsAlt,
faSpinner
faSpinner,
faTimes
}
private readonly router = inject(Router)
private readonly layout = inject(LayoutService)
private readonly filesService = inject(FilesService)

goToShare(share: { type: number; name: string }) {
this.layout.toggleRSideBar(false)
Expand All @@ -80,6 +113,25 @@ export class FilesSelectionComponent {
this.layout.showRSideBarTab(TAB_MENU.CLIPBOARD, true)
}

removeFromSelection(file: FileModel) {
this.filesService.fileSelectionRemove.next(file)
}

doAction() {
if (this.selectedAction === 'clipboard') return this.addToClipboard()
if (this.selectedAction === 'copyMove') return this.filesService.openTreeCopyMove()
const archiveProps: CompressFileDto = {
name: this.files()[0].name,
compressInDirectory: this.selectedAction === 'compress',
compression: false,
files: this.files().map((file) => ({ name: file.name, rootAlias: file.root?.alias, path: file.path })),
extension: TAR_EXTENSION
}
this.layout.openDialog(FilesCompressionDialogComponent, null, {
initialState: { archiveProps } as FilesCompressionDialogComponent
})
}

goToSync(sync: { clientId: string; clientName: string; id: number }) {
this.layout.toggleRSideBar(false)
this.router
Expand All @@ -97,14 +149,10 @@ export class FilesSelectionComponent {
}

getSizeLazy(f: FileModel) {
if (!f.isDir) return of(f.hSize)
if (!f.hDirSize) {
f.hDirSize = this.filesService.getSize(f).pipe(
map((size) => convertBytesToText(size, 0, true)),
catchError(() => (f.hDirSize = of(f.hSize))),
shareReplay(1)
)
}
return f.hDirSize
return (f.dirSize ??= this.filesService.getSize(f).pipe(
tap((size) => f.updateSize(size)),
catchError(() => of(undefined)),
shareReplay(1)
))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,15 @@ export class FilesTreeComponent implements OnInit, OnDestroy {
}

ngOnInit() {
this.initRoot()
if (this.enableCopyMove) {
this.subscriptions.push(this.filesService.treeCopyMoveOn.subscribe(() => this.onCopyMove()))
this.subscriptions.push(
this.filesService.treeCopyMoveOn.subscribe(() => {
this.onCopyMove()
this.filesService.consumeTreeCopyMove()
})
)
}
this.initRoot()
setTimeout(() => this.focusLastNode(), 100)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type SelectionAction = 'clipboard' | 'copyMove' | 'download' | 'compress'

export interface SelectionSize {
size: number
pendingDirectories: number
hasError: boolean
}
4 changes: 2 additions & 2 deletions frontend/src/app/applications/files/models/file.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export class FileModel implements File {
mimeUrl: string
hSize: string
hTimeAgo: string
hDirSize: Observable<string>
dirSize: Observable<number | undefined>
nbBadges = 0
galleryBadges: ('lock' | 'shares' | 'spaces' | 'links' | 'syncs' | 'comments')[] = []

Expand Down Expand Up @@ -280,7 +280,7 @@ export class FileModel implements File {
}

private setHSize() {
this.hSize = this.isDir ? '●' : convertBytesToText(this.size, 0, true)
this.hSize = this.isDir && this.size === 0 ? '●' : convertBytesToText(this.size, 0, true)
}

private setShares(shares: { id: number; alias: string; name: string; type: number }[]) {
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/app/applications/files/services/files.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { API_SPACES_TREE } from '@sync-in-server/backend/src/applications/spaces
import { SPACE_OPERATION } from '@sync-in-server/backend/src/applications/spaces/constants/spaces'
import { forbiddenChars, isValidFileName } from '@sync-in-server/backend/src/common/shared'
import { BsModalRef } from 'ngx-bootstrap/modal'
import { catchError, EMPTY, filter, firstValueFrom, map, Observable, Subject, switchMap, timer } from 'rxjs'
import { BehaviorSubject, catchError, EMPTY, filter, firstValueFrom, map, Observable, Subject, switchMap, timer } from 'rxjs'
import { downloadWithAnchor } from '../../../common/utils/functions'
import { TAB_MENU } from '../../../layout/layout.interfaces'
import { LayoutService } from '../../../layout/layout.service'
Expand All @@ -62,7 +62,10 @@ type ViewerHookResult = { action: 'open'; shortMime: string } | { action: 'downl
export class FilesService {
// Tree section
public treeNodeSelected: TreeNode = null
public treeCopyMoveOn = new Subject<void>()
private readonly treeCopyMoveRequested = new BehaviorSubject(false)
public readonly treeCopyMoveOn = this.treeCopyMoveRequested.pipe(filter(Boolean))
// Selection section
public readonly fileSelectionRemove = new Subject<FileModel>()
// Clipboard section
public clipboardAction: 'copyPaste' | 'cutPaste' = 'copyPaste'
// Files
Expand All @@ -77,6 +80,15 @@ export class FilesService {
private readonly filesTasksService = inject(FilesTasksService)
private readonly userService = inject(UserService)

openTreeCopyMove() {
this.layout.showRSideBarTab(TAB_MENU.TREE, true)
this.treeCopyMoveRequested.next(true)
}

consumeTreeCopyMove() {
this.treeCopyMoveRequested.next(false)
}

getTreeNode(nodePath: string, showFiles = false): Promise<FileTree[]> {
return firstValueFrom(
this.http.get<FileTree[]>(`${API_SPACES_TREE}/${nodePath}`, { params: showFiles ? new HttpParams().set('showFiles', showFiles) : null })
Expand Down
Loading