mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
255 lines
7.9 KiB
Svelte
255 lines
7.9 KiB
Svelte
<script lang="ts">
|
|
import type { Snapshot } from "@sveltejs/kit";
|
|
import superjson, { type SuperJSONResult } from "superjson";
|
|
import { untrack } from "svelte";
|
|
import { slide } from "svelte/transition";
|
|
import { goto } from "$app/navigation";
|
|
import { Chip, FullscreenDiv, RowVirtualizer } from "#lib/components/atoms/index.js";
|
|
import {
|
|
getDirectoryInfo,
|
|
type LocalCategoryInfo,
|
|
type MaybeDirectoryInfo,
|
|
} from "#lib/modules/filesystem/index.js";
|
|
import { masterKeyStore } from "#lib/stores/index.js";
|
|
import { HybridPromise, searchString, sortEntries } from "#lib/utils/index.js";
|
|
import Directory from "./Directory.svelte";
|
|
import File from "./File.svelte";
|
|
import SearchBar from "./SearchBar.svelte";
|
|
import SelectCategoryBottomSheet from "./SelectCategoryBottomSheet.svelte";
|
|
import { requestSearch, type SearchFilter, type SearchResult } from "./service";
|
|
|
|
let { data } = $props();
|
|
|
|
interface SearchFilters {
|
|
name: string;
|
|
includeImages: boolean;
|
|
includeVideos: boolean;
|
|
includeDirectories: boolean;
|
|
searchInFavorites: boolean;
|
|
searchInDirectory: boolean;
|
|
categories: SearchFilter["categories"];
|
|
}
|
|
|
|
let directoryInfo: MaybeDirectoryInfo | undefined = $state();
|
|
|
|
let filters: SearchFilters = $state({
|
|
name: "",
|
|
includeImages: false,
|
|
includeVideos: false,
|
|
includeDirectories: false,
|
|
searchInDirectory: false,
|
|
searchInFavorites: false,
|
|
categories: [],
|
|
});
|
|
let hasCategoryFilter = $derived(filters.categories.length > 0);
|
|
let hasAnyFilter = $derived(
|
|
hasCategoryFilter ||
|
|
filters.includeImages ||
|
|
filters.includeVideos ||
|
|
filters.includeDirectories ||
|
|
filters.name.trim().length > 0,
|
|
);
|
|
|
|
let isRestoredFromSnapshot = $state(false);
|
|
let serverResult: SearchResult | undefined = $state();
|
|
let result = $derived.by(() => {
|
|
if (!serverResult) return [];
|
|
|
|
const nameFilter = filters.name.trim();
|
|
const hasTypeFilter =
|
|
filters.includeImages || filters.includeVideos || filters.includeDirectories;
|
|
|
|
const directories =
|
|
!hasTypeFilter || filters.includeDirectories
|
|
? serverResult.directories.map((directory) => ({
|
|
type: "directory" as const,
|
|
...directory,
|
|
}))
|
|
: [];
|
|
const files =
|
|
!hasTypeFilter || filters.includeImages || filters.includeVideos
|
|
? serverResult.files
|
|
.filter(
|
|
({ contentType }) =>
|
|
!hasTypeFilter ||
|
|
(filters.includeImages && contentType.startsWith("image/")) ||
|
|
(filters.includeVideos && contentType.startsWith("video/")),
|
|
)
|
|
.map((file) => ({
|
|
type: "file" as const,
|
|
...file,
|
|
}))
|
|
: [];
|
|
|
|
return sortEntries(
|
|
[...directories, ...files].filter(
|
|
(entry) => !nameFilter || searchString(entry.name, nameFilter),
|
|
),
|
|
);
|
|
});
|
|
|
|
let isSelectCategoryBottomSheetOpen = $state(false);
|
|
let categorySelectMode: "include" | "exclude" = $state("include");
|
|
|
|
const openSelectCategoryBottomSheet = (mode: "include" | "exclude") => {
|
|
categorySelectMode = mode;
|
|
isSelectCategoryBottomSheetOpen = true;
|
|
};
|
|
|
|
const addCategoryFilter = (category: LocalCategoryInfo) => {
|
|
if (!filters.categories.some(({ info }) => info.id === category.id)) {
|
|
filters.categories.push({
|
|
info: category,
|
|
type: categorySelectMode,
|
|
});
|
|
isSelectCategoryBottomSheetOpen = false;
|
|
}
|
|
};
|
|
|
|
export const snapshot: Snapshot<{
|
|
filters: SearchFilters;
|
|
serverResult: SuperJSONResult;
|
|
}> = {
|
|
capture() {
|
|
return { filters, serverResult: superjson.serialize(serverResult) };
|
|
},
|
|
restore(value) {
|
|
filters = value.filters;
|
|
serverResult = superjson.deserialize(value.serverResult, { inPlace: true });
|
|
isRestoredFromSnapshot = true;
|
|
},
|
|
};
|
|
|
|
$effect.pre(() => {
|
|
if (data.directoryId) {
|
|
HybridPromise.resolve(getDirectoryInfo(data.directoryId, $masterKeyStore?.get(1)?.key!)).then(
|
|
(res) => {
|
|
directoryInfo = res;
|
|
filters.searchInDirectory = res.exists;
|
|
},
|
|
);
|
|
} else {
|
|
directoryInfo = undefined;
|
|
filters.searchInDirectory = false;
|
|
}
|
|
});
|
|
|
|
$effect.pre(() => {
|
|
filters.searchInFavorites = data.fromFavorites;
|
|
});
|
|
|
|
$effect(() => {
|
|
// Svelte sucks
|
|
hasAnyFilter;
|
|
filters.searchInDirectory;
|
|
filters.searchInFavorites;
|
|
filters.categories.length;
|
|
|
|
if (untrack(() => isRestoredFromSnapshot)) {
|
|
isRestoredFromSnapshot = false;
|
|
return;
|
|
}
|
|
|
|
if (hasAnyFilter) {
|
|
requestSearch(
|
|
{
|
|
ancestorId: filters.searchInDirectory ? data.directoryId! : "root",
|
|
inFavorites: filters.searchInFavorites,
|
|
categories: filters.categories,
|
|
},
|
|
$masterKeyStore?.get(1)?.key!,
|
|
).then((res) => {
|
|
serverResult = res;
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>검색</title>
|
|
</svelte:head>
|
|
|
|
<SearchBar bind:value={filters.name} />
|
|
<FullscreenDiv class="bg-gray-100 px-0!">
|
|
<div class="flex grow flex-col space-y-4">
|
|
<div class="space-y-2 bg-white p-4 pt-0!">
|
|
<div class="space-y-3">
|
|
<div class="flex flex-wrap gap-2">
|
|
<Chip bind:selected={filters.includeImages}>사진</Chip>
|
|
<Chip bind:selected={filters.includeVideos}>동영상</Chip>
|
|
{#if !hasCategoryFilter}
|
|
<Chip bind:selected={filters.includeDirectories}>폴더</Chip>
|
|
{/if}
|
|
<Chip bind:selected={filters.searchInFavorites}>즐겨찾기</Chip>
|
|
{#if directoryInfo?.exists}
|
|
<Chip bind:selected={filters.searchInDirectory}>
|
|
위치: {directoryInfo.name}
|
|
</Chip>
|
|
{/if}
|
|
</div>
|
|
{#if !filters.includeDirectories}
|
|
<div class="space-y-2" transition:slide={{ duration: 300 }}>
|
|
<p class="text-sm font-medium text-gray-600">카테고리</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
{#each filters.categories as { info, type }, i (info.id)}
|
|
<Chip
|
|
selected
|
|
removable
|
|
onclick={() => {}}
|
|
onRemoveClick={() => filters.categories.splice(i, 1)}
|
|
>
|
|
{#if type === "include"}
|
|
포함:
|
|
{:else}
|
|
제외:
|
|
{/if}
|
|
{info.name}
|
|
</Chip>
|
|
{/each}
|
|
<Chip onclick={() => openSelectCategoryBottomSheet("include")}>+ 포함</Chip>
|
|
<Chip onclick={() => openSelectCategoryBottomSheet("exclude")}>- 제외</Chip>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{#if hasAnyFilter}
|
|
<div class="flex grow flex-col space-y-2 bg-white p-4">
|
|
<p class="text-lg font-bold text-gray-800">
|
|
검색 결과
|
|
{#if result.length > 0}
|
|
{" "}({result.length}개)
|
|
{/if}
|
|
</p>
|
|
{#if result.length > 0}
|
|
<RowVirtualizer
|
|
count={result.length}
|
|
getItemKey={(index) => `${result[index]!.type}-${result[index]!.id}`}
|
|
estimateItemHeight={() => 56}
|
|
itemGap={4}
|
|
>
|
|
{#snippet item(index)}
|
|
{@const info = result[index]!}
|
|
{#if info.type === "directory"}
|
|
<Directory {info} onclick={() => goto(`/directory/${info.id}?from=search`)} />
|
|
{:else}
|
|
<File {info} onclick={() => goto(`/file/${info.id}?from=search`)} />
|
|
{/if}
|
|
{/snippet}
|
|
</RowVirtualizer>
|
|
{:else}
|
|
<div class="flex grow items-center justify-center py-8">
|
|
<p class="text-gray-500">검색 결과가 없어요.</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</FullscreenDiv>
|
|
|
|
<SelectCategoryBottomSheet
|
|
bind:isOpen={isSelectCategoryBottomSheetOpen}
|
|
mode={categorySelectMode}
|
|
onSelectCategoryClick={addCategoryFilter}
|
|
/>
|