From 928ce751ee6eeb03d6472e8c561f06b38db82643 Mon Sep 17 00:00:00 2001 From: flash Date: Sun, 14 Jun 2026 21:30:03 +0200 Subject: [PATCH 01/75] feat(web): add metadata sidebar panel New sidebar tab "Metadata" that displays custom metadata (user.oc.md.*) for the selected file or folder. Fetches data from the Graph API endpoint GET /drives/{driveID}/items/{itemID}/metadata. - MetadataPanel.vue: renders key-value pairs with formatted labels - useFileSideBars.ts: registers panel, visible for single item selection - Strips "oy." prefix and converts camelCase to Title Case for display Depends on: OpenCloud Graph API metadata endpoint --- .../SideBar/Metadata/MetadataPanel.vue | 96 +++++++++++++++++++ .../composables/extensions/useFileSideBars.ts | 18 ++++ 2 files changed, 114 insertions(+) create mode 100644 packages/web-app-files/src/components/SideBar/Metadata/MetadataPanel.vue diff --git a/packages/web-app-files/src/components/SideBar/Metadata/MetadataPanel.vue b/packages/web-app-files/src/components/SideBar/Metadata/MetadataPanel.vue new file mode 100644 index 0000000000..9143db1629 --- /dev/null +++ b/packages/web-app-files/src/components/SideBar/Metadata/MetadataPanel.vue @@ -0,0 +1,96 @@ + + + diff --git a/packages/web-app-files/src/composables/extensions/useFileSideBars.ts b/packages/web-app-files/src/composables/extensions/useFileSideBars.ts index 04e8c14469..0aaa527ad5 100644 --- a/packages/web-app-files/src/composables/extensions/useFileSideBars.ts +++ b/packages/web-app-files/src/composables/extensions/useFileSideBars.ts @@ -28,6 +28,7 @@ import { useGettext } from 'vue3-gettext' import { markRaw, unref } from 'vue' import { fileSideBarExtensionPoint } from '../../extensionPoints' import AudioMetaPanel from '../../components/SideBar/Audio/AudioMetaPanel.vue' +import MetadataPanel from '../../components/SideBar/Metadata/MetadataPanel.vue' import { isEmpty } from 'lodash-es' export const useSideBarPanels = (): SidebarPanelExtension[] => { @@ -177,6 +178,23 @@ export const useSideBarPanels = (): SidebarPanelExtension $gettext('Metadata'), + component: MetadataPanel, + isVisible: ({ items }) => { + if (items?.length !== 1) { + return false + } + return !isProjectSpaceResource(items[0]) + } + } + }, { id: 'com.github.opencloud-eu.web.files.sidebar-panel.actions', type: 'sidebarPanel', From 9b48ab92786d1c99f18966eaa97838342a2f3764 Mon Sep 17 00:00:00 2001 From: flash Date: Sun, 14 Jun 2026 21:55:58 +0200 Subject: [PATCH 02/75] feat(web): immutable UI - indicators + context menu actions 1. Resource model: add `immutable` boolean field 2. Indicators (useResourceIndicators.ts): - Frozen file: snowflake icon - Protected folder: shield-check icon 3. Context menu actions (useFileActionsImmutable.ts): - "Freeze file": POST /freeze with confirmation dialog (irreversible!) - "Protect folder": POST /protect - "Remove protection": DELETE /protect 4. Actions registered in useFileActions.ts for context menu Depends on: OpenCloud Graph API freeze/protect/unprotect endpoints (blocked by cs3org/cs3apis#275 - Gateway SetImmutable RPC) --- .../composables/extensions/useFileActions.ts | 31 +++++- .../web-client/src/helpers/resource/types.ts | 1 + .../src/composables/actions/files/index.ts | 1 + .../actions/files/useFileActionsImmutable.ts | 99 +++++++++++++++++++ .../resources/useResourceIndicators.ts | 22 +++++ 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts diff --git a/packages/web-app-files/src/composables/extensions/useFileActions.ts b/packages/web-app-files/src/composables/extensions/useFileActions.ts index be0a962f69..c1045619b5 100644 --- a/packages/web-app-files/src/composables/extensions/useFileActions.ts +++ b/packages/web-app-files/src/composables/extensions/useFileActions.ts @@ -14,6 +14,7 @@ import { useFileActionsDownloadArchive, useFileActionsFavorite, useFileActionsEnableSync, + useFileActionsImmutable, useFileActionsMove, useFileActionsPaste, useFileActionsOpenShortcut, @@ -45,6 +46,14 @@ export const useFileActions = (): ActionExtension[] => { const { actions: setSpaceImageActions } = useSpaceActionsSetImage() const { actions: showDetailsActions } = useFileActionsShowDetails() const { actions: toggleHideShareActions } = useFileActionsToggleHideShare() + const { actions: immutableActions } = useFileActionsImmutable() + + const singleItemActions = unref(immutableActions).filter( + (a) => !a.name.startsWith('protect-folder') && !a.name.startsWith('unprotect-folder') + ) + const batchableActions = unref(immutableActions).filter( + (a) => a.name === 'protect-folder' || a.name === 'unprotect-folder' + ) return [ { @@ -217,6 +226,26 @@ export const useFileActions = (): ActionExtension[] => { ...unref(toggleHideShareActions)[0], category: 'tertiary' } - } + }, + // Immutable: single-item quick actions (freeze, frozen, shielded indicators) + ...singleItemActions.map((action) => ({ + id: `com.github.opencloud-eu.web.files.quick-action.${action.name}`, + extensionPointIds: [quickActionsExtensionPoint.id], + type: 'action' as const, + action + })), + // Immutable: protect/unprotect as quick action + batch action + ...batchableActions.map((action) => ({ + id: `com.github.opencloud-eu.web.files.quick-action.${action.name}`, + extensionPointIds: [quickActionsExtensionPoint.id], + type: 'action' as const, + action + })), + ...batchableActions.map((action) => ({ + id: `com.github.opencloud-eu.web.files.batch-action.${action.name}`, + extensionPointIds: [batchActionsExtensionPoint.id], + type: 'action' as const, + action + })) ] } diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index ab16338726..2b783fd614 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -53,6 +53,7 @@ export interface Resource { locked?: boolean lockOwner?: string lockTime?: string + immutable?: boolean mimeType?: string isFolder?: boolean mdate?: string diff --git a/packages/web-pkg/src/composables/actions/files/index.ts b/packages/web-pkg/src/composables/actions/files/index.ts index 5be66b8bef..fcb1427091 100644 --- a/packages/web-pkg/src/composables/actions/files/index.ts +++ b/packages/web-pkg/src/composables/actions/files/index.ts @@ -8,3 +8,4 @@ export * from './useFileActionsRestore' export * from './useFileActionsSaveAs' export * from './useFileActionsUndoDelete' export * from './useFileActionFallbackToDownload' +export * from './useFileActionsImmutable' diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts new file mode 100644 index 0000000000..e6ac35c7f5 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -0,0 +1,99 @@ +import { useGettext } from 'vue3-gettext' +import { FileAction } from '../types' +import { computed } from 'vue' +import { useMessages, useModals } from '../../piniaStores' +import { useClientService } from '../../clientService' + +export const useFileActionsImmutable = () => { + const { $gettext } = useGettext() + const clientService = useClientService() + const { showMessage, showErrorMessage } = useMessages() + const { dispatchModal } = useModals() + + const callImmutableEndpoint = async ( + driveId: string, + itemId: string, + action: 'freeze' | 'protect', + method: 'POST' | 'DELETE' = 'POST' + ) => { + const httpClient = clientService.httpAuthenticated + const endpoint = action === 'freeze' ? 'freeze' : 'protect' + try { + const response = await httpClient.request({ + method, + url: `/graph/v1beta1/drives/${driveId}/items/${itemId}/${endpoint}` + }) + if (response.status === 204) { + const msg = + action === 'freeze' + ? $gettext('File has been frozen.') + : method === 'POST' + ? $gettext('Folder has been protected.') + : $gettext('Folder protection has been removed.') + showMessage({ title: msg }) + } + } catch (e) { + showErrorMessage({ + title: $gettext('Operation failed'), + errors: [e as Error] + }) + } + } + + const actions = computed((): FileAction[] => [ + { + name: 'freeze-file', + icon: 'snowflake', + label: () => $gettext('Freeze file'), + handler: ({ space, resources }) => { + const resource = resources[0] + dispatchModal({ + title: $gettext('Freeze file permanently?'), + confirmText: $gettext('Freeze'), + message: $gettext( + 'Freezing a file is irreversible. The file content cannot be changed or deleted afterwards. Are you sure?' + ), + onConfirm: () => { + callImmutableEndpoint(space.id, resource.id, 'freeze') + } + }) + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && !r.immutable + }, + class: 'oc-files-actions-freeze-trigger' + }, + { + name: 'protect-folder', + icon: 'shield-check', + label: () => $gettext('Protect folder'), + handler: ({ space, resources }) => { + callImmutableEndpoint(space.id, resources[0].id, 'protect') + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'folder' && !r.immutable + }, + class: 'oc-files-actions-protect-trigger' + }, + { + name: 'unprotect-folder', + icon: 'shield', + label: () => $gettext('Remove protection'), + handler: ({ space, resources }) => { + callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE') + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'folder' && r.immutable === true + }, + class: 'oc-files-actions-unprotect-trigger' + } + ]) + + return { actions } +} diff --git a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts index dfab943e8c..ce4c9037b4 100644 --- a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts +++ b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts @@ -167,6 +167,24 @@ export const useResourceIndicators = () => { } } + const getImmutableIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => { + const isFolder = resource.type === 'folder' + return { + id: `resource-immutable-${resource.getDomSelector()}`, + kind: 'icon', + accessibleDescription: isFolder + ? $gettext('Folder is protected') + : $gettext('File is frozen'), + label: isFolder + ? $gettext('This folder is protected') + : $gettext('This file is frozen'), + icon: isFolder ? 'shield-check' : 'snowflake', + category: 'system', + type: 'resource-immutable', + fillType: 'line' + } + } + const getProcessingIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => { return { id: `resource-processing-${resource.getDomSelector()}`, @@ -217,6 +235,10 @@ export const useResourceIndicators = () => { indicators.push(getLockedIndicator({ resource })) } + if (resource.immutable) { + indicators.push(getImmutableIndicator({ resource })) + } + if (resource.processing) { indicators.push(getProcessingIndicator({ resource })) } From fc6c7a766201688f0104728f0a3506729219447a Mon Sep 17 00:00:00 2001 From: flash Date: Sun, 14 Jun 2026 23:16:50 +0200 Subject: [PATCH 03/75] feat(web): complete immutable state integration via WebDAV PROPFIND MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read oc:immutable property from PROPFIND responses (no extra request). Reva returns "frozen" (file) or "protected" (folder/inherited) state. Data flow: Reva xattr → GetImmutableState() → oc:immutable property → WebDAV PROPFIND → DavProperty.Immutable → resource.immutableState ("frozen"|"protected"|undefined) → Quick Action icons + Indicator badges Quick Actions (hover buttons in file list): File | normal | leaf icon | click → freeze (confirmation dialog) File | frozen | snowflake icon | disabled (irreversible) File | protected | shield-fill | disabled (parent protected) Folder | normal | shield-line | click → protect Folder | protected | shield-fill | click → unprotect Indicators (badges next to filename): frozen → snowflake icon protected → shield-fill icon Changes: - DavProperty.Immutable added + included in default PROPFIND request - Resource type: immutableState?: 'frozen' | 'protected' - Resource builder: maps oc:immutable to immutableState - useFileActionsImmutable: 5 actions for all state combinations - useResourceIndicators: indicator based on immutableState - useFileActions: registered as quickActionsExtensionPoint (not context!) --- .../src/helpers/resource/functions.ts | 1 + .../web-client/src/helpers/resource/types.ts | 2 +- .../web-client/src/webdav/constants/dav.ts | 2 + .../actions/files/useFileActionsImmutable.ts | 49 ++++++++++++++++--- .../resources/useResourceIndicators.ts | 18 +++---- 5 files changed, 56 insertions(+), 16 deletions(-) diff --git a/packages/web-client/src/helpers/resource/functions.ts b/packages/web-client/src/helpers/resource/functions.ts index 2afa82fd0c..0cda11c8cb 100644 --- a/packages/web-client/src/helpers/resource/functions.ts +++ b/packages/web-client/src/helpers/resource/functions.ts @@ -147,6 +147,7 @@ export function buildResource( locked: !!activeLock, lockOwner, lockTime, + immutableState: resource.props[DavProperty.Immutable] || undefined, processing: resource.processing || false, mdate: resource.props[DavProperty.LastModifiedDate], size: isFolder diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index 2b783fd614..c300b14970 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -53,7 +53,7 @@ export interface Resource { locked?: boolean lockOwner?: string lockTime?: string - immutable?: boolean + immutableState?: 'frozen' | 'protected' mimeType?: string isFolder?: boolean mdate?: string diff --git a/packages/web-client/src/webdav/constants/dav.ts b/packages/web-client/src/webdav/constants/dav.ts index 70afd4c1fd..996490d9a4 100644 --- a/packages/web-client/src/webdav/constants/dav.ts +++ b/packages/web-client/src/webdav/constants/dav.ts @@ -77,6 +77,7 @@ const DavPropertyMapping = { value: 'photo', type: null as Photo }, + Immutable: defString('immutable' as const), ETag: defString('getetag' as const), MimeType: defString('getcontenttype' as const), ResourceType: defStringArray('resourcetype' as const), @@ -145,6 +146,7 @@ export abstract class DavProperties { DavProperty.MimeType, DavProperty.ResourceType, DavProperty.Tags, + DavProperty.Immutable, DavProperty.Audio, DavProperty.Location, DavProperty.Image, diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts index e6ac35c7f5..59eaed2464 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -41,9 +41,10 @@ export const useFileActionsImmutable = () => { } const actions = computed((): FileAction[] => [ + // File: not frozen/protected → leaf icon → click to freeze (with confirmation) { name: 'freeze-file', - icon: 'snowflake', + icon: 'leaf', label: () => $gettext('Freeze file'), handler: ({ space, resources }) => { const resource = resources[0] @@ -61,13 +62,48 @@ export const useFileActionsImmutable = () => { isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'file' && !r.immutable + return r.type === 'file' && !r.immutableState }, class: 'oc-files-actions-freeze-trigger' }, + // File: frozen → snowflake icon → no action (irreversible) + { + name: 'frozen-file', + icon: 'snowflake', + label: () => $gettext('File is frozen'), + handler: () => { + // no-op: frozen files cannot be unfrozen + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && r.immutableState === 'frozen' + }, + isDisabled: () => true, + disabledTooltip: () => $gettext('This file is permanently frozen and cannot be modified.'), + class: 'oc-files-actions-frozen-indicator' + }, + // File: protected (parent is protected) → snowflake outline → no action + { + name: 'protected-file', + icon: 'shield-fill', + label: () => $gettext('File is in a protected folder'), + handler: () => { + // no-op: inherited protection + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && r.immutableState === 'protected' + }, + isDisabled: () => true, + disabledTooltip: () => $gettext('This file is in a protected folder and cannot be modified.'), + class: 'oc-files-actions-protected-file-indicator' + }, + // Folder: not protected → empty shield → click to protect { name: 'protect-folder', - icon: 'shield-check', + icon: 'shield-line', label: () => $gettext('Protect folder'), handler: ({ space, resources }) => { callImmutableEndpoint(space.id, resources[0].id, 'protect') @@ -75,13 +111,14 @@ export const useFileActionsImmutable = () => { isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'folder' && !r.immutable + return r.type === 'folder' && !r.immutableState }, class: 'oc-files-actions-protect-trigger' }, + // Folder: protected (self) → filled shield → click to unprotect { name: 'unprotect-folder', - icon: 'shield', + icon: 'shield-fill', label: () => $gettext('Remove protection'), handler: ({ space, resources }) => { callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE') @@ -89,7 +126,7 @@ export const useFileActionsImmutable = () => { isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'folder' && r.immutable === true + return r.type === 'folder' && r.immutableState === 'protected' }, class: 'oc-files-actions-unprotect-trigger' } diff --git a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts index ce4c9037b4..5210fac8c3 100644 --- a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts +++ b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts @@ -168,17 +168,17 @@ export const useResourceIndicators = () => { } const getImmutableIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => { - const isFolder = resource.type === 'folder' + const isFrozen = resource.immutableState === 'frozen' return { id: `resource-immutable-${resource.getDomSelector()}`, kind: 'icon', - accessibleDescription: isFolder - ? $gettext('Folder is protected') - : $gettext('File is frozen'), - label: isFolder - ? $gettext('This folder is protected') - : $gettext('This file is frozen'), - icon: isFolder ? 'shield-check' : 'snowflake', + accessibleDescription: isFrozen + ? $gettext('File is frozen') + : $gettext('Item is protected'), + label: isFrozen + ? $gettext('This file is frozen') + : $gettext('This item is protected'), + icon: isFrozen ? 'snowflake' : 'shield-fill', category: 'system', type: 'resource-immutable', fillType: 'line' @@ -235,7 +235,7 @@ export const useResourceIndicators = () => { indicators.push(getLockedIndicator({ resource })) } - if (resource.immutable) { + if (resource.immutableState) { indicators.push(getImmutableIndicator({ resource })) } From a853bc61e6f33bacef97fcbd7169c472d66adbd3 Mon Sep 17 00:00:00 2001 From: flash Date: Mon, 15 Jun 2026 22:46:54 +0200 Subject: [PATCH 04/75] feat: shielded state + icon fixes - immutableState: 'frozen' | 'protected' | 'shielded' - Quick Actions: shield for protect/unprotect, leaf for freeze - Indicators: shield-fill (protected), shield-line (shielded), snowflake (frozen) - Shielded folders can still be protected/unprotected - console.error if folder has frozen state (bug detection) --- .../web-client/src/helpers/resource/types.ts | 2 +- .../actions/files/useFileActionsImmutable.ts | 49 ++++++++++--------- .../resources/useResourceIndicators.ts | 20 ++++++-- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/web-client/src/helpers/resource/types.ts b/packages/web-client/src/helpers/resource/types.ts index c300b14970..da6681d6ec 100644 --- a/packages/web-client/src/helpers/resource/types.ts +++ b/packages/web-client/src/helpers/resource/types.ts @@ -53,7 +53,7 @@ export interface Resource { locked?: boolean lockOwner?: string lockTime?: string - immutableState?: 'frozen' | 'protected' + immutableState?: 'frozen' | 'protected' | 'shielded' mimeType?: string isFolder?: boolean mdate?: string diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts index 59eaed2464..ea65cd7ff6 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -1,7 +1,7 @@ import { useGettext } from 'vue3-gettext' import { FileAction } from '../types' import { computed } from 'vue' -import { useMessages, useModals } from '../../piniaStores' +import { useMessages, useModals, useResourcesStore } from '../../piniaStores' import { useClientService } from '../../clientService' export const useFileActionsImmutable = () => { @@ -9,12 +9,14 @@ export const useFileActionsImmutable = () => { const clientService = useClientService() const { showMessage, showErrorMessage } = useMessages() const { dispatchModal } = useModals() + const resourcesStore = useResourcesStore() const callImmutableEndpoint = async ( driveId: string, itemId: string, action: 'freeze' | 'protect', - method: 'POST' | 'DELETE' = 'POST' + method: 'POST' | 'DELETE' = 'POST', + newState: 'frozen' | 'protected' | undefined = undefined ) => { const httpClient = clientService.httpAuthenticated const endpoint = action === 'freeze' ? 'freeze' : 'protect' @@ -24,6 +26,11 @@ export const useFileActionsImmutable = () => { url: `/graph/v1beta1/drives/${driveId}/items/${itemId}/${endpoint}` }) if (response.status === 204) { + resourcesStore.updateResourceField({ + id: itemId, + field: 'immutableState', + value: newState + }) const msg = action === 'freeze' ? $gettext('File has been frozen.') @@ -41,7 +48,7 @@ export const useFileActionsImmutable = () => { } const actions = computed((): FileAction[] => [ - // File: not frozen/protected → leaf icon → click to freeze (with confirmation) + // File: normal → leaf → freeze (with confirmation) { name: 'freeze-file', icon: 'leaf', @@ -55,7 +62,7 @@ export const useFileActionsImmutable = () => { 'Freezing a file is irreversible. The file content cannot be changed or deleted afterwards. Are you sure?' ), onConfirm: () => { - callImmutableEndpoint(space.id, resource.id, 'freeze') + callImmutableEndpoint(space.id, resource.id, 'freeze', 'POST', 'frozen') } }) }, @@ -66,14 +73,12 @@ export const useFileActionsImmutable = () => { }, class: 'oc-files-actions-freeze-trigger' }, - // File: frozen → snowflake icon → no action (irreversible) + // File: frozen → snowflake (disabled) { name: 'frozen-file', icon: 'snowflake', label: () => $gettext('File is frozen'), - handler: () => { - // no-op: frozen files cannot be unfrozen - }, + handler: () => {}, isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] @@ -83,45 +88,43 @@ export const useFileActionsImmutable = () => { disabledTooltip: () => $gettext('This file is permanently frozen and cannot be modified.'), class: 'oc-files-actions-frozen-indicator' }, - // File: protected (parent is protected) → snowflake outline → no action + // File: shielded (inherited from parent) → shield (disabled) { - name: 'protected-file', - icon: 'shield-fill', + name: 'shielded-file', + icon: 'shield', label: () => $gettext('File is in a protected folder'), - handler: () => { - // no-op: inherited protection - }, + handler: () => {}, isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'file' && r.immutableState === 'protected' + return r.type === 'file' && r.immutableState === 'shielded' }, isDisabled: () => true, disabledTooltip: () => $gettext('This file is in a protected folder and cannot be modified.'), - class: 'oc-files-actions-protected-file-indicator' + class: 'oc-files-actions-shielded-file-indicator' }, - // Folder: not protected → empty shield → click to protect + // Folder: normal → shield → protect { name: 'protect-folder', - icon: 'shield-line', + icon: 'shield', label: () => $gettext('Protect folder'), handler: ({ space, resources }) => { - callImmutableEndpoint(space.id, resources[0].id, 'protect') + callImmutableEndpoint(space.id, resources[0].id, 'protect', 'POST', 'protected') }, isVisible: ({ resources }) => { if (resources.length !== 1) return false const r = resources[0] - return r.type === 'folder' && !r.immutableState + return r.type === 'folder' && (!r.immutableState || r.immutableState === 'shielded') }, class: 'oc-files-actions-protect-trigger' }, - // Folder: protected (self) → filled shield → click to unprotect + // Folder: protected (self) → shield-fill → unprotect { name: 'unprotect-folder', - icon: 'shield-fill', + icon: 'shield', label: () => $gettext('Remove protection'), handler: ({ space, resources }) => { - callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE') + callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE', undefined) }, isVisible: ({ resources }) => { if (resources.length !== 1) return false diff --git a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts index 5210fac8c3..362e68cae9 100644 --- a/packages/web-pkg/src/composables/resources/useResourceIndicators.ts +++ b/packages/web-pkg/src/composables/resources/useResourceIndicators.ts @@ -168,20 +168,30 @@ export const useResourceIndicators = () => { } const getImmutableIndicator = ({ resource }: { resource: Resource }): ResourceIndicator => { - const isFrozen = resource.immutableState === 'frozen' + const state = resource.immutableState + if (state === 'frozen' && resource.type === 'folder') { + console.error(`BUG: folder "${resource.name}" has immutableState "frozen" — folders can only be "protected" or "shielded"`) + } + const isFrozen = state === 'frozen' + const isProtected = state === 'protected' + // frozen = snowflake, protected (self) = shield-fill, shielded (inherited) = shield-line return { id: `resource-immutable-${resource.getDomSelector()}`, kind: 'icon', accessibleDescription: isFrozen ? $gettext('File is frozen') - : $gettext('Item is protected'), + : isProtected + ? $gettext('Item is protected') + : $gettext('Item is in a protected folder'), label: isFrozen ? $gettext('This file is frozen') - : $gettext('This item is protected'), - icon: isFrozen ? 'snowflake' : 'shield-fill', + : isProtected + ? $gettext('This item is protected') + : $gettext('This item is in a protected folder'), + icon: isFrozen ? 'snowflake' : 'shield', category: 'system', type: 'resource-immutable', - fillType: 'line' + fillType: isFrozen ? 'line' : isProtected ? 'fill' : 'line' } } From 516de709c551e78d4b51fbf60e6ad220c2a6a173 Mon Sep 17 00:00:00 2001 From: flash Date: Tue, 16 Jun 2026 00:30:36 +0200 Subject: [PATCH 05/75] feat: parent-lookup for unprotect + batch protect/unprotect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - After unprotect: check parent state → set 'shielded' if parent still protected - Batch protect: select multiple folders → protect all - Batch unprotect: select multiple protected folders → unprotect all - No batch freeze (irreversible, single-item only with confirmation) --- .../actions/files/useFileActionsImmutable.ts | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts index ea65cd7ff6..40516afb33 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -11,9 +11,24 @@ export const useFileActionsImmutable = () => { const { dispatchModal } = useModals() const resourcesStore = useResourcesStore() + const resolveNewState = ( + resource: { id: string; parentFolderId?: string }, + explicitState: 'frozen' | 'protected' | undefined + ): 'frozen' | 'protected' | 'shielded' | undefined => { + if (explicitState) return explicitState + // After unprotect: check if parent is still protected → shielded + const parent = resourcesStore.resources.find( + (r) => r.id === resource.parentFolderId + ) + if (parent?.immutableState === 'protected' || parent?.immutableState === 'shielded') { + return 'shielded' + } + return undefined + } + const callImmutableEndpoint = async ( driveId: string, - itemId: string, + resource: { id: string; parentFolderId?: string }, action: 'freeze' | 'protect', method: 'POST' | 'DELETE' = 'POST', newState: 'frozen' | 'protected' | undefined = undefined @@ -23,13 +38,13 @@ export const useFileActionsImmutable = () => { try { const response = await httpClient.request({ method, - url: `/graph/v1beta1/drives/${driveId}/items/${itemId}/${endpoint}` + url: `/graph/v1beta1/drives/${driveId}/items/${resource.id}/${endpoint}` }) if (response.status === 204) { resourcesStore.updateResourceField({ - id: itemId, + id: resource.id, field: 'immutableState', - value: newState + value: resolveNewState(resource, newState) }) const msg = action === 'freeze' @@ -62,7 +77,7 @@ export const useFileActionsImmutable = () => { 'Freezing a file is irreversible. The file content cannot be changed or deleted afterwards. Are you sure?' ), onConfirm: () => { - callImmutableEndpoint(space.id, resource.id, 'freeze', 'POST', 'frozen') + callImmutableEndpoint(space.id, resource, 'freeze', 'POST', 'frozen') } }) }, @@ -103,33 +118,45 @@ export const useFileActionsImmutable = () => { disabledTooltip: () => $gettext('This file is in a protected folder and cannot be modified.'), class: 'oc-files-actions-shielded-file-indicator' }, - // Folder: normal → shield → protect + // Folder(s): normal/shielded → protect (single + batch) { name: 'protect-folder', icon: 'shield', - label: () => $gettext('Protect folder'), + label: ({ resources }) => + resources?.length > 1 + ? $gettext('Protect %{count} folders', { count: String(resources.length) }) + : $gettext('Protect folder'), handler: ({ space, resources }) => { - callImmutableEndpoint(space.id, resources[0].id, 'protect', 'POST', 'protected') + for (const r of resources) { + callImmutableEndpoint(space.id, r, 'protect', 'POST', 'protected') + } }, isVisible: ({ resources }) => { - if (resources.length !== 1) return false - const r = resources[0] - return r.type === 'folder' && (!r.immutableState || r.immutableState === 'shielded') + if (!resources.length) return false + return resources.every( + (r) => r.type === 'folder' && (!r.immutableState || r.immutableState === 'shielded') + ) }, class: 'oc-files-actions-protect-trigger' }, - // Folder: protected (self) → shield-fill → unprotect + // Folder(s): protected → unprotect (single + batch) { name: 'unprotect-folder', icon: 'shield', - label: () => $gettext('Remove protection'), + label: ({ resources }) => + resources?.length > 1 + ? $gettext('Unprotect %{count} folders', { count: String(resources.length) }) + : $gettext('Remove protection'), handler: ({ space, resources }) => { - callImmutableEndpoint(space.id, resources[0].id, 'protect', 'DELETE', undefined) + for (const r of resources) { + callImmutableEndpoint(space.id, r, 'protect', 'DELETE') + } }, isVisible: ({ resources }) => { - if (resources.length !== 1) return false - const r = resources[0] - return r.type === 'folder' && r.immutableState === 'protected' + if (!resources.length) return false + return resources.every( + (r) => r.type === 'folder' && r.immutableState === 'protected' + ) }, class: 'oc-files-actions-unprotect-trigger' } From 931357e8552faac195e2f53328c225203dacae90 Mon Sep 17 00:00:00 2001 From: flash Date: Tue, 16 Jun 2026 07:36:43 +0200 Subject: [PATCH 06/75] fix: parent-lookup via currentFolder for unprotect shielded state --- .../actions/files/useFileActionsImmutable.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts index 40516afb33..3dea70e712 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsImmutable.ts @@ -16,11 +16,9 @@ export const useFileActionsImmutable = () => { explicitState: 'frozen' | 'protected' | undefined ): 'frozen' | 'protected' | 'shielded' | undefined => { if (explicitState) return explicitState - // After unprotect: check if parent is still protected → shielded - const parent = resourcesStore.resources.find( - (r) => r.id === resource.parentFolderId - ) - if (parent?.immutableState === 'protected' || parent?.immutableState === 'shielded') { + // After unprotect: check if current folder (parent) is still protected → shielded + const currentFolder = resourcesStore.currentFolder + if (currentFolder?.immutableState === 'protected' || currentFolder?.immutableState === 'shielded') { return 'shielded' } return undefined @@ -122,9 +120,9 @@ export const useFileActionsImmutable = () => { { name: 'protect-folder', icon: 'shield', - label: ({ resources }) => - resources?.length > 1 - ? $gettext('Protect %{count} folders', { count: String(resources.length) }) + label: (options) => + options?.resources?.length > 1 + ? $gettext('Protect %{count} folders', { count: String(options.resources.length) }) : $gettext('Protect folder'), handler: ({ space, resources }) => { for (const r of resources) { @@ -143,9 +141,9 @@ export const useFileActionsImmutable = () => { { name: 'unprotect-folder', icon: 'shield', - label: ({ resources }) => - resources?.length > 1 - ? $gettext('Unprotect %{count} folders', { count: String(resources.length) }) + label: (options) => + options?.resources?.length > 1 + ? $gettext('Unprotect %{count} folders', { count: String(options.resources.length) }) : $gettext('Remove protection'), handler: ({ space, resources }) => { for (const r of resources) { From 00dcb35ef4136f8049b4e672d4f5dce821b868b3 Mon Sep 17 00:00:00 2001 From: flash Date: Tue, 16 Jun 2026 21:50:02 +0200 Subject: [PATCH 07/75] feat: port immutable actions to web-app-files + special folder view Move useFileActionsImmutable from web-pkg to web-app-files (where v7.x actions live). Add SpecialFolderHeader component and useSpecialFolderView composable for .special/ directory detection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../FilesList/SpecialFolderHeader.vue | 37 ++++ .../src/composables/actions/files/index.ts | 1 + .../actions/files/useFileActionsImmutable.ts | 162 ++++++++++++++++++ .../specialFolder/useSpecialFolderView.ts | 91 ++++++++++ 4 files changed, 291 insertions(+) create mode 100644 packages/web-app-files/src/components/FilesList/SpecialFolderHeader.vue create mode 100644 packages/web-app-files/src/composables/actions/files/useFileActionsImmutable.ts create mode 100644 packages/web-app-files/src/composables/specialFolder/useSpecialFolderView.ts diff --git a/packages/web-app-files/src/components/FilesList/SpecialFolderHeader.vue b/packages/web-app-files/src/components/FilesList/SpecialFolderHeader.vue new file mode 100644 index 0000000000..464dc32148 --- /dev/null +++ b/packages/web-app-files/src/components/FilesList/SpecialFolderHeader.vue @@ -0,0 +1,37 @@ + + + diff --git a/packages/web-app-files/src/composables/actions/files/index.ts b/packages/web-app-files/src/composables/actions/files/index.ts index 1cf249fa41..52a522e63a 100644 --- a/packages/web-app-files/src/composables/actions/files/index.ts +++ b/packages/web-app-files/src/composables/actions/files/index.ts @@ -17,3 +17,4 @@ export * from './useFileActionsRename' export * from './useFileActionsShowShares' export * from './useFileActionsShowDetails' export * from './useFileActionsToggleHideShare' +export * from './useFileActionsImmutable' diff --git a/packages/web-app-files/src/composables/actions/files/useFileActionsImmutable.ts b/packages/web-app-files/src/composables/actions/files/useFileActionsImmutable.ts new file mode 100644 index 0000000000..303600ac6e --- /dev/null +++ b/packages/web-app-files/src/composables/actions/files/useFileActionsImmutable.ts @@ -0,0 +1,162 @@ +import { useGettext } from 'vue3-gettext' +import { FileAction, useMessages, useModals, useResourcesStore, useClientService } from '@opencloud-eu/web-pkg' +import { computed } from 'vue' + +export const useFileActionsImmutable = () => { + const { $gettext } = useGettext() + const clientService = useClientService() + const { showMessage, showErrorMessage } = useMessages() + const { dispatchModal } = useModals() + const resourcesStore = useResourcesStore() + + const resolveNewState = ( + resource: { id: string; parentFolderId?: string }, + explicitState: 'frozen' | 'protected' | undefined + ): 'frozen' | 'protected' | 'shielded' | undefined => { + if (explicitState) return explicitState + // After unprotect: check if current folder (parent) is still protected → shielded + const currentFolder = resourcesStore.currentFolder + if (currentFolder?.immutableState === 'protected' || currentFolder?.immutableState === 'shielded') { + return 'shielded' + } + return undefined + } + + const callImmutableEndpoint = async ( + driveId: string, + resource: { id: string; parentFolderId?: string }, + action: 'freeze' | 'protect', + method: 'POST' | 'DELETE' = 'POST', + newState: 'frozen' | 'protected' | undefined = undefined + ) => { + const httpClient = clientService.httpAuthenticated + const endpoint = action === 'freeze' ? 'freeze' : 'protect' + try { + const response = await httpClient.request({ + method, + url: `/graph/v1beta1/drives/${driveId}/items/${resource.id}/${endpoint}` + }) + if (response.status === 204) { + resourcesStore.updateResourceField({ + id: resource.id, + field: 'immutableState', + value: resolveNewState(resource, newState) + }) + const msg = + action === 'freeze' + ? $gettext('File has been frozen.') + : method === 'POST' + ? $gettext('Folder has been protected.') + : $gettext('Folder protection has been removed.') + showMessage({ title: msg }) + } + } catch (e) { + showErrorMessage({ + title: $gettext('Operation failed'), + errors: [e as Error] + }) + } + } + + const actions = computed((): FileAction[] => [ + // File: normal → leaf → freeze (with confirmation) + { + name: 'freeze-file', + icon: 'leaf', + label: () => $gettext('Freeze file'), + handler: ({ space, resources }) => { + const resource = resources[0] + dispatchModal({ + title: $gettext('Freeze file permanently?'), + confirmText: $gettext('Freeze'), + message: $gettext( + 'Freezing a file is irreversible. The file content cannot be changed or deleted afterwards. Are you sure?' + ), + onConfirm: () => { + callImmutableEndpoint(space.id, resource, 'freeze', 'POST', 'frozen') + } + }) + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && !r.immutableState + }, + class: 'oc-files-actions-freeze-trigger' + }, + // File: frozen → snowflake (disabled) + { + name: 'frozen-file', + icon: 'snowflake', + label: () => $gettext('File is frozen'), + handler: () => {}, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && r.immutableState === 'frozen' + }, + isDisabled: () => true, + disabledTooltip: () => $gettext('This file is permanently frozen and cannot be modified.'), + class: 'oc-files-actions-frozen-indicator' + }, + // File: shielded (inherited from parent) → shield (disabled) + { + name: 'shielded-file', + icon: 'shield', + label: () => $gettext('File is in a protected folder'), + handler: () => {}, + isVisible: ({ resources }) => { + if (resources.length !== 1) return false + const r = resources[0] + return r.type === 'file' && r.immutableState === 'shielded' + }, + isDisabled: () => true, + disabledTooltip: () => $gettext('This file is in a protected folder and cannot be modified.'), + class: 'oc-files-actions-shielded-file-indicator' + }, + // Folder(s): normal/shielded → protect (single + batch) + { + name: 'protect-folder', + icon: 'shield', + label: (options) => + options?.resources?.length > 1 + ? $gettext('Protect %{count} folders', { count: String(options.resources.length) }) + : $gettext('Protect folder'), + handler: ({ space, resources }) => { + for (const r of resources) { + callImmutableEndpoint(space.id, r, 'protect', 'POST', 'protected') + } + }, + isVisible: ({ resources }) => { + if (!resources.length) return false + return resources.every( + (r) => r.type === 'folder' && (!r.immutableState || r.immutableState === 'shielded') + ) + }, + class: 'oc-files-actions-protect-trigger' + }, + // Folder(s): protected → unprotect (single + batch) + { + name: 'unprotect-folder', + icon: 'shield', + label: (options) => + options?.resources?.length > 1 + ? $gettext('Unprotect %{count} folders', { count: String(options.resources.length) }) + : $gettext('Remove protection'), + handler: ({ space, resources }) => { + for (const r of resources) { + callImmutableEndpoint(space.id, r, 'protect', 'DELETE') + } + }, + isVisible: ({ resources }) => { + if (!resources.length) return false + return resources.every( + (r) => r.type === 'folder' && r.immutableState === 'protected' + ) + }, + class: 'oc-files-actions-unprotect-trigger' + } + ]) + + return { actions } +} diff --git a/packages/web-app-files/src/composables/specialFolder/useSpecialFolderView.ts b/packages/web-app-files/src/composables/specialFolder/useSpecialFolderView.ts new file mode 100644 index 0000000000..c184902a58 --- /dev/null +++ b/packages/web-app-files/src/composables/specialFolder/useSpecialFolderView.ts @@ -0,0 +1,91 @@ +import { computed, ref, unref, watch, Ref } from 'vue' +import { Resource, SpaceResource } from '@opencloud-eu/web-client' +import { useClientService } from '@opencloud-eu/web-pkg' + +export interface SpecialViewConfig { + type: string + [key: string]: unknown +} + +export interface SpecialFolderState { + /** .special/ directory detected in resource list */ + hasSpecialDir: Ref + /** Parsed view.json config, null if not loaded or error */ + viewConfig: Ref + /** Error message if .special/ present but view.json missing/broken */ + errorMessage: Ref + /** True while fetching view.json */ + isLoading: Ref +} + +export const useSpecialFolderView = ( + space: Ref, + resources: Ref +): SpecialFolderState => { + const clientService = useClientService() + const { getFileContents } = clientService.webdav + + const viewConfig = ref(null) + const errorMessage = ref(null) + const isLoading = ref(false) + + const hasSpecialDir = computed(() => { + return unref(resources).some( + (r) => r.isFolder && r.name === '.special' + ) + }) + + const specialDir = computed(() => { + return unref(resources).find( + (r) => r.isFolder && r.name === '.special' + ) + }) + + watch( + hasSpecialDir, + async (detected) => { + viewConfig.value = null + errorMessage.value = null + + if (!detected) { + return + } + + isLoading.value = true + try { + const dir = unref(specialDir) + const viewJsonPath = dir.path.replace(/\/?$/, '/view.json') + + const { body } = await getFileContents(unref(space), { + path: viewJsonPath + }) + + const parsed = JSON.parse(body as string) + if (!parsed.type || typeof parsed.type !== 'string') { + errorMessage.value = '.special/view.json: "type" field missing or invalid' + return + } + + viewConfig.value = parsed + } catch (e: any) { + if (e?.statusCode === 404 || e?.response?.status === 404) { + errorMessage.value = '.special/view.json not found' + } else if (e instanceof SyntaxError) { + errorMessage.value = '.special/view.json: invalid JSON' + } else { + errorMessage.value = `.special/view.json: ${e.message || 'unknown error'}` + } + } finally { + isLoading.value = false + } + }, + { immediate: true } + ) + + return { + hasSpecialDir, + viewConfig, + errorMessage, + isLoading + } +} From e04e17e132dda68ea363e2e8f7fc5e2b7beea050 Mon Sep 17 00:00:00 2001 From: flash Date: Wed, 17 Jun 2026 01:16:02 +0200 Subject: [PATCH 08/75] docs: add TASK for typed folder views (Aktenplan) Architecture design for hierarchically typed folders with per-type JSON configs in .space/views/, xattr-based typing, and Module Federation view handlers deployed under views/. Co-Authored-By: Claude Opus 4.6 (1M context) --- TASK_typed_folderviews.md | 120 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 TASK_typed_folderviews.md diff --git a/TASK_typed_folderviews.md b/TASK_typed_folderviews.md new file mode 100644 index 0000000000..beace9e85e --- /dev/null +++ b/TASK_typed_folderviews.md @@ -0,0 +1,120 @@ +# TASK: Typed Folder Views (Aktenplan) + +## Ziel + +Hierarchisch typisierte Ordneransichten für OpenCloud — ein Ordner hat einen Typ (xattr `user.oc.md.type`), der bestimmt welche Kinder erlaubt sind, welche Spalten angezeigt werden und welche Aktionen verfügbar sind. Typ-Konfigurationen liegen als JSON im Space-Root unter `.space/views/.json`. + +## Referenz + +- xx1.png: WINYARD DMS Baumansicht + Register-Dialog (Typ-Auswahl, Aktenzeichen) +- xx2.png: Kontextmenü "Neu" mit typspezifischen Kind-Elementen (AK4-Typen) + +## Architektur + +### Datenmodell + +**Typ-xattr auf jedem Ordner:** +``` +user.oc.md.type = "akte" | "register" | "vorgang" | "sachgruppe" | ... +``` + +**Typ-Konfiguration im Space-Root:** +``` +.space/ + views/ + aktenplan.json ← Root-Typ des Space + sachgruppe.json + akte.json + register.json + vorgang.json +``` + +**Format `.json`:** +```json +{ + "label": "Akte", + "icon": "folder-archive", + "children": ["register", "vorgang"], + "columns": ["name", "aktz", "version", "status", "abgelegt-von", "abgelegt-am"], + "namePattern": "{parentAktz}-{seq}", + "actions": ["neues-register", "neuer-vorgang", "dokument-hinzufuegen"], + "metadata": { + "aktz": { "label": "Aktenzeichen", "type": "string", "auto": true }, + "status": { "label": "Status", "type": "enum", "values": ["offen", "gespeichert", "geschlossen"] } + } +} +``` + +### Flow + +1. User öffnet Ordner → PROPFIND liefert `type` (aus xattr, via DavProperty) +2. FolderView prüft: Hat der Ordner einen `type`? +3. Ja → Lade `.space/views/.json` via WebDAV getFileContents (gecacht pro Space) +4. Render: Spalten aus `columns`, Actions aus `children` +5. "Neu"-Button bietet nur die in `children` definierten Typen an +6. Beim Anlegen: Ordner erstellen + `type` xattr setzen via Metadata PUT + +### Skelett / Initialisierung + +- Space-Root bekommt `type` beim Erstellen (z.B. `type=aktenplan`) +- Die `views/*.json` werden aus einer Vorlage kopiert (Template-Space oder manueller Upload) +- Alternative: Admin-Action "Aktenplan initialisieren" die die JSONs + Root-Typ anlegt +- Langfristig: Schema-Editor im UI + +### Deployment + +Typed FolderView Handler werden unter `views/` abgelegt (neben `core/` und `apps/`): +``` +/var/lib/opencloud/web/assets/ + core/ ← OpenCloud Web Runtime + apps/ ← Web Extensions (htmlviewer, etc.) + views/ ← Typed FolderView Handler + aktenplan/ + manifest.json + remoteEntry.mjs +``` + +Jeder View-Handler ist eine Module Federation Extension die sich am Extension Point `app.files.folder-views.special-typed` registriert. + +### Generischer vs. Spezifischer Handler + +- **Generischer Handler**: Interpretiert `.json` und rendert Spalten/Actions dynamisch. Reicht für 80% der Fälle. +- **Spezifischer Handler**: Eigene Vue-Komponente pro Typ für Sonderfälle (z.B. Aktenzeichen-Generator, Formular-Ansicht). +- Fallback: Generischer Handler wenn kein spezifischer gefunden. + +## Implementierung (Schritte) + +### Phase 1: Grundgerüst +1. `DavProperty.FolderType` → xattr `user.oc.md.type` via PROPFIND +2. Schema-Loader Composable: `useTypedFolderSchema(space, type)` → lädt + cacht `.space/views/.json` +3. Typed FolderView Komponente: rendert Spalten + "Neu"-Actions basierend auf Schema +4. Integration in GenericSpace.vue: wenn `type` vorhanden → Typed View statt Default + +### Phase 2: Aktionen +5. "Neues [Kind-Typ]" Action: Erstellt Ordner + setzt `type` xattr +6. Aktenzeichen-Generierung: `namePattern` aus Schema, Sequenz-Counter per xattr am Parent +7. Typ-spezifische Metadaten: Sidebar zeigt `metadata`-Felder aus Schema + +### Phase 3: Views-Deployment +8. View-Handler als Module Federation Extension unter `views/` +9. Extension Point für typed views +10. Admin-UI: Schema-Editor (JSON) für `.space/views/` + +### Phase 4: Baumansicht +11. Treeview-Sidebar (siehe TASK_treeview.md) +12. Navigation via Baumstruktur +13. Breadcrumb zeigt Aktenzeichen-Pfad + +## Offene Fragen + +- **Performance**: Schema-Cache pro Space — wie invalidieren? (etag auf .space/views/ ?) +- **Rechte**: Wer darf `.space/views/` editieren? → Space-Manager +- **Vererbung**: Soll ein Sub-Space das Schema vom Parent erben? +- **Migration**: Bestehende Ordner typisieren → Batch-Script das type-xattr setzt +- **PROPFIND für type**: Gleiche Namespace-Problematik wie bei notice — type über Metadata API laden oder PROPFIND fixen? + +## Abhängigkeiten + +- Metadata API GET+PUT (opencloud#2960) — zum Setzen von `type` xattr +- DavProperty für `type` im PROPFIND (oder Metadata API Fallback) +- Module Federation Extension SDK 7.x für View-Handler Deployment From 973f7d779027daf6063a0152c0a37b991d67a621 Mon Sep 17 00:00:00 2001 From: flash Date: Wed, 17 Jun 2026 09:31:46 +0200 Subject: [PATCH 09/75] feat: typed folder view groundwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTypedFolderSchema: loads .space/views/.json per space (cached) - useTypedFolderActions: creates typed child folders with type xattr - GenericSpace: loads folder type via metadata API on navigation - Type definitions for schema, field defs No visual changes yet — schema loading and type detection wired up, typed view rendering comes next. Co-Authored-By: Claude Opus 4.6 (1M context) --- TASK_typed_folderviews.md | 89 +++++++++++++------ .../src/composables/typedFolder/index.ts | 3 + .../src/composables/typedFolder/types.ts | 16 ++++ .../typedFolder/useTypedFolderActions.ts | 78 ++++++++++++++++ .../typedFolder/useTypedFolderSchema.ts | 88 ++++++++++++++++++ .../src/views/spaces/GenericSpace.vue | 27 +++++- 6 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 packages/web-app-files/src/composables/typedFolder/index.ts create mode 100644 packages/web-app-files/src/composables/typedFolder/types.ts create mode 100644 packages/web-app-files/src/composables/typedFolder/useTypedFolderActions.ts create mode 100644 packages/web-app-files/src/composables/typedFolder/useTypedFolderSchema.ts diff --git a/TASK_typed_folderviews.md b/TASK_typed_folderviews.md index beace9e85e..429cc56fdb 100644 --- a/TASK_typed_folderviews.md +++ b/TASK_typed_folderviews.md @@ -45,14 +45,43 @@ user.oc.md.type = "akte" | "register" | "vorgang" | "sachgruppe" | ... } ``` +### Space-Typ + +Der `type` auf dem Space-Root (`.space`) ist der Einstieg. Wenn kein `type` gesetzt ist, wird der **normale OpenCloud FolderView** verwendet — kein Typed View, kein Schema-Lookup. Nur Spaces mit explizitem Typ aktivieren das Typed-View-System. + +Bestehende Spaces sind nicht betroffen. Erst wenn ein Admin den Space-Root typisiert (z.B. `type=aktenplan`), schaltet der Space in den Typed-View-Modus um. + +### Typ-Liste und Typ-Verwaltung + +**Verfügbare Typen** eines Space ergeben sich aus den Dateinamen in `.space/views/`: +- PROPFIND auf `.space/views/` → Dateiliste → `aktenplan.json` = Typ "aktenplan" +- Gecacht pro Space beim ersten Zugriff +- Manager kann Cache manuell refreshen (z.B. nach Upload neuer Type-JSONs) + +**Typ setzen/ändern** (Manager+): +- Dropdown "Typ" in der Sidebar (FileDetails.vue) unterhalb Notice +- Zeigt nur Typen aus der Space-Typ-Liste +- Setzt `user.oc.md.type` via `PUT /metadata { "type": "akte" }` +- Nur für Nutzer mit globaler Rolle Manager/SpaceAdmin/Admin sichtbar + +**Betroffene Dateien für Typ-Verwaltung:** +1. `useTypedFolderTypes.ts` (neu) — Lädt Typ-Liste per PROPFIND `.space/views/`, cacht pro Space, Refresh-Methode +2. `FileDetails.vue` — Dropdown "Typ" in Sidebar, nur für Manager+ +3. `GenericSpace.vue` — Liest type, übergibt an Schema-Loader (existiert bereits) +4. `useTypedFolderActions.ts` — "Neuer [Kind-Typ]" nutzt Typ-Liste für Labels/Icons + +**Keine Backend-Änderung nötig** — PROPFIND für Dateiliste und Metadata PUT existieren bereits. + ### Flow -1. User öffnet Ordner → PROPFIND liefert `type` (aus xattr, via DavProperty) -2. FolderView prüft: Hat der Ordner einen `type`? -3. Ja → Lade `.space/views/.json` via WebDAV getFileContents (gecacht pro Space) -4. Render: Spalten aus `columns`, Actions aus `children` -5. "Neu"-Button bietet nur die in `children` definierten Typen an -6. Beim Anlegen: Ordner erstellen + `type` xattr setzen via Metadata PUT +1. User öffnet Ordner → Metadata API liefert `type` (aus xattr) +2. FolderView prüft: Hat der **Space-Root** einen `type`? +3. Nein → **normaler FolderView**, keine weitere Prüfung +4. Ja → Prüfe `type` des aktuellen Ordners +5. Lade `.space/views/.json` via WebDAV getFileContents (gecacht pro Space) +6. Render: Spalten aus `columns`, Actions aus `children` +7. "Neu"-Button bietet nur die in `children` definierten Typen an +8. Beim Anlegen: Ordner erstellen + `type` xattr setzen via Metadata PUT ### Skelett / Initialisierung @@ -84,37 +113,45 @@ Jeder View-Handler ist eine Module Federation Extension die sich am Extension Po ## Implementierung (Schritte) -### Phase 1: Grundgerüst -1. `DavProperty.FolderType` → xattr `user.oc.md.type` via PROPFIND -2. Schema-Loader Composable: `useTypedFolderSchema(space, type)` → lädt + cacht `.space/views/.json` -3. Typed FolderView Komponente: rendert Spalten + "Neu"-Actions basierend auf Schema -4. Integration in GenericSpace.vue: wenn `type` vorhanden → Typed View statt Default +### Phase 1: Grundgerüst ✅ +1. ~~Schema-Loader Composable: `useTypedFolderSchema(space, type)` → lädt + cacht `.space/views/.json`~~ +2. ~~Typed Actions Composable: `useTypedFolderActions` → erstellt Kind-Ordner mit type-xattr~~ +3. ~~Integration in GenericSpace.vue: type aus Metadata API laden~~ +4. ~~Typ-Definitionen: `TypedFolderSchema`, `TypedFieldDef`~~ + +### Phase 1b: Typ-Verwaltung (aktuell) +5. `useTypedFolderTypes` — Typ-Liste per PROPFIND `.space/views/` laden + cachen +6. Sidebar Dropdown "Typ" in FileDetails.vue (Manager+) +7. Typ setzen via Metadata PUT +8. Cache-Refresh für Manager -### Phase 2: Aktionen -5. "Neues [Kind-Typ]" Action: Erstellt Ordner + setzt `type` xattr -6. Aktenzeichen-Generierung: `namePattern` aus Schema, Sequenz-Counter per xattr am Parent -7. Typ-spezifische Metadaten: Sidebar zeigt `metadata`-Felder aus Schema +### Phase 2: Rendering +9. Typed FolderView Komponente: rendert Spalten basierend auf Schema `columns` +10. "Neues [Kind-Typ]" Dialog mit Name-Input + Typ-Auswahl +11. Aktenzeichen-Generierung: `namePattern` aus Schema, Sequenz-Counter per xattr am Parent +12. Typ-spezifische Metadaten: Sidebar zeigt `metadata`-Felder aus Schema ### Phase 3: Views-Deployment -8. View-Handler als Module Federation Extension unter `views/` -9. Extension Point für typed views -10. Admin-UI: Schema-Editor (JSON) für `.space/views/` +13. View-Handler als Module Federation Extension unter `views/` +14. Extension Point für typed views +15. Admin-UI: Schema-Editor (JSON) für `.space/views/` ### Phase 4: Baumansicht -11. Treeview-Sidebar (siehe TASK_treeview.md) -12. Navigation via Baumstruktur -13. Breadcrumb zeigt Aktenzeichen-Pfad +16. Treeview-Sidebar (siehe TASK_treeview.md) +17. Navigation via Baumstruktur +18. Breadcrumb zeigt Aktenzeichen-Pfad ## Offene Fragen -- **Performance**: Schema-Cache pro Space — wie invalidieren? (etag auf .space/views/ ?) -- **Rechte**: Wer darf `.space/views/` editieren? → Space-Manager +- **Performance**: Schema-Cache pro Space — invalidieren per etag auf `.space/views/`? +- **Rechte**: Wer darf `.space/views/` editieren? → Space-Manager (reguläre Datei-Permissions) - **Vererbung**: Soll ein Sub-Space das Schema vom Parent erben? - **Migration**: Bestehende Ordner typisieren → Batch-Script das type-xattr setzt -- **PROPFIND für type**: Gleiche Namespace-Problematik wie bei notice — type über Metadata API laden oder PROPFIND fixen? +- **PROPFIND für type**: Namespace-Problematik (wie bei notice) → type über Metadata API laden (aktueller Ansatz) +- **Typ löschen**: Was passiert wenn ein Typ entfernt wird aber Ordner ihn noch haben? → Fallback auf normalen View ## Abhängigkeiten -- Metadata API GET+PUT (opencloud#2960) — zum Setzen von `type` xattr -- DavProperty für `type` im PROPFIND (oder Metadata API Fallback) +- Metadata API GET+PUT (opencloud#2960) — zum Setzen/Lesen von `type` xattr - Module Federation Extension SDK 7.x für View-Handler Deployment +- Globale Manager-Rolle mit `Drives.ManageImmutable` für Typ-Verwaltungs-Berechtigung diff --git a/packages/web-app-files/src/composables/typedFolder/index.ts b/packages/web-app-files/src/composables/typedFolder/index.ts new file mode 100644 index 0000000000..42b446fe8f --- /dev/null +++ b/packages/web-app-files/src/composables/typedFolder/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './useTypedFolderSchema' +export * from './useTypedFolderActions' diff --git a/packages/web-app-files/src/composables/typedFolder/types.ts b/packages/web-app-files/src/composables/typedFolder/types.ts new file mode 100644 index 0000000000..71088f2a1f --- /dev/null +++ b/packages/web-app-files/src/composables/typedFolder/types.ts @@ -0,0 +1,16 @@ +export interface TypedFolderSchema { + label: string + icon?: string + children: string[] + columns?: string[] + namePattern?: string + actions?: string[] + metadata?: Record +} + +export interface TypedFieldDef { + label: string + type: 'string' | 'enum' | 'date' | 'number' + values?: string[] + auto?: boolean +} diff --git a/packages/web-app-files/src/composables/typedFolder/useTypedFolderActions.ts b/packages/web-app-files/src/composables/typedFolder/useTypedFolderActions.ts new file mode 100644 index 0000000000..ae40feb74a --- /dev/null +++ b/packages/web-app-files/src/composables/typedFolder/useTypedFolderActions.ts @@ -0,0 +1,78 @@ +import { computed, unref, Ref } from 'vue' +import { SpaceResource, Resource } from '@opencloud-eu/web-client' +import { useClientService, useMessages, useResourcesStore } from '@opencloud-eu/web-pkg' +import { useGettext } from 'vue3-gettext' +import { TypedFolderSchema } from './types' + +export function useTypedFolderActions( + space: Ref, + currentFolder: Ref, + schema: Ref, + childSchemas: Ref> +) { + const clientService = useClientService() + const { $gettext } = useGettext() + const { showMessage, showErrorMessage } = useMessages() + const resourcesStore = useResourcesStore() + + async function createTypedFolder(childType: string, name: string) { + const sp = unref(space) + const folder = unref(currentFolder) + if (!sp || !folder) return + + const path = folder.path.replace(/\/?$/, `/${name}`) + + try { + // Create folder + await clientService.webdav.createFolder(sp, { path }) + + // Set type xattr via metadata API + const httpClient = clientService.httpAuthenticated + // We need the new folder's ID — re-fetch the parent to find it + const { children } = await clientService.webdav.listFiles(sp, { path: folder.path }) + const newFolder = children.find((r) => r.name === name) + + if (newFolder) { + await httpClient.put( + `/graph/v1beta1/drives/${sp.id}/items/${newFolder.id}/metadata`, + { type: childType } + ) + + // Update local state + resourcesStore.upsertResource(newFolder) + } + + const childSchema = unref(childSchemas).get(childType) + const label = childSchema?.label || childType + showMessage({ title: $gettext('%{label} "%{name}" created', { label, name }) }) + } catch (e) { + showErrorMessage({ + title: $gettext('Failed to create folder'), + errors: [e as Error] + }) + } + } + + const childActions = computed(() => { + const s = unref(schema) + if (!s?.children?.length) return [] + + return s.children.map((childType) => { + const childSchema = unref(childSchemas).get(childType) + const label = childSchema?.label || childType + const icon = childSchema?.icon || 'folder' + + return { + type: childType, + label: $gettext('New %{label}', { label }), + icon, + handler: (name: string) => createTypedFolder(childType, name) + } + }) + }) + + return { + childActions, + createTypedFolder + } +} diff --git a/packages/web-app-files/src/composables/typedFolder/useTypedFolderSchema.ts b/packages/web-app-files/src/composables/typedFolder/useTypedFolderSchema.ts new file mode 100644 index 0000000000..fc0e7cd2e2 --- /dev/null +++ b/packages/web-app-files/src/composables/typedFolder/useTypedFolderSchema.ts @@ -0,0 +1,88 @@ +import { computed, ref, unref, watch, Ref } from 'vue' +import { SpaceResource } from '@opencloud-eu/web-client' +import { useClientService } from '@opencloud-eu/web-pkg' +import { TypedFolderSchema } from './types' + +// Cache schemas per space to avoid re-fetching on every folder navigation +const schemaCache = new Map>() + +export function useTypedFolderSchema( + space: Ref, + folderType: Ref +) { + const clientService = useClientService() + const schema = ref(null) + const loading = ref(false) + const error = ref(null) + + const isTyped = computed(() => !!unref(folderType)) + + async function loadSchema() { + const type = unref(folderType) + const sp = unref(space) + if (!type || !sp) { + schema.value = null + return + } + + // Check cache + const spaceId = sp.id + if (!schemaCache.has(spaceId)) { + schemaCache.set(spaceId, new Map()) + } + const spaceSchemas = schemaCache.get(spaceId)! + if (spaceSchemas.has(type)) { + schema.value = spaceSchemas.get(type)! + return + } + + loading.value = true + error.value = null + try { + const { getFileContents } = clientService.webdav + const viewPath = `.space/views/${type}.json` + const { body } = await getFileContents(sp, { path: viewPath }) + const parsed = JSON.parse(body as string) as TypedFolderSchema + + if (!parsed.label || !Array.isArray(parsed.children)) { + error.value = `Invalid schema for type "${type}": missing label or children` + schema.value = null + spaceSchemas.set(type, null) + return + } + + schema.value = parsed + spaceSchemas.set(type, parsed) + } catch (e: any) { + if (e?.statusCode === 404 || e?.response?.status === 404) { + // No schema for this type — not an error, just no typed view + schema.value = null + spaceSchemas.set(type, null) + } else { + error.value = `Failed to load schema for type "${type}": ${e.message || e}` + schema.value = null + } + } finally { + loading.value = false + } + } + + // Invalidate cache for a space (e.g. after schema edit) + function invalidateCache(spaceId?: string) { + if (spaceId) { + schemaCache.delete(spaceId) + } else { + schemaCache.clear() + } + } + + watch([folderType, space], () => loadSchema(), { immediate: true }) + + return { + schema, + isTyped, + loading, + error, + invalidateCache + } +} diff --git a/packages/web-app-files/src/views/spaces/GenericSpace.vue b/packages/web-app-files/src/views/spaces/GenericSpace.vue index ba6b5d0fe9..16cea2cb0f 100644 --- a/packages/web-app-files/src/views/spaces/GenericSpace.vue +++ b/packages/web-app-files/src/views/spaces/GenericSpace.vue @@ -107,7 +107,7 @@ + + diff --git a/packages/web-app-files/src/components/FilesList/ResourceTree.vue b/packages/web-app-files/src/components/FilesList/ResourceTree.vue new file mode 100644 index 0000000000..e15a514b30 --- /dev/null +++ b/packages/web-app-files/src/components/FilesList/ResourceTree.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/packages/web-app-files/src/composables/extensions/useFolderViews.ts b/packages/web-app-files/src/composables/extensions/useFolderViews.ts index 48dad3032c..950f1504e5 100644 --- a/packages/web-app-files/src/composables/extensions/useFolderViews.ts +++ b/packages/web-app-files/src/composables/extensions/useFolderViews.ts @@ -12,6 +12,8 @@ import { folderViewsTrashOverviewExtensionPoint } from '../../extensionPoints' import { markRaw } from 'vue' +import ResourceTree from '../../components/FilesList/ResourceTree.vue' +import ResourceMetro from '../../components/FilesList/ResourceMetro.vue' export const useFolderViews = (): FolderViewExtension[] => { const { $gettext } = useGettext() @@ -88,6 +90,40 @@ export const useFolderViews = (): FolderViewExtension[] => { }, component: markRaw(ResourceTiles) } + }, + { + id: 'com.github.opencloud-eu.web.files.folder-view.resource-tree', + type: 'folderView', + extensionPointIds: [ + folderViewsFolderExtensionPoint.id, + folderViewsProjectSpacesExtensionPoint.id + ], + folderView: { + name: 'resource-tree', + label: $gettext('Tree view'), + icon: { + name: 'node-tree', + fillType: 'none' + }, + component: markRaw(ResourceTree) + } + }, + { + id: 'com.github.opencloud-eu.web.files.folder-view.resource-metro', + type: 'folderView', + extensionPointIds: [ + folderViewsFolderExtensionPoint.id, + folderViewsProjectSpacesExtensionPoint.id + ], + folderView: { + name: 'resource-metro', + label: $gettext('Metro tiles view'), + icon: { + name: 'layout-grid', + fillType: 'none' + }, + component: markRaw(ResourceMetro) + } } ] } From 8f3c1c832e22027e073c4c11e61a5617c83f6ec5 Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 15:17:34 +0200 Subject: [PATCH 28/75] =?UTF-8?q?fix:=20Tree=20and=20Metro=20views=20?= =?UTF-8?q?=E2=80=94=20working=20clicks,=20proper=20styling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tree View: - Table layout with name, size, date columns (like condensed) - Expand arrows clickable, lazy-loads children via PROPFIND - Reactive Set/Map for expand state - Click row navigates to folder Metro View: - Colors in CSS classes (not inline styles) - Bold titles - Click navigates to folder (correct fileClick emit format) - Hover scale + shadow effect Both: correct emit signature { resources, space } matching GenericSpace. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FilesList/ResourceMetro.vue | 56 +++++----- .../src/components/FilesList/ResourceTree.vue | 105 +++++++++--------- 2 files changed, 85 insertions(+), 76 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue index c39a0797ef..c56c279648 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue @@ -3,13 +3,13 @@
-
+
-
+
{{ resource.name }}
@@ -38,7 +38,7 @@ const props = defineProps<{ }>() const emit = defineEmits<{ - 'fileClick': [{ resources: Resource[], space: SpaceResource, event: Event }] + 'fileClick': [{ resources: Resource[], space: SpaceResource }] 'fileDropped': [string] 'itemVisible': [Resource] 'sort': [{ sortBy: string; sortDir: string }] @@ -48,11 +48,10 @@ const selectedIds = defineModel('selectedIds', { default: () => [] }) const { $gettext } = useGettext() -// Color palette for tiles — deterministic based on name hash -const colors = [ - '#e3f2fd', '#e8f5e9', '#fff3e0', '#fce4ec', '#f3e5f5', - '#e0f2f1', '#fff8e1', '#e8eaf6', '#fbe9e7', '#e0f7fa', - '#f1f8e9', '#ede7f6', '#efebe9', '#eceff1', '#e1f5fe' +// Deterministic color class based on name hash +const colorClasses = [ + 'metro-color-0', 'metro-color-1', 'metro-color-2', 'metro-color-3', 'metro-color-4', + 'metro-color-5', 'metro-color-6', 'metro-color-7', 'metro-color-8', 'metro-color-9' ] function hashName(name: string): number { @@ -63,24 +62,12 @@ function hashName(name: string): number { return Math.abs(h) } -function tileColor(resource: Resource): string { - return colors[hashName(resource.name) % colors.length] -} - -function tileStyle(resource: Resource) { - return { - background: tileColor(resource), - minHeight: '120px' - } -} - -function textColor(resource: Resource): string { - // Dark text on light backgrounds - return '#333' +function tileClass(resource: Resource): string { + return colorClasses[hashName(resource.name) % colorClasses.length] } function handleClick(resource: Resource) { - emit('fileClick', { resources: [resource], space: props.space, event: new MouseEvent('click') }) + emit('fileClick', { resources: [resource], space: props.space }) } const filteredResources = computed(() => { @@ -98,9 +85,28 @@ const gridStyle = computed(() => { diff --git a/packages/web-app-files/src/components/FilesList/ResourceTree.vue b/packages/web-app-files/src/components/FilesList/ResourceTree.vue index e15a514b30..d6a4b65f70 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceTree.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceTree.vue @@ -1,36 +1,40 @@ + + From 8ab1517c4690a4113b559b4e8ddd0079586bb438 Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 16:16:49 +0200 Subject: [PATCH 48/75] feat: Metro view with 3-dot context menu button per tile --- .../components/FilesList/ResourceMetro.vue | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue index c4851c69b5..88abb4a375 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue @@ -7,6 +7,15 @@ @click="handleClick(resource)" >
{{ resource.name }}
+ +
+ +
No items @@ -15,7 +24,7 @@ From 05d63133b308450d28299a6290e8ebaadba68053 Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 16:24:36 +0200 Subject: [PATCH 50/75] fix: derive tree depth from resource.path segments (no Map needed) --- .../src/components/FilesList/ResourceTree.vue | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceTree.vue b/packages/web-app-files/src/components/FilesList/ResourceTree.vue index af1eeaa552..24432e0c52 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceTree.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceTree.vue @@ -15,7 +15,7 @@ - From 4b973b84576794aa731b5ff341c9b5bc115ff984 Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 17:00:58 +0200 Subject: [PATCH 62/75] =?UTF-8?q?fix:=20Metro=20style=20=E2=80=94=20primar?= =?UTF-8?q?y=20bg,=20white=20bold=20centered=20text,=20fixed=20checkbox/me?= =?UTF-8?q?nu=20positions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Background: primary color (blue) - Text: white, bold, centered - Checkbox: absolute top-left - Context menu: absolute bottom-right - Preview/thumbnail: hidden - Tile items: position relative for absolute children Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FilesList/ResourceMetro.vue | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue index 576ea24a2d..5950d7f6f9 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue @@ -61,28 +61,50 @@ const filteredResources = computed(() => { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) !important; } .resource-metro-wrapper .oc-tile-card { - background: var(--oc-color-background-hover, #f5f5f5) !important; - border: 1px solid var(--oc-color-border, #e0e0e0) !important; + background: var(--oc-role-primary, #1565c0) !important; + border: none !important; border-radius: 10px !important; - aspect-ratio: 4 / 3; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.15s, box-shadow 0.15s; + overflow: hidden; } .resource-metro-wrapper .oc-tile-card:hover { - transform: scale(1.04); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + filter: brightness(1.1); } -.resource-metro-wrapper .oc-tile-card .oc-resource-name { +/* Hide thumbnail/preview */ +.resource-metro-wrapper .oc-tile-card-preview { + display: none !important; +} +/* Title: white, bold, centered */ +.resource-metro-wrapper .oc-resource-name, +.resource-metro-wrapper .oc-resource-basename, +.resource-metro-wrapper .oc-resource-extension { + color: white !important; font-weight: 700 !important; - justify-content: center; } -.resource-metro-wrapper .metro-image-placeholder { - display: none; +.resource-metro-wrapper .oc-resource-name { + justify-content: center !important; + text-align: center; } -/* Hide tile thumbnail area */ -.resource-metro-wrapper .oc-tile-card-preview { - display: none !important; +/* Content area fills tile, centers content */ +.resource-metro-wrapper .oc-tile-card-content { + display: flex !important; + align-items: center !important; + justify-content: center !important; + min-height: 100px; +} +/* Checkbox: fixed top-left */ +.resource-metro-wrapper .oc-tile-card-selection { + position: absolute !important; + top: 8px !important; + left: 8px !important; +} +/* Context menu button: fixed bottom-right */ +.resource-metro-wrapper .resource-tiles-btn-action-dropdown { + position: absolute !important; + bottom: 8px !important; + right: 8px !important; +} +/* Make tile position relative for absolute children */ +.resource-metro-wrapper .oc-tiles-item { + position: relative; } From 965223d8678f60874e2591d78101f3d9f5fd82cb Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 17:03:09 +0200 Subject: [PATCH 63/75] fix: Metro uses theme surface colors, title centered with on-surface color --- .../components/FilesList/ResourceMetro.vue | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue index 5950d7f6f9..a2ac9a39d0 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue @@ -61,35 +61,47 @@ const filteredResources = computed(() => { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) !important; } .resource-metro-wrapper .oc-tile-card { - background: var(--oc-role-primary, #1565c0) !important; - border: none !important; + background: var(--oc-role-surface-container, #f0f0f0) !important; + border: 1px solid var(--oc-role-outline, #ccc) !important; border-radius: 10px !important; overflow: hidden; } .resource-metro-wrapper .oc-tile-card:hover { - filter: brightness(1.1); + background: var(--oc-role-surface-container-highlight, #e4e4e4) !important; } /* Hide thumbnail/preview */ .resource-metro-wrapper .oc-tile-card-preview { display: none !important; } -/* Title: white, bold, centered */ +/* Title: inversed color, bold, centered vertically + horizontally */ .resource-metro-wrapper .oc-resource-name, .resource-metro-wrapper .oc-resource-basename, .resource-metro-wrapper .oc-resource-extension { - color: white !important; + color: var(--oc-role-on-surface, #333) !important; font-weight: 700 !important; } .resource-metro-wrapper .oc-resource-name { justify-content: center !important; text-align: center; } -/* Content area fills tile, centers content */ +.resource-metro-wrapper .oc-resource-details { + text-align: center !important; +} +/* Content area fills tile, centers content vertically */ .resource-metro-wrapper .oc-tile-card-content { display: flex !important; align-items: center !important; justify-content: center !important; - min-height: 100px; + flex: 1 !important; + padding: 16px !important; +} +/* Hide resource icon in tile — just show name */ +.resource-metro-wrapper .oc-tile-card-content .oc-resource-icon { + display: none !important; +} +/* Make the oc-resource fill and center */ +.resource-metro-wrapper .oc-tile-card-content .oc-resource { + justify-content: center !important; } /* Checkbox: fixed top-left */ .resource-metro-wrapper .oc-tile-card-selection { From 1d852bc38bdb8223a41fa17f30f7a0b2dbe05d65 Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 17:05:14 +0200 Subject: [PATCH 64/75] fix: Metro uses primary-container bg with on-primary-container text --- .../src/components/FilesList/ResourceMetro.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue index a2ac9a39d0..3ad11f276d 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue @@ -61,23 +61,23 @@ const filteredResources = computed(() => { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)) !important; } .resource-metro-wrapper .oc-tile-card { - background: var(--oc-role-surface-container, #f0f0f0) !important; - border: 1px solid var(--oc-role-outline, #ccc) !important; + background: var(--oc-role-primary-container) !important; + border: none !important; border-radius: 10px !important; overflow: hidden; } .resource-metro-wrapper .oc-tile-card:hover { - background: var(--oc-role-surface-container-highlight, #e4e4e4) !important; + filter: brightness(0.95); } /* Hide thumbnail/preview */ .resource-metro-wrapper .oc-tile-card-preview { display: none !important; } -/* Title: inversed color, bold, centered vertically + horizontally */ +/* Title: matching container text color, bold, centered */ .resource-metro-wrapper .oc-resource-name, .resource-metro-wrapper .oc-resource-basename, .resource-metro-wrapper .oc-resource-extension { - color: var(--oc-role-on-surface, #333) !important; + color: var(--oc-role-on-primary-container) !important; font-weight: 700 !important; } .resource-metro-wrapper .oc-resource-name { From b0853730ace9d8306452acc84f59649760ed8213 Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 17:09:40 +0200 Subject: [PATCH 65/75] =?UTF-8?q?rewrite:=20Metro=20=E2=80=94=20minimal=20?= =?UTF-8?q?ResourceTiles=20wrapper,=20outline-variant=20fill,=20centered?= =?UTF-8?q?=20bold=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only 7 CSS lines: - Hide preview - Fill tile with outline-variant color (subtle, theme-aware) - Center content area - Center + bold resource name - No position hacks, no color overrides, no broken context menus Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/FilesList/ResourceMetro.vue | 78 +++---------------- 1 file changed, 9 insertions(+), 69 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue index 3ad11f276d..8f19902dc8 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue @@ -11,22 +11,18 @@ :header-position="headerPosition" :view-size="viewSize" :drag-drop="dragDrop" - class="resource-metro-wrapper" + class="metro-view" @file-click="$emit('fileClick', $event)" @file-dropped="$emit('fileDropped', $event)" @item-visible="$emit('itemVisible', $event)" @sort="$emit('sort', $event)" > - @@ -56,67 +52,11 @@ const filteredResources = computed(() => { From 35fa720716b9165dc60b4e004856d2de5de0af91 Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 17:12:03 +0200 Subject: [PATCH 66/75] fix: Metro name absolutely positioned center over tile, outline-variant fill --- .../components/FilesList/ResourceMetro.vue | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue index 8f19902dc8..c37dead56e 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue @@ -52,11 +52,56 @@ const filteredResources = computed(() => { From 92be97aa4972cd065700374836a60028b303e281 Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 17:13:49 +0200 Subject: [PATCH 67/75] =?UTF-8?q?revert:=20Metro=20minimal=20CSS=20?= =?UTF-8?q?=E2=80=94=20only=20hide=20preview,=20fill=20tile,=20bold=20cent?= =?UTF-8?q?er=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/FilesList/ResourceMetro.vue | 50 ++----------------- 1 file changed, 3 insertions(+), 47 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue index c37dead56e..9802468eaf 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue @@ -52,56 +52,12 @@ const filteredResources = computed(() => { From 4fcde31f923b4fc48d05a89043e9cebb463e721b Mon Sep 17 00:00:00 2001 From: flash Date: Thu, 18 Jun 2026 17:14:56 +0200 Subject: [PATCH 68/75] fix: Metro name in preview area via #image slot, bottom bar hidden --- .../components/FilesList/ResourceMetro.vue | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue index 9802468eaf..7509dd5ca6 100644 --- a/packages/web-app-files/src/components/FilesList/ResourceMetro.vue +++ b/packages/web-app-files/src/components/FilesList/ResourceMetro.vue @@ -17,8 +17,8 @@ @item-visible="$emit('itemVisible', $event)" @sort="$emit('sort', $event)" > -