diff --git a/package-lock.json b/package-lock.json index 5e600704..5aa0d8ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8284,9 +8284,9 @@ "license": "MIT" }, "node_modules/n3": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/n3/-/n3-2.2.5.tgz", - "integrity": "sha512-lR/0N7zVayC6N0uWqrMkPpJn8Up4+qBsjWiBnoOWWEi/ldTcygs2Dw1iAgozzDXLW/fcxXFfbMzpGHlc/1MBLQ==", + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/n3/-/n3-2.2.7.tgz", + "integrity": "sha512-+fyUtz1gQn7LS9GVOGwFjT2HURt/0nHYxuRvyaxQbOwFFPXsZJa41Accf9vo/sj3YhgYvb21wOOJT9fN82r9SQ==", "license": "MIT", "dependencies": { "buffer": "^6.0.3", diff --git a/src/components/file-explorer-header/FileExplorerHeader.styles.css b/src/components/file-explorer-header/FileExplorerHeader.styles.css index d75d59f4..866bd8cb 100644 --- a/src/components/file-explorer-header/FileExplorerHeader.styles.css +++ b/src/components/file-explorer-header/FileExplorerHeader.styles.css @@ -7,6 +7,7 @@ flex-wrap: nowrap; width: 100%; text-align: right; - padding: 15px; + padding: 10px; + border-bottom: 1px solid var(--solid-ui-color-gray-200, #cbd5e1); } } diff --git a/src/components/file-explorer-header/FileExplorerHeader.ts b/src/components/file-explorer-header/FileExplorerHeader.ts index 136cdc3f..b51c4d82 100644 --- a/src/components/file-explorer-header/FileExplorerHeader.ts +++ b/src/components/file-explorer-header/FileExplorerHeader.ts @@ -10,8 +10,8 @@ import styles from './FileExplorerHeader.styles.css' import './FileExplorerHeaderSummary' import './FileExplorerHeaderControls' import { PaneIcon } from './types' -import { fetchContentAndMetadata, type FileExplorerResourceMetadata } from './helper' - +import { fetchResourceMetadata } from '../../utils/podUtils' +import { type FileExplorerResourceMetadata } from './types' @customElement('file-explorer-header') export default class FileExplorerHeader extends WebComponent { static styles = styles @@ -55,7 +55,7 @@ export default class FileExplorerHeader extends WebComponent { if (!this.fileExplorerContext?.store || !this.fileExplorerContext.subjectUri) return try { - const { metadata } = await fetchContentAndMetadata(this.fileExplorerContext.store, sym(this.fileExplorerContext.subjectUri)) + const metadata = await fetchResourceMetadata(this.fileExplorerContext.store, sym(this.fileExplorerContext.subjectUri)) this.responseMetadata = { modified: metadata.modified, isPublic: metadata.isPublic, diff --git a/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css b/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css index d962f83b..626163df 100644 --- a/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css +++ b/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css @@ -31,6 +31,12 @@ white-space: nowrap; } + icon-lucide-share-2, + icon-lucide-pencil { + width: 16px; + height: 16px; + } + @media (max-width: 900px) { div { padding-right: 3px; @@ -42,5 +48,17 @@ justify-content: flex-start; gap: 5px; } + + .file-explorer-header-access-button, + .file-explorer-header-edit-button { + display: none; + } + + icon-lucide-ellipsis-vertical { + width: 15.36px; + height: 15.36px; + flex-shrink: 0; + aspect-ratio: 1 / 1; + } } } diff --git a/src/components/file-explorer-header/FileExplorerHeaderControls.ts b/src/components/file-explorer-header/FileExplorerHeaderControls.ts index b6668ce4..5d5364dc 100644 --- a/src/components/file-explorer-header/FileExplorerHeaderControls.ts +++ b/src/components/file-explorer-header/FileExplorerHeaderControls.ts @@ -1,5 +1,5 @@ import { WebComponent } from 'solid-ui' -import { customElement, property } from 'lit/decorators.js' +import { customElement, property, state } from 'lit/decorators.js' import { consume } from '@lit/context' import { html, nothing } from 'lit' import 'solid-ui/components/button' @@ -8,20 +8,50 @@ import '~icons/lucide/pencil' import styles from './FileExplorerHeaderControls.styles.css' import '../resource-actions-menu/ResourceActionsMenu' import { fileExplorerContext, type FileExplorerContext } from 'solid-ui' +import { isContainerSubject } from '../../utils/podUtils' @customElement('file-explorer-header-controls') export default class FileExplorerHeaderControls extends WebComponent { static styles = styles + private mobileMediaQuery: MediaQueryList | undefined + private readonly mobileQuery = '(max-width: 600px)' + private readonly handleMobileMediaChange = (event: MediaQueryListEvent) => { + this.isMobile = event.matches + } + @consume({ context: fileExplorerContext, subscribe: true }) accessor fileExplorerContext: FileExplorerContext = undefined as unknown as FileExplorerContext @property({ attribute: false }) - accessor menuItems: Array<{ label: string, action: (event: Event) => void }> = [] + accessor menuItems: Array<{ label: string, action: (event: Event) => void, icon?: HTMLElement }> = [] @property({ type: Boolean }) accessor canEdit: boolean = false + @state() + accessor isMobile = typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(max-width: 600px)').matches + : false + + connectedCallback () { + super.connectedCallback() + + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return + } + + this.mobileMediaQuery = window.matchMedia(this.mobileQuery) + this.isMobile = this.mobileMediaQuery.matches + this.mobileMediaQuery.addEventListener('change', this.handleMobileMediaChange) + } + + disconnectedCallback () { + this.mobileMediaQuery?.removeEventListener('change', this.handleMobileMediaChange) + this.mobileMediaQuery = undefined + super.disconnectedCallback() + } + // TODO: Add broken then use this function to set tooltip and disable edit button /* private setEditable() { const sourcePaneState = this.sourceContext?.sourcePaneState @@ -32,10 +62,6 @@ export default class FileExplorerHeaderControls extends WebComponent { this.sourceContext?.setEditing?.() } */ - private handleEditingClick () { - this.fileExplorerContext.edit?.onEdit?.() - } - private getEditTooltip () { if (!this.fileExplorerContext.paneSupportsEditing) return 'Not Supported' if (!this.canEdit) return 'No Access' @@ -49,24 +75,36 @@ export default class FileExplorerHeaderControls extends WebComponent { } render () { + const isContainerResource = isContainerSubject(this.fileExplorerContext.store, this.fileExplorerContext.subjectUri) + return html`
${this.renderDirtyIndicator()} - - - - - - + ${!isContainerResource && !this.isMobile + ? html` + + + + + + + ` + : nothing}
` diff --git a/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css b/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css index 0fce991a..db9952b7 100644 --- a/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css @@ -12,8 +12,25 @@ h1 { color: var(--solid-ui-color-gray-700, #364153); - font-size: var(--solid-ui-font-size-2xl, 1.5rem); + font-size: var(--solid-ui-font-size-xl, 1.25rem); /* while in expand; will go to 2xl when new design is complete */ font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + } + + .resource-info { + display: flex; + flex-direction: column; + min-width: 0; + } + + .container-info { + display: flex; + flex-direction: row; + gap: 10px; + min-width: 0; } .pane-icon { @@ -82,8 +99,50 @@ height: 0.875rem; } + .resource-date { + font-size: inherit; + } + icon-lucide-arrow-left { width: 1.125rem; height: 1.125rem; } + + @media (max-width: 600px) { + .file-explorer-header-summary { + gap: 7px; + } + + h1 { + font-size: 14px; + } + + .pane-icon { + width: 18.2px; + height: 18.2px; + flex-shrink: 0; + aspect-ratio: 1 / 1; + padding: 0; + } + + p { + font-size: 14px; + } + + .resource-date { + font-size: 10px; + } + + .public, + .private { + font-size: 9px; + } + + icon-lucide-arrow-left { + width: 15px; + height: 18px; + flex-shrink: 0; + aspect-ratio: 5 / 6; + } + } } diff --git a/src/components/file-explorer-header/FileExplorerHeaderSummary.ts b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts index a02b6947..2ca785d5 100644 --- a/src/components/file-explorer-header/FileExplorerHeaderSummary.ts +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts @@ -8,8 +8,10 @@ import { PaneIcon } from './types' import '~icons/lucide/globe' import '~icons/lucide/lock-keyhole' import '~icons/lucide/arrow-left' +import '~icons/lucide/folder' import styles from './FileExplorerHeaderSummary.styles.css' -import { type FileExplorerResourceMetadata } from './helper' +import { getContainerItemCount, isContainerSubject } from '../../utils/podUtils' +import type { FileExplorerResourceMetadata } from './types' @customElement('file-explorer-header-summary') export default class FileExplorerHeaderSummary extends WebComponent { @@ -93,11 +95,41 @@ export default class FileExplorerHeaderSummary extends WebComponent { } } + private renderContainerResourceHeader (label: string, isPublic: boolean) { + const itemCount = getContainerItemCount(this.fileExplorerContext?.store, this.fileExplorerContext?.subjectUri) ?? 0 + return html` +
+

+ ${label} +

+

+ ${itemCount} items + ${isPublic + ? html`` + : html``} +

+
+ ` + } + + private renderResourceHeader (label: string, isPublic: boolean) { + const modified = this.formatModifiedDate(this.responseMetadata.modified) + + return html` +
+

+ ${label} +

+

${modified} ${isPublic ? html` Public` : html` Private`}

+
+ ` + } + render () { const subject = this.fileExplorerContext?.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined const label = subject ? utils.label(subject) : '' - const modified = this.formatModifiedDate(this.responseMetadata.modified) const isPublic = this.responseMetadata.isPublic + const isContainerResource = isContainerSubject(this.fileExplorerContext?.store, this.fileExplorerContext?.subjectUri) return html`
@@ -111,10 +143,8 @@ export default class FileExplorerHeaderSummary extends WebComponent { ${this.resolvedPaneIcon ? html`` : ''} -
-

${label}

-

${modified} ${isPublic ? html` Public` : html` Private`}

-
+ ${isContainerResource ? this.renderContainerResourceHeader(label, isPublic) : this.renderResourceHeader(label, isPublic)} +
` } diff --git a/src/components/file-explorer-header/FileExplorerProvider.ts b/src/components/file-explorer-header/FileExplorerProvider.ts index 07ea4c24..3696b3e5 100644 --- a/src/components/file-explorer-header/FileExplorerProvider.ts +++ b/src/components/file-explorer-header/FileExplorerProvider.ts @@ -9,6 +9,7 @@ import './FileExplorerHeader' import styles from './FileExplorerProvider.styles.css' import personIcon from '../../icons/person.svg' import friendsIcon from '../../icons/friends.svg' +import '~icons/lucide/share-2' const PERSON_ICON = personIcon const FRIENDS_ICON = friendsIcon @@ -20,7 +21,7 @@ function createFileExplorerContextValue (value: { soloPane?: boolean onBack?: () => void openPane?: (subject: NamedNode, paneName: string) => void - handleSharingClick?: () => void + handleAccessClick?: () => void paneSupportsEditing?: boolean edit?: { onEdit?: () => void @@ -35,7 +36,7 @@ function createFileExplorerContextValue (value: { soloPane: value.soloPane, onBack: value.onBack, openPane: value.openPane, - handleSharingClick: value.handleSharingClick, + handleAccessClick: value.handleAccessClick, paneSupportsEditing: value.paneSupportsEditing, edit: value.edit } @@ -66,7 +67,7 @@ export default class FileExplorerProvider extends WebComponent { accessor showHeader: boolean = true @property({ attribute: false }) - accessor handleSharingClick: (() => void) | undefined = undefined + accessor handleAccessClick: (() => void) | undefined = undefined // TODO: Need to research this more, brought it over from manager. @property({ attribute: false }) @@ -125,7 +126,7 @@ export default class FileExplorerProvider extends WebComponent { soloPane: this.soloPane, onBack: this.onBack, openPane: this.openPane, - handleSharingClick: this.handleSharingClick, + handleAccessClick: this.handleAccessClick, paneSupportsEditing: false, edit: this.edit }) @@ -181,7 +182,7 @@ export default class FileExplorerProvider extends WebComponent { soloPane: this.soloPane, onBack: this.onBack, openPane: this.openPane, - handleSharingClick: this.handleSharingClick, + handleAccessClick: this.handleAccessClick, paneSupportsEditing: this.paneSupportsEditing, edit: this.edit }) @@ -228,7 +229,7 @@ export default class FileExplorerProvider extends WebComponent { changedProperties.has('soloPane') || changedProperties.has('onBack') || changedProperties.has('openPane') || - changedProperties.has('handleSharingClick') || + changedProperties.has('handleAccessClick') || changedProperties.has('pane') || changedProperties.has('isDirty') ) { diff --git a/src/components/file-explorer-header/helper.ts b/src/components/file-explorer-header/helper.ts deleted file mode 100644 index 129c30bb..00000000 --- a/src/components/file-explorer-header/helper.ts +++ /dev/null @@ -1,105 +0,0 @@ -// TODO: this code will need to be moved later -import { ACL_LINK } from 'solid-logic' -import { ns } from 'solid-ui' -import type { LiveStore, NamedNode } from 'rdflib' - -export type FileExplorerResourceMetadata = { - contentType: string | undefined - canEdit: boolean - isPublic: boolean - aclUri: string | undefined - eTag: string | undefined - modified: string | undefined -} - -function parseWacAllowHeader (headerValue: string | null | undefined) { - const permissions = new Map>() - if (!headerValue) return permissions - - for (const entry of headerValue.split(',')) { - const match = entry.trim().match(/^([A-Za-z]+)\s*=\s*"([^"]*)"$/) - if (match) { - const [, permissionGroup, accessModes] = match - const modes = accessModes.trim().split(/\s+/).filter(Boolean) - permissions.set(permissionGroup.toLowerCase(), new Set(modes.map(mode => mode.toLowerCase()))) - } - } - - return permissions -} - -function deriveAccessFlags (wacAllow: string | null | undefined) { - if (!wacAllow) { - return { canEdit: false, isPublic: false } - } - - const permissions = parseWacAllowHeader(wacAllow) - const userModes = permissions.get('user') ?? new Set() - const publicModes = permissions.get('public') ?? new Set() - - return { - canEdit: userModes.has('write'), - isPublic: publicModes.has('read') || publicModes.has('write') - } -} - -export function getResponseMetadata (store: LiveStore, subject: NamedNode, response: Response): FileExplorerResourceMetadata { - let contentType: string | undefined - let canEdit = false - let isPublic = false - let eTag: string | undefined - let modified: string | undefined - - if (response.headers && response.headers.get('content-type')) { - contentType = response.headers.get('content-type')?.split(';')[0] ?? undefined - const accessFlags = deriveAccessFlags(response.headers.get('wac-allow')) - - canEdit = accessFlags.canEdit - isPublic = accessFlags.isPublic - eTag = response.headers.get('etag') ?? undefined - modified = store.anyValue(subject as any, ns.dct('modified')) || store.anyValue(subject as any, ns.dc('modified')) || undefined - } else { - const reqs = store.each( - null, - store.sym('http://www.w3.org/2007/ont/link#requestedURI'), - subject - ) - reqs.forEach((req: any) => { - const responseNode = store.any( - req as any, - store.sym('http://www.w3.org/2007/ont/link#response') - ) - if (responseNode && responseNode.termType === 'NamedNode') { - contentType = store.anyValue(responseNode as any, ns.httph('content-type')) || undefined - const wacAllow = (store.anyValue(responseNode as any, ns.httph('wac-allow')) as string | undefined) || - (store.anyValue(responseNode as any, ns.httph('WAC-Allow')) as string | undefined) - const accessFlags = deriveAccessFlags(wacAllow) - canEdit = accessFlags.canEdit - isPublic = accessFlags.isPublic - eTag = store.anyValue(responseNode as any, ns.httph('etag')) || undefined - modified = store.anyValue(subject as any, ns.dct('modified')) || store.anyValue(subject as any, ns.dc('modified')) || undefined - } - }) - } - - const aclUri = store.any(subject, ACL_LINK)?.value || undefined - return { contentType, canEdit, isPublic, aclUri, eTag, modified } -} - -export async function fetchContentAndMetadata (store: LiveStore, subject: NamedNode): Promise<{ content: string, metadata: FileExplorerResourceMetadata }> { - const fetcher = store.fetcher - - const response = await fetcher.webOperation('GET', subject.uri) - const content = (response as Response & { responseText?: string }).responseText - - if (content === undefined) { - throw new Error('No text in response object!!') - } - - const metadata = getResponseMetadata(store, subject, response) - if (!metadata.contentType) { - throw new Error('No content-type available!') - } - - return { content, metadata } -} diff --git a/src/components/file-explorer-header/index.ts b/src/components/file-explorer-header/index.ts new file mode 100644 index 00000000..2140b416 --- /dev/null +++ b/src/components/file-explorer-header/index.ts @@ -0,0 +1,2 @@ +export { default as FileExplorerHeader } from './FileExplorerHeader' +export { default as FileExplorerProvider } from './FileExplorerProvider' diff --git a/src/components/file-explorer-header/types.ts b/src/components/file-explorer-header/types.ts index b3b3ecbd..826d13a8 100644 --- a/src/components/file-explorer-header/types.ts +++ b/src/components/file-explorer-header/types.ts @@ -1 +1,10 @@ export type PaneIcon = string | Promise | null | undefined + +export type FileExplorerResourceMetadata = { + contentType: string | undefined + canEdit: boolean + isPublic: boolean + aclUri: string | undefined + eTag: string | undefined + modified: string | undefined +} diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.styles.css b/src/components/resource-actions-menu/ResourceActionsMenu.styles.css new file mode 100644 index 00000000..33a5392f --- /dev/null +++ b/src/components/resource-actions-menu/ResourceActionsMenu.styles.css @@ -0,0 +1,6 @@ +:host { + .ellipsisIcon { + width: 16px; + height: 16px; + } +} diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.ts b/src/components/resource-actions-menu/ResourceActionsMenu.ts index 884b3004..e441e70d 100644 --- a/src/components/resource-actions-menu/ResourceActionsMenu.ts +++ b/src/components/resource-actions-menu/ResourceActionsMenu.ts @@ -1,17 +1,19 @@ -import { showDialog, utils, WebComponent } from 'solid-ui' +import { WebComponent } from 'solid-ui' import { customElement, property } from 'lit/decorators.js' -import { html } from 'lit' +import { html, nothing } from 'lit' import 'solid-ui/components/button' import 'solid-ui/components/menu' import 'solid-ui/components/menu-item' -import DeleteResourceDialog from './DeleteResourceDialog' import '~icons/lucide/ellipsis-vertical' -import '~icons/lucide/trash-2' +import '~icons/lucide/share-2' +import '~icons/lucide/pencil' import { LiveStore } from 'rdflib' -// TODO: Add Error Status section - +import styles from './ResourceActionsMenu.styles.css' +import { isContainerSubject } from '../../utils/podUtils' @customElement('resource-actions-menu') export default class ResourceActionsMenu extends WebComponent { + static styles = styles + @property({ attribute: false }) accessor store: LiveStore | undefined @@ -19,74 +21,31 @@ export default class ResourceActionsMenu extends WebComponent { accessor subjectUri: string | undefined @property({ attribute: false }) - accessor menuItems: Array<{ label: string, icon?: HTMLElement, action: (event: Event) => void }> = [] - - private confirmDelete (resourceName: string) { - return new Promise(resolve => { - showDialog(DeleteResourceDialog, { - props: { resourceName }, - onClose: (result) => resolve(result === true) - }) - }) - } - - /* private gotoParentFolder(resourceUri: string) { - const sourceContext = this.sourceContext - const outliner = sourceContext?.context?.getOutliner?.(sourceContext.context.dom) - - if (!outliner) return - - const parentFolderUri = this.getParentFolderUri(resourceUri) - ;(outliner as any).GotoSubject(sourceContext.context.session.store.sym(parentFolderUri), true, undefined, true, undefined) - } */ + accessor handleAccessClick: (() => void) | undefined = undefined - private async deleteResourceIfPresent (store: LiveStore, uri: string) { - try { - await store.fetcher.webOperation('DELETE', uri) - } catch (err: any) { - const status = err?.response?.status ?? err?.status - if (status === 404) return - throw err - } - } - - // TODO: Below is for a file only. I need to move this to a function - // that function needs to check if it's a container and if so we can do the - // recursive delete function. - // dont' forget public and private type indexes. - private async handleDelete (event: Event) { - event.preventDefault() - - if (!this.store?.fetcher || !this.subjectUri) return - - const store = this.store - const resourceNode = store.sym(this.subjectUri) + @property({ attribute: false }) + accessor handleEditingClick: (() => void) | undefined = undefined - const confirmation = await this.confirmDelete(utils.label(resourceNode)) - if (!confirmation) return + @property({ type: Boolean }) + accessor paneSupportsEditing = false - try { - await this.deleteResourceIfPresent(store, resourceNode.value) + @property({ type: Boolean }) + accessor canEdit = false - const aclUri = this.subjectUri + '.acl' - if (aclUri) { - await this.deleteResourceIfPresent(store, aclUri) - store.removeDocument(store.sym(aclUri)) - } + @property({ type: Boolean }) + accessor isMobile = false - store.removeDocument(resourceNode) - // this.gotoParentFolder(resourceNode.value) - } catch (err) { - // error('Error deleting resource:', err) - // getStatusSection()?.showError('Failed to delete resource. Check console for details.') - } - } + @property({ attribute: false }) + accessor menuItems: Array<{ label: string, icon?: unknown, action: (event: Event) => void }> = [] render () { + const isContainerResource = isContainerSubject(this.store, this.subjectUri) + const canEdit = !isContainerResource && this.isMobile && this.paneSupportsEditing && this.canEdit && !!this.handleEditingClick + const canManageAccess = !!this.handleAccessClick return html` - + ${this.menuItems.map(item => html` item.action(event)}> @@ -94,10 +53,30 @@ export default class ResourceActionsMenu extends WebComponent { ${item.label} `)} - this.handleDelete(event)}> - - Delete - + ${canEdit + ? html` + + + Edit + + ` + : nothing} + ${!isContainerResource && this.isMobile && canManageAccess + ? html` + + + Manage Access + + ` + : nothing} + ${isContainerResource && canManageAccess + ? html` + + + Manage Access + + ` + : nothing} ` } diff --git a/src/outline/manager.css b/src/outline/manager.css index 4915eed5..8e7901e2 100644 --- a/src/outline/manager.css +++ b/src/outline/manager.css @@ -80,3 +80,12 @@ width: 12px; height: 12px; } + +@media (max-width: 600px) { + .obj, + .iconTD { + font-size: 100%; + margin: 0 !important; + vertical-align: top; + } +} diff --git a/src/outline/manager.js b/src/outline/manager.js index 1207ad30..afb06da2 100644 --- a/src/outline/manager.js +++ b/src/outline/manager.js @@ -13,7 +13,7 @@ import { UserInput } from './userInput.js' import * as queryByExample from './queryByExample.js' import { loadContainerRepresentation } from '../utils/podUtils' import { isWebIdUri } from '../utils/webIdUtils' -import '../components/file-explorer-header/FileExplorerProvider' +import '../components/file-explorer-header' export default function (context) { const dom = context.dom @@ -711,10 +711,7 @@ export default function (context) { // Add the x more here const moreTR = dom.createElement('tr') const moreTD = moreTR.appendChild(dom.createElement('td')) - moreTD.setAttribute( - 'style', - 'margin: 0.2em; border: none; padding: 0; vertical-align: top;' - ) + moreTD.classList.add('obj') moreTD.setAttribute('notSelectable', 'false') if (predDups > n) { // what is this for?? @@ -790,23 +787,14 @@ export default function (context) { termWidget.construct = function (dom) { dom = dom || document const td = dom.createElement('TD') - td.setAttribute( - 'style', - 'margin: 0.2em; border: none; padding: 0; vertical-align: top;' - ) td.setAttribute('class', 'iconTD') td.setAttribute('notSelectable', 'true') - td.style.width = '0px' return td } termWidget.addIcon = function (td, icon, listener) { const iconTD = td.childNodes[1] if (!iconTD) return - let width = iconTD.style.width const img = UI.utils.AJARImage(icon.src, icon.alt, icon.tooltip, dom) - width = parseInt(width) - width = width + icon.width - iconTD.style.width = width + 'px' iconTD.appendChild(img) if (listener) { img.addEventListener('click', listener) @@ -816,10 +804,6 @@ export default function (context) { const iconTD = td.childNodes[1] let baseURI if (!iconTD) return - let width = iconTD.style.width - width = parseInt(width) - width = width - icon.width - iconTD.style.width = width + 'px' for (let x = 0; x < iconTD.childNodes.length; x++) { const elt = iconTD.childNodes[x] const eltSrc = elt.src diff --git a/src/utils/podUtils.ts b/src/utils/podUtils.ts index aa7b4aaa..27abd7dd 100644 --- a/src/utils/podUtils.ts +++ b/src/utils/podUtils.ts @@ -1,7 +1,40 @@ import { store } from 'solid-logic' import { ns } from 'solid-ui' -import { NamedNode, parse } from 'rdflib' +import { LiveStore, NamedNode, parse } from 'rdflib' import { isWebIdUri } from './webIdUtils' +import { ACL_LINK } from 'solid-logic' +import { FileExplorerResourceMetadata } from 'src/components/file-explorer-header/types' + +function parseWacAllowHeader (headerValue: string | null | undefined) { + const permissions = new Map>() + if (!headerValue) return permissions + + for (const entry of headerValue.split(',')) { + const match = entry.trim().match(/^([A-Za-z]+)\s*=\s*"([^"]*)"$/) + if (match) { + const [, permissionGroup, accessModes] = match + const modes = accessModes.trim().split(/\s+/).filter(Boolean) + permissions.set(permissionGroup.toLowerCase(), new Set(modes.map(mode => mode.toLowerCase()))) + } + } + + return permissions +} + +function deriveAccessFlags (wacAllow: string | null | undefined) { + if (!wacAllow) { + return { canEdit: false, isPublic: false } + } + + const permissions = parseWacAllowHeader(wacAllow) + const userModes = permissions.get('user') ?? new Set() + const publicModes = permissions.get('public') ?? new Set() + + return { + canEdit: userModes.has('write'), + isPublic: publicModes.has('read') || publicModes.has('write') + } +} export async function getPodStorages (url: NamedNode): Promise { if (isWebIdUri(url)) { @@ -56,3 +89,102 @@ export async function loadContainerRepresentation (subject) { } } } + +export function isContainerSubject (store: LiveStore | undefined, subjectUri: string | undefined): boolean { + if (!store || !subjectUri) return false + + const subject = store.sym(subjectUri) + const typeUris = store.findTypeURIs(subject) + return Boolean( + typeUris[ns.ldp('Container').uri] || + typeUris[ns.ldp('BasicContainer').uri] || + subject.uri.endsWith('/') + ) +} + +// The shared rdflib store can hold duplicate ldp:contains statements for the +// same child resource when the container and companion metadata are both loaded. +// Count unique visible children here so the header summary matches the list UI. +export function getContainerItemCount (store: LiveStore | undefined, subjectUri: string | undefined): number { + if (!store || !subjectUri) return 0 + + const subject = store.sym(subjectUri) + const seen = new Set() + + for (const item of store.each(subject, ns.ldp('contains'))) { + if (item.termType === 'NamedNode') { + const resource = item as NamedNode + const parent = resource.dir() + + if (parent) { + const pathEnd = resource.uri.slice(parent.uri.length) + if ( + !pathEnd.startsWith('.') && + !pathEnd.endsWith('.acl') && + !pathEnd.endsWith('~') + ) { + seen.add(resource.uri) + } + } + } + } + return seen.size +} + +export function getResponseMetadata (store: LiveStore, subject: NamedNode, response: Response): FileExplorerResourceMetadata { + let contentType: string | undefined + let canEdit = false + let isPublic = false + let eTag: string | undefined + let modified: string | undefined + + if (response.headers && response.headers.get('content-type')) { + contentType = response.headers.get('content-type')?.split(';')[0] ?? undefined + const accessFlags = deriveAccessFlags(response.headers.get('wac-allow')) + + canEdit = accessFlags.canEdit + isPublic = accessFlags.isPublic + eTag = response.headers.get('etag') ?? undefined + modified = store.anyValue(subject as any, ns.dct('modified')) || store.anyValue(subject as any, ns.dc('modified')) || undefined + } else { + const reqs = store.each( + null, + store.sym('http://www.w3.org/2007/ont/link#requestedURI'), + subject + ) + reqs.forEach((req: any) => { + const responseNode = store.any( + req as any, + store.sym('http://www.w3.org/2007/ont/link#response') + ) + if (responseNode && responseNode.termType === 'NamedNode') { + contentType = store.anyValue(responseNode as any, ns.httph('content-type')) || undefined + const wacAllow = (store.anyValue(responseNode as any, ns.httph('wac-allow')) as string | undefined) || + (store.anyValue(responseNode as any, ns.httph('WAC-Allow')) as string | undefined) + const accessFlags = deriveAccessFlags(wacAllow) + canEdit = accessFlags.canEdit + isPublic = accessFlags.isPublic + eTag = store.anyValue(responseNode as any, ns.httph('etag')) || undefined + modified = store.anyValue(subject as any, ns.dct('modified')) || store.anyValue(subject as any, ns.dc('modified')) || undefined + } + }) + } + + const aclUri = store.any(subject, ACL_LINK)?.value || undefined + return { contentType, canEdit, isPublic, aclUri, eTag, modified } +} + +export async function fetchResourceMetadata (store: LiveStore, subject: NamedNode): Promise { + const response = await store.fetcher.webOperation('HEAD', subject.uri) + + if (!response.ok) { + throw new Error(`HEAD request failed with status ${response.status}`) + } + + const metadata = getResponseMetadata(store, subject, response) + if (!metadata.contentType) { + throw new Error('No content-type available!') + } + + return metadata +}