갤러리 페이지 구현

This commit is contained in:
static
2025-12-26 22:29:44 +09:00
parent b7a7536461
commit ed21a9cd31
27 changed files with 307 additions and 59 deletions

View File

@@ -21,6 +21,7 @@
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tanstack/svelte-virtual": "^3.13.13",
"@trpc/client": "^11.8.1",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",

18
pnpm-lock.yaml generated
View File

@@ -54,6 +54,9 @@ importers:
'@sveltejs/vite-plugin-svelte':
specifier: ^6.2.1
version: 6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@1.21.7)(yaml@2.8.0))
'@tanstack/svelte-virtual':
specifier: ^3.13.13
version: 3.13.13(svelte@5.46.1)
'@trpc/client':
specifier: ^11.8.1
version: 11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)
@@ -610,6 +613,14 @@ packages:
svelte: ^5.0.0
vite: ^6.3.0 || ^7.0.0
'@tanstack/svelte-virtual@3.13.13':
resolution: {integrity: sha512-VDOvbRw3R+XBQdFodEJ4E7AOmEyo3Bmr4zL4DLVnJ0fxICdbvY5F5t8zSwJ4f7lqjckXi0yKFzY8WBtjaNbsGQ==}
peerDependencies:
svelte: ^3.48.0 || ^4.0.0 || ^5.0.0
'@tanstack/virtual-core@3.13.13':
resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==}
'@trpc/client@11.8.1':
resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==}
peerDependencies:
@@ -2367,6 +2378,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/svelte-virtual@3.13.13(svelte@5.46.1)':
dependencies:
'@tanstack/virtual-core': 3.13.13
svelte: 5.46.1
'@tanstack/virtual-core@3.13.13': {}
'@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@trpc/server': 11.8.1(typescript@5.9.3)

View File

