Skip to content
Open
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
@@ -0,0 +1,37 @@
<template>
<div
class="special-folder-header flex items-center gap-3 rounded-md px-4 py-3 mx-4 my-2 bg-warning-100 text-warning-800 border border-warning-300"
>
<oc-icon name="tools" fill-type="line" size="medium" />
<div class="flex flex-col">
<span class="font-medium" v-text="title" />
<span v-if="detail" class="text-sm opacity-80" v-text="detail" />
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useGettext } from 'vue3-gettext'

const props = defineProps<{
errorMessage?: string | null
viewType?: string | null
}>()

const { $gettext } = useGettext()

const title = computed(() => {
if (props.errorMessage) {
return $gettext('Special folder view not available')
}
if (props.viewType) {
return $gettext('No view handler for type "%{type}"', { type: props.viewType })
}
return $gettext('Special folder view not configured')
})

const detail = computed(() => {
return props.errorMessage || null
})
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@
<tags-select data-testid="tags" :resource="resource" class="w-full" />
</div>
</div>
<div class="mt-2">
<div class="text-sm mb-0.5">{{ $gettext('Notice') }}</div>
<textarea
v-model="noticeText"
data-testid="notice"
class="w-full rounded border border-role-outline p-2 text-sm"
rows="2"
:placeholder="$gettext('Add a notice…')"
@blur="saveNotice"
@keydown.ctrl.enter="saveNotice"
/>
<div v-if="noticeSaving" class="text-xs text-role-on-surface-muted">
{{ $gettext('Saving…') }}
</div>
</div>
</div>
<p v-else data-testid="noContentText" v-text="$gettext('No information to display')" />
</div>
Expand All @@ -131,7 +146,8 @@ import {
useResourceContents,
useLoadPreview,
useSideBar,
useResourceIndicators
useResourceIndicators,
useClientService
} from '@opencloud-eu/web-pkg'
import upperFirst from 'lodash-es/upperFirst'
import {
Expand Down Expand Up @@ -166,6 +182,7 @@ const { loadPreview, previewsLoading } = useLoadPreview()
const { openSideBarPanel } = useSideBar()
const { getIndicators } = useResourceIndicators()
const { tagsHelper } = useContextualHelpers()
const clientService = useClientService()

const language = useGettext()
const { $gettext, current: currentLanguage } = language
Expand All @@ -181,6 +198,55 @@ const space = inject<Ref<SpaceResource>>('space')

const preview = ref<string>(undefined)

// Notice (user.oc.md.oy.notice xattr, loaded via metadata API)
const noticeText = ref('')
const noticeSaving = ref(false)
const noticeOriginal = ref('')

async function loadNotice() {
const res = unref(resource)
const sp = unref(space)
if (!res?.id || !sp?.id) return
try {
const httpClient = clientService.httpAuthenticated
const response = await httpClient.get(
`/graph/v1beta1/drives/${sp.id}/items/${res.id}/metadata`
)
const val = response.data?.['note'] || ''
noticeText.value = val
noticeOriginal.value = val
} catch {
noticeText.value = ''
noticeOriginal.value = ''
}
}

watch(() => unref(resource)?.id, () => loadNotice(), { immediate: true })

async function saveNotice() {
if (unref(noticeText) === unref(noticeOriginal)) return
noticeSaving.value = true
try {
const res = unref(resource)
const sp = unref(space)
const httpClient = clientService.httpAuthenticated
await httpClient.put(
`/graph/v1beta1/drives/${sp.id}/items/${res.id}/metadata`,
{ note: unref(noticeText) }
)
noticeOriginal.value = unref(noticeText)
resourcesStore.updateResourceField({
id: res.id,
field: 'notice',
value: unref(noticeText) || undefined
})
} catch (e) {
console.error('Failed to save notice', e)
} finally {
noticeSaving.value = false
}
}

const authStore = useAuthStore()
const { publicLinkContextReady } = storeToRefs(authStore)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<template>
<div id="files-sidebar-panel-metadata" class="rounded-sm p-4 bg-role-surface-container">
<div v-if="loading" class="flex justify-center">
<oc-spinner :aria-label="$gettext('Loading metadata')" />
</div>
<div v-else-if="error" class="text-role-on-surface-variant">
{{ $gettext('Could not load metadata.') }}
</div>
<div v-else-if="isEmpty" class="text-role-on-surface-variant">
{{ $gettext('No metadata available.') }}
</div>
<dl
v-else
class="details-list grid grid-cols-[auto_minmax(0,1fr)] m-0"
:aria-label="$gettext('Custom metadata for the selected item')"
>
<template v-for="(value, key) in metadata" :key="key">
<dt class="font-medium" :data-testid="`metadata-key-${key}`">{{ formatKey(key) }}</dt>
<dd :data-testid="`metadata-value-${key}`">{{ value || '-' }}</dd>
</template>
</dl>
</div>
</template>

<script lang="ts">
import { computed, defineComponent, inject, onMounted, ref, Ref, unref, watch } from 'vue'
import { Resource, SpaceResource } from '@opencloud-eu/web-client'
import { useClientService } from '@opencloud-eu/web-pkg'

export default defineComponent({
name: 'MetadataPanel',
setup() {
const resource = inject<Ref<Resource>>('resource')
const space = inject<Ref<SpaceResource>>('space')
const clientService = useClientService()

const metadata = ref<Record<string, string>>({})
const loading = ref(false)
const error = ref(false)

const isEmpty = computed(() => Object.keys(unref(metadata)).length === 0)

const formatKey = (key: string): string => {
// Strip common prefixes for display
// "oy.subject" → "Subject", "oy.creatorName" → "Creator Name"
let display = key
if (display.startsWith('oy.')) {
display = display.substring(3)
}
// camelCase to Title Case
display = display.replace(/([A-Z])/g, ' $1')
return display.charAt(0).toUpperCase() + display.slice(1)
}

const fetchMetadata = async () => {
const res = unref(resource)
const sp = unref(space)
if (!res?.id || !sp?.id) return

loading.value = true
error.value = false
try {
const httpClient = clientService.httpAuthenticated
const response = await httpClient.get(
`/graph/v1beta1/drives/${sp.id}/items/${res.id}/metadata`
)
if (response.status === 200) {
metadata.value = response.data as Record<string, string>
} else {
metadata.value = {}
}
} catch (e) {
error.value = true
metadata.value = {}
} finally {
loading.value = false
}
}

onMounted(fetchMetadata)

watch(
() => unref(resource)?.id,
() => fetchMetadata()
)

return {
metadata,
loading,
error,
isEmpty,
formatKey
}
}
})
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export * from './useFileActionsRename'
export * from './useFileActionsShowShares'
export * from './useFileActionsShowDetails'
export * from './useFileActionsToggleHideShare'
export * from './useFileActionsImmutable'
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading