visual continunity
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
||||
import type { FileItem, FolderItem, SearchResults, SortBy } from '$lib/api/types';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||
|
||||
const query = $derived(page.url.searchParams.get('q') ?? '');
|
||||
|
||||
let results = $state<SearchResults | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let sortBy = $state<SortBy>('relevance');
|
||||
// Scope: search everywhere, or within the folder last open in the files view.
|
||||
// Default to the current folder when one is set (and we're not in the trash
|
||||
// section), mirroring the legacy searchView behaviour.
|
||||
let scope = $state<'all' | 'folder'>(
|
||||
filesStore.currentFolder && filesStore.section !== 'trash' ? 'folder' : 'all'
|
||||
);
|
||||
|
||||
// Filters
|
||||
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
|
||||
type SizeKey = 'all' | 'small' | 'medium' | 'large';
|
||||
type DateKey = 'all' | 'day' | 'week' | 'month' | 'year';
|
||||
let typeFilter = $state<TypeKey>('all');
|
||||
let sizeFilter = $state<SizeKey>('all');
|
||||
let dateFilter = $state<DateKey>('all');
|
||||
|
||||
const TYPE_EXT: Record<Exclude<TypeKey, 'all'>, string[]> = {
|
||||
image: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
|
||||
video: ['mp4', 'mov', 'mkv', 'avi', 'webm', 'm4v', 'wmv', 'flv'],
|
||||
document: [
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'txt',
|
||||
'md',
|
||||
'odt',
|
||||
'rtf',
|
||||
'csv'
|
||||
],
|
||||
audio: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus'],
|
||||
archive: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz']
|
||||
};
|
||||
const TYPES: { v: TypeKey; l: string }[] = [
|
||||
{ v: 'all', l: t('search.type.all', 'All types') },
|
||||
{ v: 'image', l: t('search.type.image', 'Images') },
|
||||
{ v: 'video', l: t('search.type.video', 'Videos') },
|
||||
{ v: 'document', l: t('search.type.document', 'Documents') },
|
||||
{ v: 'audio', l: t('search.type.audio', 'Audio') },
|
||||
{ v: 'archive', l: t('search.type.archive', 'Archives') }
|
||||
];
|
||||
const SIZES: { v: SizeKey; l: string }[] = [
|
||||
{ v: 'all', l: t('search.size.all', 'Any size') },
|
||||
{ v: 'small', l: t('search.size.small', '< 1 MB') },
|
||||
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
|
||||
{ v: 'large', l: t('search.size.large', '> 100 MB') }
|
||||
];
|
||||
const DATES: { v: DateKey; l: string }[] = [
|
||||
{ v: 'all', l: t('search.date.all', 'Any time') },
|
||||
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
|
||||
{ v: 'week', l: t('search.date.week', 'Past week') },
|
||||
{ v: 'month', l: t('search.date.month', 'Past month') },
|
||||
{ v: 'year', l: t('search.date.year', 'Past year') }
|
||||
];
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
function sizeBounds(k: SizeKey): { minSize?: number; maxSize?: number } {
|
||||
switch (k) {
|
||||
case 'small':
|
||||
return { maxSize: MB };
|
||||
case 'medium':
|
||||
return { minSize: MB, maxSize: 100 * MB };
|
||||
case 'large':
|
||||
return { minSize: 100 * MB };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function dateBound(k: DateKey): number | undefined {
|
||||
const day = 86400;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
switch (k) {
|
||||
case 'day':
|
||||
return now - day;
|
||||
case 'week':
|
||||
return now - 7 * day;
|
||||
case 'month':
|
||||
return now - 30 * day;
|
||||
case 'year':
|
||||
return now - 365 * day;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = $derived(typeFilter !== 'all' || sizeFilter !== 'all' || dateFilter !== 'all');
|
||||
function clearFilters() {
|
||||
typeFilter = 'all';
|
||||
sizeFilter = 'all';
|
||||
dateFilter = 'all';
|
||||
}
|
||||
|
||||
const SORTS: { v: SortBy; l: string }[] = [
|
||||
{ v: 'relevance', l: t('search.sort.relevance', 'Relevance') },
|
||||
{ v: 'name', l: t('search.sort.name_asc', 'Name A-Z') },
|
||||
{ v: 'name_desc', l: t('search.sort.name_desc', 'Name Z-A') },
|
||||
{ v: 'date_desc', l: t('search.sort.newest', 'Newest') },
|
||||
{ v: 'date', l: t('search.sort.oldest', 'Oldest') },
|
||||
{ v: 'size_desc', l: t('search.sort.largest', 'Largest') },
|
||||
{ v: 'size', l: t('search.sort.smallest', 'Smallest') }
|
||||
];
|
||||
|
||||
async function run(q: string) {
|
||||
if (!q) {
|
||||
results = null;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
// Trash section searches are always global — there is no folder to scope to.
|
||||
const folderId =
|
||||
scope === 'folder' && filesStore.section !== 'trash'
|
||||
? (filesStore.currentFolder ?? undefined)
|
||||
: undefined;
|
||||
results = await searchFiles(q, {
|
||||
recursive: true,
|
||||
sortBy,
|
||||
folderId,
|
||||
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
|
||||
...sizeBounds(sizeFilter),
|
||||
modifiedAfter: dateBound(dateFilter)
|
||||
});
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openFolder(folder: FolderItem) {
|
||||
goto(`/files/${folder.id}`);
|
||||
}
|
||||
|
||||
function openFile(file: FileItem) {
|
||||
window.open(fileInlineUrl(file.id), '_blank', 'noopener');
|
||||
}
|
||||
|
||||
const isEmpty = $derived(!!results && results.files.length === 0 && results.folders.length === 0);
|
||||
|
||||
$effect(() => {
|
||||
// re-run when query, sort, scope, or any filter changes
|
||||
void sortBy;
|
||||
void scope;
|
||||
void typeFilter;
|
||||
void sizeFilter;
|
||||
void dateFilter;
|
||||
void run(query);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('search.title', 'Search')} · OxiCloud</title></svelte:head>
|
||||
|
||||
<div class="page-sticky-header search-head">
|
||||
<h1 class="page-title">
|
||||
{#if query}{t('search.results_for', { q: query }, 'Results for “{{q}}”')}{:else}{t(
|
||||
'search.title',
|
||||
'Search'
|
||||
)}{/if}
|
||||
{#if results?.query_time_ms != null}
|
||||
<span class="search-time">({results.query_time_ms} ms)</span>
|
||||
{/if}
|
||||
</h1>
|
||||
{#if query}
|
||||
<div class="search-controls">
|
||||
{#if filesStore.currentFolder}
|
||||
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
|
||||
<button class="seg__btn" class:active={scope === 'all'} onclick={() => (scope = 'all')}>
|
||||
{t('search.everywhere', 'Everywhere')}
|
||||
</button>
|
||||
<button
|
||||
class="seg__btn"
|
||||
class:active={scope === 'folder'}
|
||||
onclick={() => (scope = 'folder')}
|
||||
>
|
||||
{t('search.this_folder', 'This folder')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={typeFilter}
|
||||
aria-label={t('search.type_label', 'Type')}
|
||||
>
|
||||
{#each TYPES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
|
||||
</select>
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={sizeFilter}
|
||||
aria-label={t('search.size_label', 'Size')}
|
||||
>
|
||||
{#each SIZES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
|
||||
</select>
|
||||
<select
|
||||
class="sort-select"
|
||||
bind:value={dateFilter}
|
||||
aria-label={t('search.date_label', 'Date')}
|
||||
>
|
||||
{#each DATES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
|
||||
</select>
|
||||
<select class="sort-select" bind:value={sortBy} aria-label={t('search.sort_by', 'Sort by')}>
|
||||
{#each SORTS as s (s.v)}<option value={s.v}>{s.l}</option>{/each}
|
||||
</select>
|
||||
{#if hasFilters}
|
||||
<button class="clear-filters" onclick={clearFilters}>
|
||||
<Icon name="times" />
|
||||
{t('search.clear_filters', 'Clear filters')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="search-loading">
|
||||
<Icon name="spinner" class="search-loading__spinner" />
|
||||
<h2 class="search-loading__text">
|
||||
{t('search.searching_for', { q: query }, 'Searching for “{{q}}”…')}
|
||||
</h2>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="empty-state"><p>{error}</p></div>
|
||||
{:else if !query}
|
||||
<div class="empty-state">
|
||||
<p>{t('search.prompt', 'Type a query in the search bar above.')}</p>
|
||||
</div>
|
||||
{:else if isEmpty}
|
||||
<div class="empty-state search-empty">
|
||||
<Icon name="search" />
|
||||
<p>{t('search.no_results', 'No results found for this search')}</p>
|
||||
</div>
|
||||
{:else if results}
|
||||
<div class="files-container">
|
||||
<div class="files-list-view" style="--files-list-columns: minmax(200px, 2fr) 1fr 110px 140px">
|
||||
<div class="list-header">
|
||||
<div>{t('files.col_name', 'Name')}</div>
|
||||
<div>{t('files.col_path', 'Path')}</div>
|
||||
<div>{t('files.col_size', 'Size')}</div>
|
||||
<div>{t('files.col_modified', 'Modified')}</div>
|
||||
</div>
|
||||
|
||||
{#each results.folders as folder (folder.id)}
|
||||
<div
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openFolder(folder)}
|
||||
onkeydown={(e) => e.key === 'Enter' && openFolder(folder)}
|
||||
>
|
||||
<div class="name-cell">
|
||||
<span class="file-icon"><Icon name="folder" /></span>
|
||||
<span>{folder.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{folder.path}</div>
|
||||
<div class="size-cell">—</div>
|
||||
<div class="date-cell">{formatDate(folder.modified_at)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each results.files as file (file.id)}
|
||||
<div
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openFile(file)}
|
||||
onkeydown={(e) => e.key === 'Enter' && openFile(file)}
|
||||
>
|
||||
<div class="name-cell">
|
||||
<span class="file-icon"><Icon name={iconNameFromClass(file.icon_class)} /></span>
|
||||
<span>{file.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{file.path}</div>
|
||||
<div class="size-cell">{file.size != null ? formatBytes(file.size) : ''}</div>
|
||||
<div class="date-cell">{formatDate(file.modified_at)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.search-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sort-select {
|
||||
padding: var(--space-2) var(--space-2-5);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seg__btn {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: none;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seg__btn.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.search-time {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-normal);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.clear-filters {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clear-filters:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.search-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.search-loading :global(.search-loading__spinner) {
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-accent);
|
||||
animation: spin var(--spin-duration) linear infinite;
|
||||
}
|
||||
|
||||
.search-loading__text {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.search-empty :global(.oxi-icon) {
|
||||
font-size: var(--text-3xl);
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user