검색 기능 구현

This commit is contained in:
static
2026-01-15 15:11:03 +09:00
parent 96d5397cb5
commit 37bd6a9315
26 changed files with 757 additions and 35 deletions
+53
View File
@@ -0,0 +1,53 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import IconClose from "~icons/material-symbols/close";
interface Props {
children: Snippet;
class?: ClassValue;
onclick?: () => void;
onRemoveClick?: () => void;
selected?: boolean;
removable?: boolean;
}
let {
children,
class: className,
onclick = () => (selected = !selected),
onRemoveClick,
removable = false,
selected = $bindable(false),
}: Props = $props();
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
onclick={onclick && (() => setTimeout(onclick, 100))}
class={[
"inline-flex cursor-pointer items-center gap-x-1 rounded-lg px-3 py-1.5 text-sm font-medium transition active:scale-95",
selected
? "bg-primary-500 text-white active:bg-primary-400"
: "bg-gray-100 text-gray-700 active:bg-gray-200",
className,
]}
>
<span>
{@render children()}
</span>
{#if selected && removable}
<button
onclick={(e) => {
e.stopPropagation();
if (onRemoveClick) {
setTimeout(onRemoveClick, 100);
}
}}
>
<IconClose />
</button>
{/if}
</div>
+13 -3
View File
@@ -6,13 +6,22 @@
interface Props {
class?: ClassValue;
count: number;
estimateItemHeight: (index: number) => number;
getItemKey?: (index: number) => string | number;
item: Snippet<[index: number]>;
itemHeight: (index: number) => number;
itemGap?: number;
placeholder?: Snippet;
}
let { class: className, count, item, itemHeight, itemGap, placeholder }: Props = $props();
let {
class: className,
count,
estimateItemHeight,
getItemKey,
item,
itemGap,
placeholder,
}: Props = $props();
let element: HTMLElement | undefined = $state();
let scrollMargin = $state(0);
@@ -20,8 +29,9 @@
let virtualizer = $derived(
createWindowVirtualizer({
count,
estimateSize: itemHeight,
estimateSize: estimateItemHeight,
gap: itemGap,
getItemKey: getItemKey,
scrollMargin,
}),
);
@@ -49,7 +49,7 @@
</div>
</div>
<style>
<style lang="postcss">
#container:active:not(:has(#action-button:active)) {
@apply bg-gray-100;
}
+1
View File
@@ -1,5 +1,6 @@
export { default as BottomSheet } from "./BottomSheet.svelte";
export * from "./buttons";
export { default as Chip } from "./Chip.svelte";
export * from "./divs";
export * from "./inputs";
export { default as Modal } from "./Modal.svelte";
@@ -28,7 +28,7 @@
</div>
</div>
<style>
<style lang="postcss">
input:focus,
input:not(:placeholder-shown) {
@apply border-primary-300;
@@ -10,9 +10,9 @@
class?: ClassValue;
info: CategoryInfo;
onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick: () => void;
onSubCategoryCreateClick?: () => void;
onSubCategoryMenuClick?: (category: SelectedCategory) => void;
subCategoryCreatePosition?: "top" | "bottom";
subCategoryCreatePosition?: "top" | "bottom" | "none";
subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
}
@@ -22,7 +22,7 @@
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
subCategoryCreatePosition = "bottom",
subCategoryCreatePosition = "none",
subCategoryMenuIcon,
}: Props = $props();
</script>
+12 -7
View File
@@ -8,10 +8,11 @@
children?: Snippet;
class?: ClassValue;
onBackClick?: () => void;
showBackButton?: boolean;
title?: string;
}
let { children, class: className, onBackClick, title }: Props = $props();
let { children, class: className, onBackClick, showBackButton = true, title }: Props = $props();
</script>
<div
@@ -20,12 +21,16 @@
className,
]}
>
<button
onclick={onBackClick || (() => history.back())}
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
>
<IconArrowBack class="text-2xl" />
</button>
<div class="w-[2.3rem] flex-shrink-0">
{#if showBackButton}
<button
onclick={onBackClick ?? (() => history.back())}
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
>
<IconArrowBack class="text-2xl" />
</button>
{/if}
</div>
{#if title}
<p class="flex-grow truncate text-center text-lg font-semibold">{title}</p>
{/if}
+2
View File
@@ -10,6 +10,7 @@ import { Scheduler } from "$lib/utils";
import { trpc } from "$trpc/client";
export interface FileUploadState {
id: string;
name: string;
parentId: DirectoryId;
status:
@@ -208,6 +209,7 @@ export const uploadFile = async (
onDuplicate: () => Promise<boolean>,
) => {
uploadingFiles.push({
id: crypto.randomUUID(),
name: file.name,
parentId,
status: "queued",
+4 -4
View File
@@ -1,7 +1,7 @@
export type DataKey = { key: CryptoKey; version: Date };
type AllUndefined<T> = { [K in keyof T]?: undefined };
interface LocalDirectoryInfo {
export interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
@@ -10,7 +10,7 @@ interface LocalDirectoryInfo {
files: SummarizedFileInfo[];
}
interface RootDirectoryInfo {
export interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
@@ -45,7 +45,7 @@ export type MaybeFileInfo =
export type SummarizedFileInfo = Omit<FileInfo, "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
interface LocalCategoryInfo {
export interface LocalCategoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
@@ -55,7 +55,7 @@ interface LocalCategoryInfo {
isFileRecursive: boolean;
}
interface RootCategoryInfo {
export interface RootCategoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
+132
View File
@@ -101,6 +101,39 @@ export const getAllDirectoriesByParent = async (userId: number, parentId: Direct
);
};
export const getAllRecursiveDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
const directories = await db
.withRecursive("directory_tree", (db) =>
db
.selectFrom("directory")
.selectAll()
.$if(parentId === "root", (qb) => qb.where("parent_id", "is", null))
.$if(parentId !== "root", (qb) => qb.where("parent_id", "=", parentId as number))
.where("user_id", "=", userId)
.unionAll((db) =>
db
.selectFrom("directory")
.innerJoin("directory_tree", "directory.parent_id", "directory_tree.id")
.selectAll("directory"),
),
)
.selectFrom("directory_tree")
.selectAll()
.execute();
return directories.map(
(directory) =>
({
id: directory.id,
parentId: directory.parent_id ?? "root",
userId: directory.user_id,
mekVersion: directory.master_encryption_key_version,
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
}) satisfies Directory,
);
};
export const getDirectory = async (userId: number, directoryId: number) => {
const directory = await db
.selectFrom("directory")
@@ -434,6 +467,105 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[])
);
};
export const searchFiles = async (
userId: number,
filters: {
parentId: DirectoryId;
includeCategoryIds: number[];
excludeCategoryIds: number[];
},
) => {
const ctes: string[] = [];
const conditions: string[] = [];
if (filters.parentId === "root") {
conditions.push(`user_id = ${userId}`);
} else {
ctes.push(`
directory_tree AS (
SELECT id FROM directory WHERE user_id = ${userId} AND id = ${filters.parentId}
UNION ALL
SELECT d.id FROM directory d INNER JOIN directory_tree dt ON d.parent_id = dt.id
)`);
conditions.push(`parent_id IN (SELECT id FROM directory_tree)`);
}
filters.includeCategoryIds.forEach((categoryId, index) => {
ctes.push(`
include_category_tree_${index} AS (
SELECT id FROM category WHERE user_id = ${userId} AND id = ${categoryId}
UNION ALL
SELECT c.id FROM category c INNER JOIN include_category_tree_${index} ct ON c.parent_id = ct.id
)`);
conditions.push(`
EXISTS(
SELECT 1 FROM file_category
WHERE file_id = file.id
AND EXISTS (SELECT 1 FROM include_category_tree_${index} ct WHERE ct.id = category_id)
)`);
});
if (filters.excludeCategoryIds.length > 0) {
ctes.push(`
exclude_category_tree AS (
SELECT id FROM category WHERE user_id = ${userId} AND id IN (${filters.excludeCategoryIds.join(",")})
UNION ALL
SELECT c.id FROM category c INNER JOIN exclude_category_tree ct ON c.parent_id = ct.id
)`);
conditions.push(`
NOT EXISTS(
SELECT 1 FROM file_category
WHERE file_id = id
AND EXISTS (SELECT 1 FROM exclude_category_tree ct WHERE ct.id = category_id)
)`);
}
const query = `
${ctes.length > 0 ? `WITH RECURSIVE ${ctes.join(",")}` : ""}
SELECT * FROM file
WHERE ${conditions.join(" AND ")}
`;
const { rows } = await sql
.raw<{
id: number;
parent_id: number | null;
user_id: number;
path: string;
master_encryption_key_version: number;
encrypted_data_encryption_key: string;
data_encryption_key_version: Date;
hmac_secret_key_version: number;
content_hmac: string;
content_type: string;
encrypted_content_iv: string;
encrypted_content_hash: string;
encrypted_name: Ciphertext;
encrypted_created_at: Ciphertext | null;
encrypted_last_modified_at: Ciphertext;
}>(query)
.execute(db);
return rows.map(
(file) =>
({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
}) satisfies File,
);
};
export const setFileEncName = async (
userId: number,
fileId: number,