@@ -3,7 +3,7 @@
import type { SvelteHTMLElements } from "svelte/elements";
import { get, type Writable } from "svelte/store";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import { SortBy, sortEntries } from "$lib/utils";
import Category from "./Category.svelte";
import type { SelectedCategory } from "./service";

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
import Thumbnail from "./Thumbnail.svelte";
interface Props {
files: Writable<FileInfo | null>[];
onFileClick?: (file: FileInfo) => void;
}
let { files, onFileClick }: Props = $props();
type FileEntry = { date?: Date; info: Writable<FileInfo | null> };
type Row =
| { type: "header"; key: string; label: string }
| { type: "items"; key: string; items: FileEntry[] };
let filesWithDate: FileEntry[] = $state([]);
let rows: Row[] = $state([]);
let listElement: HTMLDivElement | undefined = $state();
const virtualizer = createWindowVirtualizer({
count: 0,
getItemKey: (index) => rows[index]!.key,
estimateSize: () => 1000, // TODO
});
const measureRow = (node: HTMLElement) => {
$virtualizer.measureElement(node);
return {
update: () => $virtualizer.measureElement(node),
};
};
$effect(() => {
filesWithDate = files.map((file) => {
const { createdAt, lastModifiedAt } = get(file) ?? {};
return { date: createdAt ?? lastModifiedAt, info: file };
});
const buildRows = () => {
const map = new Map<string, FileEntry[]>();
for (const file of filesWithDate) {
if (!file.date) continue;
const date = formatDateSortable(file.date);
const entries = map.get(date) ?? [];
entries.push(file);
map.set(date, entries);
}
const newRows: Row[] = [];
const sortedDates = Array.from(map.keys()).sort((a, b) => b.localeCompare(a));
for (const date of sortedDates) {
const entries = map.get(date)!;
sortEntries(entries, SortBy.DATE_DESC);
newRows.push({
type: "header",
key: `header-${date}`,
label: formatDate(entries[0]!.date!),
});
newRows.push({
type: "items",
key: `items-${date}`,
items: entries,
});
}
rows = newRows;
$virtualizer.setOptions({ count: rows.length });
};
return untrack(() => {
buildRows();
const unsubscribes = filesWithDate.map((file) =>
file.info.subscribe((value) => {
const newDate = value?.createdAt ?? value?.lastModifiedAt;
if (file.date?.getTime() === newDate?.getTime()) return;
file.date = newDate;
buildRows();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
<div bind:this={listElement} class="relative flex flex-grow flex-col">
<div style="height: {$virtualizer.getTotalSize()}px;">
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)}
{@const row = rows[virtualRow.index]!}
<div
use:measureRow
data-index={virtualRow.index}
class="absolute left-0 top-0 w-full"
style="transform: translateY({virtualRow.start}px);"
>
{#if row.type === "header"}
<p class="pb-2 font-medium">{row.label}</p>
{:else}
<div class="grid grid-cols-4 gap-1 pb-4">
{#each row.items as { info }}
<Thumbnail {info} onclick={onFileClick} />
{/each}
</div>
{/if}
</div>
{/each}
</div>
{#if $virtualizer.getVirtualItems().length === 0}
<div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500">
{#if files.length === 0}
업로드된 파일이 없어요.
{:else if filesWithDate.length === 0}
파일 목록을 불러오고 있어요.
{:else}
사진 또는 동영상이 없어요.
{/if}
</p>
</div>
{/if}
</div>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
interface Props {
info: Writable<FileInfo | null>;
onclick?: (file: FileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail: string | undefined = $state();
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script>
{#if $info}
<button
onclick={() => onclick?.($info)}
class="relative aspect-square w-full overflow-hidden rounded transition active:scale-95 active:brightness-90"
>
{#if thumbnail}
<img src={thumbnail} alt={$info.name} class="h-full w-full object-cover" />
{:else}
<div class="h-full w-full bg-gray-100"></div>
{/if}
</button>
{/if}

View File

@@ -0,0 +1 @@
export { default as Gallery } from "./Gallery.svelte";

View File

@@ -2,6 +2,7 @@ export * from "./ActionModal.svelte";
export { default as ActionModal } from "./ActionModal.svelte";
export * from "./Categories";
export { default as Categories } from "./Categories";
export * from "./Gallery";
export { default as IconEntryButton } from "./IconEntryButton.svelte";
export * from "./labels";
export { default as SubCategories } from "./SubCategories.svelte";

View File

@@ -4,8 +4,8 @@
import { CheckBox } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import { SortBy, sortEntries } from "$lib/utils";
import File from "./File.svelte";
import type { SelectedFile } from "./service";

View File

@@ -7,6 +7,13 @@ export const formatDate = (date: Date) => {
return `${year}. ${month}. ${day}.`;
};
export const formatDateSortable = (date: Date) => {
const year = date.getFullYear();
const month = pad2(date.getMonth() + 1);
const day = pad2(date.getDate());
return `${year}${month}${day}`;
};
export const formatDateTime = (date: Date) => {
const dateFormatted = formatDate(date);
const hours = date.getHours();
@@ -32,32 +39,3 @@ export const truncateString = (str: string, maxLength = 20) => {
if (str.length <= maxLength) return str;
return `${str.slice(0, maxLength)}...`;
};
export enum SortBy {
NAME_ASC,
NAME_DESC,
}
type SortFunc = (a?: string, b?: string) => number;
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const sortByNameAsc: SortFunc = (a, b) => {
if (a && b) return collator.compare(a, b);
if (a) return -1;
if (b) return 1;
return 0;
};
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
export const sortEntries = <T extends { name?: string }>(entries: T[], sortBy: SortBy) => {
let sortFunc: SortFunc;
if (sortBy === SortBy.NAME_ASC) {
sortFunc = sortByNameAsc;
} else {
sortFunc = sortByNameDesc;
}
entries.sort((a, b) => sortFunc(a.name, b.name));
};

View File

@@ -1 +1,3 @@
export * from "./format";
export * from "./gotoStateful";
export * from "./sort";

57
src/lib/utils/sort.ts Normal file
View File

@@ -0,0 +1,57 @@
interface SortEntry {
name?: string;
date?: Date;
}
export enum SortBy {
NAME_ASC,
NAME_DESC,
DATE_ASC,
DATE_DESC,
}
type SortFunc = (a: SortEntry, b: SortEntry) => number;
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
const sortByNameAsc: SortFunc = ({ name: a }, { name: b }) => {
if (a && b) return collator.compare(a, b);
if (a) return -1;
if (b) return 1;
return 0;
};
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
const sortByDateAsc: SortFunc = ({ date: a }, { date: b }) => {
if (a && b) return a.getTime() - b.getTime();
if (a) return -1;
if (b) return 1;
return 0;
};
const sortByDateDesc: SortFunc = (a, b) => -sortByDateAsc(a, b);
export const sortEntries = <T extends SortEntry>(entries: T[], sortBy: SortBy) => {
let sortFunc: SortFunc;
switch (sortBy) {
case SortBy.NAME_ASC:
sortFunc = sortByNameAsc;
break;
case SortBy.NAME_DESC:
sortFunc = sortByNameDesc;
break;
case SortBy.DATE_ASC:
sortFunc = sortByDateAsc;
break;
case SortBy.DATE_DESC:
sortFunc = sortByDateDesc;
break;
default:
const exhaustive: never = sortBy;
sortFunc = exhaustive;
}
entries.sort(sortFunc);
};

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils";
interface Props {
status?: Writable<FileDownloadStatus>;

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { get, type Writable } from "svelte/store";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/modules/util";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils";
import IconCloud from "~icons/material-symbols/cloud";
import IconCloudDownload from "~icons/material-symbols/cloud-download";

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import type { FileUploadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils";
import IconPending from "~icons/material-symbols/pending";
import IconLockClock from "~icons/material-symbols/lock-clock";

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { FullscreenDiv } from "$lib/components/atoms";
import { Gallery, TopBar } from "$lib/components/molecules";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
let { data } = $props();
let files: Writable<FileInfo | null>[] = $state([]);
$effect(() => {
files = data.files.map((file) => getFileInfo(file, $masterKeyStore?.get(1)?.key!));
});
</script>
<svelte:head>
<title>사진 및 동영상</title>
</svelte:head>
<TopBar title="사진 및 동영상" />
<FullscreenDiv>
<Gallery {files} onFileClick={({ id }) => goto(`/file/${id}`)} />
</FullscreenDiv>

View File

@@ -0,0 +1,7 @@
import { trpc } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const files = await trpc(fetch).file.list.query();
return { files };
};

View File

@@ -6,8 +6,8 @@
import type { FileCacheIndex } from "$lib/indexedDB";
import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatFileSize } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import { formatFileSize } from "$lib/utils";
import File from "./File.svelte";
interface FileCache {

View File

@@ -2,7 +2,7 @@
import type { Writable } from "svelte/store";
import type { FileCacheIndex } from "$lib/indexedDB";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "$lib/modules/util";
import { formatDate, formatFileSize } from "$lib/utils";
import IconDraft from "~icons/material-symbols/draft";
import IconScanDelete from "~icons/material-symbols/scan-delete";

View File

@@ -1,13 +1,7 @@
import { error } from "@sveltejs/kit";
import { trpc } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
try {
const files = await trpc(fetch).file.listWithoutThumbnail.query();
return { files };
} catch {
// TODO: Error Handling
error(500, "Internal server error");
}
};

View File

@@ -14,7 +14,7 @@
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util";
import { formatDateTime } from "$lib/utils";
import type { GenerationStatus } from "./service.svelte";
import IconCamera from "~icons/material-symbols/camera";

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { ActionModal } from "$lib/components/molecules";
import { truncateString } from "$lib/modules/util";
import { truncateString } from "$lib/utils";
import { useContext } from "./service.svelte";
interface Props {

View File

@@ -7,13 +7,13 @@
type DirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import {
fileUploadStatusStore,
isFileUploading,
masterKeyStore,
type FileUploadStatus,
} from "$lib/stores";
import { SortBy, sortEntries } from "$lib/utils";
import File from "./File.svelte";
import SubDirectory from "./SubDirectory.svelte";
import UploadingFile from "./UploadingFile.svelte";

View File

@@ -3,7 +3,7 @@
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util";
import { formatDateTime } from "$lib/utils";
import { requestFileThumbnailDownload } from "./service";
import type { SelectedEntry } from "../service.svelte";

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import { isFileUploading, type FileUploadStatus } from "$lib/stores";
import { formatNetworkSpeed } from "$lib/utils";
import IconDraft from "~icons/material-symbols/draft";

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { ActionModal } from "$lib/components/molecules";
import { truncateString } from "$lib/modules/util";
import { truncateString } from "$lib/utils";
interface Props {
file: File | undefined;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { ActionModal } from "$lib/components/molecules";
import { truncateString } from "$lib/modules/util";
import { truncateString } from "$lib/utils";
import { useContext } from "./service.svelte";
interface Props {

View File

@@ -1,13 +1,7 @@
import { error } from "@sveltejs/kit";
import { trpc } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
try {
const { nickname } = await trpc(fetch).user.get.query();
return { nickname };
} catch {
// TODO: Error Handling
error(500, "Internal server error");
}
};