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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 91 additions & 91 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "solid-ui",
"version": "5.0.0-2",
"version": "5.0.0-3",
"description": "UI library for Solid applications",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts",
Expand Down
13 changes: 13 additions & 0 deletions src/components/file-explorer-header/FileExplorerHeader.styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
:host {
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
width: 100%;
text-align: right;
padding: 10px;
border-bottom: 1px solid var(--solid-ui-color-gray-200, #cbd5e1);
}
}
108 changes: 108 additions & 0 deletions src/components/file-explorer-header/FileExplorerHeader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { sym } from 'rdflib'
import { html, nothing } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { consume } from '@lit/context'
import { WebComponent } from '@/lib/components'
import { fileExplorerContext, type FileExplorerContext } from '@/lib/file-explorer/context'
import { storeContext, DEFAULT_STORE } from '@/lib/store/context'
import type { LiveStore } from 'rdflib'
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 { type PaneIcon } from './types'
import { type FileExplorerResourceMetadata } from './types'
import { solidLogicSingleton } from 'solid-logic'

@customElement('file-explorer-header')
export default class FileExplorerHeader extends WebComponent {
static styles = styles

private _loadedMetadataKey: string | undefined

@consume({ context: fileExplorerContext, subscribe: true })
accessor fileExplorerContext: FileExplorerContext = undefined as unknown as FileExplorerContext

@consume({ context: storeContext, subscribe: true })
accessor store: LiveStore = DEFAULT_STORE

@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<FileExplorerResourceMetadata, 'modified' | 'isPublic' | 'canEdit' | 'aclUri'> & { canDelete: boolean } = {
modified: undefined,
isPublic: false,
canEdit: false,
canDelete: false,
aclUri: undefined
}

private getDefaultResponseMetadata (): Pick<FileExplorerResourceMetadata, 'modified' | 'isPublic' | 'canEdit' | 'aclUri'> & { canDelete: boolean } {
return {
modified: undefined,
isPublic: false,
canEdit: false,
canDelete: false,
aclUri: undefined
}
}

private get shouldRenderHeader () {
return !this.fileExplorerContext?.pane?.mintClass
}

protected updated () {
const subjectUri = this.fileExplorerContext.subjectUri
const metadataKey = `${subjectUri}:${this.fileExplorerContext.resourceRevision ?? 0}`

if (this.store && subjectUri && this._loadedMetadataKey !== metadataKey) {
this._loadedMetadataKey = metadataKey
this.loadResponseMetadata()
}
}

private async loadResponseMetadata () {
if (!this.store || !this.fileExplorerContext.subjectUri) return

try {
const metadata = await solidLogicSingleton.resource.fetchMetadata(sym(this.fileExplorerContext.subjectUri))
this.responseMetadata = {
modified: metadata.modified,
isPublic: metadata.access.isPublic,
canEdit: metadata.access.canEdit,
canDelete: metadata.access.canDelete,
aclUri: metadata.aclUri
}
Comment on lines +74 to +81
} catch (error) {
this.responseMetadata = this.getDefaultResponseMetadata()
console.warn('Failed to load response metadata', error)
}
}

render () {
if (!this.shouldRenderHeader) {
return html`${nothing}`
}

return html`
<header>
<file-explorer-header-summary
.paneIcon=${this.paneIcon}
.onBackClick=${this.fileExplorerContext?.onBack}
.responseMetadata=${this.responseMetadata}
></file-explorer-header-summary>
<file-explorer-header-controls
.menuItems=${this.menuItems}
.canEdit=${this.responseMetadata.canEdit}
.canDelete=${this.responseMetadata.canDelete}
></file-explorer-header-controls>
</header>
`
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
: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;
}

/* 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-share-2,
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-access-button,
.file-explorer-header-edit-button {
display: none;
}
}
}
206 changes: 206 additions & 0 deletions src/components/file-explorer-header/FileExplorerHeaderControls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { html, nothing } from 'lit'
import type { PropertyValues } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { WebComponent } from '@/lib/components'
import '@/components/button'
import '@/components/resource-actions-menu'
import { consume } from '@lit/context'
import { fileExplorerContext, type FileExplorerContext } from '@/lib/file-explorer/context'
import { storeContext, DEFAULT_STORE } from '@/lib/store/context'
import type { LiveStore } from 'rdflib'
import { sym } from 'rdflib'
import { solidLogicSingleton } from 'solid-logic'
import { buildResourceActionsMenuBindings } from '@/lib/resource-actions-menu'
import { loadDiscoveryState, toggleDiscoveryState } from '@/lib/discovery'
import styles from './FileExplorerHeaderControls.styles.css'

@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
}

private handleMobileAccessClick = () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more of a pet peeve of mine, nothing technically wrong with this, but I think it's always better to define private methods rather than variable methods. (also move this down in the file, the first things we should see are the properties, etc.).

Suggested change
private handleMobileAccessClick = () => {
private handleMobileAccessClick() {

const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined

if (subject && this.fileExplorerContext.openPane) {
this.fileExplorerContext.openPane(subject, 'sharing')
return
}

this.fileExplorerContext.handleAccessClick?.()
}

private handleDeleteClick = async () => {
const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined
if (!subject || !this.fileExplorerContext.deleteResource) return

try {
await this.fileExplorerContext.deleteResource(subject)
} catch (error) {
console.error('[file-explorer-header.deleteResource] failed', error)
globalThis.alert(this.deleteLabel === 'Permanently Delete'
? 'Error deleting resource'
: 'Error moving resource to Trash')
}
}

private async refreshDiscoveryState () {
const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined
const discoverClass = this.fileExplorerContext.discoverClass

if (!subject || !discoverClass) {
this.discoveryState = undefined
return
}

try {
this.discoveryState = await loadDiscoveryState(subject)
} catch (error) {
this.discoveryState = undefined
console.warn('Failed to load discovery state', error)
}
}

private handleDiscoverPublicClick = async () => {
const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined
const discoverClass = this.fileExplorerContext.discoverClass

if (!subject || !discoverClass) return

this.discoveryState = await toggleDiscoveryState(subject, discoverClass, 'public', this.discoveryState)
}

private handleDiscoverPrivateClick = async () => {
const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined
const discoverClass = this.fileExplorerContext.discoverClass

if (!subject || !discoverClass) return

this.discoveryState = await toggleDiscoveryState(subject, discoverClass, 'private', this.discoveryState)
}

@consume({ context: fileExplorerContext, subscribe: true })
accessor fileExplorerContext: FileExplorerContext = undefined as unknown as FileExplorerContext

@consume({ context: storeContext, subscribe: true })
accessor store: LiveStore = DEFAULT_STORE

@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 canDelete: boolean = false

@state()
accessor isMobile = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(max-width: 600px)').matches
: false

@state()
accessor discoveryState: { public: boolean, private: boolean } | undefined = undefined

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()
}

protected willUpdate (changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties)

if (
changedProperties.has('fileExplorerContext') ||
changedProperties.has('menuItems')
) {
void this.refreshDiscoveryState()
}
}

private getEditTooltip () {
if (!this.fileExplorerContext.paneSupportsEditing) return 'Not Supported'
if (!this.canEdit) return 'No Access'
return 'Edit'
}

private get deleteLabel () {
const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined
return subject?.dir()?.uri.endsWith('/Trash/') ? 'Permanently Delete' : 'Move to Trash'
}

private renderDirtyIndicator () {
if (!this.fileExplorerContext.edit?.isDirty) return nothing

return html`<span class="dirtyIndicator" title="This file has unsaved changes">Unsaved</span>`
}

render () {
const subject = this.fileExplorerContext.subjectUri ? sym(this.fileExplorerContext.subjectUri) : undefined
const isContainerResource = subject ? solidLogicSingleton.resource.isContainer(subject) : false
const resourceActions = buildResourceActionsMenuBindings({
subject,
discoveryState: this.discoveryState,
handleAccessClick: this.isMobile ? this.handleMobileAccessClick : undefined,
canDelete: this.canDelete && !!this.fileExplorerContext.deleteResource,
handleDeleteClick: this.handleDeleteClick,
handleDiscoverPublicClick: this.fileExplorerContext.discoverClass ? this.handleDiscoverPublicClick : undefined,
handleDiscoverPrivateClick: this.fileExplorerContext.discoverClass ? this.handleDiscoverPrivateClick : undefined,
})

return html`
<div>
${this.renderDirtyIndicator()}
${!this.isMobile
? html`
<solid-ui-button class="file-explorer-header-access-button" variant="ghost" title="Manage Access" @click=${this.fileExplorerContext.handleAccessClick}>
<icon-lucide-share-2 slot="icon"></icon-lucide-share-2>
</solid-ui-button>
<solid-ui-button
class="file-explorer-header-edit-button"
variant="ghost"
title=${this.getEditTooltip()}
?disabled=${!this.fileExplorerContext.paneSupportsEditing || !this.canEdit}
@click=${this.fileExplorerContext.edit?.onEdit}
>
<icon-lucide-pencil slot="icon"></icon-lucide-pencil>
</solid-ui-button>
`
: nothing}
<solid-ui-resource-actions-menu
.isContainerResource=${isContainerResource}
.handleAccessClick=${resourceActions.handleAccessClick}
.handleEditingClick=${this.fileExplorerContext.edit?.onEdit}
.handleDeleteClick=${resourceActions.handleDeleteClick}
.deleteLabel=${resourceActions.deleteLabel}
.paneSupportsEditing=${this.fileExplorerContext.paneSupportsEditing}
.canEdit=${this.canEdit}
.menuItems=${this.menuItems}
.discoverPublicly=${resourceActions.discoverPublicly}
.discoverPrivately=${resourceActions.discoverPrivately}
.handleDiscoverPublicClick=${resourceActions.handleDiscoverPublicClick}
.handleDiscoverPrivateClick=${resourceActions.handleDiscoverPrivateClick}
.isMobile=${this.isMobile}
></solid-ui-resource-actions-menu>
</div>
`
}
}
Loading
Loading