feat(share) : recherche globale récursive par nom de fichier dans le partage SMB

Endpoint GET /api/share/search?q= parcourant tout le partage en largeur
(garde-fous 200 résultats / 2000 dossiers). Le champ de l'explorateur
déclenche une recherche globale debouncée dès 2 caractères (filtre local
en deçà), avec affichage du dossier parent de chaque résultat.
This commit is contained in:
Matthieu
2026-06-12 15:49:57 +02:00
parent 682b5747b1
commit 1964ea5fb4
8 changed files with 267 additions and 18 deletions
+2 -1
View File
@@ -439,7 +439,8 @@
"title": "Documents",
"root": "Racine",
"empty": "Ce dossier est vide.",
"filterPlaceholder": "Filtrer ce dossier…",
"noResults": "Aucun document ne correspond à votre recherche.",
"searchPlaceholder": "Rechercher dans tout le partage…",
"download": "Télécharger",
"reload": "Recharger",
"previewError": "Aperçu impossible. Téléchargez le fichier pour l'ouvrir.",
+67 -8
View File
@@ -16,7 +16,7 @@
<div class="max-w-sm flex-1">
<MalioInputText
v-model="filter"
:placeholder="$t('sharedFiles.filterPlaceholder')"
:placeholder="$t('sharedFiles.searchPlaceholder')"
input-class="w-full"
/>
</div>
@@ -32,11 +32,13 @@
</div>
<!-- États -->
<div v-if="loading" class="mt-10 flex justify-center">
<div v-if="loading || searching" class="mt-10 flex justify-center">
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
</div>
<p v-else-if="error" class="mt-10 text-sm text-red-600">{{ error }}</p>
<p v-else-if="visibleEntries.length === 0" class="mt-10 text-sm text-neutral-400">{{ $t('sharedFiles.empty') }}</p>
<p v-else-if="error || searchError" class="mt-10 text-sm text-red-600">{{ error || searchError }}</p>
<p v-else-if="visibleEntries.length === 0" class="mt-10 text-sm text-neutral-400">
{{ isSearchMode ? $t('sharedFiles.noResults') : $t('sharedFiles.empty') }}
</p>
<!-- Tableau -->
<table v-else class="mt-6 w-full text-sm">
@@ -55,8 +57,11 @@
@click="onEntryClick(entry)"
>
<td class="flex items-center gap-2 py-2">
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 text-neutral-400" />
<span class="truncate">{{ entry.name }}</span>
<Icon :name="entry.isDir ? 'mdi:folder-outline' : iconForMime(entry.mimeType)" class="h-5 w-5 shrink-0 text-neutral-400" />
<span class="flex min-w-0 flex-col">
<span class="truncate">{{ entry.name }}</span>
<span v-if="isSearchMode && parentDir(entry)" class="truncate text-xs text-neutral-400">{{ parentDir(entry) }}</span>
</span>
</td>
<td class="py-2 text-neutral-500">{{ entry.isDir ? '' : formatFileSize(entry.size) }}</td>
<td class="py-2 text-neutral-500">{{ formatDate(entry.modifiedAt) }}</td>
@@ -82,9 +87,11 @@ import { formatFileSize } from '~/utils/format'
useHead({ title: 'Documents' })
const { browse } = useShareService()
const { browse, search } = useShareService()
const { enabled, ensureLoaded } = useShareStatus()
const MIN_SEARCH_LENGTH = 2
const currentPath = ref('')
const breadcrumb = ref<Breadcrumb[]>([])
const entries = ref<FileEntry[]>([])
@@ -92,12 +99,64 @@ const filter = ref('')
const loading = ref(false)
const error = ref<string | null>(null)
const searchResults = ref<FileEntry[]>([])
const searching = ref(false)
const searchError = ref<string | null>(null)
const previewEntry = ref<FileEntry | null>(null)
// Recherche globale (récursive sur tout le partage) dès 2 caractères ;
// en deçà, simple filtre local sur le dossier courant.
const isSearchMode = computed(() => filter.value.trim().length >= MIN_SEARCH_LENGTH)
const visibleEntries = computed(() => {
const f = filter.value.trim().toLowerCase()
if (!f) return entries.value
return entries.value.filter((e) => e.name.toLowerCase().includes(f))
if (f.length < MIN_SEARCH_LENGTH) {
return entries.value.filter((e) => e.name.toLowerCase().includes(f))
}
return searchResults.value
})
function parentDir(entry: FileEntry): string {
const idx = entry.path.lastIndexOf('/')
return idx === -1 ? '' : entry.path.slice(0, idx)
}
let searchTimer: ReturnType<typeof setTimeout> | null = null
let searchSeq = 0
async function runSearch(query: string) {
const seq = ++searchSeq
searching.value = true
searchError.value = null
try {
const result = await search(query)
if (seq !== searchSeq) return // une frappe plus récente a pris le relais
searchResults.value = result.entries
} catch (e: unknown) {
if (seq !== searchSeq) return
searchError.value = (e as Error)?.message ?? 'Erreur'
searchResults.value = []
} finally {
if (seq === searchSeq) searching.value = false
}
}
watch(filter, (value) => {
if (searchTimer) clearTimeout(searchTimer)
const q = value.trim()
if (q.length < MIN_SEARCH_LENGTH) {
searchSeq++ // invalide toute recherche en vol
searching.value = false
searchError.value = null
searchResults.value = []
return
}
searching.value = true
searchTimer = setTimeout(() => runSearch(q), 300)
})
const fileEntries = computed(() => visibleEntries.value.filter((e) => !e.isDir))
+5
View File
@@ -18,6 +18,11 @@ export type ShareBrowseResult = {
entries: FileEntry[]
}
export type ShareSearchResult = {
query: string
entries: FileEntry[]
}
export type ShareStatus = {
enabled: boolean
}
+6 -2
View File
@@ -1,4 +1,4 @@
import type { ShareBrowseResult, ShareStatus } from './dto/share'
import type { ShareBrowseResult, ShareSearchResult, ShareStatus } from './dto/share'
export function useShareService() {
const api = useApi()
@@ -9,6 +9,10 @@ export function useShareService() {
return api.get<ShareBrowseResult>(`/share/browse${query}`)
}
async function search(query: string): Promise<ShareSearchResult> {
return api.get<ShareSearchResult>(`/share/search?q=${encodeURIComponent(query)}`)
}
async function getStatus(): Promise<ShareStatus> {
return api.get<ShareStatus>('/share/status')
}
@@ -18,5 +22,5 @@ export function useShareService() {
return `${base}/share/download?path=${encodeURIComponent(path)}&disposition=${disposition}`
}
return { browse, getStatus, getDownloadUrl }
return { browse, search, getStatus, getDownloadUrl }
}