Skip to content
Merged
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
22 changes: 11 additions & 11 deletions weblens-vue/weblens-nuxt/components/atom/WeblensButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
import { useElementSize } from '@vueuse/core'
import type { ButtonProps } from '~/types/button'

const props = defineProps<ButtonProps>()
const { selected = true, allowCollapse, label, onClick, errorText } = defineProps<ButtonProps>()

const slots = useSlots()

Expand All @@ -74,7 +74,7 @@ const textWidth = computed(() => {

const justIcon = computed(() => {
if (
props.allowCollapse &&
allowCollapse &&
slots.default &&
buttonSize.width.value < fakeTextSize.width.value + 24 /* Icon size without padding */
) {
Expand All @@ -89,19 +89,19 @@ const textContent = computed(() => {
return buttonError.value
}

if (props.label) {
return props.label
if (label) {
return label
}

return ''
})

async function handleClick(e: MouseEvent) {
if (!props.onClick) {
if (!onClick) {
return
}

const maybePromise = props.onClick(e)
const maybePromise = onClick(e)
try {
if (maybePromise instanceof Promise) {
doingClick.value = true
Expand All @@ -110,12 +110,12 @@ async function handleClick(e: MouseEvent) {
} catch (error) {
console.error('Error during button click:', error)

if (!props.errorText) {
if (!errorText) {
buttonError.value = 'Error'
} else if (typeof props.errorText === 'string') {
buttonError.value = props.errorText
} else if (typeof props.errorText === 'function') {
buttonError.value = props.errorText(error as Error)
} else if (typeof errorText === 'string') {
buttonError.value = errorText
} else if (typeof errorText === 'function') {
buttonError.value = errorText(error as Error)
}

await new Promise((resolve) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
<FolderPickerModal
v-if="showFolderPicker"
:visible="showFolderPicker"
:suggested-path="coverTargetFile ? (coverTargetFile.GetFilepath().parent ?? undefined) : undefined"
@close="showFolderPicker = false"
@select="handleSelectFolder"
/>
Expand Down
103 changes: 66 additions & 37 deletions weblens-vue/weblens-nuxt/components/organism/FolderPickerModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,11 @@
>
<h4>Select Folder</h4>

<input
<WeblensInput
ref="searchInput"
v-model="displayPath"
class="bg-background-secondary w-full rounded border px-3 py-2 text-sm outline-none"
placeholder="Type a folder path..."
@input="onSearchInput"
v-model:value="displayPath"
placeholder="File path..."
@update:value="onSearchInput"
/>

<div class="flex max-h-64 flex-col gap-1 overflow-y-auto">
Expand All @@ -44,9 +43,12 @@
</div>

<div
v-for="folder in folderResults"
v-for="(folder, i) in folderResults"
:key="folder.id"
class="hover:bg-background-secondary flex cursor-pointer items-center gap-2 rounded px-3 py-2 transition"
:class="{
'hover:bg-background-secondary flex cursor-pointer items-center gap-2 rounded px-3 py-2 transition': true,
'bg-background-secondary': selectedResult === i,
}"
@click="navigateInto(folder)"
>
<IconFolder
Expand Down Expand Up @@ -96,48 +98,62 @@

<script setup lang="ts">
import { IconChevronRight, IconFolder } from '@tabler/icons-vue'
import { useDebounceFn } from '@vueuse/core'
import { onKeyDown, useDebounceFn } from '@vueuse/core'
import type { FileInfo } from '@ethanrous/weblens-api'
import WeblensButton from '../atom/WeblensButton.vue'
import { useWeblensAPI } from '~/api/AllApi'
import { PortablePath } from '~/types/portablePath'
import WeblensInput from '../atom/WeblensInput.vue'

const props = defineProps<{
suggestedPath?: PortablePath
visible: boolean
}>()

const emit = defineEmits<{
(e: 'close'): void
(e: 'select', folderID: string): void
}>()

const userStore = useUserStore()
const searchInput = ref<HTMLInputElement>()
const initialPath = props.suggestedPath ?? PortablePath.Home()

const homePrefix = computed(() => `USERS:${userStore.user.username}/`)
const displayPath = ref<string>(initialPath.friendlyPath)
const folderResults = ref<FileInfo[]>([])
const selectedResult = ref<number>(-1)
const loading = ref(false)
const errorMessage = ref('')

function toDisplayPath(portablePath: string): string {
if (portablePath.startsWith(homePrefix.value)) {
return '~/' + portablePath.slice(homePrefix.value.length)
onKeyDown(['ArrowUp', 'ArrowDown', 'Tab', 'Enter'], (e) => {
if ((selectedResult.value === -1 || folderResults.value.length === 0) && e.key === 'Enter') {
selectCurrentFolder()
return
} else if (folderResults.value.length === 0) {
return
}
return portablePath
}

function toPortablePath(display: string): string {
if (display.startsWith('~/')) {
return homePrefix.value + display.slice(2)
e.preventDefault()
if (e.key === 'ArrowUp') {
selectedResult.value = Math.max(selectedResult.value - 1, -1)
} else if (e.key === 'ArrowDown') {
selectedResult.value = Math.min(selectedResult.value + 1, folderResults.value.length - 1)
} else if (
(e.key === 'Enter' || e.key === 'Tab') &&
selectedResult.value >= 0 &&
selectedResult.value < folderResults.value.length
) {
navigateInto(folderResults.value[selectedResult.value])
selectedResult.value = -1
}
return display
}
})

const displayPath = ref('~/')
const folderResults = ref<FileInfo[]>([])
const currentFolder = ref<FileInfo>()
const loading = ref(false)
const errorMessage = ref('')
const currentFolder = ref<FileInfo | undefined>(
props.suggestedPath ? { portablePath: props.suggestedPath.toString() } : undefined,
)

const emit = defineEmits<{
(e: 'close'): void
(e: 'select', folderID: string): void
}>()

const currentFolderName = computed(() => {
if (!currentFolder.value?.portablePath) return ''
return toDisplayPath(currentFolder.value.portablePath).replace(/\/$/, '').split('/').pop() || '~'
return new PortablePath(currentFolder.value.portablePath).friendlyPath
})

function folderDisplayName(f: FileInfo): string {
Expand Down Expand Up @@ -170,16 +186,27 @@ async function fetchAutocomplete(portable: string) {
const debouncedFetch = useDebounceFn((portable: string) => fetchAutocomplete(portable), 250)

function onSearchInput() {
debouncedFetch(toPortablePath(displayPath.value))
try {
const portable = PortablePath.fromFriendly(displayPath.value)
debouncedFetch(portable.toString())
} catch {
// The user is mid-edit and the path is not yet a valid friendly path
// (e.g. they cleared the input). Drop results until they type more.
folderResults.value = []
currentFolder.value = undefined
errorMessage.value = ''
}
}

function navigateInto(folder: FileInfo) {
if (!folder.portablePath) return

const portable = folder.portablePath.endsWith('/') ? folder.portablePath : folder.portablePath + '/'
const folderPath = new PortablePath(
folder.portablePath.endsWith('/') ? folder.portablePath : folder.portablePath + '/',
)

displayPath.value = toDisplayPath(portable)
fetchAutocomplete(portable)
displayPath.value = folderPath.friendlyPath
fetchAutocomplete(folderPath.toString())
}

function selectCurrentFolder() {
Expand All @@ -191,13 +218,15 @@ watch(
() => props.visible,
(vis) => {
if (vis) {
displayPath.value = '~/'
fetchAutocomplete(homePrefix.value)
const target = props.suggestedPath ?? PortablePath.Home()
displayPath.value = target.friendlyPath
fetchAutocomplete(target.toString())
nextTick(() => searchInput.value?.focus())
} else {
folderResults.value = []
currentFolder.value = undefined
}
},
{ immediate: true },
)
</script>
74 changes: 74 additions & 0 deletions weblens-vue/weblens-nuxt/e2e/context-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { test, expect, createFolder } from './fixtures'
import path from 'path'
import { fileURLToPath } from 'url'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const testMediaDir = path.resolve(__dirname, '../../../images/testMedia')

/**
* Tests for the file context menu system.
Expand Down Expand Up @@ -166,3 +171,72 @@ test.describe('Trash Context Menu', () => {
await page.keyboard.press('Escape')
})
})

test.describe('FolderPickerModal via Set as Cover', () => {
test.beforeEach(async ({ page, login: _login }) => {
// Need a folder so we have a non-home target to pick as the cover destination.
await createFolder(page, 'CoverTargetFolder')
})

test('opens picker at the image parent and sets the folder cover', async ({ page }) => {
test.slow() // media upload + scan + autocomplete

// Upload a real image so the file ends up with hasMedia + contentID,
// which is what enables the "Set as Cover" action.
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByRole('button', { name: 'Upload' }).click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles([path.join(testMediaDir, 'DSC08113.jpg')])

const imageCard = page.locator('[id^="file-card-"]').filter({ hasText: 'DSC08113.jpg' })
await expect(imageCard).toBeVisible({ timeout: 15_000 })
// Wait for the backend to scan the media — the thumbnail rendering means
// contentID has been set on the file via WebSocket FileUpdatedEvent.
await expect(imageCard.locator('.media-image-lowres')).toBeVisible({ timeout: 15_000 })

// Reload so the folder is re-fetched via the GET endpoint, which populates
// hasMedia from the media map. Without this, the in-memory file keeps
// hasMedia=false (the WS update doesn't carry hasMedia) and the
// "Set as Cover" button stays hidden.
await page.reload()
await expect(page.locator('h3').filter({ hasText: 'Home' })).toBeVisible({ timeout: 15_000 })

const reloadedImageCard = page.locator('[id^="file-card-"]').filter({ hasText: 'DSC08113.jpg' })
await expect(reloadedImageCard).toBeVisible({ timeout: 15_000 })
await reloadedImageCard.click({ button: 'right' })

const fileBrowser = page.locator('#filebrowser-container')
const setCoverBtn = fileBrowser.getByRole('button', { name: 'Set as Cover' })
await expect(setCoverBtn).toBeVisible({ timeout: 15_000 })
await setCoverBtn.click()

// FolderPickerModal opens. The input is a WeblensInput, so the actual <input>
// is nested. Match by placeholder text the modal sets.
const pickerInput = page.getByPlaceholder('File path...')
await expect(pickerInput).toBeVisible({ timeout: 5_000 })

// The image lives at USERS:<user>/DSC08113.jpg, so its parent is the home
// directory. The picker should open showing "~/" as the suggested path.
await expect(pickerInput).toHaveValue('~/')

// The newly-created folder should be listed as a child of home (i.e. the
// initial autocomplete call must have actually been made for the home dir,
// not skipped or pointed at the wrong path).
// Scope to the modal's scrollable folder list and match cursor-pointer rows.
const folderRow = page.locator('div.cursor-pointer').filter({ hasText: /^CoverTargetFolder$/ })
await expect(folderRow.first()).toBeVisible({ timeout: 10_000 })

// Navigate into the folder by clicking it. This exercises navigateInto(),
// which currently throws ReferenceError on toDisplayPath.
await folderRow.first().click()
await expect(pickerInput).toHaveValue('~/CoverTargetFolder/')

// The "Select" button on the current-folder row commits the cover.
const selectBtn = page.getByRole('button', { name: 'Select' })
await expect(selectBtn).toBeVisible()
await selectBtn.click()

// Modal closes after a successful set.
await expect(pickerInput).not.toBeVisible({ timeout: 10_000 })
})
})
45 changes: 43 additions & 2 deletions weblens-vue/weblens-nuxt/types/portablePath.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const USERS_ROOT_ALIAS = 'USERS'

export class PortablePath {
private rootAlias: string
private relativePath: string[]
Expand Down Expand Up @@ -37,6 +39,15 @@ export class PortablePath {
return true
}

public get parent(): PortablePath | null {
if (this.relativePath.length <= 1) {
return null
}

const parentPath = this.relativePath.slice(0, -1).join('/')
return new PortablePath(`${this.rootAlias}:${parentPath}/`)
}

public equals(other: PortablePath): boolean {
if (this.rootAlias !== other.rootAlias || this.relativePath.length !== other.relativePath.length) {
return false
Expand All @@ -53,15 +64,26 @@ export class PortablePath {
return this.relativePath[1] === '.user_trash'
}

public toString(opts?: { noHome: boolean }): string {
public toString(opts?: { noHome?: boolean; noTrailingSlash?: boolean }): string {
if (opts?.noHome) {
const path = [...this.relativePath]
path.splice(0, 1)

return `${path.join('/')}`
}

return `${this.rootAlias}:${this.relativePath.join('/')}`
const trailing = this.isDirectory && !opts?.noTrailingSlash ? '/' : ''
return `${this.rootAlias}:${this.relativePath.join('/')}${trailing}`
}

public get friendlyPath(): string {
let parts = this.relativePath
if (parts.length > 0 && parts[0] === useUserStore().user.username) {
parts = parts.slice(1)
}

const trailing = this.isDirectory && parts.length > 0 ? '/' : ''
return '~/' + parts.join('/') + trailing
}

public get filename(): string {
Expand Down Expand Up @@ -91,4 +113,23 @@ export class PortablePath {
static empty(): PortablePath {
return new PortablePath(':')
}

static fromFriendly(friendly: string): PortablePath {
if (friendly.startsWith('~/')) {
const username = useUserStore().user.username
const rest = friendly.slice(2)
return new PortablePath(`${USERS_ROOT_ALIAS}:${username}${rest ? '/' + rest : '/'}`)
}

if (friendly.includes(':')) {
return new PortablePath(friendly)
}

throw new Error(`Cannot interpret as a friendly path: '${friendly}'`)
}

static Home(): PortablePath {
const username = useUserStore().user.username
return new PortablePath(`${USERS_ROOT_ALIAS}:${username}/`)
}
}
Loading