mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-14 22:08:45 +00:00
Merge pull request #9 from kmc7468/refactor-to-atomic-patterns
Atomic 디자인 패턴 도입
This commit is contained in:
@@ -1,39 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import { fade, fly } from "svelte/transition";
|
|
||||||
import { AdaptiveDiv } from "$lib/components/divs";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: Snippet;
|
|
||||||
onclose?: () => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children, onclose, isOpen = $bindable() }: Props = $props();
|
|
||||||
|
|
||||||
const closeBottomSheet = $derived(
|
|
||||||
onclose ||
|
|
||||||
(() => {
|
|
||||||
isOpen = false;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if isOpen}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div onclick={closeBottomSheet} class="fixed inset-0 z-10 flex items-end justify-center">
|
|
||||||
<div class="absolute inset-0 bg-black bg-opacity-50" transition:fade={{ duration: 100 }}></div>
|
|
||||||
<div class="z-20 w-full">
|
|
||||||
<AdaptiveDiv>
|
|
||||||
<div
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
class="flex max-h-[70vh] min-h-[30vh] overflow-y-auto rounded-t-2xl bg-white px-4"
|
|
||||||
transition:fly={{ y: 100, duration: 200 }}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
</AdaptiveDiv>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
import IconArrowBack from "~icons/material-symbols/arrow-back";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children?: Snippet;
|
|
||||||
onback?: () => void;
|
|
||||||
title?: string;
|
|
||||||
xPadding?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children, onback, title, xPadding = false }: Props = $props();
|
|
||||||
|
|
||||||
const back = $derived(() => {
|
|
||||||
setTimeout(onback || (() => history.back()), 100);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-between bg-white py-4
|
|
||||||
{xPadding ? 'px-4' : ''}"
|
|
||||||
>
|
|
||||||
<button onclick={back} class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-gray-100">
|
|
||||||
<IconArrowBack class="text-2xl" />
|
|
||||||
</button>
|
|
||||||
{#if title}
|
|
||||||
<p class="flex-grow truncate px-2 text-center text-lg font-semibold">{title}</p>
|
|
||||||
{/if}
|
|
||||||
<div class="w-[2.3rem] flex-shrink-0">
|
|
||||||
{#if children}
|
|
||||||
{@render children?.()}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
42
src/lib/components/atoms/BottomSheet.svelte
Normal file
42
src/lib/components/atoms/BottomSheet.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { AdaptiveDiv } from "$lib/components/atoms";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
isOpen: boolean;
|
||||||
|
onclose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className, isOpen = $bindable(), onclose }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
onclick={onclose || (() => (isOpen = false))}
|
||||||
|
class="fixed inset-0 z-10 flex items-end justify-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black bg-opacity-50"
|
||||||
|
transition:fade|global={{ duration: 100 }}
|
||||||
|
></div>
|
||||||
|
<div class="z-20 w-full">
|
||||||
|
<AdaptiveDiv>
|
||||||
|
<div
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
class="flex max-h-[70vh] min-h-[30vh] flex-col rounded-t-2xl bg-white"
|
||||||
|
transition:fly|global={{ y: 100, duration: 200 }}
|
||||||
|
>
|
||||||
|
<div class={["flex-grow overflow-y-auto", className]}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdaptiveDiv>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,36 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { AdaptiveDiv } from "$lib/components/divs";
|
import { AdaptiveDiv } from "$lib/components/atoms";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
onclose?: () => void;
|
class?: ClassValue;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
onclose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children, onclose, isOpen = $bindable() }: Props = $props();
|
let { children, class: className, isOpen = $bindable(), onclose }: Props = $props();
|
||||||
|
|
||||||
const closeModal = $derived(
|
|
||||||
onclose ||
|
|
||||||
(() => {
|
|
||||||
isOpen = false;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
onclick={closeModal}
|
onclick={onclose || (() => (isOpen = false))}
|
||||||
class="fixed inset-0 z-10 bg-black bg-opacity-50"
|
class="fixed inset-0 z-10 bg-black bg-opacity-50"
|
||||||
transition:fade={{ duration: 100 }}
|
transition:fade|global={{ duration: 100 }}
|
||||||
>
|
>
|
||||||
<AdaptiveDiv>
|
<AdaptiveDiv class="h-full">
|
||||||
<div class="flex h-full items-center justify-center px-4">
|
<div class="flex h-full items-center justify-center px-4">
|
||||||
<div onclick={(e) => e.stopPropagation()} class="rounded-2xl bg-white p-4">
|
<div onclick={(e) => e.stopPropagation()} class={["rounded-2xl bg-white p-4", className]}>
|
||||||
{@render children?.()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AdaptiveDiv>
|
</AdaptiveDiv>
|
||||||
59
src/lib/components/atoms/buttons/ActionEntryButton.svelte
Normal file
59
src/lib/components/atoms/buttons/ActionEntryButton.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from "svelte";
|
||||||
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
actionButtonClass?: ClassValue;
|
||||||
|
actionButtonIcon?: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
children: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
onActionButtonClick?: () => void;
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
actionButtonIcon: ActionButtonIcon,
|
||||||
|
actionButtonClass: actionButtonClassName,
|
||||||
|
children,
|
||||||
|
class: className,
|
||||||
|
onActionButtonClick,
|
||||||
|
onclick,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
id="container"
|
||||||
|
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||||
|
class={["rounded-xl", className]}
|
||||||
|
>
|
||||||
|
<div id="children" class="flex h-full items-center gap-x-4 p-2 transition">
|
||||||
|
<div class="flex-grow overflow-x-hidden">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{#if ActionButtonIcon}
|
||||||
|
<button
|
||||||
|
id="action-button"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onActionButtonClick) {
|
||||||
|
setTimeout(onActionButtonClick, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class={["flex-shrink-0 rounded-full p-1 text-lg active:bg-gray-100", actionButtonClassName]}
|
||||||
|
>
|
||||||
|
<ActionButtonIcon />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#container:active:not(:has(#action-button:active)) {
|
||||||
|
@apply bg-gray-100;
|
||||||
|
}
|
||||||
|
#children:active:not(:has(#action-button:active)) {
|
||||||
|
@apply scale-95;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
38
src/lib/components/atoms/buttons/Button.svelte
Normal file
38
src/lib/components/atoms/buttons/Button.svelte
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
color?: "primary" | "gray";
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className, color = "primary", onclick }: Props = $props();
|
||||||
|
|
||||||
|
let bgColor = $derived(
|
||||||
|
{
|
||||||
|
primary: "bg-primary-600 active:bg-primary-500",
|
||||||
|
gray: "bg-gray-300 active:bg-gray-400",
|
||||||
|
}[color],
|
||||||
|
);
|
||||||
|
let textColor = $derived(
|
||||||
|
{
|
||||||
|
primary: "text-white",
|
||||||
|
gray: "text-gray-800",
|
||||||
|
}[color],
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||||
|
class={[
|
||||||
|
"h-12 min-w-fit rounded-xl p-3 font-medium transition active:scale-95",
|
||||||
|
bgColor,
|
||||||
|
textColor,
|
||||||
|
className,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</button>
|
||||||
26
src/lib/components/atoms/buttons/EntryButton.svelte
Normal file
26
src/lib/components/atoms/buttons/EntryButton.svelte
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
|
||||||
|
import IconChevronRight from "~icons/material-symbols/chevron-right";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className, onclick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||||
|
class={["rounded-xl active:bg-gray-100", className]}
|
||||||
|
>
|
||||||
|
<div class="flex h-full items-center gap-x-4 p-2 transition active:scale-95">
|
||||||
|
<div class="flex-grow">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
<IconChevronRight class="flex-shrink-0 text-xl text-gray-800" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
27
src/lib/components/atoms/buttons/FloatingButton.svelte
Normal file
27
src/lib/components/atoms/buttons/FloatingButton.svelte
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component } from "svelte";
|
||||||
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
|
import { AdaptiveDiv } from "$lib/components/atoms";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class: ClassValue;
|
||||||
|
icon: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className, icon: Icon, onclick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pointer-events-none fixed inset-0">
|
||||||
|
<AdaptiveDiv class="relative h-full">
|
||||||
|
<button
|
||||||
|
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||||
|
class={[
|
||||||
|
"pointer-events-auto absolute flex h-14 w-14 items-center justify-center rounded-full bg-gray-300 text-xl shadow-lg transition active:scale-95 active:bg-gray-400",
|
||||||
|
className,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</button>
|
||||||
|
</AdaptiveDiv>
|
||||||
|
</div>
|
||||||
@@ -10,14 +10,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={() => {
|
onclick={onclick && (() => setTimeout(onclick, 100))}
|
||||||
setTimeout(() => {
|
|
||||||
onclick?.();
|
|
||||||
}, 100);
|
|
||||||
}}
|
|
||||||
class="text-sm font-medium text-gray-800 underline underline-offset-2 active:rounded-xl active:bg-gray-100"
|
class="text-sm font-medium text-gray-800 underline underline-offset-2 active:rounded-xl active:bg-gray-100"
|
||||||
>
|
>
|
||||||
<div class="h-full w-full p-1 transition active:scale-95">
|
<div class="h-full p-1 transition active:scale-95">
|
||||||
{@render children?.()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { default as ActionEntryButton } from "./ActionEntryButton.svelte";
|
||||||
export { default as Button } from "./Button.svelte";
|
export { default as Button } from "./Button.svelte";
|
||||||
export { default as EntryButton } from "./EntryButton.svelte";
|
export { default as EntryButton } from "./EntryButton.svelte";
|
||||||
export { default as FloatingButton } from "./FloatingButton.svelte";
|
export { default as FloatingButton } from "./FloatingButton.svelte";
|
||||||
15
src/lib/components/atoms/divs/AdaptiveDiv.svelte
Normal file
15
src/lib/components/atoms/divs/AdaptiveDiv.svelte
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={["mx-auto max-w-screen-md", className]}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
15
src/lib/components/atoms/divs/BottomDiv.svelte
Normal file
15
src/lib/components/atoms/divs/BottomDiv.svelte
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={["sticky bottom-0 bg-white pb-4", className]}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
7
src/lib/components/atoms/divs/FullscreenDiv.svelte
Normal file
7
src/lib/components/atoms/divs/FullscreenDiv.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-grow flex-col justify-between px-4">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte";
|
export { default as AdaptiveDiv } from "./AdaptiveDiv.svelte";
|
||||||
export { default as BottomDiv } from "./BottomDiv.svelte";
|
export { default as BottomDiv } from "./BottomDiv.svelte";
|
||||||
export { default as TitleDiv } from "./TitleDiv.svelte";
|
export { default as FullscreenDiv } from "./FullscreenDiv.svelte";
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export { default as BottomSheet } from "./BottomSheet.svelte";
|
export { default as BottomSheet } from "./BottomSheet.svelte";
|
||||||
|
export * from "./buttons";
|
||||||
|
export * from "./divs";
|
||||||
|
export * from "./inputs";
|
||||||
export { default as Modal } from "./Modal.svelte";
|
export { default as Modal } from "./Modal.svelte";
|
||||||
export { default as TopBar } from "./TopBar.svelte";
|
|
||||||
@@ -5,16 +5,16 @@
|
|||||||
import IconCheckCircleOutline from "~icons/material-symbols/check-circle-outline";
|
import IconCheckCircleOutline from "~icons/material-symbols/check-circle-outline";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children, checked = $bindable(false) }: Props = $props();
|
let { checked = $bindable(false), children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label class="flex items-center gap-x-1">
|
<label class="flex items-center gap-x-1">
|
||||||
<input bind:checked type="checkbox" class="hidden" />
|
<input bind:checked type="checkbox" class="hidden" />
|
||||||
{@render children?.()}
|
{@render children()}
|
||||||
{#if checked}
|
{#if checked}
|
||||||
<IconCheckCircle class="text-primary-600" />
|
<IconCheckCircle class="text-primary-600" />
|
||||||
{:else}
|
{:else}
|
||||||
40
src/lib/components/atoms/inputs/TextInput.svelte
Normal file
40
src/lib/components/atoms/inputs/TextInput.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: ClassValue;
|
||||||
|
placeholder: string;
|
||||||
|
type?: "text" | "password";
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className, placeholder, type = "text", value = $bindable("") }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={className}>
|
||||||
|
<div class="relative mt-5">
|
||||||
|
<input
|
||||||
|
bind:value
|
||||||
|
{type}
|
||||||
|
placeholder=""
|
||||||
|
class="w-full border-b-2 border-gray-300 py-1 text-xl outline-none transition duration-300 ease-in-out"
|
||||||
|
/>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label
|
||||||
|
class="pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 transform text-xl text-gray-400 transition-all duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
input:focus,
|
||||||
|
input:not(:placeholder-shown) {
|
||||||
|
@apply border-primary-300;
|
||||||
|
}
|
||||||
|
input:focus + label,
|
||||||
|
input:not(:placeholder-shown) + label {
|
||||||
|
@apply top-0 -translate-y-full text-sm text-primary-400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: Snippet;
|
|
||||||
color?: "primary" | "gray";
|
|
||||||
onclick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children, color = "primary", onclick }: Props = $props();
|
|
||||||
|
|
||||||
const bgColorStyle = $derived(
|
|
||||||
{
|
|
||||||
primary: "bg-primary-600 active:bg-primary-500",
|
|
||||||
gray: "bg-gray-300 active:bg-gray-400",
|
|
||||||
}[color],
|
|
||||||
);
|
|
||||||
const fontColorStyle = $derived(
|
|
||||||
{
|
|
||||||
primary: "text-white",
|
|
||||||
gray: "text-gray-800",
|
|
||||||
}[color],
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
onclick?.();
|
|
||||||
}, 100);
|
|
||||||
}}
|
|
||||||
class="{bgColorStyle} {fontColorStyle} h-12 w-full min-w-fit rounded-xl font-medium"
|
|
||||||
>
|
|
||||||
<div class="h-full w-full p-3 transition active:scale-95">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
import IconChevronRight from "~icons/material-symbols/chevron-right";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: Snippet;
|
|
||||||
onclick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { children, onclick }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
onclick?.();
|
|
||||||
}, 100);
|
|
||||||
}}
|
|
||||||
class="w-full rounded-xl active:bg-gray-100"
|
|
||||||
>
|
|
||||||
<div class="flex w-full justify-between p-2 transition active:scale-95">
|
|
||||||
<div>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-center">
|
|
||||||
<IconChevronRight class="text-xl text-gray-800" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Component } from "svelte";
|
|
||||||
import type { SvelteHTMLElements } from "svelte/elements";
|
|
||||||
import { AdaptiveDiv } from "$lib/components/divs";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
icon: Component<SvelteHTMLElements["svg"]>;
|
|
||||||
offset?: string;
|
|
||||||
onclick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { icon: Icon, offset = "bottom-20", onclick }: Props = $props();
|
|
||||||
|
|
||||||
const click = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
onclick?.();
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0">
|
|
||||||
<div class="absolute w-full {offset}">
|
|
||||||
<AdaptiveDiv>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute bottom-4 right-4">
|
|
||||||
<button
|
|
||||||
onclick={click}
|
|
||||||
class="pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full bg-gray-300 shadow-lg transition active:scale-95 active:bg-gray-400"
|
|
||||||
>
|
|
||||||
<Icon class="text-xl" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AdaptiveDiv>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let { children } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mx-auto h-full w-full max-w-screen-md">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let { children } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="sticky bottom-0 flex flex-col items-center gap-y-2 bg-white pb-4">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Component, Snippet } from "svelte";
|
|
||||||
import type { SvelteHTMLElements } from "svelte/elements";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: Snippet;
|
|
||||||
icon?: Component<SvelteHTMLElements["svg"]>;
|
|
||||||
topPadding?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { topPadding = true, children, icon: Icon }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="box-content flex min-h-[10vh] items-center {topPadding ? 'pt-4' : ''}">
|
|
||||||
{#if Icon}
|
|
||||||
<Icon class="text-5xl text-gray-600" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
placeholder: string;
|
|
||||||
type?: "text" | "password";
|
|
||||||
value?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { placeholder, type = "text", value = $bindable("") }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="relative mt-5">
|
|
||||||
<input
|
|
||||||
bind:value
|
|
||||||
{type}
|
|
||||||
placeholder=""
|
|
||||||
class="w-full border-b-2 border-gray-300 py-1 text-xl outline-none transition duration-300 ease-in-out"
|
|
||||||
/>
|
|
||||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
|
||||||
<label
|
|
||||||
class="absolute left-0 top-1/2 -translate-y-1/2 transform text-xl text-gray-400 transition-all duration-300 ease-in-out"
|
|
||||||
>
|
|
||||||
{placeholder}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
input:focus,
|
|
||||||
input:not(:placeholder-shown) {
|
|
||||||
@apply border-primary-300;
|
|
||||||
}
|
|
||||||
input:focus + label,
|
|
||||||
input:not(:placeholder-shown) + label {
|
|
||||||
@apply top-0 -translate-y-full text-sm text-primary-400;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
50
src/lib/components/molecules/ActionModal.svelte
Normal file
50
src/lib/components/molecules/ActionModal.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
export type ConfirmHandler = () => void | Promise<void> | boolean | Promise<boolean>;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { Button, Modal } from "$lib/components/atoms";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cancelText?: string;
|
||||||
|
children: Snippet;
|
||||||
|
confirmText: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onbeforeclose?: () => void;
|
||||||
|
onConfirmClick: ConfirmHandler;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
cancelText = "닫기",
|
||||||
|
children,
|
||||||
|
confirmText,
|
||||||
|
isOpen = $bindable(),
|
||||||
|
onbeforeclose,
|
||||||
|
onConfirmClick,
|
||||||
|
title,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
onbeforeclose?.();
|
||||||
|
isOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAction = async () => {
|
||||||
|
if ((await onConfirmClick()) !== false) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:isOpen onclose={closeModal} class="space-y-4">
|
||||||
|
<div class="flex flex-col gap-y-2 break-keep">
|
||||||
|
<p class="text-xl font-bold">{title}</p>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-x-2">
|
||||||
|
<Button color="gray" onclick={closeModal} class="flex-1">{cancelText}</Button>
|
||||||
|
<Button onclick={confirmAction} class="flex-1">{confirmText}</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
43
src/lib/components/molecules/Categories/Category.svelte
Normal file
43
src/lib/components/molecules/Categories/Category.svelte
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component } from "svelte";
|
||||||
|
import type { SvelteHTMLElements } from "svelte/elements";
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { ActionEntryButton } from "$lib/components/atoms";
|
||||||
|
import { CategoryLabel } from "$lib/components/molecules";
|
||||||
|
import type { CategoryInfo } from "$lib/modules/filesystem";
|
||||||
|
import type { SelectedCategory } from "./service";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
info: Writable<CategoryInfo | null>;
|
||||||
|
menuIcon?: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
onclick: (category: SelectedCategory) => void;
|
||||||
|
onMenuClick?: (category: SelectedCategory) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { info, menuIcon, onclick, onMenuClick }: Props = $props();
|
||||||
|
|
||||||
|
const openCategory = () => {
|
||||||
|
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
|
onclick({ id, dataKey, dataKeyVersion, name });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
|
onMenuClick!({ id, dataKey, dataKeyVersion, name });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $info}
|
||||||
|
<ActionEntryButton
|
||||||
|
class="h-12"
|
||||||
|
onclick={openCategory}
|
||||||
|
actionButtonIcon={menuIcon}
|
||||||
|
onActionButtonClick={openMenu}
|
||||||
|
>
|
||||||
|
<CategoryLabel name={$info.name!} />
|
||||||
|
</ActionEntryButton>
|
||||||
|
{/if}
|
||||||
30
src/lib/components/molecules/IconEntryButton.svelte
Normal file
30
src/lib/components/molecules/IconEntryButton.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from "svelte";
|
||||||
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
|
import { EntryButton } from "$lib/components/atoms";
|
||||||
|
import { IconLabel } from "$lib/components/molecules";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
icon: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
iconClass?: ClassValue;
|
||||||
|
onclick?: () => void;
|
||||||
|
textClass?: ClassValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
class: className,
|
||||||
|
icon,
|
||||||
|
iconClass: iconClassName,
|
||||||
|
onclick,
|
||||||
|
textClass: textClassName,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EntryButton {onclick} class={className}>
|
||||||
|
<IconLabel {icon} class="h-full" iconClass={iconClassName} textClass={textClassName}>
|
||||||
|
{@render children()}
|
||||||
|
</IconLabel>
|
||||||
|
</EntryButton>
|
||||||
@@ -2,9 +2,8 @@
|
|||||||
import type { Component } from "svelte";
|
import type { Component } from "svelte";
|
||||||
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { EntryButton } from "$lib/components/buttons";
|
import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
|
||||||
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||||
import Categories, { type SelectedCategory } from "$lib/molecules/Categories";
|
|
||||||
import { masterKeyStore } from "$lib/stores";
|
import { masterKeyStore } from "$lib/stores";
|
||||||
|
|
||||||
import IconAddCircle from "~icons/material-symbols/add-circle";
|
import IconAddCircle from "~icons/material-symbols/add-circle";
|
||||||
@@ -20,13 +19,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
class: className,
|
||||||
info,
|
info,
|
||||||
onSubCategoryClick,
|
onSubCategoryClick,
|
||||||
onSubCategoryCreateClick,
|
onSubCategoryCreateClick,
|
||||||
onSubCategoryMenuClick,
|
onSubCategoryMenuClick,
|
||||||
subCategoryCreatePosition = "bottom",
|
subCategoryCreatePosition = "bottom",
|
||||||
subCategoryMenuIcon,
|
subCategoryMenuIcon,
|
||||||
...props
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
|
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
|
||||||
@@ -38,14 +37,17 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={["space-y-1", props.class]}>
|
<div class={["space-y-1", className]}>
|
||||||
{#snippet subCategoryCreate()}
|
{#snippet subCategoryCreate()}
|
||||||
<EntryButton onclick={onSubCategoryCreateClick}>
|
<IconEntryButton
|
||||||
<div class="flex h-8 items-center gap-x-4">
|
icon={IconAddCircle}
|
||||||
<IconAddCircle class="text-lg text-gray-600" />
|
onclick={onSubCategoryCreateClick}
|
||||||
<p class="font-medium text-gray-700">카테고리 추가하기</p>
|
class="h-12 w-full"
|
||||||
</div>
|
iconClass="text-gray-600"
|
||||||
</EntryButton>
|
textClass="text-gray-700"
|
||||||
|
>
|
||||||
|
카테고리 추가하기
|
||||||
|
</IconEntryButton>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if subCategoryCreatePosition === "top"}
|
{#if subCategoryCreatePosition === "top"}
|
||||||
43
src/lib/components/molecules/TitledDiv.svelte
Normal file
43
src/lib/components/molecules/TitledDiv.svelte
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from "svelte";
|
||||||
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
|
import { TitleLabel } from "$lib/components/molecules";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: Snippet;
|
||||||
|
childrenClass?: ClassValue;
|
||||||
|
class?: ClassValue;
|
||||||
|
description?: Snippet;
|
||||||
|
icon?: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
title: Snippet;
|
||||||
|
titleClass?: ClassValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
childrenClass: childrenClassName,
|
||||||
|
class: className,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
titleClass: titleClassName,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={["space-y-4 py-4", className]}>
|
||||||
|
<div class="space-y-2 break-keep">
|
||||||
|
<TitleLabel {icon} textClass={titleClassName}>
|
||||||
|
{@render title()}
|
||||||
|
</TitleLabel>
|
||||||
|
{#if description}
|
||||||
|
<p>
|
||||||
|
{@render description()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if children}
|
||||||
|
<div class={childrenClassName}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
37
src/lib/components/molecules/TopBar.svelte
Normal file
37
src/lib/components/molecules/TopBar.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
|
||||||
|
import IconArrowBack from "~icons/material-symbols/arrow-back";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
onBackClick?: () => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className, onBackClick, title }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"sticky top-0 z-10 flex items-center justify-between gap-x-2 px-2 py-3 backdrop-blur-2xl",
|
||||||
|
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>
|
||||||
|
{#if title}
|
||||||
|
<p class="flex-grow truncate text-center text-lg font-semibold">{title}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="w-[2.3rem] flex-shrink-0">
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
9
src/lib/components/molecules/index.ts
Normal file
9
src/lib/components/molecules/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export * from "./ActionModal.svelte";
|
||||||
|
export { default as ActionModal } from "./ActionModal.svelte";
|
||||||
|
export * from "./Categories";
|
||||||
|
export { default as Categories } from "./Categories";
|
||||||
|
export { default as IconEntryButton } from "./IconEntryButton.svelte";
|
||||||
|
export * from "./labels";
|
||||||
|
export { default as SubCategories } from "./SubCategories.svelte";
|
||||||
|
export { default as TitledDiv } from "./TitledDiv.svelte";
|
||||||
|
export { default as TopBar } from "./TopBar.svelte";
|
||||||
28
src/lib/components/molecules/labels/CategoryLabel.svelte
Normal file
28
src/lib/components/molecules/labels/CategoryLabel.svelte
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
import { IconLabel } from "$lib/components/molecules";
|
||||||
|
|
||||||
|
import IconCategory from "~icons/material-symbols/category";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: ClassValue;
|
||||||
|
name: string;
|
||||||
|
subtext?: string;
|
||||||
|
textClass?: ClassValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className, name, subtext, textClass: textClassName }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet subtextSnippet()}
|
||||||
|
{subtext}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<IconLabel
|
||||||
|
icon={IconCategory}
|
||||||
|
subtext={subtext ? subtextSnippet : undefined}
|
||||||
|
class={className}
|
||||||
|
textClass={textClassName}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</IconLabel>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
import { IconLabel } from "$lib/components/molecules";
|
||||||
|
|
||||||
|
import IconFolder from "~icons/material-symbols/folder";
|
||||||
|
import IconDraft from "~icons/material-symbols/draft";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: ClassValue;
|
||||||
|
name: string;
|
||||||
|
subtext?: string;
|
||||||
|
textClass?: ClassValue;
|
||||||
|
type: "directory" | "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: className, name, subtext, textClass: textClassName, type }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet subtextSnippet()}
|
||||||
|
{subtext}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<IconLabel
|
||||||
|
icon={type === "directory" ? IconFolder : IconDraft}
|
||||||
|
iconClass={type === "file" ? "text-blue-400" : undefined}
|
||||||
|
subtext={subtext ? subtextSnippet : undefined}
|
||||||
|
class={className}
|
||||||
|
textClass={textClassName}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</IconLabel>
|
||||||
38
src/lib/components/molecules/labels/IconLabel.svelte
Normal file
38
src/lib/components/molecules/labels/IconLabel.svelte
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from "svelte";
|
||||||
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
icon: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
iconClass?: ClassValue;
|
||||||
|
subtext?: Snippet;
|
||||||
|
textClass?: ClassValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
class: className,
|
||||||
|
icon: Icon,
|
||||||
|
iconClass: iconClassName,
|
||||||
|
subtext,
|
||||||
|
textClass: textClassName,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={["flex items-center gap-x-4", className]}>
|
||||||
|
<div class={["flex-shrink-0 text-lg", iconClassName]}>
|
||||||
|
<Icon />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-grow flex-col overflow-x-hidden text-left">
|
||||||
|
<p class={["truncate font-medium", textClassName]}>
|
||||||
|
{@render children()}
|
||||||
|
</p>
|
||||||
|
{#if subtext}
|
||||||
|
<p class="truncate text-xs text-gray-800">
|
||||||
|
{@render subtext()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
24
src/lib/components/molecules/labels/TitleLabel.svelte
Normal file
24
src/lib/components/molecules/labels/TitleLabel.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from "svelte";
|
||||||
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: ClassValue;
|
||||||
|
icon?: Component<SvelteHTMLElements["svg"]>;
|
||||||
|
textClass?: ClassValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className, icon: Icon, textClass: textClassName }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={className}>
|
||||||
|
<div class="flex min-h-[10vh] items-center">
|
||||||
|
{#if Icon}
|
||||||
|
<Icon class="text-5xl text-gray-600" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class={["text-3xl font-bold", textClassName]}>
|
||||||
|
{@render children()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
4
src/lib/components/molecules/labels/index.ts
Normal file
4
src/lib/components/molecules/labels/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as CategoryLabel } from "./CategoryLabel.svelte";
|
||||||
|
export { default as DirectoryEntryLabel } from "./DirectoryEntryLabel.svelte";
|
||||||
|
export { default as IconLabel } from "./IconLabel.svelte";
|
||||||
|
export { default as TitleLabel } from "./TitleLabel.svelte";
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { get, type Writable } from "svelte/store";
|
import { get, type Writable } from "svelte/store";
|
||||||
import { CheckBox } from "$lib/components/inputs";
|
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 { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||||
import { SortBy, sortEntries } from "$lib/modules/util";
|
import { SortBy, sortEntries } from "$lib/modules/util";
|
||||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
|
||||||
import SubCategories from "$lib/molecules/SubCategories.svelte";
|
|
||||||
import { masterKeyStore } from "$lib/stores";
|
import { masterKeyStore } from "$lib/stores";
|
||||||
import File from "./File.svelte";
|
import File from "./File.svelte";
|
||||||
import type { SelectedFile } from "./service";
|
import type { SelectedFile } from "./service";
|
||||||
42
src/lib/components/organisms/Category/File.svelte
Normal file
42
src/lib/components/organisms/Category/File.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
import { ActionEntryButton } from "$lib/components/atoms";
|
||||||
|
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||||
|
import type { FileInfo } from "$lib/modules/filesystem";
|
||||||
|
import type { SelectedFile } from "./service";
|
||||||
|
|
||||||
|
import IconClose from "~icons/material-symbols/close";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
info: Writable<FileInfo | null>;
|
||||||
|
onclick: (selectedFile: SelectedFile) => void;
|
||||||
|
onRemoveClick?: (selectedFile: SelectedFile) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { info, onclick, onRemoveClick }: Props = $props();
|
||||||
|
|
||||||
|
const openFile = () => {
|
||||||
|
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
|
onclick({ id, dataKey, dataKeyVersion, name });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = () => {
|
||||||
|
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
|
||||||
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
|
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $info}
|
||||||
|
<ActionEntryButton
|
||||||
|
class="h-12"
|
||||||
|
onclick={openFile}
|
||||||
|
actionButtonIcon={onRemoveClick && IconClose}
|
||||||
|
onActionButtonClick={removeFile}
|
||||||
|
>
|
||||||
|
<DirectoryEntryLabel type="file" name={$info.name} />
|
||||||
|
</ActionEntryButton>
|
||||||
|
{/if}
|
||||||
3
src/lib/components/organisms/index.ts
Normal file
3
src/lib/components/organisms/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./Category";
|
||||||
|
export { default as Category } from "./Category";
|
||||||
|
export * from "./modals";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { TextInputModal } from "$lib/components/organisms";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onCreateClick: (name: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onCreateClick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TextInputModal
|
||||||
|
bind:isOpen
|
||||||
|
title="새 카테고리"
|
||||||
|
placeholder="카테고리 이름"
|
||||||
|
submitText="만들기"
|
||||||
|
onSubmitClick={onCreateClick}
|
||||||
|
/>
|
||||||
22
src/lib/components/organisms/modals/RenameModal.svelte
Normal file
22
src/lib/components/organisms/modals/RenameModal.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { TextInputModal } from "$lib/components/organisms";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onbeforeclose?: () => void;
|
||||||
|
onRenameClick: (newName: string) => Promise<boolean>;
|
||||||
|
originalName: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onbeforeclose, onRenameClick, originalName }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TextInputModal
|
||||||
|
bind:isOpen
|
||||||
|
{onbeforeclose}
|
||||||
|
title="이름 바꾸기"
|
||||||
|
placeholder="이름"
|
||||||
|
defaultValue={originalName}
|
||||||
|
submitText="바꾸기"
|
||||||
|
onSubmitClick={onRenameClick}
|
||||||
|
/>
|
||||||
42
src/lib/components/organisms/modals/TextInputModal.svelte
Normal file
42
src/lib/components/organisms/modals/TextInputModal.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { TextInput } from "$lib/components/atoms";
|
||||||
|
import { ActionModal, type ConfirmHandler } from "$lib/components/molecules";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
defaultValue?: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onbeforeclose?: () => void;
|
||||||
|
onSubmitClick: (value: string) => ReturnType<ConfirmHandler>;
|
||||||
|
placeholder: string;
|
||||||
|
submitText: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
defaultValue = "",
|
||||||
|
isOpen = $bindable(),
|
||||||
|
onbeforeclose,
|
||||||
|
onSubmitClick,
|
||||||
|
placeholder,
|
||||||
|
submitText,
|
||||||
|
title,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let value = $state("");
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
value = defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ActionModal
|
||||||
|
bind:isOpen
|
||||||
|
{onbeforeclose}
|
||||||
|
{title}
|
||||||
|
confirmText={submitText}
|
||||||
|
onConfirmClick={() => onSubmitClick(value)}
|
||||||
|
>
|
||||||
|
<TextInput bind:value {placeholder} class="mb-3" />
|
||||||
|
</ActionModal>
|
||||||
3
src/lib/components/organisms/modals/index.ts
Normal file
3
src/lib/components/organisms/modals/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as CategoryCreateModal } from "./CategoryCreateModal.svelte";
|
||||||
|
export { default as RenameModal } from "./RenameModal.svelte";
|
||||||
|
export { default as TextInputModal } from "./TextInputModal.svelte";
|
||||||
@@ -28,6 +28,11 @@ export const formatNetworkSpeed = (speed: number) => {
|
|||||||
return `${(speed / 1000 / 1000 / 1000).toFixed(1)} Gbps`;
|
return `${(speed / 1000 / 1000 / 1000).toFixed(1)} Gbps`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const truncateString = (str: string, maxLength = 20) => {
|
||||||
|
if (str.length <= maxLength) return str;
|
||||||
|
return `${str.slice(0, maxLength)}...`;
|
||||||
|
};
|
||||||
|
|
||||||
export enum SortBy {
|
export enum SortBy {
|
||||||
NAME_ASC,
|
NAME_ASC,
|
||||||
NAME_DESC,
|
NAME_DESC,
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Component } from "svelte";
|
|
||||||
import type { SvelteHTMLElements } from "svelte/elements";
|
|
||||||
import type { Writable } from "svelte/store";
|
|
||||||
import type { CategoryInfo } from "$lib/modules/filesystem";
|
|
||||||
import type { SelectedCategory } from "./service";
|
|
||||||
|
|
||||||
import IconCategory from "~icons/material-symbols/category";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
info: Writable<CategoryInfo | null>;
|
|
||||||
menuIcon?: Component<SvelteHTMLElements["svg"]>;
|
|
||||||
onclick: (category: SelectedCategory) => void;
|
|
||||||
onMenuClick?: (category: SelectedCategory) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { info, menuIcon: MenuIcon, onclick, onMenuClick }: Props = $props();
|
|
||||||
|
|
||||||
const openCategory = () => {
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
|
|
||||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
onclick({ id, dataKey, dataKeyVersion, name });
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openMenu = (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
|
|
||||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
onMenuClick!({ id, dataKey, dataKeyVersion, name });
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $info}
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<div id="button" onclick={openCategory} class="h-12 rounded-xl">
|
|
||||||
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
|
|
||||||
<div class="flex-shrink-0 text-lg">
|
|
||||||
<IconCategory />
|
|
||||||
</div>
|
|
||||||
<p title={$info.name} class="flex-grow truncate font-medium">
|
|
||||||
{$info.name}
|
|
||||||
</p>
|
|
||||||
{#if MenuIcon && onMenuClick}
|
|
||||||
<button
|
|
||||||
id="open-menu"
|
|
||||||
onclick={openMenu}
|
|
||||||
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
|
|
||||||
>
|
|
||||||
<MenuIcon class="text-lg" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#button:active:not(:has(#open-menu:active)) {
|
|
||||||
@apply bg-gray-100;
|
|
||||||
}
|
|
||||||
#button-content:active:not(:has(#open-menu:active)) {
|
|
||||||
@apply scale-95;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Writable } from "svelte/store";
|
|
||||||
import type { FileInfo } from "$lib/modules/filesystem";
|
|
||||||
import type { SelectedFile } from "./service";
|
|
||||||
|
|
||||||
import IconDraft from "~icons/material-symbols/draft";
|
|
||||||
import IconClose from "~icons/material-symbols/close";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
info: Writable<FileInfo | null>;
|
|
||||||
onclick: (selectedFile: SelectedFile) => void;
|
|
||||||
onRemoveClick?: (selectedFile: SelectedFile) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { info, onclick, onRemoveClick }: Props = $props();
|
|
||||||
|
|
||||||
const openFile = () => {
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
|
|
||||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
onclick({ id, dataKey, dataKeyVersion, name });
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFile = (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
|
|
||||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $info}
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<div id="button" onclick={openFile} class="h-12 rounded-xl">
|
|
||||||
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
|
|
||||||
<div class="flex-shrink-0 text-lg text-blue-400">
|
|
||||||
<IconDraft />
|
|
||||||
</div>
|
|
||||||
<p title={$info.name} class="flex-grow truncate font-medium">
|
|
||||||
{$info.name}
|
|
||||||
</p>
|
|
||||||
{#if onRemoveClick}
|
|
||||||
<button
|
|
||||||
id="remove-file"
|
|
||||||
onclick={removeFile}
|
|
||||||
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
|
|
||||||
>
|
|
||||||
<IconClose class="text-lg" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#button:active:not(:has(#remove-file:active)) {
|
|
||||||
@apply bg-gray-100;
|
|
||||||
}
|
|
||||||
#button-content:active:not(:has(#remove-file:active)) {
|
|
||||||
@apply scale-95;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Modal } from "$lib/components";
|
|
||||||
import { Button } from "$lib/components/buttons";
|
|
||||||
import { TextInput } from "$lib/components/inputs";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onCreateClick: (name: string) => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onCreateClick, isOpen = $bindable() }: Props = $props();
|
|
||||||
|
|
||||||
let name = $state("");
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
name = "";
|
|
||||||
isOpen = false;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal bind:isOpen onclose={closeModal}>
|
|
||||||
<p class="text-xl font-bold">새 카테고리</p>
|
|
||||||
<div class="mt-2 flex w-full">
|
|
||||||
<TextInput bind:value={name} placeholder="카테고리 이름" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-7 flex gap-2">
|
|
||||||
<Button color="gray" onclick={closeModal}>닫기</Button>
|
|
||||||
<Button onclick={() => onCreateClick(name)}>만들기</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
@@ -10,7 +10,8 @@ export const requestCategoryCreation = async (
|
|||||||
) => {
|
) => {
|
||||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||||
const nameEncrypted = await encryptString(name, dataKey);
|
const nameEncrypted = await encryptString(name, dataKey);
|
||||||
await callPostApi<CategoryCreateRequest>("/api/category/create", {
|
|
||||||
|
const res = await callPostApi<CategoryCreateRequest>("/api/category/create", {
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
mekVersion: masterKey.version,
|
mekVersion: masterKey.version,
|
||||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
dek: await wrapDataKey(dataKey, masterKey.key),
|
||||||
@@ -18,6 +19,7 @@ export const requestCategoryCreation = async (
|
|||||||
name: nameEncrypted.ciphertext,
|
name: nameEncrypted.ciphertext,
|
||||||
nameIv: nameEncrypted.iv,
|
nameIv: nameEncrypted.iv,
|
||||||
});
|
});
|
||||||
|
return res.ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => {
|
export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => {
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AdaptiveDiv } from "$lib/components/divs";
|
import { AdaptiveDiv } from "$lib/components/atoms";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdaptiveDiv>
|
<AdaptiveDiv class="flex min-h-screen flex-grow flex-col">
|
||||||
<div class="flex h-screen flex-col justify-between px-4">
|
{@render children()}
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</AdaptiveDiv>
|
</AdaptiveDiv>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { TopBar } from "$lib/components";
|
import { BottomDiv, Button, FullscreenDiv, TextInput } from "$lib/components/atoms";
|
||||||
import { Button } from "$lib/components/buttons";
|
import { TitledDiv, TopBar } from "$lib/components/molecules";
|
||||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
|
||||||
import { TextInput } from "$lib/components/inputs";
|
|
||||||
import { requestPasswordChange } from "./service";
|
import { requestPasswordChange } from "./service";
|
||||||
|
|
||||||
let oldPassword = $state("");
|
let oldPassword = $state("");
|
||||||
@@ -20,19 +18,20 @@
|
|||||||
<title>비밀번호 바꾸기</title>
|
<title>비밀번호 바꾸기</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div>
|
<TopBar class="flex-shrink-0" />
|
||||||
<TopBar />
|
<FullscreenDiv>
|
||||||
<TitleDiv topPadding={false}>
|
<TitledDiv class="!pt-0" titleClass="!text-2xl" childrenClass="flex flex-col gap-y-2">
|
||||||
<div class="space-y-2 break-keep">
|
{#snippet title()}
|
||||||
<p class="text-2xl font-bold">기존 비밀번호와 새 비밀번호를 입력해 주세요.</p>
|
기존 비밀번호와 새 비밀번호를 입력해 주세요.
|
||||||
<p>새 비밀번호는 8자 이상이어야 해요. 다른 사람들이 알 수 없도록 안전하게 설정해 주세요.</p>
|
{/snippet}
|
||||||
</div>
|
{#snippet description()}
|
||||||
<div class="my-4 flex flex-col gap-y-2">
|
새 비밀번호는 8자 이상이어야 해요. 다른 사람들이 알 수 없도록 안전하게 설정해 주세요.
|
||||||
<TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" />
|
{/snippet}
|
||||||
<TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" />
|
|
||||||
</div>
|
<TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" />
|
||||||
</TitleDiv>
|
<TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" />
|
||||||
</div>
|
</TitledDiv>
|
||||||
<BottomDiv>
|
<BottomDiv>
|
||||||
<Button onclick={changePassword}>비밀번호 바꾸기</Button>
|
<Button onclick={changePassword} class="w-full">비밀번호 바꾸기</Button>
|
||||||
</BottomDiv>
|
</BottomDiv>
|
||||||
|
</FullscreenDiv>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { Button, TextButton } from "$lib/components/buttons";
|
import { BottomDiv, Button, FullscreenDiv, TextButton, TextInput } from "$lib/components/atoms";
|
||||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
import { TitledDiv } from "$lib/components/molecules";
|
||||||
import { TextInput } from "$lib/components/inputs";
|
|
||||||
import { clientKeyStore, masterKeyStore } from "$lib/stores";
|
import { clientKeyStore, masterKeyStore } from "$lib/stores";
|
||||||
import { requestLogin, requestSessionUpgrade, requestMasterKeyDownload } from "./service";
|
import { requestLogin, requestSessionUpgrade, requestMasterKeyDownload } from "./service";
|
||||||
|
|
||||||
@@ -47,17 +46,20 @@
|
|||||||
<title>로그인</title>
|
<title>로그인</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<TitleDiv>
|
<FullscreenDiv>
|
||||||
<div class="space-y-2 break-keep">
|
<TitledDiv childrenClass="flex flex-col gap-y-2">
|
||||||
<p class="text-3xl font-bold">환영합니다!</p>
|
{#snippet title()}
|
||||||
<p>서비스를 이용하려면 로그인을 해야해요.</p>
|
환영합니다!
|
||||||
</div>
|
{/snippet}
|
||||||
<div class="my-4 flex flex-col gap-y-2">
|
{#snippet description()}
|
||||||
|
서비스를 이용하려면 로그인을 해야해요.
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<TextInput bind:value={email} placeholder="이메일" />
|
<TextInput bind:value={email} placeholder="이메일" />
|
||||||
<TextInput bind:value={password} placeholder="비밀번호" type="password" />
|
<TextInput bind:value={password} placeholder="비밀번호" type="password" />
|
||||||
</div>
|
</TitledDiv>
|
||||||
</TitleDiv>
|
<BottomDiv class="flex flex-col items-center gap-y-2">
|
||||||
<BottomDiv>
|
<Button onclick={login} class="w-full">로그인</Button>
|
||||||
<Button onclick={login}>로그인</Button>
|
<TextButton>계정이 없어요</TextButton>
|
||||||
<TextButton>계정이 없어요</TextButton>
|
</BottomDiv>
|
||||||
</BottomDiv>
|
</FullscreenDiv>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { TitleDiv } from "$lib/components/divs";
|
import { FullscreenDiv } from "$lib/components/atoms";
|
||||||
|
import { TitledDiv } from "$lib/components/molecules";
|
||||||
import { clientKeyStore, masterKeyStore } from "$lib/stores";
|
import { clientKeyStore, masterKeyStore } from "$lib/stores";
|
||||||
import { generatePublicKeyFingerprint, requestMasterKeyDownload } from "./service";
|
import { generatePublicKeyFingerprint, requestMasterKeyDownload } from "./service";
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const fingerprint = $derived(
|
let fingerprint = $derived(
|
||||||
$clientKeyStore
|
$clientKeyStore
|
||||||
? generatePublicKeyFingerprint($clientKeyStore.encryptKey, $clientKeyStore.verifyKey)
|
? generatePublicKeyFingerprint($clientKeyStore.encryptKey, $clientKeyStore.verifyKey)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -30,14 +31,15 @@
|
|||||||
<title>승인을 기다리고 있어요.</title>
|
<title>승인을 기다리고 있어요.</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<TitleDiv>
|
<FullscreenDiv>
|
||||||
<div class="space-y-2 break-keep">
|
<TitledDiv childrenClass="space-y-4">
|
||||||
<p class="text-3xl font-bold">승인을 기다리고 있어요.</p>
|
{#snippet title()}
|
||||||
<p>
|
승인을 기다리고 있어요.
|
||||||
|
{/snippet}
|
||||||
|
{#snippet description()}
|
||||||
회원님의 다른 디바이스에서 이 디바이스의 데이터 접근을 승인해야 서비스를 이용할 수 있어요.
|
회원님의 다른 디바이스에서 이 디바이스의 데이터 접근을 승인해야 서비스를 이용할 수 있어요.
|
||||||
</p>
|
{/snippet}
|
||||||
</div>
|
|
||||||
<div class="my-4 space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<IconFingerprint class="mx-auto text-7xl" />
|
<IconFingerprint class="mx-auto text-7xl" />
|
||||||
<p class="text-center text-xl font-bold text-primary-500">암호 키 지문</p>
|
<p class="text-center text-xl font-bold text-primary-500">암호 키 지문</p>
|
||||||
@@ -57,5 +59,5 @@
|
|||||||
암호 키 지문은 디바이스마다 다르게 생성돼요. <br />
|
암호 키 지문은 디바이스마다 다르게 생성돼요. <br />
|
||||||
지문이 일치하는지 확인 후 승인해 주세요.
|
지문이 일치하는지 확인 후 승인해 주세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</TitledDiv>
|
||||||
</TitleDiv>
|
</FullscreenDiv>
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { get, type Writable } from "svelte/store";
|
import { get, type Writable } from "svelte/store";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { TopBar } from "$lib/components";
|
import { FullscreenDiv } from "$lib/components/atoms";
|
||||||
import { EntryButton } from "$lib/components/buttons";
|
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
|
||||||
import {
|
import {
|
||||||
getFileInfo,
|
getFileInfo,
|
||||||
getCategoryInfo,
|
getCategoryInfo,
|
||||||
type FileInfo,
|
type FileInfo,
|
||||||
type CategoryInfo,
|
type CategoryInfo,
|
||||||
} from "$lib/modules/filesystem";
|
} from "$lib/modules/filesystem";
|
||||||
import Categories from "$lib/molecules/Categories";
|
|
||||||
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
|
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
|
||||||
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
|
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
|
||||||
import DownloadStatus from "./DownloadStatus.svelte";
|
import DownloadStatus from "./DownloadStatus.svelte";
|
||||||
@@ -31,7 +30,7 @@
|
|||||||
|
|
||||||
let isAddToCategoryBottomSheetOpen = $state(false);
|
let isAddToCategoryBottomSheetOpen = $state(false);
|
||||||
|
|
||||||
const downloadStatus = $derived(
|
let downloadStatus = $derived(
|
||||||
$fileDownloadStatusStore.find((statusStore) => {
|
$fileDownloadStatusStore.find((statusStore) => {
|
||||||
const { id, status } = get(statusStore);
|
const { id, status } = get(statusStore);
|
||||||
return id === data.id && isFileDownloading(status);
|
return id === data.id && isFileDownloading(status);
|
||||||
@@ -116,8 +115,8 @@
|
|||||||
<title>파일</title>
|
<title>파일</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<TopBar title={$info?.name} />
|
||||||
<TopBar title={$info?.name} />
|
<FullscreenDiv>
|
||||||
<div class="space-y-4 pb-4">
|
<div class="space-y-4 pb-4">
|
||||||
<DownloadStatus status={downloadStatus} />
|
<DownloadStatus status={downloadStatus} />
|
||||||
{#if $info && viewerType}
|
{#if $info && viewerType}
|
||||||
@@ -151,16 +150,19 @@
|
|||||||
onCategoryClick={({ id }) => goto(`/category/${id}`)}
|
onCategoryClick={({ id }) => goto(`/category/${id}`)}
|
||||||
onCategoryMenuClick={({ id }) => removeFromCategory(id)}
|
onCategoryMenuClick={({ id }) => removeFromCategory(id)}
|
||||||
/>
|
/>
|
||||||
<EntryButton onclick={() => (isAddToCategoryBottomSheetOpen = true)}>
|
<IconEntryButton
|
||||||
<div class="flex h-8 items-center gap-x-4">
|
icon={IconAddCircle}
|
||||||
<IconAddCircle class="text-lg text-gray-600" />
|
onclick={() => (isAddToCategoryBottomSheetOpen = true)}
|
||||||
<p class="font-medium text-gray-700">카테고리에 추가하기</p>
|
class="h-12 w-full"
|
||||||
</div>
|
iconClass="text-gray-600"
|
||||||
</EntryButton>
|
textClass="text-gray-700"
|
||||||
|
>
|
||||||
|
카테고리에 추가하기
|
||||||
|
</IconEntryButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FullscreenDiv>
|
||||||
|
|
||||||
<AddToCategoryBottomSheet
|
<AddToCategoryBottomSheet
|
||||||
bind:isOpen={isAddToCategoryBottomSheetOpen}
|
bind:isOpen={isAddToCategoryBottomSheetOpen}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { BottomSheet } from "$lib/components";
|
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms";
|
||||||
import { Button } from "$lib/components/buttons";
|
import { SubCategories } from "$lib/components/molecules";
|
||||||
import { BottomDiv } from "$lib/components/divs";
|
import { CategoryCreateModal } from "$lib/components/organisms";
|
||||||
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||||
import SubCategories from "$lib/molecules/SubCategories.svelte";
|
|
||||||
import CreateCategoryModal from "$lib/organisms/CreateCategoryModal.svelte";
|
|
||||||
import { masterKeyStore } from "$lib/stores";
|
import { masterKeyStore } from "$lib/stores";
|
||||||
import { requestCategoryCreation } from "./service";
|
import { requestCategoryCreation } from "./service";
|
||||||
|
|
||||||
@@ -18,15 +16,7 @@
|
|||||||
|
|
||||||
let category: Writable<CategoryInfo | null> | undefined = $state();
|
let category: Writable<CategoryInfo | null> | undefined = $state();
|
||||||
|
|
||||||
let isCreateCategoryModalOpen = $state(false);
|
let isCategoryCreateModalOpen = $state(false);
|
||||||
|
|
||||||
const createCategory = async (name: string) => {
|
|
||||||
if (!$category) return; // TODO: Error handling
|
|
||||||
|
|
||||||
await requestCategoryCreation(name, $category.id, $masterKeyStore?.get(1)!);
|
|
||||||
isCreateCategoryModalOpen = false;
|
|
||||||
category = getCategoryInfo($category.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -35,24 +25,35 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BottomSheet bind:isOpen>
|
{#if $category}
|
||||||
<div class="flex w-full flex-col justify-between">
|
<BottomSheet bind:isOpen class="flex flex-col">
|
||||||
{#if $category}
|
<FullscreenDiv>
|
||||||
<SubCategories
|
<SubCategories
|
||||||
class="h-fit py-4"
|
class="py-4"
|
||||||
info={$category}
|
info={$category}
|
||||||
onSubCategoryClick={({ id }) =>
|
onSubCategoryClick={({ id }) =>
|
||||||
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
|
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
|
||||||
onSubCategoryCreateClick={() => (isCreateCategoryModalOpen = true)}
|
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
|
||||||
subCategoryCreatePosition="top"
|
subCategoryCreatePosition="top"
|
||||||
/>
|
/>
|
||||||
{#if $category.id !== "root"}
|
{#if $category.id !== "root"}
|
||||||
<BottomDiv>
|
<BottomDiv>
|
||||||
<Button onclick={() => onAddToCategoryClick($category.id)}>이 카테고리에 추가하기</Button>
|
<Button onclick={() => onAddToCategoryClick($category.id)} class="w-full">
|
||||||
|
이 카테고리에 추가하기
|
||||||
|
</Button>
|
||||||
</BottomDiv>
|
</BottomDiv>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</FullscreenDiv>
|
||||||
</div>
|
</BottomSheet>
|
||||||
</BottomSheet>
|
{/if}
|
||||||
|
|
||||||
<CreateCategoryModal bind:isOpen={isCreateCategoryModalOpen} onCreateClick={createCategory} />
|
<CategoryCreateModal
|
||||||
|
bind:isOpen={isCategoryCreateModalOpen}
|
||||||
|
onCreateClick={async (name: string) => {
|
||||||
|
if (await requestCategoryCreation(name, $category!.id, $masterKeyStore?.get(1)!)) {
|
||||||
|
category = getCategoryInfo($category!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { TopBar } from "$lib/components";
|
import { FullscreenDiv } from "$lib/components/atoms";
|
||||||
|
import { TopBar } from "$lib/components/molecules";
|
||||||
import { fileDownloadStatusStore, isFileDownloading } from "$lib/stores";
|
import { fileDownloadStatusStore, isFileDownloading } from "$lib/stores";
|
||||||
import File from "./File.svelte";
|
import File from "./File.svelte";
|
||||||
|
|
||||||
const downloadingFiles = $derived(
|
let downloadingFiles = $derived(
|
||||||
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
|
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -19,11 +20,11 @@
|
|||||||
<title>진행 중인 다운로드</title>
|
<title>진행 중인 다운로드</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<TopBar />
|
||||||
<TopBar />
|
<FullscreenDiv>
|
||||||
<div class="space-y-2 pb-4">
|
<div class="space-y-2 pb-4">
|
||||||
{#each downloadingFiles as status}
|
{#each downloadingFiles as status}
|
||||||
<File {status} />
|
<File {status} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FullscreenDiv>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { TopBar } from "$lib/components";
|
import { FullscreenDiv } from "$lib/components/atoms";
|
||||||
|
import { TopBar } from "$lib/components/molecules";
|
||||||
import { fileUploadStatusStore, isFileUploading } from "$lib/stores";
|
import { fileUploadStatusStore, isFileUploading } from "$lib/stores";
|
||||||
import File from "./File.svelte";
|
import File from "./File.svelte";
|
||||||
|
|
||||||
const uploadingFiles = $derived(
|
let uploadingFiles = $derived(
|
||||||
$fileUploadStatusStore.filter((status) => isFileUploading(get(status).status)),
|
$fileUploadStatusStore.filter((status) => isFileUploading(get(status).status)),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -19,11 +20,11 @@
|
|||||||
<title>진행 중인 업로드</title>
|
<title>진행 중인 업로드</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<TopBar />
|
||||||
<TopBar />
|
<FullscreenDiv>
|
||||||
<div class="space-y-2 pb-4">
|
<div class="space-y-2 pb-4">
|
||||||
{#each uploadingFiles as status}
|
{#each uploadingFiles as status}
|
||||||
<File {status} />
|
<File {status} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FullscreenDiv>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FileSaver from "file-saver";
|
import FileSaver from "file-saver";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { Button, TextButton } from "$lib/components/buttons";
|
import { BottomDiv, Button, FullscreenDiv, TextButton } from "$lib/components/atoms";
|
||||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
import { TitledDiv } from "$lib/components/molecules";
|
||||||
import { clientKeyStore } from "$lib/stores";
|
import { clientKeyStore } from "$lib/stores";
|
||||||
import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte";
|
import BeforeContinueBottomSheet from "./BeforeContinueBottomSheet.svelte";
|
||||||
import BeforeContinueModal from "./BeforeContinueModal.svelte";
|
import BeforeContinueModal from "./BeforeContinueModal.svelte";
|
||||||
@@ -89,27 +89,24 @@
|
|||||||
<title>암호 키 생성하기</title>
|
<title>암호 키 생성하기</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<TitleDiv icon={IconKey}>
|
<FullscreenDiv>
|
||||||
<div class="space-y-4 break-keep">
|
<TitledDiv icon={IconKey}>
|
||||||
<p class="text-3xl font-bold">암호 키를 파일로 내보낼까요?</p>
|
{#snippet title()}
|
||||||
<div class="space-y-2 text-lg text-gray-800">
|
암호 키를 파일로 내보낼까요?
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="space-y-2 break-keep text-lg text-gray-800">
|
||||||
<p>
|
<p>
|
||||||
모든 디바이스의 암호 키가 유실되면, 서버에 저장된 데이터를 영원히 복호화할 수 없게 돼요.
|
모든 디바이스의 암호 키가 유실되면, 서버에 저장된 데이터를 영원히 복호화할 수 없게 돼요.
|
||||||
</p>
|
</p>
|
||||||
<p>만약의 상황을 위해 암호 키를 파일로 내보낼 수 있어요.</p>
|
<p>만약의 상황을 위해 암호 키를 파일로 내보낼 수 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TitledDiv>
|
||||||
</TitleDiv>
|
<BottomDiv class="flex flex-col items-center gap-y-2">
|
||||||
<BottomDiv>
|
<Button onclick={exportClientKeys} class="w-full">암호 키 내보내기</Button>
|
||||||
<Button onclick={exportClientKeys}>암호 키 내보내기</Button>
|
<TextButton onclick={() => (isBeforeContinueModalOpen = true)}>내보내지 않을래요</TextButton>
|
||||||
<TextButton
|
</BottomDiv>
|
||||||
onclick={() => {
|
</FullscreenDiv>
|
||||||
isBeforeContinueModalOpen = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
내보내지 않을래요
|
|
||||||
</TextButton>
|
|
||||||
</BottomDiv>
|
|
||||||
|
|
||||||
<BeforeContinueModal bind:isOpen={isBeforeContinueModalOpen} onContinueClick={registerPubKeys} />
|
<BeforeContinueModal bind:isOpen={isBeforeContinueModalOpen} onContinueClick={registerPubKeys} />
|
||||||
<BeforeContinueBottomSheet
|
<BeforeContinueBottomSheet
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { BottomSheet } from "$lib/components";
|
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms";
|
||||||
import { Button } from "$lib/components/buttons";
|
|
||||||
import { BottomDiv } from "$lib/components/divs";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onRetryClick: () => void;
|
onRetryClick: () => void;
|
||||||
@@ -12,20 +10,18 @@
|
|||||||
let { onRetryClick, onContinueClick, isOpen = $bindable() }: Props = $props();
|
let { onRetryClick, onContinueClick, isOpen = $bindable() }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BottomSheet bind:isOpen>
|
<BottomSheet bind:isOpen class="flex flex-col">
|
||||||
<div class="flex flex-col justify-between gap-y-4 pt-4">
|
<FullscreenDiv>
|
||||||
<div class="space-y-2 break-keep">
|
<div class="space-y-2 break-keep py-4">
|
||||||
<p class="text-xl font-bold">암호 키 파일을 저장하셨나요?</p>
|
<p class="text-xl font-bold">암호 키 파일을 저장하셨나요?</p>
|
||||||
<p>
|
<p>
|
||||||
암호 키 파일은 유출 방지를 위해 이 화면에서만 저장할 수 있어요. 파일이 잘 저장되었는지 다시
|
암호 키 파일은 유출 방지를 위해 이 화면에서만 저장할 수 있어요. 파일이 잘 저장되었는지 다시
|
||||||
한 번 확인해 주세요.
|
한 번 확인해 주세요.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<BottomDiv>
|
<BottomDiv class="flex gap-x-2">
|
||||||
<div class="flex w-full gap-2">
|
<Button color="gray" onclick={onRetryClick} class="flex-1">다시 저장할래요</Button>
|
||||||
<Button color="gray" onclick={onRetryClick}>다시 저장할래요</Button>
|
<Button onclick={onContinueClick} class="flex-1">잘 저장되었어요</Button>
|
||||||
<Button onclick={onContinueClick}>잘 저장되었어요</Button>
|
|
||||||
</div>
|
|
||||||
</BottomDiv>
|
</BottomDiv>
|
||||||
</div>
|
</FullscreenDiv>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|||||||
@@ -1,31 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Modal } from "$lib/components";
|
import { ActionModal } from "$lib/components/molecules";
|
||||||
import { Button } from "$lib/components/buttons";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onContinueClick: () => void;
|
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
onContinueClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onContinueClick, isOpen = $bindable() }: Props = $props();
|
let { isOpen = $bindable(), onContinueClick }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal bind:isOpen>
|
<ActionModal
|
||||||
<div class="space-y-4">
|
bind:isOpen
|
||||||
<div class="space-y-2 break-keep">
|
title="내보내지 않고 계속할까요?"
|
||||||
<p class="text-xl font-bold">내보내지 않고 계속할까요?</p>
|
cancelText="아니요"
|
||||||
<p>암호 키 파일은 유출 방지를 위해 이 화면에서만 저장할 수 있어요.</p>
|
confirmText="계속할게요"
|
||||||
</div>
|
onConfirmClick={onContinueClick}
|
||||||
<div class="flex gap-2">
|
>
|
||||||
<Button
|
<p>암호 키 파일은 유출 방지를 위해 이 화면에서만 저장할 수 있어요.</p>
|
||||||
color="gray"
|
</ActionModal>
|
||||||
onclick={() => {
|
|
||||||
isOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
아니요
|
|
||||||
</Button>
|
|
||||||
<Button onclick={onContinueClick}>계속할게요</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { Button, TextButton } from "$lib/components/buttons";
|
import { BottomDiv, Button, FullscreenDiv, TextButton } from "$lib/components/atoms";
|
||||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
import { TitledDiv } from "$lib/components/molecules";
|
||||||
import { gotoStateful } from "$lib/hooks";
|
import { gotoStateful } from "$lib/hooks";
|
||||||
import { clientKeyStore } from "$lib/stores";
|
import { clientKeyStore } from "$lib/stores";
|
||||||
import Order from "./Order.svelte";
|
import Order from "./Order.svelte";
|
||||||
@@ -62,12 +62,15 @@
|
|||||||
<title>암호 키 생성하기</title>
|
<title>암호 키 생성하기</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<TitleDiv>
|
<FullscreenDiv>
|
||||||
<div class="space-y-2 break-keep">
|
<TitledDiv childrenClass="space-y-4">
|
||||||
<p class="text-3xl font-bold">암호 키 생성하기</p>
|
{#snippet title()}
|
||||||
<p>회원님의 디바이스 간의 안전한 데이터 동기화를 위해 암호 키를 생성해야 해요.</p>
|
암호 키 생성하기
|
||||||
</div>
|
{/snippet}
|
||||||
<div class="my-4 space-y-4">
|
{#snippet description()}
|
||||||
|
회원님의 디바이스 간의 안전한 데이터 동기화를 위해 암호 키를 생성해야 해요.
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<IconKey class="mx-auto text-7xl" />
|
<IconKey class="mx-auto text-7xl" />
|
||||||
<p class="text-center text-xl font-bold text-primary-500">왜 암호 키가 필요한가요?</p>
|
<p class="text-center text-xl font-bold text-primary-500">왜 암호 키가 필요한가요?</p>
|
||||||
@@ -77,9 +80,9 @@
|
|||||||
<Order order={i + 1} isLast={i === orders.length - 1} {title} {description} />
|
<Order order={i + 1} isLast={i === orders.length - 1} {title} {description} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TitledDiv>
|
||||||
</TitleDiv>
|
<BottomDiv class="flex flex-col items-center gap-y-2">
|
||||||
<BottomDiv>
|
<Button onclick={generateKeys} class="w-full">새 암호 키 생성하기</Button>
|
||||||
<Button onclick={generateKeys}>새 암호 키 생성하기</Button>
|
<TextButton>키를 갖고 있어요</TextButton>
|
||||||
<TextButton>키를 갖고 있어요</TextButton>
|
</BottomDiv>
|
||||||
</BottomDiv>
|
</FullscreenDiv>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { TopBar } from "$lib/components";
|
import { FullscreenDiv } from "$lib/components/atoms";
|
||||||
|
import { TopBar } from "$lib/components/molecules";
|
||||||
import type { FileCacheIndex } from "$lib/indexedDB";
|
import type { FileCacheIndex } from "$lib/indexedDB";
|
||||||
import { getFileCacheIndex } from "$lib/modules/file";
|
import { getFileCacheIndex } from "$lib/modules/file";
|
||||||
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||||
@@ -43,8 +44,8 @@
|
|||||||
<title>캐시 설정</title>
|
<title>캐시 설정</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<TopBar title="캐시" />
|
||||||
<TopBar title="캐시" />
|
<FullscreenDiv>
|
||||||
{#if fileCache && fileCache.length > 0}
|
{#if fileCache && fileCache.length > 0}
|
||||||
<div class="space-y-4 pb-4">
|
<div class="space-y-4 pb-4">
|
||||||
<div class="space-y-1 break-keep text-gray-800">
|
<div class="space-y-1 break-keep text-gray-800">
|
||||||
@@ -71,4 +72,4 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</FullscreenDiv>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AdaptiveDiv } from "$lib/components/divs";
|
import { AdaptiveDiv } from "$lib/components/atoms";
|
||||||
import BottomBar from "./BottomBar.svelte";
|
import BottomBar from "./BottomBar.svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen flex-col justify-between">
|
<div class="flex h-screen flex-col justify-between">
|
||||||
<div class="flex-grow">
|
<AdaptiveDiv class="w-full flex-grow">
|
||||||
<AdaptiveDiv>
|
{@render children()}
|
||||||
{@render children()}
|
</AdaptiveDiv>
|
||||||
</AdaptiveDiv>
|
|
||||||
</div>
|
|
||||||
<BottomBar />
|
<BottomBar />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import { AdaptiveDiv } from "$lib/components/divs";
|
import { AdaptiveDiv } from "$lib/components/atoms";
|
||||||
|
|
||||||
import IconHome from "~icons/material-symbols/home";
|
import IconHome from "~icons/material-symbols/home";
|
||||||
import IconFolder from "~icons/material-symbols/folder";
|
import IconFolder from "~icons/material-symbols/folder";
|
||||||
@@ -19,20 +19,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="sticky bottom-0 h-20 flex-shrink-0 rounded-t-2xl border-t border-gray-300 bg-white">
|
<div class="sticky bottom-0 h-20 flex-shrink-0 rounded-t-2xl border-t border-gray-300 bg-white">
|
||||||
<AdaptiveDiv>
|
<AdaptiveDiv class="flex justify-evenly px-4 py-2">
|
||||||
<div class="flex justify-evenly px-4 py-2">
|
{#each pages as { path, label, icon: Icon }}
|
||||||
{#each pages as { path, label, icon: Icon }}
|
{@const textColor = !page.url.pathname.startsWith(path) ? "text-gray-600" : ""}
|
||||||
{@const textColor = !page.url.pathname.startsWith(path) ? "text-gray-600" : ""}
|
<button
|
||||||
<button
|
onclick={() => goto(path)}
|
||||||
onclick={() => goto(path)}
|
class="w-16 active:rounded-xl active:bg-gray-100 {textColor}"
|
||||||
class="w-16 active:rounded-xl active:bg-gray-100 {textColor}"
|
>
|
||||||
>
|
<div class="flex flex-col items-center gap-y-1 p-1 transition active:scale-95">
|
||||||
<div class="flex flex-col items-center gap-y-1 p-1 transition active:scale-95">
|
<Icon class="text-xl" />
|
||||||
<Icon class="text-xl" />
|
<p class="text-sm">{label}</p>
|
||||||
<p class="text-sm">{label}</p>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
{/each}
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</AdaptiveDiv>
|
</AdaptiveDiv>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { TopBar } from "$lib/components";
|
import { TopBar } from "$lib/components/molecules";
|
||||||
|
import { Category, CategoryCreateModal } from "$lib/components/organisms";
|
||||||
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
|
||||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
|
||||||
import Category from "$lib/organisms/Category";
|
|
||||||
import CreateCategoryModal from "$lib/organisms/CreateCategoryModal.svelte";
|
|
||||||
import { masterKeyStore } from "$lib/stores";
|
import { masterKeyStore } from "$lib/stores";
|
||||||
|
import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
|
||||||
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
|
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
|
||||||
import DeleteCategoryModal from "./DeleteCategoryModal.svelte";
|
import CategoryRenameModal from "./CategoryRenameModal.svelte";
|
||||||
import RenameCategoryModal from "./RenameCategoryModal.svelte";
|
|
||||||
import {
|
import {
|
||||||
|
createContext,
|
||||||
requestCategoryCreation,
|
requestCategoryCreation,
|
||||||
requestFileRemovalFromCategory,
|
requestFileRemovalFromCategory,
|
||||||
requestCategoryRename,
|
requestCategoryRename,
|
||||||
requestCategoryDeletion,
|
requestCategoryDeletion,
|
||||||
} from "./service";
|
} from "./service.svelte";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
let context = createContext();
|
||||||
|
|
||||||
let info: Writable<CategoryInfo | null> | undefined = $state();
|
let info: Writable<CategoryInfo | null> | undefined = $state();
|
||||||
let selectedSubCategory: SelectedCategory | undefined = $state();
|
|
||||||
|
|
||||||
let isFileRecursive = $state(false);
|
let isFileRecursive = $state(false);
|
||||||
|
|
||||||
let isCreateCategoryModalOpen = $state(false);
|
let isCategoryCreateModalOpen = $state(false);
|
||||||
let isSubCategoryMenuBottomSheetOpen = $state(false);
|
let isCategoryMenuBottomSheetOpen = $state(false);
|
||||||
let isRenameCategoryModalOpen = $state(false);
|
let isCategoryRenameModalOpen = $state(false);
|
||||||
let isDeleteCategoryModalOpen = $state(false);
|
let isCategoryDeleteModalOpen = $state(false);
|
||||||
|
|
||||||
const createCategory = async (name: string) => {
|
|
||||||
await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!);
|
|
||||||
isCreateCategoryModalOpen = false;
|
|
||||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||||
@@ -44,63 +37,65 @@
|
|||||||
<title>카테고리</title>
|
<title>카테고리</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex min-h-full flex-col">
|
{#if data.id !== "root"}
|
||||||
{#if data.id !== "root"}
|
<TopBar title={$info?.name} />
|
||||||
<TopBar title={$info?.name} xPadding />
|
{/if}
|
||||||
|
<div class="min-h-full bg-gray-100 pb-[5.5em]">
|
||||||
|
{#if $info}
|
||||||
|
<Category
|
||||||
|
bind:isFileRecursive
|
||||||
|
info={$info}
|
||||||
|
onFileClick={({ id }) => goto(`/file/${id}`)}
|
||||||
|
onFileRemoveClick={async ({ id }) => {
|
||||||
|
await requestFileRemovalFromCategory(id, data.id as number);
|
||||||
|
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||||
|
}}
|
||||||
|
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
|
||||||
|
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
|
||||||
|
onSubCategoryMenuClick={(subCategory) => {
|
||||||
|
context.selectedCategory = subCategory;
|
||||||
|
isCategoryMenuBottomSheetOpen = true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex-grow bg-gray-100 pb-[5.5em]">
|
|
||||||
{#if $info}
|
|
||||||
<Category
|
|
||||||
bind:isFileRecursive
|
|
||||||
info={$info}
|
|
||||||
onFileClick={({ id }) => goto(`/file/${id}`)}
|
|
||||||
onFileRemoveClick={async ({ id }) => {
|
|
||||||
await requestFileRemovalFromCategory(id, data.id as number);
|
|
||||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
|
||||||
}}
|
|
||||||
onSubCategoryClick={({ id }) => goto(`/category/${id}`)}
|
|
||||||
onSubCategoryCreateClick={() => (isCreateCategoryModalOpen = true)}
|
|
||||||
onSubCategoryMenuClick={(subCategory) => {
|
|
||||||
selectedSubCategory = subCategory;
|
|
||||||
isSubCategoryMenuBottomSheetOpen = true;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreateCategoryModal bind:isOpen={isCreateCategoryModalOpen} onCreateClick={createCategory} />
|
<CategoryCreateModal
|
||||||
|
bind:isOpen={isCategoryCreateModalOpen}
|
||||||
|
onCreateClick={async (name: string) => {
|
||||||
|
if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
|
||||||
|
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<CategoryMenuBottomSheet
|
<CategoryMenuBottomSheet
|
||||||
bind:isOpen={isSubCategoryMenuBottomSheetOpen}
|
bind:isOpen={isCategoryMenuBottomSheetOpen}
|
||||||
bind:selectedCategory={selectedSubCategory}
|
|
||||||
onRenameClick={() => {
|
onRenameClick={() => {
|
||||||
isSubCategoryMenuBottomSheetOpen = false;
|
isCategoryMenuBottomSheetOpen = false;
|
||||||
isRenameCategoryModalOpen = true;
|
isCategoryRenameModalOpen = true;
|
||||||
}}
|
}}
|
||||||
onDeleteClick={() => {
|
onDeleteClick={() => {
|
||||||
isSubCategoryMenuBottomSheetOpen = false;
|
isCategoryMenuBottomSheetOpen = false;
|
||||||
isDeleteCategoryModalOpen = true;
|
isCategoryDeleteModalOpen = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<RenameCategoryModal
|
<CategoryRenameModal
|
||||||
bind:isOpen={isRenameCategoryModalOpen}
|
bind:isOpen={isCategoryRenameModalOpen}
|
||||||
bind:selectedCategory={selectedSubCategory}
|
onRenameClick={async (newName: string) => {
|
||||||
onRenameClick={async (newName) => {
|
if (await requestCategoryRename(context.selectedCategory!, newName)) {
|
||||||
if (selectedSubCategory) {
|
|
||||||
await requestCategoryRename(selectedSubCategory, newName);
|
|
||||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<DeleteCategoryModal
|
<CategoryDeleteModal
|
||||||
bind:isOpen={isDeleteCategoryModalOpen}
|
bind:isOpen={isCategoryDeleteModalOpen}
|
||||||
bind:selectedCategory={selectedSubCategory}
|
|
||||||
onDeleteClick={async () => {
|
onDeleteClick={async () => {
|
||||||
if (selectedSubCategory) {
|
if (await requestCategoryDeletion(context.selectedCategory!)) {
|
||||||
await requestCategoryDeletion(selectedSubCategory);
|
|
||||||
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/routes/(main)/category/[[id]]/CategoryDeleteModal.svelte
Normal file
29
src/routes/(main)/category/[[id]]/CategoryDeleteModal.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ActionModal } from "$lib/components/molecules";
|
||||||
|
import { truncateString } from "$lib/modules/util";
|
||||||
|
import { useContext } from "./service.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onDeleteClick: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onDeleteClick }: Props = $props();
|
||||||
|
let context = useContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if context.selectedCategory}
|
||||||
|
{@const { name } = context.selectedCategory}
|
||||||
|
<ActionModal
|
||||||
|
bind:isOpen
|
||||||
|
title="'{truncateString(name)}' 카테고리를 삭제할까요?"
|
||||||
|
cancelText="아니요"
|
||||||
|
confirmText="삭제할게요"
|
||||||
|
onConfirmClick={onDeleteClick}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
모든 하위 카테고리도 함께 삭제돼요. <br />
|
||||||
|
하지만 카테고리에 추가된 파일들은 삭제되지 않아요.
|
||||||
|
</p>
|
||||||
|
</ActionModal>
|
||||||
|
{/if}
|
||||||
@@ -1,57 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { BottomSheet } from "$lib/components";
|
import { BottomSheet } from "$lib/components/atoms";
|
||||||
import { EntryButton } from "$lib/components/buttons";
|
import { CategoryLabel, IconEntryButton } from "$lib/components/molecules";
|
||||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
import { useContext } from "./service.svelte";
|
||||||
|
|
||||||
import IconCategory from "~icons/material-symbols/category";
|
|
||||||
import IconEdit from "~icons/material-symbols/edit";
|
import IconEdit from "~icons/material-symbols/edit";
|
||||||
import IconDelete from "~icons/material-symbols/delete";
|
import IconDelete from "~icons/material-symbols/delete";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onRenameClick: () => void;
|
|
||||||
onDeleteClick: () => void;
|
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
selectedCategory: SelectedCategory | undefined;
|
onDeleteClick: () => void;
|
||||||
|
onRenameClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { isOpen = $bindable(), onDeleteClick, onRenameClick }: Props = $props();
|
||||||
onRenameClick,
|
let context = useContext();
|
||||||
onDeleteClick,
|
|
||||||
isOpen = $bindable(),
|
|
||||||
selectedCategory = $bindable(),
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const closeBottomSheet = () => {
|
|
||||||
isOpen = false;
|
|
||||||
selectedCategory = undefined;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BottomSheet bind:isOpen onclose={closeBottomSheet}>
|
{#if context.selectedCategory}
|
||||||
<div class="w-full py-4">
|
{@const { name } = context.selectedCategory}
|
||||||
{#if selectedCategory}
|
<BottomSheet bind:isOpen class="p-4">
|
||||||
{@const { name } = selectedCategory}
|
<CategoryLabel {name} class="h-12 p-2" textClass="!font-semibold" />
|
||||||
<div class="flex h-12 items-center gap-x-4 p-2">
|
<div class="my-2 h-px w-full bg-gray-200"></div>
|
||||||
<div class="flex-shrink-0 text-lg">
|
<IconEntryButton icon={IconEdit} onclick={onRenameClick} class="h-12 w-full">
|
||||||
<IconCategory />
|
이름 바꾸기
|
||||||
</div>
|
</IconEntryButton>
|
||||||
<p title={name} class="flex-grow truncate font-semibold">
|
<IconEntryButton icon={IconDelete} onclick={onDeleteClick} class="h-12 w-full text-red-500">
|
||||||
{name}
|
삭제하기
|
||||||
</p>
|
</IconEntryButton>
|
||||||
</div>
|
</BottomSheet>
|
||||||
<div class="my-2 h-px w-full bg-gray-200"></div>
|
{/if}
|
||||||
{/if}
|
|
||||||
<EntryButton onclick={onRenameClick}>
|
|
||||||
<div class="flex h-8 items-center gap-x-4">
|
|
||||||
<IconEdit class="text-lg" />
|
|
||||||
<p class="font-medium">이름 바꾸기</p>
|
|
||||||
</div>
|
|
||||||
</EntryButton>
|
|
||||||
<EntryButton onclick={onDeleteClick}>
|
|
||||||
<div class="flex h-8 items-center gap-x-4 text-red-500">
|
|
||||||
<IconDelete class="text-lg" />
|
|
||||||
<p class="font-medium">삭제하기</p>
|
|
||||||
</div>
|
|
||||||
</EntryButton>
|
|
||||||
</div>
|
|
||||||
</BottomSheet>
|
|
||||||
|
|||||||
17
src/routes/(main)/category/[[id]]/CategoryRenameModal.svelte
Normal file
17
src/routes/(main)/category/[[id]]/CategoryRenameModal.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { RenameModal } from "$lib/components/organisms";
|
||||||
|
import { useContext } from "./service.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onRenameClick: (newName: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onRenameClick }: Props = $props();
|
||||||
|
let context = useContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if context.selectedCategory}
|
||||||
|
{@const { name } = context.selectedCategory}
|
||||||
|
<RenameModal bind:isOpen originalName={name} {onRenameClick} />
|
||||||
|
{/if}
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Modal } from "$lib/components";
|
|
||||||
import { Button } from "$lib/components/buttons";
|
|
||||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onDeleteClick: () => Promise<boolean>;
|
|
||||||
isOpen: boolean;
|
|
||||||
selectedCategory: SelectedCategory | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onDeleteClick, isOpen = $bindable(), selectedCategory = $bindable() }: Props = $props();
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
isOpen = false;
|
|
||||||
selectedCategory = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteEntry = async () => {
|
|
||||||
// TODO: Validation
|
|
||||||
|
|
||||||
if (await onDeleteClick()) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal bind:isOpen onclose={closeModal}>
|
|
||||||
{#if selectedCategory}
|
|
||||||
{@const { name } = selectedCategory}
|
|
||||||
{@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="space-y-2 break-keep">
|
|
||||||
<p class="text-xl font-bold">
|
|
||||||
'{nameShort}' 카테고리를 삭제할까요?
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
모든 하위 카테고리도 함께 삭제돼요. <br />
|
|
||||||
하지만 카테고리에 추가된 파일들은 삭제되지 않아요.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button color="gray" onclick={closeModal}>아니요</Button>
|
|
||||||
<Button onclick={deleteEntry}>삭제할게요</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Modal>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Modal } from "$lib/components";
|
|
||||||
import { Button } from "$lib/components/buttons";
|
|
||||||
import { TextInput } from "$lib/components/inputs";
|
|
||||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onRenameClick: (newName: string) => Promise<boolean>;
|
|
||||||
isOpen: boolean;
|
|
||||||
selectedCategory: SelectedCategory | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onRenameClick, isOpen = $bindable(), selectedCategory = $bindable() }: Props = $props();
|
|
||||||
|
|
||||||
let name = $state("");
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
name = "";
|
|
||||||
isOpen = false;
|
|
||||||
selectedCategory = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renameEntry = async () => {
|
|
||||||
// TODO: Validation
|
|
||||||
|
|
||||||
if (await onRenameClick(name)) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (selectedCategory) {
|
|
||||||
name = selectedCategory.name;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal bind:isOpen onclose={closeModal}>
|
|
||||||
<p class="text-xl font-bold">이름 바꾸기</p>
|
|
||||||
<div class="mt-2 flex w-full">
|
|
||||||
<TextInput bind:value={name} placeholder="이름" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-7 flex gap-2">
|
|
||||||
<Button color="gray" onclick={closeModal}>닫기</Button>
|
|
||||||
<Button onclick={renameEntry}>바꾸기</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
|
import { getContext, setContext } from "svelte";
|
||||||
import { callPostApi } from "$lib/hooks";
|
import { callPostApi } from "$lib/hooks";
|
||||||
import { encryptString } from "$lib/modules/crypto";
|
import { encryptString } from "$lib/modules/crypto";
|
||||||
import type { SelectedCategory } from "$lib/molecules/Categories";
|
import type { SelectedCategory } from "$lib/components/molecules";
|
||||||
import type { CategoryRenameRequest } from "$lib/server/schemas";
|
import type { CategoryRenameRequest } from "$lib/server/schemas";
|
||||||
|
|
||||||
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
|
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
|
||||||
|
|
||||||
|
export const createContext = () => {
|
||||||
|
const context = $state({
|
||||||
|
selectedCategory: undefined as SelectedCategory | undefined,
|
||||||
|
});
|
||||||
|
return setContext("context", context);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useContext = () => {
|
||||||
|
return getContext<ReturnType<typeof createContext>>("context");
|
||||||
|
};
|
||||||
|
|
||||||
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
|
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
|
||||||
const newNameEncrypted = await encryptString(newName, category.dataKey);
|
const newNameEncrypted = await encryptString(newName, category.dataKey);
|
||||||
|
|
||||||
@@ -2,51 +2,45 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { TopBar } from "$lib/components";
|
import { FloatingButton } from "$lib/components/atoms";
|
||||||
import { FloatingButton } from "$lib/components/buttons";
|
import { TopBar } from "$lib/components/molecules";
|
||||||
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
|
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
|
||||||
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
|
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
|
||||||
import CreateBottomSheet from "./CreateBottomSheet.svelte";
|
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
|
||||||
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
|
|
||||||
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
|
|
||||||
import DirectoryEntries from "./DirectoryEntries";
|
import DirectoryEntries from "./DirectoryEntries";
|
||||||
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
|
|
||||||
import DownloadStatusCard from "./DownloadStatusCard.svelte";
|
import DownloadStatusCard from "./DownloadStatusCard.svelte";
|
||||||
import DuplicateFileModal from "./DuplicateFileModal.svelte";
|
import DuplicateFileModal from "./DuplicateFileModal.svelte";
|
||||||
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
|
import EntryCreateBottomSheet from "./EntryCreateBottomSheet.svelte";
|
||||||
|
import EntryDeleteModal from "./EntryDeleteModal.svelte";
|
||||||
|
import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte";
|
||||||
|
import EntryRenameModal from "./EntryRenameModal.svelte";
|
||||||
import UploadStatusCard from "./UploadStatusCard.svelte";
|
import UploadStatusCard from "./UploadStatusCard.svelte";
|
||||||
import {
|
import {
|
||||||
|
createContext,
|
||||||
requestHmacSecretDownload,
|
requestHmacSecretDownload,
|
||||||
requestDirectoryCreation,
|
requestDirectoryCreation,
|
||||||
requestFileUpload,
|
requestFileUpload,
|
||||||
requestDirectoryEntryRename,
|
requestEntryRename,
|
||||||
requestDirectoryEntryDeletion,
|
requestEntryDeletion,
|
||||||
type SelectedDirectoryEntry,
|
} from "./service.svelte";
|
||||||
} from "./service";
|
|
||||||
|
|
||||||
import IconAdd from "~icons/material-symbols/add";
|
import IconAdd from "~icons/material-symbols/add";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
let context = createContext();
|
||||||
|
|
||||||
let info: Writable<DirectoryInfo | null> | undefined = $state();
|
let info: Writable<DirectoryInfo | null> | undefined = $state();
|
||||||
let fileInput: HTMLInputElement | undefined = $state();
|
let fileInput: HTMLInputElement | undefined = $state();
|
||||||
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
|
|
||||||
let duplicatedFile: File | undefined = $state();
|
let duplicatedFile: File | undefined = $state();
|
||||||
let selectedEntry: SelectedDirectoryEntry | undefined = $state();
|
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
|
||||||
|
|
||||||
let isCreateBottomSheetOpen = $state(false);
|
let isEntryCreateBottomSheetOpen = $state(false);
|
||||||
let isCreateDirectoryModalOpen = $state(false);
|
let isDirectoryCreateModalOpen = $state(false);
|
||||||
let isDuplicateFileModalOpen = $state(false);
|
let isDuplicateFileModalOpen = $state(false);
|
||||||
|
|
||||||
let isDirectoryEntryMenuBottomSheetOpen = $state(false);
|
let isEntryMenuBottomSheetOpen = $state(false);
|
||||||
let isRenameDirectoryEntryModalOpen = $state(false);
|
let isEntryRenameModalOpen = $state(false);
|
||||||
let isDeleteDirectoryEntryModalOpen = $state(false);
|
let isEntryDeleteModalOpen = $state(false);
|
||||||
|
|
||||||
const createDirectory = async (name: string) => {
|
|
||||||
await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!);
|
|
||||||
isCreateDirectoryModalOpen = false;
|
|
||||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFile = () => {
|
const uploadFile = () => {
|
||||||
const files = fileInput?.files;
|
const files = fileInput?.files;
|
||||||
@@ -55,22 +49,19 @@
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
|
requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
resolveForDuplicateFileModal = resolve;
|
|
||||||
duplicatedFile = file;
|
duplicatedFile = file;
|
||||||
|
resolveForDuplicateFileModal = resolve;
|
||||||
isDuplicateFileModalOpen = true;
|
isDuplicateFileModalOpen = true;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
|
|
||||||
// TODO: FIXME
|
// TODO: FIXME
|
||||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||||
window.alert(`'${file.name}' 파일이 업로드되었어요.`);
|
|
||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
// TODO: FIXME
|
// TODO: FIXME
|
||||||
console.error(e);
|
console.error(e);
|
||||||
window.alert(`'${file.name}' 파일 업로드에 실패했어요.\n${e.message}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,13 +85,12 @@
|
|||||||
|
|
||||||
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
|
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
|
||||||
|
|
||||||
<div class="flex min-h-full flex-col px-4">
|
<div class="flex h-full flex-col">
|
||||||
{#if data.id !== "root"}
|
{#if data.id !== "root"}
|
||||||
<TopBar title={$info?.name} />
|
<TopBar title={$info?.name} class="flex-shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if $info}
|
{#if $info}
|
||||||
{@const topMargin = data.id === "root" ? "mt-4" : ""}
|
<div class={["flex flex-grow flex-col px-4 pb-4", data.id === "root" && "pt-4"]}>
|
||||||
<div class="mb-4 flex flex-grow flex-col {topMargin}">
|
|
||||||
<div class="flex gap-x-2">
|
<div class="flex gap-x-2">
|
||||||
<UploadStatusCard onclick={() => goto("/file/uploads")} />
|
<UploadStatusCard onclick={() => goto("/file/uploads")} />
|
||||||
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
|
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
|
||||||
@@ -110,8 +100,8 @@
|
|||||||
info={$info}
|
info={$info}
|
||||||
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
|
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
|
||||||
onEntryMenuClick={(entry) => {
|
onEntryMenuClick={(entry) => {
|
||||||
selectedEntry = entry;
|
context.selectedEntry = entry;
|
||||||
isDirectoryEntryMenuBottomSheetOpen = true;
|
isEntryMenuBottomSheetOpen = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
@@ -122,65 +112,72 @@
|
|||||||
<FloatingButton
|
<FloatingButton
|
||||||
icon={IconAdd}
|
icon={IconAdd}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
isCreateBottomSheetOpen = true;
|
isEntryCreateBottomSheetOpen = true;
|
||||||
}}
|
}}
|
||||||
|
class="bottom-24 right-4"
|
||||||
/>
|
/>
|
||||||
<CreateBottomSheet
|
<EntryCreateBottomSheet
|
||||||
bind:isOpen={isCreateBottomSheetOpen}
|
bind:isOpen={isEntryCreateBottomSheetOpen}
|
||||||
onDirectoryCreateClick={() => {
|
onDirectoryCreateClick={() => {
|
||||||
isCreateBottomSheetOpen = false;
|
isEntryCreateBottomSheetOpen = false;
|
||||||
isCreateDirectoryModalOpen = true;
|
isDirectoryCreateModalOpen = true;
|
||||||
}}
|
}}
|
||||||
onFileUploadClick={() => {
|
onFileUploadClick={() => {
|
||||||
isCreateBottomSheetOpen = false;
|
isEntryCreateBottomSheetOpen = false;
|
||||||
fileInput?.click();
|
fileInput?.click();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} />
|
<DirectoryCreateModal
|
||||||
|
bind:isOpen={isDirectoryCreateModalOpen}
|
||||||
|
onCreateClick={async (name) => {
|
||||||
|
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
|
||||||
|
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<DuplicateFileModal
|
<DuplicateFileModal
|
||||||
bind:isOpen={isDuplicateFileModalOpen}
|
bind:isOpen={isDuplicateFileModalOpen}
|
||||||
file={duplicatedFile}
|
file={duplicatedFile}
|
||||||
onclose={() => {
|
onbeforeclose={() => {
|
||||||
resolveForDuplicateFileModal?.(false);
|
resolveForDuplicateFileModal?.(false);
|
||||||
resolveForDuplicateFileModal = undefined;
|
|
||||||
duplicatedFile = undefined;
|
|
||||||
isDuplicateFileModalOpen = false;
|
isDuplicateFileModalOpen = false;
|
||||||
}}
|
}}
|
||||||
onDuplicateClick={() => {
|
onUploadClick={() => {
|
||||||
resolveForDuplicateFileModal?.(true);
|
resolveForDuplicateFileModal?.(true);
|
||||||
resolveForDuplicateFileModal = undefined;
|
|
||||||
duplicatedFile = undefined;
|
|
||||||
isDuplicateFileModalOpen = false;
|
isDuplicateFileModalOpen = false;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DirectoryEntryMenuBottomSheet
|
<EntryMenuBottomSheet
|
||||||
bind:isOpen={isDirectoryEntryMenuBottomSheetOpen}
|
bind:isOpen={isEntryMenuBottomSheetOpen}
|
||||||
bind:selectedEntry
|
|
||||||
onRenameClick={() => {
|
onRenameClick={() => {
|
||||||
isDirectoryEntryMenuBottomSheetOpen = false;
|
isEntryMenuBottomSheetOpen = false;
|
||||||
isRenameDirectoryEntryModalOpen = true;
|
isEntryRenameModalOpen = true;
|
||||||
}}
|
}}
|
||||||
onDeleteClick={() => {
|
onDeleteClick={() => {
|
||||||
isDirectoryEntryMenuBottomSheetOpen = false;
|
isEntryMenuBottomSheetOpen = false;
|
||||||
isDeleteDirectoryEntryModalOpen = true;
|
isEntryDeleteModalOpen = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<RenameDirectoryEntryModal
|
<EntryRenameModal
|
||||||
bind:isOpen={isRenameDirectoryEntryModalOpen}
|
bind:isOpen={isEntryRenameModalOpen}
|
||||||
bind:selectedEntry
|
onRenameClick={async (newName: string) => {
|
||||||
onRenameClick={async (newName) => {
|
if (await requestEntryRename(context.selectedEntry!, newName)) {
|
||||||
await requestDirectoryEntryRename(selectedEntry!, newName);
|
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
return true;
|
||||||
return true;
|
}
|
||||||
|
return false;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<DeleteDirectoryEntryModal
|
<EntryDeleteModal
|
||||||
bind:isOpen={isDeleteDirectoryEntryModalOpen}
|
bind:isOpen={isEntryDeleteModalOpen}
|
||||||
bind:selectedEntry
|
|
||||||
onDeleteClick={async () => {
|
onDeleteClick={async () => {
|
||||||
await requestDirectoryEntryDeletion(selectedEntry!);
|
if (await requestEntryDeletion(context.selectedEntry!)) {
|
||||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { BottomSheet } from "$lib/components";
|
|
||||||
import { EntryButton } from "$lib/components/buttons";
|
|
||||||
|
|
||||||
import IconCreateNewFolder from "~icons/material-symbols/create-new-folder";
|
|
||||||
import IconUploadFile from "~icons/material-symbols/upload-file";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onDirectoryCreateClick: () => void;
|
|
||||||
onFileUploadClick: () => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onDirectoryCreateClick, onFileUploadClick, isOpen = $bindable() }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<BottomSheet bind:isOpen>
|
|
||||||
<div class="w-full py-4">
|
|
||||||
<EntryButton onclick={onDirectoryCreateClick}>
|
|
||||||
<div class="flex h-12 items-center gap-x-4">
|
|
||||||
<IconCreateNewFolder class="text-2xl text-yellow-500" />
|
|
||||||
<p class="font-medium">폴더 만들기</p>
|
|
||||||
</div>
|
|
||||||
</EntryButton>
|
|
||||||
<EntryButton onclick={onFileUploadClick}>
|
|
||||||
<div class="flex h-12 items-center gap-x-4">
|
|
||||||
<IconUploadFile class="text-2xl text-blue-400" />
|
|
||||||
<p class="font-medium">파일 업로드</p>
|
|
||||||
</div>
|
|
||||||
</EntryButton>
|
|
||||||
</div>
|
|
||||||
</BottomSheet>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Modal } from "$lib/components";
|
|
||||||
import { Button } from "$lib/components/buttons";
|
|
||||||
import { TextInput } from "$lib/components/inputs";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onCreateClick: (name: string) => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onCreateClick, isOpen = $bindable() }: Props = $props();
|
|
||||||
|
|
||||||
let name = $state("");
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
name = "";
|
|
||||||
isOpen = false;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal bind:isOpen onclose={closeModal}>
|
|
||||||
<p class="text-xl font-bold">새 폴더</p>
|
|
||||||
<div class="mt-2 flex w-full">
|
|
||||||
<TextInput bind:value={name} placeholder="폴더 이름" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-7 flex gap-2">
|
|
||||||
<Button color="gray" onclick={closeModal}>닫기</Button>
|
|
||||||
<Button onclick={() => onCreateClick(name)}>만들기</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Modal } from "$lib/components";
|
|
||||||
import { Button } from "$lib/components/buttons";
|
|
||||||
import type { SelectedDirectoryEntry } from "./service";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onDeleteClick: () => Promise<boolean>;
|
|
||||||
isOpen: boolean;
|
|
||||||
selectedEntry: SelectedDirectoryEntry | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onDeleteClick, isOpen = $bindable(), selectedEntry = $bindable() }: Props = $props();
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
isOpen = false;
|
|
||||||
selectedEntry = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteEntry = async () => {
|
|
||||||
// TODO: Validation
|
|
||||||
|
|
||||||
if (await onDeleteClick()) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal bind:isOpen onclose={closeModal}>
|
|
||||||
{#if selectedEntry}
|
|
||||||
{@const { type, name } = selectedEntry}
|
|
||||||
{@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="space-y-2 break-keep">
|
|
||||||
<p class="text-xl font-bold">
|
|
||||||
{#if type === "directory"}
|
|
||||||
'{nameShort}' 폴더를 삭제할까요?
|
|
||||||
{:else}
|
|
||||||
'{nameShort}' 파일을 삭제할까요?
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{#if type === "directory"}
|
|
||||||
삭제한 폴더는 복구할 수 없어요. <br />
|
|
||||||
폴더 안의 모든 파일과 폴더도 함께 삭제돼요.
|
|
||||||
{:else}
|
|
||||||
삭제한 파일은 복구할 수 없어요.
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button color="gray" onclick={closeModal}>아니요</Button>
|
|
||||||
<Button onclick={deleteEntry}>삭제할게요</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Modal>
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { TextInputModal } from "$lib/components/organisms";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onCreateClick: (name: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onCreateClick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TextInputModal
|
||||||
|
bind:isOpen
|
||||||
|
title="새 폴더"
|
||||||
|
placeholder="폴더 이름"
|
||||||
|
submitText="만들기"
|
||||||
|
onSubmitClick={onCreateClick}
|
||||||
|
/>
|
||||||
@@ -17,12 +17,12 @@
|
|||||||
import File from "./File.svelte";
|
import File from "./File.svelte";
|
||||||
import SubDirectory from "./SubDirectory.svelte";
|
import SubDirectory from "./SubDirectory.svelte";
|
||||||
import UploadingFile from "./UploadingFile.svelte";
|
import UploadingFile from "./UploadingFile.svelte";
|
||||||
import type { SelectedDirectoryEntry } from "../service";
|
import type { SelectedEntry } from "../service.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
info: DirectoryInfo;
|
info: DirectoryInfo;
|
||||||
onEntryClick: (entry: SelectedDirectoryEntry) => void;
|
onEntryClick: (entry: SelectedEntry) => void;
|
||||||
onEntryMenuClick: (entry: SelectedDirectoryEntry) => void;
|
onEntryMenuClick: (entry: SelectedEntry) => void;
|
||||||
sortBy?: SortBy;
|
sortBy?: SortBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
|
import { ActionEntryButton } from "$lib/components/atoms";
|
||||||
|
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||||
import type { FileInfo } from "$lib/modules/filesystem";
|
import type { FileInfo } from "$lib/modules/filesystem";
|
||||||
import { formatDateTime } from "$lib/modules/util";
|
import { formatDateTime } from "$lib/modules/util";
|
||||||
import type { SelectedDirectoryEntry } from "../service";
|
import type { SelectedEntry } from "../service.svelte";
|
||||||
|
|
||||||
import IconDraft from "~icons/material-symbols/draft";
|
|
||||||
import IconMoreVert from "~icons/material-symbols/more-vert";
|
import IconMoreVert from "~icons/material-symbols/more-vert";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
info: Writable<FileInfo | null>;
|
info: Writable<FileInfo | null>;
|
||||||
onclick: (selectedEntry: SelectedDirectoryEntry) => void;
|
onclick: (selectedEntry: SelectedEntry) => void;
|
||||||
onOpenMenuClick: (selectedEntry: SelectedDirectoryEntry) => void;
|
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { info, onclick, onOpenMenuClick }: Props = $props();
|
let { info, onclick, onOpenMenuClick }: Props = $props();
|
||||||
@@ -19,55 +20,28 @@
|
|||||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
|
||||||
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openMenu = (e: Event) => {
|
const openMenu = () => {
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
|
||||||
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $info}
|
{#if $info}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<ActionEntryButton
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
class="h-14"
|
||||||
<div id="button" onclick={openFile} class="h-14 rounded-xl">
|
onclick={openFile}
|
||||||
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
|
actionButtonIcon={IconMoreVert}
|
||||||
<div class="flex-shrink-0 text-lg">
|
onActionButtonClick={openMenu}
|
||||||
<IconDraft class="text-blue-400" />
|
>
|
||||||
</div>
|
<DirectoryEntryLabel
|
||||||
<div class="flex-grow overflow-hidden">
|
type="file"
|
||||||
<p title={$info.name} class="truncate font-medium">
|
name={$info.name}
|
||||||
{$info.name}
|
subtext={formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
|
||||||
</p>
|
/>
|
||||||
<p class="text-xs text-gray-800">
|
</ActionEntryButton>
|
||||||
{formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="open-menu"
|
|
||||||
onclick={openMenu}
|
|
||||||
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
|
|
||||||
>
|
|
||||||
<IconMoreVert class="text-lg" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
#button:active:not(:has(#open-menu:active)) {
|
|
||||||
@apply bg-gray-100;
|
|
||||||
}
|
|
||||||
#button-content:active:not(:has(#open-menu:active)) {
|
|
||||||
@apply scale-95;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
|
import { ActionEntryButton } from "$lib/components/atoms";
|
||||||
|
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||||
import type { DirectoryInfo } from "$lib/modules/filesystem";
|
import type { DirectoryInfo } from "$lib/modules/filesystem";
|
||||||
import type { SelectedDirectoryEntry } from "../service";
|
import type { SelectedEntry } from "../service.svelte";
|
||||||
|
|
||||||
import IconFolder from "~icons/material-symbols/folder";
|
|
||||||
import IconMoreVert from "~icons/material-symbols/more-vert";
|
import IconMoreVert from "~icons/material-symbols/more-vert";
|
||||||
|
|
||||||
type SubDirectoryInfo = DirectoryInfo & { id: number };
|
type SubDirectoryInfo = DirectoryInfo & { id: number };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
info: Writable<DirectoryInfo | null>;
|
info: Writable<DirectoryInfo | null>;
|
||||||
onclick: (selectedEntry: SelectedDirectoryEntry) => void;
|
onclick: (selectedEntry: SelectedEntry) => void;
|
||||||
onOpenMenuClick: (selectedEntry: SelectedDirectoryEntry) => void;
|
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { info, onclick, onOpenMenuClick }: Props = $props();
|
let { info, onclick, onOpenMenuClick }: Props = $props();
|
||||||
@@ -20,50 +21,24 @@
|
|||||||
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
||||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
||||||
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openMenu = (e: Event) => {
|
const openMenu = () => {
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
||||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||||
|
|
||||||
setTimeout(() => {
|
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
||||||
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $info}
|
{#if $info}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<ActionEntryButton
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
class="h-14"
|
||||||
<div id="button" onclick={openDirectory} class="h-14 rounded-xl">
|
onclick={openDirectory}
|
||||||
<div id="button-content" class="flex h-full items-center gap-x-4 p-2 transition">
|
actionButtonIcon={IconMoreVert}
|
||||||
<div class="flex-shrink-0 text-lg">
|
onActionButtonClick={openMenu}
|
||||||
<IconFolder />
|
>
|
||||||
</div>
|
<DirectoryEntryLabel type="directory" name={$info.name!} />
|
||||||
<p title={$info.name} class="flex-grow truncate font-medium">
|
</ActionEntryButton>
|
||||||
{$info.name}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
id="open-menu"
|
|
||||||
onclick={openMenu}
|
|
||||||
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
|
|
||||||
>
|
|
||||||
<IconMoreVert class="text-lg" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
|
||||||
#button:active:not(:has(#open-menu:active)) {
|
|
||||||
@apply bg-gray-100;
|
|
||||||
}
|
|
||||||
#button-content:active:not(:has(#open-menu:active)) {
|
|
||||||
@apply scale-95;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { BottomSheet } from "$lib/components";
|
|
||||||
import { EntryButton } from "$lib/components/buttons";
|
|
||||||
import type { SelectedDirectoryEntry } from "./service";
|
|
||||||
|
|
||||||
import IconFolder from "~icons/material-symbols/folder";
|
|
||||||
import IconDraft from "~icons/material-symbols/draft";
|
|
||||||
import IconEdit from "~icons/material-symbols/edit";
|
|
||||||
import IconDelete from "~icons/material-symbols/delete";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onRenameClick: () => void;
|
|
||||||
onDeleteClick: () => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
selectedEntry: SelectedDirectoryEntry | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
onRenameClick,
|
|
||||||
onDeleteClick,
|
|
||||||
isOpen = $bindable(),
|
|
||||||
selectedEntry = $bindable(),
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const closeBottomSheet = () => {
|
|
||||||
isOpen = false;
|
|
||||||
selectedEntry = undefined;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<BottomSheet bind:isOpen onclose={closeBottomSheet}>
|
|
||||||
<div class="w-full py-4">
|
|
||||||
{#if selectedEntry}
|
|
||||||
{@const { type, name } = selectedEntry}
|
|
||||||
<div class="flex h-12 items-center gap-x-4 p-2">
|
|
||||||
<div class="flex-shrink-0 text-lg">
|
|
||||||
{#if type === "directory"}
|
|
||||||
<IconFolder />
|
|
||||||
{:else}
|
|
||||||
<IconDraft class="text-blue-400" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p title={name} class="flex-grow truncate font-semibold">
|
|
||||||
{name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="my-2 h-px w-full bg-gray-200"></div>
|
|
||||||
{/if}
|
|
||||||
<EntryButton onclick={onRenameClick}>
|
|
||||||
<div class="flex h-8 items-center gap-x-4">
|
|
||||||
<IconEdit class="text-lg" />
|
|
||||||
<p class="font-medium">이름 바꾸기</p>
|
|
||||||
</div>
|
|
||||||
</EntryButton>
|
|
||||||
<EntryButton onclick={onDeleteClick}>
|
|
||||||
<div class="flex h-8 items-center gap-x-4 text-red-500">
|
|
||||||
<IconDelete class="text-lg" />
|
|
||||||
<p class="font-medium">삭제하기</p>
|
|
||||||
</div>
|
|
||||||
</EntryButton>
|
|
||||||
</div>
|
|
||||||
</BottomSheet>
|
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Modal } from "$lib/components";
|
import { ActionModal } from "$lib/components/molecules";
|
||||||
import { Button } from "$lib/components/buttons";
|
import { truncateString } from "$lib/modules/util";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
file: File | undefined;
|
file: File | undefined;
|
||||||
onclose: () => void;
|
|
||||||
onDuplicateClick: () => void;
|
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
onbeforeclose: () => void;
|
||||||
|
onUploadClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { file, onclose, onDuplicateClick, isOpen = $bindable() }: Props = $props();
|
let { file, isOpen = $bindable(), onbeforeclose, onUploadClick }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal bind:isOpen {onclose}>
|
{#if file}
|
||||||
{#if file}
|
{@const { name } = file}
|
||||||
{@const { name } = file}
|
<ActionModal
|
||||||
{@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
|
bind:isOpen
|
||||||
<div class="space-y-4">
|
{onbeforeclose}
|
||||||
<div class="space-y-2 break-keep">
|
title="'{truncateString(name)}' 파일이 있어요."
|
||||||
<p class="text-xl font-bold">'{nameShort}' 파일이 있어요.</p>
|
cancelText="아니요"
|
||||||
<p>예전에 이미 업로드된 파일이에요. 그래도 업로드할까요?</p>
|
confirmText="업로드할게요"
|
||||||
</div>
|
onConfirmClick={onUploadClick}
|
||||||
<div class="flex gap-2">
|
>
|
||||||
<Button color="gray" onclick={onclose}>아니요</Button>
|
<p>예전에 이미 업로드된 파일이에요. 그래도 업로드할까요?</p>
|
||||||
<Button onclick={onDuplicateClick}>업로드할게요</Button>
|
</ActionModal>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Modal>
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BottomSheet } from "$lib/components/atoms";
|
||||||
|
import { IconEntryButton } from "$lib/components/molecules";
|
||||||
|
|
||||||
|
import IconCreateNewFolder from "~icons/material-symbols/create-new-folder";
|
||||||
|
import IconUploadFile from "~icons/material-symbols/upload-file";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onDirectoryCreateClick: () => void;
|
||||||
|
onFileUploadClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onDirectoryCreateClick, onFileUploadClick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BottomSheet bind:isOpen class="p-4">
|
||||||
|
<IconEntryButton
|
||||||
|
icon={IconCreateNewFolder}
|
||||||
|
onclick={onDirectoryCreateClick}
|
||||||
|
class="h-16 w-full"
|
||||||
|
iconClass="!text-2xl text-yellow-500"
|
||||||
|
>
|
||||||
|
폴더 만들기
|
||||||
|
</IconEntryButton>
|
||||||
|
<IconEntryButton
|
||||||
|
icon={IconUploadFile}
|
||||||
|
onclick={onFileUploadClick}
|
||||||
|
class="h-16 w-full"
|
||||||
|
iconClass="!text-2xl text-blue-400"
|
||||||
|
>
|
||||||
|
파일 업로드
|
||||||
|
</IconEntryButton>
|
||||||
|
</BottomSheet>
|
||||||
33
src/routes/(main)/directory/[[id]]/EntryDeleteModal.svelte
Normal file
33
src/routes/(main)/directory/[[id]]/EntryDeleteModal.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ActionModal } from "$lib/components/molecules";
|
||||||
|
import { truncateString } from "$lib/modules/util";
|
||||||
|
import { useContext } from "./service.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onDeleteClick: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onDeleteClick }: Props = $props();
|
||||||
|
let context = useContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if context.selectedEntry}
|
||||||
|
{@const { name, type } = context.selectedEntry}
|
||||||
|
<ActionModal
|
||||||
|
bind:isOpen
|
||||||
|
title="'{truncateString(name)}' {type === 'directory' ? '폴더를' : '파일을'} 삭제할까요?"
|
||||||
|
cancelText="아니요"
|
||||||
|
confirmText="삭제할게요"
|
||||||
|
onConfirmClick={onDeleteClick}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{#if type === "directory"}
|
||||||
|
삭제한 폴더는 복구할 수 없어요. <br />
|
||||||
|
폴더 안의 모든 파일과 폴더도 함께 삭제돼요.
|
||||||
|
{:else}
|
||||||
|
삭제한 파일은 복구할 수 없어요.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</ActionModal>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BottomSheet } from "$lib/components/atoms";
|
||||||
|
import { DirectoryEntryLabel, IconEntryButton } from "$lib/components/molecules";
|
||||||
|
import { useContext } from "./service.svelte";
|
||||||
|
|
||||||
|
import IconEdit from "~icons/material-symbols/edit";
|
||||||
|
import IconDelete from "~icons/material-symbols/delete";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onDeleteClick: () => void;
|
||||||
|
onRenameClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onDeleteClick, onRenameClick }: Props = $props();
|
||||||
|
let context = useContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if context.selectedEntry}
|
||||||
|
{@const { name, type } = context.selectedEntry}
|
||||||
|
<BottomSheet bind:isOpen class="p-4">
|
||||||
|
<DirectoryEntryLabel {type} {name} class="h-12 p-2" textClass="!font-semibold" />
|
||||||
|
<div class="my-2 h-px w-full bg-gray-200"></div>
|
||||||
|
<IconEntryButton icon={IconEdit} onclick={onRenameClick} class="h-12 w-full">
|
||||||
|
이름 바꾸기
|
||||||
|
</IconEntryButton>
|
||||||
|
<IconEntryButton icon={IconDelete} onclick={onDeleteClick} class="h-12 w-full text-red-500">
|
||||||
|
삭제하기
|
||||||
|
</IconEntryButton>
|
||||||
|
</BottomSheet>
|
||||||
|
{/if}
|
||||||
17
src/routes/(main)/directory/[[id]]/EntryRenameModal.svelte
Normal file
17
src/routes/(main)/directory/[[id]]/EntryRenameModal.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { RenameModal } from "$lib/components/organisms";
|
||||||
|
import { useContext } from "./service.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onRenameClick: (newName: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(), onRenameClick }: Props = $props();
|
||||||
|
let context = useContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if context.selectedEntry}
|
||||||
|
{@const { name } = context.selectedEntry}
|
||||||
|
<RenameModal bind:isOpen originalName={name} {onRenameClick} />
|
||||||
|
{/if}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Modal } from "$lib/components";
|
|
||||||
import { Button } from "$lib/components/buttons";
|
|
||||||
import { TextInput } from "$lib/components/inputs";
|
|
||||||
import type { SelectedDirectoryEntry } from "./service";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onRenameClick: (newName: string) => Promise<boolean>;
|
|
||||||
isOpen: boolean;
|
|
||||||
selectedEntry: SelectedDirectoryEntry | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onRenameClick, isOpen = $bindable(), selectedEntry = $bindable() }: Props = $props();
|
|
||||||
|
|
||||||
let name = $state("");
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
name = "";
|
|
||||||
isOpen = false;
|
|
||||||
selectedEntry = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renameEntry = async () => {
|
|
||||||
// TODO: Validation
|
|
||||||
|
|
||||||
if (await onRenameClick(name)) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (selectedEntry) {
|
|
||||||
name = selectedEntry.name;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Modal bind:isOpen onclose={closeModal}>
|
|
||||||
<p class="text-xl font-bold">이름 바꾸기</p>
|
|
||||||
<div class="mt-2 flex w-full">
|
|
||||||
<TextInput bind:value={name} placeholder="이름" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-7 flex gap-2">
|
|
||||||
<Button color="gray" onclick={closeModal}>닫기</Button>
|
|
||||||
<Button onclick={renameEntry}>바꾸기</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getContext, setContext } from "svelte";
|
||||||
import { callGetApi, callPostApi } from "$lib/hooks";
|
import { callGetApi, callPostApi } from "$lib/hooks";
|
||||||
import { storeHmacSecrets } from "$lib/indexedDB";
|
import { storeHmacSecrets } from "$lib/indexedDB";
|
||||||
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
|
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
|
||||||
@@ -11,7 +12,7 @@ import type {
|
|||||||
} from "$lib/server/schemas";
|
} from "$lib/server/schemas";
|
||||||
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
|
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
|
||||||
|
|
||||||
export interface SelectedDirectoryEntry {
|
export interface SelectedEntry {
|
||||||
type: "directory" | "file";
|
type: "directory" | "file";
|
||||||
id: number;
|
id: number;
|
||||||
dataKey: CryptoKey;
|
dataKey: CryptoKey;
|
||||||
@@ -19,6 +20,17 @@ export interface SelectedDirectoryEntry {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createContext = () => {
|
||||||
|
const context = $state({
|
||||||
|
selectedEntry: undefined as SelectedEntry | undefined,
|
||||||
|
});
|
||||||
|
return setContext("context", context);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useContext = () => {
|
||||||
|
return getContext<ReturnType<typeof createContext>>("context");
|
||||||
|
};
|
||||||
|
|
||||||
export const requestHmacSecretDownload = async (masterKey: CryptoKey) => {
|
export const requestHmacSecretDownload = async (masterKey: CryptoKey) => {
|
||||||
// TODO: MEK rotation
|
// TODO: MEK rotation
|
||||||
|
|
||||||
@@ -46,7 +58,8 @@ export const requestDirectoryCreation = async (
|
|||||||
) => {
|
) => {
|
||||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||||
const nameEncrypted = await encryptString(name, dataKey);
|
const nameEncrypted = await encryptString(name, dataKey);
|
||||||
await callPostApi<DirectoryCreateRequest>("/api/directory/create", {
|
|
||||||
|
const res = await callPostApi<DirectoryCreateRequest>("/api/directory/create", {
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
mekVersion: masterKey.version,
|
mekVersion: masterKey.version,
|
||||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
dek: await wrapDataKey(dataKey, masterKey.key),
|
||||||
@@ -54,6 +67,7 @@ export const requestDirectoryCreation = async (
|
|||||||
name: nameEncrypted.ciphertext,
|
name: nameEncrypted.ciphertext,
|
||||||
nameIv: nameEncrypted.iv,
|
nameIv: nameEncrypted.iv,
|
||||||
});
|
});
|
||||||
|
return res.ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestFileUpload = async (
|
export const requestFileUpload = async (
|
||||||
@@ -70,28 +84,27 @@ export const requestFileUpload = async (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestDirectoryEntryRename = async (
|
export const requestEntryRename = async (entry: SelectedEntry, newName: string) => {
|
||||||
entry: SelectedDirectoryEntry,
|
|
||||||
newName: string,
|
|
||||||
) => {
|
|
||||||
const newNameEncrypted = await encryptString(newName, entry.dataKey);
|
const newNameEncrypted = await encryptString(newName, entry.dataKey);
|
||||||
|
|
||||||
|
let res;
|
||||||
if (entry.type === "directory") {
|
if (entry.type === "directory") {
|
||||||
await callPostApi<DirectoryRenameRequest>(`/api/directory/${entry.id}/rename`, {
|
res = await callPostApi<DirectoryRenameRequest>(`/api/directory/${entry.id}/rename`, {
|
||||||
dekVersion: entry.dataKeyVersion.toISOString(),
|
dekVersion: entry.dataKeyVersion.toISOString(),
|
||||||
name: newNameEncrypted.ciphertext,
|
name: newNameEncrypted.ciphertext,
|
||||||
nameIv: newNameEncrypted.iv,
|
nameIv: newNameEncrypted.iv,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await callPostApi<FileRenameRequest>(`/api/file/${entry.id}/rename`, {
|
res = await callPostApi<FileRenameRequest>(`/api/file/${entry.id}/rename`, {
|
||||||
dekVersion: entry.dataKeyVersion.toISOString(),
|
dekVersion: entry.dataKeyVersion.toISOString(),
|
||||||
name: newNameEncrypted.ciphertext,
|
name: newNameEncrypted.ciphertext,
|
||||||
nameIv: newNameEncrypted.iv,
|
nameIv: newNameEncrypted.iv,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return res.ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const requestDirectoryEntryDeletion = async (entry: SelectedDirectoryEntry) => {
|
export const requestEntryDeletion = async (entry: SelectedEntry) => {
|
||||||
const res = await callPostApi(`/api/${entry.type}/${entry.id}/delete`);
|
const res = await callPostApi(`/api/${entry.type}/${entry.id}/delete`);
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
|
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Component, Snippet } from "svelte";
|
import type { Component, Snippet } from "svelte";
|
||||||
import type { SvelteHTMLElements } from "svelte/elements";
|
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
|
||||||
import { EntryButton } from "$lib/components/buttons";
|
import { IconEntryButton } from "$lib/components/molecules";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
icon: Component<SvelteHTMLElements["svg"]>;
|
icon: Component<SvelteHTMLElements["svg"]>;
|
||||||
iconColor: string;
|
iconColor: ClassValue;
|
||||||
onclick: () => void;
|
onclick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children, icon: Icon, iconColor, onclick }: Props = $props();
|
let { children, icon, iconColor, onclick }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EntryButton {onclick}>
|
<IconEntryButton
|
||||||
<div class="flex items-center gap-x-4">
|
{icon}
|
||||||
<div class="rounded-lg bg-gray-200 p-1 {iconColor}">
|
{onclick}
|
||||||
<Icon />
|
class="w-full"
|
||||||
</div>
|
iconClass={["rounded-lg bg-gray-200 p-1 !text-base", iconColor]}
|
||||||
<p class="font-medium">
|
>
|
||||||
{@render children?.()}
|
{@render children()}
|
||||||
</p>
|
</IconEntryButton>
|
||||||
</div>
|
|
||||||
</EntryButton>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user