diff --git a/package-lock.json b/package-lock.json index cd23609f4..5dc62ea20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-ui", - "version": "5.0.2-2", + "version": "5.0.2-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-ui", - "version": "5.0.2-2", + "version": "5.0.2-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -30,9 +30,9 @@ "escape-html": "^1.0.3", "lit": "^3.3.3", "mime-types": "^3.0.2", - "pane-registry": "5.0.2-0", + "pane-registry": "5.0.2-1", "rdflib": "2.4.1", - "solid-logic": "6.0.1-3", + "solid-logic": "6.0.1-4", "solid-namespace": "^0.5.4", "uuid": "^14.0.0" }, @@ -3265,9 +3265,9 @@ "license": "MIT" }, "node_modules/@lezer/common": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", - "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.4.tgz", + "integrity": "sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w==", "license": "MIT" }, "node_modules/@lezer/css": { @@ -6250,32 +6250,62 @@ } }, "node_modules/@volar/language-core": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", - "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.30.tgz", + "integrity": "sha512-73fjwEV8wat6aOZ6U/y/hqtE30ocrIWECg67b0txGczOMNw+MffLmvJ5fnFfN9drVUEYZzLdaNo6VAy6G6avMw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.28" + "@volar/source-map": "2.4.30" + } + }, + "node_modules/@volar/language-service": { + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.30.tgz", + "integrity": "sha512-w/xDsASiqC71ggCOm7sTDXDNk/l72XEptodeRC68tgZ0e5QM4hM3zAqBGC7mBctvhrpYwKfMG+t9xcUo2hEFtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.30", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "<7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@volar/source-map": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", - "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.30.tgz", + "integrity": "sha512-zym1ob/KwDDyN+0jGVtxwbxJSRRnUvv5HspCtz3yEfVVdCG4/dzxJktMGPAgemqRuVs81O0o/y0q3qOUErR8kg==", "dev": true, "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", - "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "version": "2.4.30", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.30.tgz", + "integrity": "sha512-/DgA5tgYFPfrLFuSjIMXMGHk1LQv2+P8eny6+BhGNzFJELWIW7UZlc56ZLIRFhLsPpMfIFxM8/HvOPBggrjTZg==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.28", + "@volar/language-core": "2.4.30", + "@volar/language-service": "2.4.30", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "<7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@webcontainer/env": { @@ -6732,9 +6762,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.30", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.30.tgz", - "integrity": "sha512-YJs6Pz17RMNv8HoyKREtCY38E3BV1nPkWVmIb+XN1uHC1Vqt3KZCH/uJH9ZKRoiKb/icZ6tvehyee6RZNbFfRA==", + "version": "2.11.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.31.tgz", + "integrity": "sha512-b0k7wXlWjpuL/Uxb/pYlpjY0ej5BjAmurtPUk3eRGuloI4yjK32m8TiKUQ81l1ptcLjKS/KH2+uqIolZti1x4Q==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6929,9 +6959,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001818", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001818.tgz", - "integrity": "sha512-XJbgo4adscw7R4MZU4/qVFkoMyPaNUAWmdNY4o/k9x7fFwmv4frIlndI9lWuaxviW9ObiZel9LuncnQPn6hQ5A==", + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", "dev": true, "funding": [ { @@ -11115,12 +11145,13 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.6.tgz", - "integrity": "sha512-RzlO2IIhNxWEahiVrwUYvk+PRoP2SdKLTUt7Yfmwjstc2MRULti2uwc9EkBRTMwkYtfITwVzgBLkS4K4esCRGg==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.7.tgz", + "integrity": "sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ==", "dev": true, "license": "MIT", "bin": { + "napi-patch": "lib/patch.js", "napi-postinstall": "lib/cli.js" }, "engines": { @@ -11366,9 +11397,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.59", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", - "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", "dev": true, "license": "MIT", "engines": { @@ -11712,29 +11743,13 @@ "license": "MIT" }, "node_modules/pane-registry": { - "version": "5.0.2-0", - "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.2-0.tgz", - "integrity": "sha512-6fU9IyEAttMJwFThbGjLvyrXXNYx0+BmNc4InKA7kFJdVq4ozyojMcrpq4lT2g7xzR1o5xt+Ah+urTxEMQdpcQ==", + "version": "5.0.2-1", + "resolved": "https://registry.npmjs.org/pane-registry/-/pane-registry-5.0.2-1.tgz", + "integrity": "sha512-gDub2yjXejZ0JQSsEvDpUhff4e205+IIKV2iYJCbBBs65QiN/2a4wmpv2/NZJjoF6FADW+ndv8nSc6w3CxuYFg==", "license": "MIT", "dependencies": { "rdflib": "2.4.1", - "solid-logic": "6.0.1-0" - } - }, - "node_modules/pane-registry/node_modules/solid-logic": { - "version": "6.0.1-0", - "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-0.tgz", - "integrity": "sha512-ZEJ4aRK5Q16sYoNTiH8aJ2ctvpZPwvsPYKawL9pmPC7qvF6wVnOucuNLp1EHHYhdX1Wkp9a25mp/my1IsQgofA==", - "license": "MIT", - "dependencies": { - "@dokieli/web-access-control": "^1.0.0", - "@uvdsl/solid-oidc-client-browser": "^0.2.3", - "solid-namespace": "^0.5.4", - "vite": "^8.0.16", - "vitest": "^4.1.9" - }, - "engines": { - "node": ">=18" + "solid-logic": "6.0.1-4" } }, "node_modules/parent-module": { @@ -12776,9 +12791,9 @@ } }, "node_modules/solid-logic": { - "version": "6.0.1-3", - "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-3.tgz", - "integrity": "sha512-4g3Hue5NiuGJZxdrFa2Q6OWGog5L6esBwUOIheCPQvIXxn/Cmd8WvykaIdFYWDwpytMmxqdwW034sdVVzY7bdw==", + "version": "6.0.1-4", + "resolved": "https://registry.npmjs.org/solid-logic/-/solid-logic-6.0.1-4.tgz", + "integrity": "sha512-EVvS+5g91aUoONqGtM1vPnMimtwH6c/72td5b4qBRcudDjeRU/hycPg8p5abmx1YWZseLA1EFK76+E/t5bcPFw==", "license": "MIT", "dependencies": { "@dokieli/web-access-control": "^1.1.0", @@ -14424,6 +14439,41 @@ "node": ">=18" } }, + "node_modules/vscode-jsonrpc": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-10.0.0.tgz", + "integrity": "sha512-USSfxg0vDIpTQqlQBGxF3mBARyoS8cU7LHLpbYNh6dOJt32dlskxPWB0Irrh5TllTmyf7oNLAvqDqzklbwwnpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.5.tgz", + "integrity": "sha512-FW4fBMzZ99N8xeob4py0l050wgCoRgTpEq5Xcsb9lM1bc29UKGR/wSV+4knaqFxssFHajy7OzNiKP/m3U1ZEuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "10.0.0", + "vscode-languageserver-types": "3.18.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.16.tgz", + "integrity": "sha512-qeNEuOi9a3qkqRf7Gfez2WL2VwlBTLCNekHBGO9kxPVUp9XuY15yfTJ9jwvOwKI0I2MhUUfZq95NSp+LK1CLQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.5.tgz", + "integrity": "sha512-kPd6+JQNz3HMJvjKlqUxERSlZHAntl/aGpsk7ImwIh0ewDKDcjgAEyVSYciPiOJD3ftbK9IrfiqUeTZLSbCizw==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", diff --git a/package.json b/package.json index 5d4e76bca..0a97bd893 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-ui", - "version": "5.0.2-2", + "version": "5.0.2-3", "description": "UI library for Solid applications", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", @@ -112,9 +112,9 @@ "escape-html": "^1.0.3", "lit": "^3.3.3", "mime-types": "^3.0.2", - "pane-registry": "5.0.2-0", + "pane-registry": "5.0.2-1", "rdflib": "2.4.1", - "solid-logic": "6.0.1-3", + "solid-logic": "6.0.1-4", "solid-namespace": "^0.5.4", "uuid": "^14.0.0" }, diff --git a/src/components/access-control-modal/AccessControlModal.ts b/src/components/access-control-modal/AccessControlModal.ts index cc1a5446a..bf0c157f8 100644 --- a/src/components/access-control-modal/AccessControlModal.ts +++ b/src/components/access-control-modal/AccessControlModal.ts @@ -405,7 +405,7 @@ export default class AccessControlModal extends DialogComponent { return html` ` } diff --git a/src/components/file-explorer-header/FileExplorerHeader.styles.css b/src/components/file-explorer-header/FileExplorerHeader.styles.css new file mode 100644 index 000000000..5902ad65d --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeader.styles.css @@ -0,0 +1,14 @@ +:host { + header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; + flex-wrap: nowrap; + box-sizing: border-box; + width: 100%; + text-align: right; + 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 new file mode 100644 index 000000000..079e905c9 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeader.ts @@ -0,0 +1,109 @@ +import { sym } from 'rdflib' +import { solidLogicSingleton } from 'solid-logic' +import { WebComponent } from '@/lib/components' +import { DEFAULT_FILE_EXPLORER_CONTEXT, fileExplorerContext } from '@/lib/file-explorer/context' +import { FILE_EXPLORER_BACK_EVENT } from '@/lib/file-explorer/events' +import { customElement, property, state } from 'lit/decorators.js' +import { consume } from '@lit/context' +import { html } from 'lit' +import '~icons/lucide/share-2' +import '~icons/lucide/pencil' +import '~icons/lucide/ellipsis-vertical' +import styles from './FileExplorerHeader.styles.css' +import './FileExplorerHeaderSummary' +import './FileExplorerHeaderControls' +import { PaneIcon } from './types' +import { type FileExplorerResourceMetadata } from './types' + +@customElement('solid-ui-file-explorer-header') +export default class FileExplorerHeader extends WebComponent { + static styles = styles + + private _loadedMetadataForUri: string | undefined + private _metadataGeneration = 0 + + @consume({ context: fileExplorerContext, subscribe: true }) + accessor fileExplorerContext = DEFAULT_FILE_EXPLORER_CONTEXT + + @property({ attribute: false }) + accessor menuItems: Array<{ label: string, action: (event: Event) => void }> = [] + + @property({ attribute: false }) + accessor paneIcon: PaneIcon = undefined as unknown as PaneIcon + + @state() + accessor responseMetadata: Pick = { + modified: undefined, + isPublic: false, + canEdit: false, + canManageAccess: false, + aclUri: undefined + } + + private handleBack = () => { + this.dispatchEvent(new CustomEvent(FILE_EXPLORER_BACK_EVENT, { + bubbles: true, + composed: true, + detail: { + subjectUri: this.fileExplorerContext.subjectUri + } + })) + } + + private getDefaultResponseMetadata (): Pick { + return { + modified: undefined, + isPublic: false, + canEdit: false, + canManageAccess: false, + aclUri: undefined + } + } + + protected willUpdate () { + const subjectUri = this.fileExplorerContext?.subjectUri + if (this.fileExplorerContext?.store && subjectUri && this._loadedMetadataForUri !== subjectUri) { + this._loadedMetadataForUri = subjectUri + const generation = ++this._metadataGeneration + this.responseMetadata = this.getDefaultResponseMetadata() + this.loadResponseMetadata(subjectUri, generation) + } + } + + private async loadResponseMetadata (subjectUri: string, generation: number) { + try { + const metadata = await solidLogicSingleton.resource.fetchMetadata(sym(subjectUri)) + if (generation === this._metadataGeneration && this.fileExplorerContext.subjectUri === subjectUri) { + this.responseMetadata = { + modified: metadata.modified, + isPublic: metadata.access.isPublic, + canEdit: metadata.access.canEdit, + canManageAccess: metadata.access.canManageAccess, + aclUri: metadata.aclUri + } + } + } catch (error) { + if (generation === this._metadataGeneration && this.fileExplorerContext.subjectUri === subjectUri) { + this.responseMetadata = this.getDefaultResponseMetadata() + console.warn('Failed to load response metadata', error) + } + } + } + + render () { + return html` +
+ + +
+ ` + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css b/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css new file mode 100644 index 000000000..dc411a3ca --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderControls.styles.css @@ -0,0 +1,69 @@ +:host { + flex: 0 1 auto; + min-width: 0; + + div { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 6px; + flex: 0 0 auto; + text-align: right; + } + + /* generative ai */ + div > * { + flex: 0 1 auto; + } + + .file-explorer-header-action-button { + width: var(--solid-ui-clickable-area, 42px); + height: var(--solid-ui-clickable-area, 42px); + padding: 0; + } + + /* TODO: look into what colors to use this is just to show */ + .dirtyIndicator { + display: inline-flex; + align-items: center; + border: 1px solid #F59E0B; + color: #92400E; + background: #FFFBEB; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + line-height: 1; + padding: 4px 8px; + white-space: nowrap; + } + + icon-lucide-user-round-cog, + icon-lucide-pencil { + width: 16px; + height: 16px; + } + + @media (max-width: 900px) { + div { + padding-right: 3px; + } + } + + @media (max-width: 600px) { + div { + justify-content: flex-start; + gap: 5px; + } + + .file-explorer-header-action-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 new file mode 100644 index 000000000..16097e4b1 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderControls.ts @@ -0,0 +1,134 @@ +import { WebComponent } from '@/lib/components' +import { customElement, property, state } from 'lit/decorators.js' +import { consume } from '@lit/context' +import { html, nothing } from 'lit' +import '@/components/button' +import '~icons/lucide/user-round-cog' +import '~icons/lucide/pencil' +import styles from './FileExplorerHeaderControls.styles.css' +import '@/components/resource-actions-menu/ResourceActionsMenu' +import { DEFAULT_FILE_EXPLORER_CONTEXT, fileExplorerContext } from '@/lib/file-explorer/context' +import { FILE_EXPLORER_MANAGE_ACCESS_EVENT } from '@/lib/file-explorer/events' +import { isContainerSubject } from '@/utils/podUtils' + +@customElement('solid-ui-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 = DEFAULT_FILE_EXPLORER_CONTEXT + + @property({ attribute: false }) + accessor menuItems: Array<{ label: string, action: (event: Event) => void, icon?: HTMLElement }> = [] + + @property({ type: Boolean }) + accessor canEdit: boolean = false + + @property({ type: Boolean }) + accessor canManageAccess = 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 + const { canEdit } = this.sourceContext?.headerMetadata ?? { canEdit: false } + const subject = this.sourceContext?.subject + if (!sourcePaneState || !canEdit || sourcePaneState.broken || (subject && subject.endsWith('/'))) return + + this.sourceContext?.setEditing?.() + } */ + + private getEditTooltip () { + if (!this.canEdit) return 'No Access' + return 'Edit' + } + + private handleManageAccess = () => { + const subjectUri = this.fileExplorerContext.subjectUri + if (!subjectUri) return + + this.dispatchEvent(new CustomEvent(FILE_EXPLORER_MANAGE_ACCESS_EVENT, { + bubbles: true, + composed: true, + detail: { subjectUri } + })) + } + + private renderDirtyIndicator () { + if (!this.fileExplorerContext.isDirty) return nothing + + return html`Unsaved` + } + + render () { + const isContainerResource = isContainerSubject(this.fileExplorerContext.store, this.fileExplorerContext.subjectUri) + const supportsEditing = this.fileExplorerContext.canEdit + + return html` +
+ ${this.renderDirtyIndicator()} + ${!isContainerResource && !this.isMobile && this.canManageAccess + ? html` + + + Manage access + + ${supportsEditing + ? html` + + + Edit + + ` + : nothing} + ` + : nothing} + +
+ ` + } +} diff --git a/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css b/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css new file mode 100644 index 000000000..db9952b73 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.styles.css @@ -0,0 +1,148 @@ + :host { + .file-explorer-header-summary { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 6px; + flex-wrap: wrap; + flex: 1 1 auto; + min-width: 0; + text-align: left; + } + + h1 { + color: var(--solid-ui-color-gray-700, #364153); + 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 { + display: flex; + width: 40px; + height: 40px; + padding: 7.5px 7px 6.5px 7px; + justify-content: center; + align-items: center; + aspect-ratio: 1/1; + /* border-radius: 100px; */ + /* background: var(--slate-600, #45556C); */ + } + + .pane-icon img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + + p { + display: flex; + align-items: center; + gap: 5px; + color: var(--solid-ui-gray-500, #6A7282); + font-size: var(--solid-ui-font-size-xs, .75rem); + font-weight: 500; + } + + .public { + display: flex; + padding: 2px 5px; + justify-content: center; + align-items: center; + gap: 3px; + color: var(--blue-400, #51A2FF); + border-radius: 5px; + background: var(--blue-50, #EFF6FF); + } + + icon-lucide-globe { + stroke-width: 1.021px; + stroke: var(--blue-400, #51A2FF); + } + + .private { + display: flex; + padding: 2px 5px; + justify-content: center; + align-items: center; + gap: 3px; + color: var(--yellow-600, #D08700); + border-radius: 5px; + background: var(--yellow-100, #FEF9C2); + } + + icon-lucide-lock-keyhole { + stroke-width: 1.021px; + stroke: var(--yellow-600, #D08700); + } + + icon-lucide-globe, + icon-lucide-lock-keyhole { + width: 0.875rem; + 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 new file mode 100644 index 000000000..656b19b89 --- /dev/null +++ b/src/components/file-explorer-header/FileExplorerHeaderSummary.ts @@ -0,0 +1,160 @@ +import { sym } from 'rdflib' +import { WebComponent } from '@/lib/components' +import * as utils from '@/utils' +import * as widgets from '@/widgets/index' +import { DEFAULT_FILE_EXPLORER_CONTEXT, fileExplorerContext } from '@/lib/file-explorer/context' +import '@/components/button' +import { customElement, property, query, state } from 'lit/decorators.js' +import type { PropertyValues } from 'lit' +import { html } from 'lit' +import { consume } from '@lit/context' +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 { getContainerItemCount, isContainerSubject } from '@/utils/podUtils' +import type { FileExplorerResourceMetadata } from './types' + +@customElement('solid-ui-file-explorer-header-summary') +export default class FileExplorerHeaderSummary extends WebComponent { + static styles = styles + + private _draggableSubjectUri: string | undefined + private _paneIconGeneration = 0 + + @consume({ context: fileExplorerContext, subscribe: true }) + accessor fileExplorerContext = DEFAULT_FILE_EXPLORER_CONTEXT + + @property({ attribute: false }) + accessor paneIcon: PaneIcon | undefined + + @property({ attribute: false }) + accessor onBackClick: (() => void) | undefined + + @property({ attribute: false }) + accessor responseMetadata: Pick = { + modified: undefined, + isPublic: false + } + + @state() + accessor resolvedPaneIcon: string | undefined = undefined + + @query('h1') + private accessor titleHeading: HTMLHeadingElement | null = null + + private formatModifiedDate (modified: string | undefined) { + if (!modified) return '' + + const date = new Date(modified) + if (Number.isNaN(date.getTime())) return modified + + const parts = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true + }).formatToParts(date) + + const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)?.value ?? '' + const day = getPart('day') + const month = getPart('month') + const year = getPart('year') + const hour = getPart('hour') + const minute = getPart('minute') + const dayPeriod = getPart('dayPeriod').toUpperCase() + + return `${day} ${month}, ${year} at ${hour}:${minute} ${dayPeriod}` + } + + protected willUpdate (changedProperties: PropertyValues) { + if (changedProperties.has('paneIcon')) { + const generation = ++this._paneIconGeneration + this.resolvedPaneIcon = undefined + this.resolvePaneIcon(this.paneIcon, generation) + } + } + + protected updated () { + if (!this.titleHeading || !this.fileExplorerContext?.subjectUri || this._draggableSubjectUri === this.fileExplorerContext.subjectUri) return + + widgets.makeDraggable(this.titleHeading, sym(this.fileExplorerContext.subjectUri)) // doing it this way for now, just to keep the same functionality + this._draggableSubjectUri = this.fileExplorerContext.subjectUri + } + + // Needed because humanReadable pane can return a promise for an icon. + private async resolvePaneIcon (paneIcon: PaneIcon | undefined, generation: number) { + try { + if (paneIcon == null) return + + const icon = await paneIcon + if (generation === this._paneIconGeneration) { + this.resolvedPaneIcon = icon ?? undefined + } + } catch (error) { + if (generation === this._paneIconGeneration) { + this.resolvedPaneIcon = undefined + console.warn('file-explorer-header-summary: failed to resolve pane icon', error) + } + } + } + + private renderContainerResourceHeader (label: string, isPublic: boolean) { + const itemCount = getContainerItemCount(this.fileExplorerContext?.store, this.fileExplorerContext?.subjectUri) ?? 0 + return html` +
+

+ ${label} +

+

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

+
+ ` + } + + 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 isPublic = this.responseMetadata.isPublic + const isContainerResource = isContainerSubject(this.fileExplorerContext?.store, this.fileExplorerContext?.subjectUri) + + return html` +
+ + + Back + + + ${this.resolvedPaneIcon ? html`` : ''} + + ${isContainerResource ? this.renderContainerResourceHeader(label, isPublic) : this.renderResourceHeader(label, isPublic)} +
+ + ` + } +} diff --git a/src/components/file-explorer-header/index.ts b/src/components/file-explorer-header/index.ts new file mode 100644 index 000000000..ad71f8812 --- /dev/null +++ b/src/components/file-explorer-header/index.ts @@ -0,0 +1,4 @@ +import FileExplorerHeader from './FileExplorerHeader' + +export { FileExplorerHeader } +export default FileExplorerHeader diff --git a/src/components/file-explorer-header/types.ts b/src/components/file-explorer-header/types.ts new file mode 100644 index 000000000..01305d80b --- /dev/null +++ b/src/components/file-explorer-header/types.ts @@ -0,0 +1,11 @@ +export type PaneIcon = string | Promise | null | undefined + +export type FileExplorerResourceMetadata = { + contentType: string | undefined + canEdit: boolean + canManageAccess: boolean + isPublic: boolean + aclUri: string | undefined + eTag: string | undefined + modified: string | undefined +} diff --git a/src/components/file-explorer-provider/FileExplorerProvider.ts b/src/components/file-explorer-provider/FileExplorerProvider.ts new file mode 100644 index 000000000..93e9d025b --- /dev/null +++ b/src/components/file-explorer-provider/FileExplorerProvider.ts @@ -0,0 +1,52 @@ +import { html } from 'lit-html' +import { provide } from '@lit/context' +import { customElement, property, state } from 'lit/decorators.js' +import { DataBrowserContext, PaneDefinition } from 'pane-registry' +import { WebComponent } from '@/lib/components' +import { FileExplorerContext, FileExplorerEditor, fileExplorerContext } from '@/lib/file-explorer/context' + +@customElement('solid-ui-file-explorer-provider') +export default class FileExplorerProvider extends WebComponent { + @property({ attribute: false }) + accessor context: DataBrowserContext | undefined + + @property({ attribute: false }) + accessor subjectUri: string | undefined + + @property({ attribute: false }) + accessor pane: PaneDefinition | undefined + + @property({ attribute: false }) + accessor relevantPanes: PaneDefinition[] = [] + + @property({ attribute: false }) + accessor soloPane: boolean | undefined + + @state() private accessor editor: FileExplorerEditor | undefined + @state() private accessor isDirty = false + + @provide({ context: fileExplorerContext }) + accessor value!: FileExplorerContext + + private registerEditor = (editor: FileExplorerEditor) => { + this.editor = editor + return () => { if (this.editor === editor) this.editor = undefined } + } + + protected willUpdate () { + this.value = { + store: this.context?.session.store, + subjectUri: this.subjectUri, + pane: this.pane, + isDirty: this.isDirty, + canEdit: !!this.editor, + registerEditor: this.registerEditor, + beginEditing: () => { this.editor?.beginEditing() }, + setDirty: (dirty) => { this.isDirty = dirty } + } + } + + render () { + return html`` + } +} diff --git a/src/components/file-explorer-provider/index.ts b/src/components/file-explorer-provider/index.ts new file mode 100644 index 000000000..10e75175e --- /dev/null +++ b/src/components/file-explorer-provider/index.ts @@ -0,0 +1,4 @@ +import FileExplorerProvider from './FileExplorerProvider' + +export { FileExplorerProvider } +export default FileExplorerProvider diff --git a/src/components/index.ts b/src/components/index.ts index 916e49f9f..e47751a43 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ // Make sure that is registered first, so that contexts // are available to nested components when they mount. import './provider' +import './file-explorer-provider' import './all' diff --git a/src/components/resource-actions-menu/DeleteResourceDialog.ts b/src/components/resource-actions-menu/DeleteResourceDialog.ts new file mode 100644 index 000000000..1b5c427b1 --- /dev/null +++ b/src/components/resource-actions-menu/DeleteResourceDialog.ts @@ -0,0 +1,36 @@ +import { customElement, DialogComponent } from '@/lib/components' +import { html } from 'lit' +import { property } from 'lit/decorators.js' + +import '@/components/button' +import '@/components/dialog' +import '@/components/dialog-content' +import '@/components/dialog-footer' + +@customElement('resource-delete-dialog') +export default class DeleteResourceDialog extends DialogComponent { + @property({ type: String }) + accessor resourceName = 'this resource' + + private cancel = () => this.close(false) + + private confirm = () => this.close(true) + + protected render () { + return html` + + +

Are you sure you want to permanently delete ${this.resourceName}?

+
+ + + Go back + + + Yes + + +
+ ` + } +} 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 000000000..9b9e7b4d1 --- /dev/null +++ b/src/components/resource-actions-menu/ResourceActionsMenu.styles.css @@ -0,0 +1,12 @@ +:host { + .file-explorer-header-more-button { + width: var(--solid-ui-clickable-area, 42px); + height: var(--solid-ui-clickable-area, 42px); + padding: 0; + } + + .ellipsisIcon { + width: 16px; + height: 16px; + } +} diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.test.ts b/src/components/resource-actions-menu/ResourceActionsMenu.test.ts new file mode 100644 index 000000000..ed98590ce --- /dev/null +++ b/src/components/resource-actions-menu/ResourceActionsMenu.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest' +import './ResourceActionsMenu.ts' + +describe('solid-ui-resource-actions-menu', () => { + it('does not duplicate the desktop file header access action in the menu', async () => { + const menu = document.createElement('solid-ui-resource-actions-menu') as HTMLElement & { + handleAccessClick?: () => void + canManageAccess?: boolean + subjectUri?: string + updateComplete: Promise + } + const handleAccessClick = vi.fn() + menu.subjectUri = 'https://example.test/resource.ttl' + menu.handleAccessClick = handleAccessClick + menu.canManageAccess = true + + document.body.appendChild(menu) + await menu.updateComplete + + const accessItem = Array.from(menu.shadowRoot?.querySelectorAll('solid-ui-menu-item') || []) + .find(item => item.textContent?.includes('Manage Access')) + expect(accessItem).toBeUndefined() + + menu.remove() + }) + + it('does not render Manage Access without the capability', async () => { + const menu = document.createElement('solid-ui-resource-actions-menu') as HTMLElement & { + handleAccessClick?: () => void + subjectUri?: string + showAccessInMenu?: boolean + updateComplete: Promise + } + menu.subjectUri = 'https://example.test/resource.ttl' + menu.handleAccessClick = vi.fn() + menu.showAccessInMenu = true + + document.body.appendChild(menu) + await menu.updateComplete + + expect(menu.shadowRoot?.textContent).not.toContain('Manage Access') + menu.remove() + }) + + it('shows Manage Access when the containing view explicitly enables it', async () => { + const menu = document.createElement('solid-ui-resource-actions-menu') as HTMLElement & { + handleAccessClick?: () => void + subjectUri?: string + showAccessInMenu?: boolean + canManageAccess?: boolean + updateComplete: Promise + } + const handleAccessClick = vi.fn() + menu.subjectUri = 'https://example.test/resource.ttl' + menu.handleAccessClick = handleAccessClick + menu.showAccessInMenu = true + menu.canManageAccess = true + + document.body.appendChild(menu) + await menu.updateComplete + + const accessItem = Array.from(menu.shadowRoot?.querySelectorAll('solid-ui-menu-item') || []) + .find(item => item.textContent?.includes('Manage Access')) + expect(accessItem).toBeTruthy() + + accessItem?.dispatchEvent(new CustomEvent('solid-ui-select', { bubbles: true, composed: true })) + expect(handleAccessClick).toHaveBeenCalledOnce() + + menu.remove() + }) +}) diff --git a/src/components/resource-actions-menu/ResourceActionsMenu.ts b/src/components/resource-actions-menu/ResourceActionsMenu.ts new file mode 100644 index 000000000..4cfa139a6 --- /dev/null +++ b/src/components/resource-actions-menu/ResourceActionsMenu.ts @@ -0,0 +1,98 @@ +import { WebComponent } from '@/lib/components' +import { customElement, property } from 'lit/decorators.js' +import { html, nothing } from 'lit' +import '@/components/button' +import '@/components/menu' +import '@/components/menu-item' +import '~icons/lucide/ellipsis-vertical' +import '~icons/lucide/user-round-cog' +import '~icons/lucide/pencil' +import '~icons/lucide/file' +import { LiveStore } from 'rdflib' +import styles from './ResourceActionsMenu.styles.css' +import { isContainerSubject } from '@/utils/podUtils' + +type ResourceActionMenuItem = { + label: string + icon?: HTMLElement | string + action?: (event: Event) => void +} + +type ActionableResourceActionMenuItem = ResourceActionMenuItem & { + action: (event: Event) => void +} + +@customElement('solid-ui-resource-actions-menu') +export default class ResourceActionsMenu extends WebComponent { + static styles = styles + + @property({ attribute: false }) + accessor store: LiveStore | undefined + + @property({ type: String }) + accessor subjectUri: string | undefined + + @property({ attribute: false }) + accessor handleAccessClick: (() => void) | undefined = undefined + + @property({ attribute: false }) + accessor handleEditingClick: (() => void) | undefined = undefined + + @property({ type: Boolean }) + accessor paneSupportsEditing = false + + @property({ type: Boolean }) + accessor canEdit = false + + @property({ type: Boolean }) + accessor canManageAccess = false + + @property({ type: Boolean }) + accessor isMobile = false + + @property({ type: Boolean, attribute: 'show-access-in-menu' }) + accessor showAccessInMenu = false + + @property({ attribute: false }) + accessor menuItems: ResourceActionMenuItem[] = [] + + render () { + const isContainerResource = isContainerSubject(this.store, this.subjectUri) + const canEdit = !isContainerResource && this.isMobile && this.paneSupportsEditing && this.canEdit && !!this.handleEditingClick + const canManageAccess = this.canManageAccess && !!this.handleAccessClick && + (this.showAccessInMenu || this.isMobile) + const actionableMenuItems = this.menuItems.filter((item): item is ActionableResourceActionMenuItem => typeof item.action === 'function') + return html` + + + + More options + + ${actionableMenuItems.map(item => html` + item.action(event)}> + ${typeof item.icon === 'string' + ? html`` + : item.icon ?? html``} + ${item.label} + + `)} + ${canEdit + ? html` + + + Edit + + ` + : nothing} + ${canManageAccess + ? html` + + + Manage Access + + ` + : nothing} + + ` + } +} diff --git a/src/components/resource-actions-menu/index.ts b/src/components/resource-actions-menu/index.ts new file mode 100644 index 000000000..a00c20ed1 --- /dev/null +++ b/src/components/resource-actions-menu/index.ts @@ -0,0 +1 @@ +export { default } from './ResourceActionsMenu' diff --git a/src/index.ts b/src/index.ts index 10b6aac46..fab8d4808 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,5 +66,5 @@ export type { CreateContext, NewAppInstanceOptions } from './create/types' export * from './lib/auth' export * from './lib/components' export * from './lib/dialogs' -export * from './lib/file-explorer/context' +export * from './lib/file-explorer' export * from './lib/code-editor' diff --git a/src/lib/context/noopContext.ts b/src/lib/context/noopContext.ts new file mode 100644 index 000000000..dd9e04eba --- /dev/null +++ b/src/lib/context/noopContext.ts @@ -0,0 +1,13 @@ +export function noopContext (name: string): T { + const fail = () => { throw new Error(`Context '${name}' is missing. Is the component inside its provider?`) } + + return new Proxy({}, { + get: fail, + set: fail, + has: fail, + ownKeys: fail, + getOwnPropertyDescriptor: fail, + defineProperty: fail, + deleteProperty: fail + }) as T +} diff --git a/src/lib/file-explorer/compose-explorer.ts b/src/lib/file-explorer/compose-explorer.ts new file mode 100644 index 000000000..29efbb984 --- /dev/null +++ b/src/lib/file-explorer/compose-explorer.ts @@ -0,0 +1,50 @@ +import type FileExplorerProvider from '@/components/file-explorer-provider/FileExplorerProvider' +import type FileExplorerHeader from '@/components/file-explorer-header/FileExplorerHeader' +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' + +export type ExplorerComposition = { + context: DataBrowserContext + subjectUri: string + pane: PaneDefinition | undefined + relevantPanes: PaneDefinition[] + header?: Pick + paneElement?: HTMLElement +} + +/** + * Composes one explorer without touching nested explorers or owning navigation. + * Callers register the provider/header components and render the pane content. + */ +export function composeExplorer ( + provider: FileExplorerProvider, + composition: ExplorerComposition +): void { + provider.context = composition.context + provider.subjectUri = composition.subjectUri + provider.pane = composition.pane + provider.relevantPanes = composition.relevantPanes + + const currentHeader = provider.querySelector( + ':scope > solid-ui-file-explorer-header' + ) + if (composition.header) { + const header = currentHeader || + provider.ownerDocument.createElement('solid-ui-file-explorer-header') + header.paneIcon = composition.header.paneIcon + header.menuItems = composition.header.menuItems + if (provider.firstElementChild !== header) provider.prepend(header) + } else { + currentHeader?.remove() + } + + if (composition.paneElement) { + const paneElement = composition.paneElement + paneElement.classList.add('paneDiv') + const currentPane = provider.querySelector(':scope > .paneDiv') + if (currentPane) { + if (currentPane !== paneElement) currentPane.replaceWith(paneElement) + } else { + provider.appendChild(paneElement) + } + } +} diff --git a/src/lib/file-explorer/context.ts b/src/lib/file-explorer/context.ts index 9d291174f..1e349fe37 100644 --- a/src/lib/file-explorer/context.ts +++ b/src/lib/file-explorer/context.ts @@ -1,26 +1,22 @@ -import { createContext } from '@lit/context' -import { LiveStore } from 'rdflib' -import type { NamedNode } from 'rdflib' +import type { LiveStore } from 'rdflib' import type { PaneDefinition } from 'pane-registry' +import { createContext } from '@lit/context' +import { noopContext } from '@/lib/context/noopContext' -export interface FileExplorerEdit { - onEdit?: () => void - isDirty?: boolean - updateDirtyState?: (dirty: boolean) => void +export interface FileExplorerEditor { + beginEditing (): void } export interface FileExplorerContext { - store: LiveStore | undefined - subjectUri: string | undefined - pane?: PaneDefinition - soloPane?: boolean - - onBack?: () => void - openPane?: (subject: NamedNode, paneName: string) => void - handleAccessClick?: () => void - - paneSupportsEditing?: boolean - edit?: FileExplorerEdit + readonly store: LiveStore | undefined + readonly subjectUri: string | undefined + readonly pane: PaneDefinition | undefined + readonly isDirty: boolean + readonly canEdit: boolean // true while an editor is registered + registerEditor (editor: FileExplorerEditor): () => void // returns unregister + beginEditing (): void + setDirty (dirty: boolean): void } export const fileExplorerContext = createContext(Symbol('file-explorer')) +export const DEFAULT_FILE_EXPLORER_CONTEXT = noopContext('file-explorer') diff --git a/src/lib/file-explorer/events.ts b/src/lib/file-explorer/events.ts new file mode 100644 index 000000000..b8621925e --- /dev/null +++ b/src/lib/file-explorer/events.ts @@ -0,0 +1,25 @@ +export const FILE_EXPLORER_BACK_EVENT = 'solid-ui-file-explorer-back' + +export interface FileExplorerBackDetail { + subjectUri?: string +} + +export const FILE_EXPLORER_MANAGE_ACCESS_EVENT = 'solid-ui-file-explorer-manage-access' + +export interface FileExplorerManageAccessDetail { + subjectUri: string +} + +export const FILE_EXPLORER_OPEN_PANE_EVENT = 'solid-ui-file-explorer-open-pane' + +export interface FileExplorerOpenPaneDetail { + subjectUri: string + paneName: string +} + +/** Whether an event belongs to this explorer rather than a nested one. */ +export function isExplorerEvent (event: Event, provider: HTMLElement): boolean { + return event.composedPath().find(target => + 'localName' in target && target.localName === 'solid-ui-file-explorer-provider' + ) === provider +} diff --git a/src/lib/file-explorer/index.ts b/src/lib/file-explorer/index.ts new file mode 100644 index 000000000..b495aab66 --- /dev/null +++ b/src/lib/file-explorer/index.ts @@ -0,0 +1,4 @@ +export * from './context' +export * from './events' +export * from './relevant-panes' +export * from './compose-explorer' diff --git a/src/lib/file-explorer/relevant-panes.ts b/src/lib/file-explorer/relevant-panes.ts new file mode 100644 index 000000000..a8cf10af1 --- /dev/null +++ b/src/lib/file-explorer/relevant-panes.ts @@ -0,0 +1,45 @@ +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' +import type { NamedNode } from 'rdflib' +import { filterAvailablePanes } from '@/login/login' + +export type RelevantPaneOptions = { + excludedPaneNames?: readonly string[] + fallbackPaneName?: string +} + +/** + * Returns panes that can represent a subject, preserving legacy outliner ordering. + * + * Audience filtering narrows the list, but the first relevant pane is retained + * even when its audience does not match. The legacy outliner deliberately kept + * this most relevant view available, including when no panes match the audience. + */ +export async function getRelevantPanes ( + subject: NamedNode, + context: DataBrowserContext, + options: RelevantPaneOptions = {} +): Promise { + const excludedPaneNames = new Set(options.excludedPaneNames || []) + const paneRegistry = context.session.paneRegistry + const relevantPanes = paneRegistry.list.filter( + pane => !pane.global && + !excludedPaneNames.has(pane.name) && + pane.label(subject, context) + ) + + if (relevantPanes.length === 0) { + const fallbackPane = options.fallbackPaneName + ? paneRegistry.byName(options.fallbackPaneName) + : undefined + return fallbackPane ? [fallbackPane] : [] + } + + const filteredPanes = await filterAvailablePanes(relevantPanes) + if (filteredPanes.length === 0) return [relevantPanes[0]] + + const firstRelevantPaneIndex = paneRegistry.list.indexOf(relevantPanes[0]) + const firstFilteredPaneIndex = paneRegistry.list.indexOf(filteredPanes[0]) + return firstRelevantPaneIndex < firstFilteredPaneIndex + ? [relevantPanes[0]].concat(filteredPanes) + : filteredPanes +} diff --git a/src/styles/theme.css b/src/styles/theme.css index 7e52057cd..99ed3dae7 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -28,6 +28,7 @@ --solid-ui-color-gray-900: #101828; --solid-ui-color-slate-50: #f8fafc; + --solid-ui-color-neutral-50: #fafafa; --solid-ui-color-gray-50: #f9fafb; --solid-ui-color-slate-200: #e2e8f0; --solid-ui-color-slate-400: #90a1b9; diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts index fde924599..b5df7d19f 100644 --- a/src/types/custom-elements.d.ts +++ b/src/types/custom-elements.d.ts @@ -11,6 +11,8 @@ import type DialogHeader from '../components/dialog-header/DialogHeader' import type DialogProvider from '../components/dialog-provider/DialogProvider' import type DialogsRoot from '../components/dialogs-root/DialogsRoot' import type ErrorState from '../components/error-state/ErrorState' +import type FileExplorerHeader from '../components/file-explorer-header/FileExplorerHeader' +import type FileExplorerProvider from '../components/file-explorer-provider/FileExplorerProvider' import type Guard from '../components/guard/Guard' import type Input from '../components/input/Input' import type LoginButton from '../components/login-button/LoginButton' @@ -23,6 +25,7 @@ import type PhotoCaptureModal from '../components/photo-capture-modal/PhotoCaptu import type Provider from '../components/provider/Provider' import type RDFForm from '../components/rdf-form/RDFForm' import type RDFInput from '../components/rdf-input/RDFInput' +import type ResourceActionsMenu from '../components/resource-actions-menu/ResourceActionsMenu' import type Select from '../components/select/Select' import type SelectOption from '../components/select-option/SelectOption' import type SignupButton from '../components/signup-button/SignupButton' @@ -43,6 +46,8 @@ declare global { 'solid-ui-dialog-provider': DialogProvider 'solid-ui-dialogs-root': DialogsRoot 'solid-ui-error-state': ErrorState + 'solid-ui-file-explorer-header': FileExplorerHeader + 'solid-ui-file-explorer-provider': FileExplorerProvider 'solid-ui-guard': Guard 'solid-ui-input': Input 'solid-ui-login-button': LoginButton @@ -55,6 +60,7 @@ declare global { 'solid-ui-provider': Provider 'solid-ui-rdf-form': RDFForm 'solid-ui-rdf-input': RDFInput + 'solid-ui-resource-actions-menu': ResourceActionsMenu 'solid-ui-select': Select 'solid-ui-select-option': SelectOption 'solid-ui-signup-button': SignupButton diff --git a/src/utils/podUtils.ts b/src/utils/podUtils.ts new file mode 100644 index 000000000..db25cd393 --- /dev/null +++ b/src/utils/podUtils.ts @@ -0,0 +1,90 @@ +import { solidLogicSingleton, store } from 'solid-logic' +import ns from '@/lib/ns' +import { LiveStore, NamedNode, parse } from 'rdflib' +import { isWebIdUri } from './webIdUtils' + +export async function getPodStorages (url: NamedNode): Promise { + if (isWebIdUri(url)) { + try { + await store.fetcher.load(url.doc()) + } catch (err) { + console.warn('Unable to load profile document for WebID', url.doc().uri, err) + } + const podStorages = store.each(url, ns.space('storage')) + const results = await Promise.all(podStorages.map(async pod => await isPodStorage(pod as NamedNode) ? pod as NamedNode : null)) + return results.filter(pod => pod !== null) as NamedNode[] + } + try { + const storage = await findPodStorageFromUrl(url) + return storage ? [storage] : [] + } catch (err) { + console.error('cannot load container', err) + return [] + } +} + +async function findPodStorageFromUrl (url: NamedNode): Promise { + const podStorage = new URL(url.value || url.uri) + let pathStorage = podStorage.pathname + + while (pathStorage.length) { + pathStorage = pathStorage.substring(0, pathStorage.lastIndexOf('/')) + const candidate = store.sym(`${podStorage.origin}${pathStorage}/`) + if (await isPodStorage(candidate)) return candidate + } + + // TODO should url.origin be added to pods list when there are no pim:Storage ??? + return null +} + +async function isPodStorage (pod: NamedNode): Promise { + await loadContainerRepresentation(pod) + return store.holds(pod, ns.rdf('type'), ns.space('Storage'), pod.doc()) +} + +export async function loadContainerRepresentation (subject) { + // force reload for index.html with RDFa + if (!store.any(subject, ns.ldp('contains'), undefined, subject.doc())) { + try { + const response = await store.fetcher.webOperation('GET', subject.uri, store.fetcher.initFetchOptions(subject.uri, { headers: { accept: 'text/turtle' } })) + const containerTurtle = response.responseText + if (subject.uri && containerTurtle) { + parse(containerTurtle, store, subject.uri, 'text/turtle') + } + } catch (err) { + console.warn('Unable to load container representation for', subject.uri, err) + } + } +} + +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 + if (!solidLogicSingleton.resource.isHiddenFile(resource)) { + seen.add(resource.uri) + } + } + } + return seen.size +} diff --git a/src/utils/webIdUtils.ts b/src/utils/webIdUtils.ts new file mode 100644 index 000000000..5eb9cac32 --- /dev/null +++ b/src/utils/webIdUtils.ts @@ -0,0 +1,122 @@ +/* +* Utility functions to help load the profile +* especially when I am not logged in +*/ + +import { IndexedFormula, NamedNode, sym } from 'rdflib' +import ns from '@/lib/ns' +import { authn, store } from 'solid-logic' + +const DEFAULT_PROFILE_PATH = 'profile/card#me' + +export function isOwnWebIdProfile (subject: NamedNode): boolean { + const viewer = authn.currentUser() + return Boolean(viewer && (viewer.sameTerm(subject) || viewer.doc().sameTerm(subject))) +} + +export async function loadProfileFromURI ( + uri: NamedNode +): Promise { + try { + const pod = uri.site().uri + // TODO: This is a hack - we cannot assume that the profile is at this document, but we will live with it for now + const webId = sym(`${pod}${DEFAULT_PROFILE_PATH}`) + try { + await store.fetcher.load(webId) + return webId + } catch (err) { + // ignore any failure and continue fallback lookup + } + + // we try a prefixed pod structure + try { + const uriUrl = new URL(uri.uri) + const pathSegments = uriUrl.pathname.split('/').filter(Boolean) + if (pathSegments.length > 0) { + const derivedPod = `${uriUrl.origin}/${pathSegments[0]}/` + const derivedWebId = sym(`${derivedPod}${DEFAULT_PROFILE_PATH}`) + await store.fetcher.load(derivedWebId) + return derivedWebId + } + } catch (err) { + // ignore any failure and continue fallback lookup + } + + try { + await store.fetcher.load(uri) + } catch (err) { + return uri + } + + const primaryTopic = store.any(uri, ns.foaf('primaryTopic'), null, uri.doc()) + if (primaryTopic && primaryTopic.termType === 'NamedNode') { + try { + await store.fetcher.load(primaryTopic as NamedNode) + return primaryTopic as NamedNode + } catch (err) { + return uri + } + } + + return uri + } catch (err) { + return uri + } +} + +export function isWebIdUri (uri: NamedNode): boolean { + try { + const parsedURL = new URL(uri.uri) + return parsedURL.hash === '#me' + } catch (err) { + return false + } +} + +export async function getNameOfPodOwner ( + pod: NamedNode +): Promise { + // TODO: This is a hack - we cannot assume that the profile is at this document, but we will live with it for now + const webId = sym(`${pod.uri}${DEFAULT_PROFILE_PATH}`) + try { + await store.fetcher.load(webId) + return getName(store, webId) + } catch (err) { + if (!isFetchErrorStatus(err, 403)) { + console.error('getNameOfPodOwner failed on default profile:', err) + } + } + + // we try a prefixed pod structure + try { + const uriUrl = new URL(pod.uri) + const pathSegments = uriUrl.pathname.split('/').filter(Boolean) + if (pathSegments.length > 0) { + const derivedPod = `${uriUrl.origin}/${pathSegments[0]}/` + const derivedWebId = sym(`${derivedPod}${DEFAULT_PROFILE_PATH}`) + await store.fetcher.load(derivedWebId) + return getName(store, derivedWebId) + } + } catch (err) { + if (!isFetchErrorStatus(err, 403)) { + console.error('getNameOfPodOwner failed on derived profile:', err) + } + } + + return '' +} + +export function getName (store: IndexedFormula, ownersProfile: NamedNode): string { + return ( + store.anyValue(ownersProfile, ns.vcard('fn'), null, ownersProfile.doc()) || + store.anyValue(ownersProfile, ns.foaf('name'), null, ownersProfile.doc()) || + new URL(ownersProfile.uri).host.split('.')[0] + ) +} + +function isFetchErrorStatus (err: unknown, status: number): boolean { + if (!err || typeof err !== 'object') return false + const statusCode = (err as any)?.response?.status ?? (err as any)?.status + const parsedStatus = Number(statusCode) + return Number.isFinite(parsedStatus) && parsedStatus === status +} diff --git a/test/components/access-control-modal.test.ts b/test/components/access-control-modal.test.ts index 026bcdd4d..46ef67c96 100644 --- a/test/components/access-control-modal.test.ts +++ b/test/components/access-control-modal.test.ts @@ -1687,7 +1687,7 @@ describe('AccessControlModal submit', () => { const warning = element.shadowRoot.querySelector('.access-control-mixed-policy-warning') expect(warning?.getAttribute('role')).toBe('alert') expect(warning?.textContent).toContain('different access settings for the folder and its contents') - expect(warning?.textContent).toContain('available in the next release') + expect(warning?.textContent).toContain('You can still change access for the folder or its contents separately below.') expect(element.shadowRoot.querySelector('solid-ui-access-control-add-access')?.disabled).toBe(true) expect(element.shadowRoot.querySelector('solid-ui-access-control-shared-access')?.disabled).toBe(true) expect(element.shadowRoot.querySelector('solid-ui-access-control-general-access')?.disabled).toBe(true) diff --git a/test/components/compose-explorer.test.ts b/test/components/compose-explorer.test.ts new file mode 100644 index 000000000..9ff0f8741 --- /dev/null +++ b/test/components/compose-explorer.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' +import * as paneRegistry from 'pane-registry' +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' +import { solidLogicSingleton } from 'solid-logic' +import '../../src/components/file-explorer-provider' +import '../../src/components/file-explorer-header' +import { isExplorerEvent } from '../../src/lib/file-explorer/events' +import { composeExplorer } from '../../src/lib/file-explorer/compose-explorer' + +const context: DataBrowserContext = { + dom: document, + getOutliner: () => undefined, + session: { paneRegistry, store: solidLogicSingleton.store, logic: solidLogicSingleton } +} +const pane: PaneDefinition = { + name: 'test', + icon: '', + label: () => 'Test', + render: () => document.createElement('div') +} +const composition = { + context, + subjectUri: 'https://example.test/resource', + pane, + relevantPanes: [pane], + header: { paneIcon: '', menuItems: [] } +} + +describe('explorer composition', () => { + it('places the header first and pane content after it, with provider context', () => { + const provider = document.createElement('solid-ui-file-explorer-provider') + const paneElement = document.createElement('div') + composeExplorer(provider, { ...composition, paneElement }) + + expect(provider.context).toBe(context) + expect(provider.subjectUri).toBe(composition.subjectUri) + expect(provider.pane).toBe(pane) + expect(provider.relevantPanes).toBe(composition.relevantPanes) + expect(provider.firstElementChild?.localName).toBe('solid-ui-file-explorer-header') + expect(provider.lastElementChild).toBe(paneElement) + expect(paneElement.classList.contains('paneDiv')).toBe(true) + }) + + it('reuses the header and preserves content during a menu-only update', () => { + const provider = document.createElement('solid-ui-file-explorer-provider') + const paneElement = document.createElement('div') + composeExplorer(provider, { ...composition, paneElement }) + const header = provider.firstElementChild + const menuItems = [{ label: 'Other pane', action: () => {} }] + composeExplorer(provider, { ...composition, header: { paneIcon: '', menuItems } }) + + expect(provider.firstElementChild).toBe(header) + expect(provider.querySelector('solid-ui-file-explorer-header')?.menuItems).toBe(menuItems) + expect(provider.lastElementChild).toBe(paneElement) + expect(provider.children).toHaveLength(2) + }) + + it('hides and restores only the outer header without touching nested explorers', () => { + const outer = document.createElement('solid-ui-file-explorer-provider') + const inner = document.createElement('solid-ui-file-explorer-provider') + composeExplorer(inner, { ...composition, paneElement: document.createElement('div') }) + const innerHeader = inner.firstElementChild + const content = document.createElement('div') + content.appendChild(inner) + composeExplorer(outer, { ...composition, paneElement: content }) + + composeExplorer(outer, { ...composition, header: undefined }) + expect(outer.firstElementChild).toBe(content) + expect(inner.firstElementChild).toBe(innerHeader) + + composeExplorer(outer, composition) + expect(outer.firstElementChild?.localName).toBe('solid-ui-file-explorer-header') + expect(outer.lastElementChild).toBe(content) + expect(inner.firstElementChild).toBe(innerHeader) + }) + + it('replaces only direct pane content even when the current pane is a provider', () => { + const outer = document.createElement('solid-ui-file-explorer-provider') + const inner = document.createElement('solid-ui-file-explorer-provider') + const innerContent = document.createElement('div') + composeExplorer(inner, { ...composition, paneElement: innerContent }) + composeExplorer(outer, { ...composition, paneElement: inner }) + + const replacement = document.createElement('div') + composeExplorer(outer, { ...composition, paneElement: replacement }) + expect(outer.lastElementChild).toBe(replacement) + expect(inner.lastElementChild).toBe(innerContent) + expect(outer.contains(inner)).toBe(false) + }) + + it('identifies the nearest provider for bubbling events', () => { + const outer = document.createElement('solid-ui-file-explorer-provider') + const inner = document.createElement('solid-ui-file-explorer-provider') + const header = document.createElement('div') + inner.appendChild(header) + outer.appendChild(inner) + const owners: boolean[] = [] + inner.addEventListener('test-explorer', event => owners.push(isExplorerEvent(event, inner))) + outer.addEventListener('test-explorer', event => owners.push(isExplorerEvent(event, outer))) + + header.dispatchEvent(new CustomEvent('test-explorer', { bubbles: true, composed: true })) + expect(owners).toEqual([true, false]) + }) +}) diff --git a/test/unit/file-explorer/relevant-panes.test.ts b/test/unit/file-explorer/relevant-panes.test.ts new file mode 100644 index 000000000..5dbca9b73 --- /dev/null +++ b/test/unit/file-explorer/relevant-panes.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DataBrowserContext, PaneDefinition } from 'pane-registry' +import { sym } from 'rdflib' +import { solidLogicSingleton } from 'solid-logic' +import { filterAvailablePanes } from '../../../src/login/login' +import { getRelevantPanes } from '../../../src/lib/file-explorer/relevant-panes' + +vi.mock('../../../src/login/login', () => ({ + filterAvailablePanes: vi.fn() +})) + +const subject = sym('https://example.test/resource') + +function createPane (name: string, properties: Partial = {}): PaneDefinition { + return { + name, + icon: '', + label: () => name, + render: () => document.createElement('div'), + ...properties + } +} + +function createContext (panes: PaneDefinition[]): DataBrowserContext { + return { + dom: document, + getOutliner: () => undefined, + session: { + logic: solidLogicSingleton, + store: solidLogicSingleton.store, + paneRegistry: { + list: panes, + paneForIcon: {}, + paneForPredicate: {}, + register: vi.fn(), + byName: name => panes.find(pane => pane.name === name) || null + } + } + } +} + +describe('getRelevantPanes', () => { + beforeEach(() => { + vi.mocked(filterAvailablePanes).mockReset() + vi.mocked(filterAvailablePanes).mockImplementation(async panes => panes) + }) + + it('preserves registry order when all relevant panes are available', async () => { + const panes = [createPane('first'), createPane('second')] + + expect(await getRelevantPanes(subject, createContext(panes))).toEqual(panes) + expect(filterAvailablePanes).toHaveBeenCalledWith(panes) + }) + + it('preserves the first relevant pane when audience filtering removes every pane', async () => { + const first = createPane('first') + const second = createPane('second') + const fallback = createPane('internal', { label: () => null }) + vi.mocked(filterAvailablePanes).mockResolvedValue([]) + + expect(await getRelevantPanes(subject, createContext([first, second, fallback]), { + fallbackPaneName: 'internal' + })).toEqual([first]) + }) + + it('prepends the first relevant pane when audience filtering removes it', async () => { + const first = createPane('first') + const second = createPane('second') + const third = createPane('third') + vi.mocked(filterAvailablePanes).mockResolvedValue([second, third]) + + expect(await getRelevantPanes(subject, createContext([first, second, third]))).toEqual([ + first, second, third + ]) + }) + + it('does not restore other audience-filtered panes or duplicate the first pane', async () => { + const first = createPane('first') + const second = createPane('second') + const third = createPane('third') + vi.mocked(filterAvailablePanes).mockResolvedValue([first, third]) + + expect(await getRelevantPanes(subject, createContext([first, second, third]))).toEqual([ + first, third + ]) + }) + + it('does not restore global, excluded, or nonmatching panes as the first relevant pane', async () => { + const global = createPane('global', { global: true }) + const excluded = createPane('sharing') + const nonmatching = createPane('nonmatching', { label: () => null }) + const first = createPane('first') + const second = createPane('second') + const context = createContext([global, excluded, nonmatching, first, second]) + vi.mocked(filterAvailablePanes).mockResolvedValue([]) + + expect(await getRelevantPanes(subject, context, { + excludedPaneNames: ['sharing'] + })).toEqual([first]) + expect(filterAvailablePanes).toHaveBeenCalledWith([first, second]) + }) + + it('uses the configured fallback without audience filtering when no pane is relevant', async () => { + const fallback = createPane('internal', { label: () => null }) + + expect(await getRelevantPanes(subject, createContext([fallback]), { + fallbackPaneName: 'internal' + })).toEqual([fallback]) + expect(filterAvailablePanes).not.toHaveBeenCalled() + }) + + it('returns an empty list when no pane is relevant and no fallback is configured', async () => { + expect(await getRelevantPanes(subject, createContext([]))).toEqual([]) + expect(filterAvailablePanes).not.toHaveBeenCalled() + }) + + it('returns an empty list when the configured fallback is not registered', async () => { + expect(await getRelevantPanes(subject, createContext([]), { + fallbackPaneName: 'missing' + })).toEqual([]) + expect(filterAvailablePanes).not.toHaveBeenCalled() + }) + + it('propagates audience-filtering errors instead of hiding them with a fallback', async () => { + const error = new Error('Unable to load audience roles') + vi.mocked(filterAvailablePanes).mockRejectedValue(error) + + await expect(getRelevantPanes(subject, createContext([createPane('first')]))).rejects.toBe(error) + }) +}) diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 89a6d9818..33bf40ebb 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -12,10 +12,14 @@ describe('Index', () => { 'CodeEditor', 'DEFAULT_AUTH_CONTEXT', 'DEFAULT_DIALOG_CONTEXT', + 'DEFAULT_FILE_EXPLORER_CONTEXT', 'DEFAULT_SIGNUP_URL', 'Dialog', 'DialogComponent', 'DialogTrait', + 'FILE_EXPLORER_BACK_EVENT', + 'FILE_EXPLORER_MANAGE_ACCESS_EVENT', + 'FILE_EXPLORER_OPEN_PANE_EVENT', 'FormControlComponent', 'FormControlTrait', 'NoopAuth', @@ -25,6 +29,7 @@ describe('Index', () => { 'acl', 'aclControl', 'authContext', + 'composeExplorer', 'create', 'createTypes', 'customElement', @@ -32,10 +37,12 @@ describe('Index', () => { 'dom', 'fileExplorerContext', 'generateId', + 'getRelevantPanes', 'icons', 'infiniteMessageArea', 'initFooter', 'initHeader', + 'isExplorerEvent', 'language', 'log', 'login',