|
|
@@ -0,0 +1,202 @@
|
|
|
+<script lang="ts">
|
|
|
+ import {
|
|
|
+ ChevronDown,
|
|
|
+ ChevronUp,
|
|
|
+ Copy,
|
|
|
+ Eye,
|
|
|
+ EyeOff,
|
|
|
+ Pencil,
|
|
|
+ Plus,
|
|
|
+ Save,
|
|
|
+ StickyNote,
|
|
|
+ Trash,
|
|
|
+ X,
|
|
|
+ } from '@lucide/svelte';
|
|
|
+ import type { ClassValue } from 'svelte/elements';
|
|
|
+ import { slide } from 'svelte/transition';
|
|
|
+
|
|
|
+ import { CopyButton } from '$lib/components/common/copy';
|
|
|
+ import Empty from '$lib/components/common/empty/empty.svelte';
|
|
|
+ import { Button } from '$lib/components/controls/button';
|
|
|
+ import { Input } from '$lib/components/controls/input';
|
|
|
+ import * as Table from '$lib/components/controls/table';
|
|
|
+ import type { Secret, SecretItem } from '$lib/types/entities';
|
|
|
+
|
|
|
+ let {
|
|
|
+ class: className = '',
|
|
|
+ secret,
|
|
|
+ onsave,
|
|
|
+ onrename,
|
|
|
+ ondelete,
|
|
|
+ }: {
|
|
|
+ class?: ClassValue;
|
|
|
+ secret: Secret;
|
|
|
+ onsave?: (secret: Secret) => void;
|
|
|
+ onrename?: () => void;
|
|
|
+ ondelete?: () => void;
|
|
|
+ } = $props();
|
|
|
+ let shown = $state(false);
|
|
|
+ let valueVisible = $state(false);
|
|
|
+ let editingIdx = $state<number | undefined>(undefined);
|
|
|
+ let editingItem = $state<SecretItem>({ key: '', value: '' });
|
|
|
+ let addingItem = $state(false);
|
|
|
+ let secretItems = $derived<SecretItem[]>(secret.items);
|
|
|
+
|
|
|
+ const onAddItem = () => {
|
|
|
+ if (editingIdx !== undefined) return;
|
|
|
+ secretItems = [...secretItems, { key: '', value: '' }];
|
|
|
+ editingItem = { key: '', value: '' };
|
|
|
+ editingIdx = secretItems.length - 1;
|
|
|
+ addingItem = true;
|
|
|
+ shown = true;
|
|
|
+ };
|
|
|
+
|
|
|
+ const onToggleVisibility = () => {
|
|
|
+ valueVisible = !valueVisible;
|
|
|
+ if (valueVisible) {
|
|
|
+ shown = true;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const onCopy = (text: string) => {
|
|
|
+ navigator.clipboard.writeText(text);
|
|
|
+ };
|
|
|
+
|
|
|
+ const onEdit = (idx: number) => {
|
|
|
+ editingIdx = idx;
|
|
|
+ editingItem = { ...secretItems[idx] };
|
|
|
+ };
|
|
|
+
|
|
|
+ const canSave = () => {
|
|
|
+ if (editingIdx === undefined || !editingItem) return false;
|
|
|
+ if (!editingItem.key.trim()) return false;
|
|
|
+ if (!editingItem.value.trim()) return false;
|
|
|
+ return true;
|
|
|
+ };
|
|
|
+
|
|
|
+ const onSaveItem = () => {
|
|
|
+ if (editingIdx === undefined || !editingItem) return;
|
|
|
+ secretItems[editingIdx] = editingItem;
|
|
|
+ editingIdx = undefined;
|
|
|
+ addingItem = false;
|
|
|
+ onsave?.({ ...secret, items: secretItems });
|
|
|
+ };
|
|
|
+
|
|
|
+ const onDeleteItem = (idx: number) => {
|
|
|
+ if (editingIdx === idx) {
|
|
|
+ editingIdx = undefined;
|
|
|
+ }
|
|
|
+ secretItems = secretItems.filter((_, i) => i !== idx);
|
|
|
+ if (!addingItem) {
|
|
|
+ onsave?.({ ...secret, items: secretItems });
|
|
|
+ }
|
|
|
+ addingItem = false;
|
|
|
+ };
|
|
|
+</script>
|
|
|
+
|
|
|
+<div class={['overflow-hidden rounded-md border border-white/10', className]}>
|
|
|
+ <div class="bg-white/5">
|
|
|
+ <div class="flex items-center justify-between p-2">
|
|
|
+ <div class="p-2 font-thin">{secret.name} ({secret.items.length} Entries)</div>
|
|
|
+ <div class="flex items-center gap-1">
|
|
|
+ <Button
|
|
|
+ disabled={editingIdx !== undefined}
|
|
|
+ size="icon-sm"
|
|
|
+ title="View"
|
|
|
+ variant="ghost"
|
|
|
+ onclick={() => onAddItem()}
|
|
|
+ >
|
|
|
+ <Plus />
|
|
|
+ </Button>
|
|
|
+ <Button size="icon-sm" title="View" variant="ghost" onclick={() => onToggleVisibility()}>
|
|
|
+ {#if valueVisible}
|
|
|
+ <EyeOff />
|
|
|
+ {:else}
|
|
|
+ <Eye />
|
|
|
+ {/if}
|
|
|
+ </Button>
|
|
|
+ <Button size="icon-xs" title="Delete" variant="ghost" onclick={() => ondelete?.()}>
|
|
|
+ <Trash />
|
|
|
+ </Button>
|
|
|
+ <Button size="icon-xs" title="Rename" variant="ghost" onclick={() => onrename?.()}>
|
|
|
+ <Pencil />
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ disabled={editingIdx !== undefined}
|
|
|
+ size="icon-sm"
|
|
|
+ variant="ghost"
|
|
|
+ onclick={() => (shown = !shown)}
|
|
|
+ >
|
|
|
+ {#if shown}
|
|
|
+ <ChevronUp size={14} />
|
|
|
+ {:else}
|
|
|
+ <ChevronDown size={14} />
|
|
|
+ {/if}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {#if shown}
|
|
|
+ <div class="overflow-hidden" transition:slide={{ duration: 200 }}>
|
|
|
+ <Table.Root>
|
|
|
+ <Table.Body>
|
|
|
+ {#each secretItems as entry, idx (idx)}
|
|
|
+ <Table.Row>
|
|
|
+ <Table.Cell class="inline-flex h-12 w-48 items-center gap-1 truncate">
|
|
|
+ {#if editingIdx === idx}
|
|
|
+ <Input placeholder="Key" type="text" bind:value={editingItem.key} />
|
|
|
+ {:else}
|
|
|
+ <CopyButton content={entry.key} />
|
|
|
+ {entry.key}
|
|
|
+ {/if}
|
|
|
+ </Table.Cell>
|
|
|
+ <Table.Cell class="inline-flex h-12 items-center gap-1">
|
|
|
+ {#if editingIdx === idx}
|
|
|
+ <Input
|
|
|
+ placeholder="Value"
|
|
|
+ type={valueVisible ? 'text' : 'password'}
|
|
|
+ bind:value={editingItem.value}
|
|
|
+ />
|
|
|
+ {:else}
|
|
|
+ <CopyButton content={entry.value} />
|
|
|
+ {valueVisible ? entry.value : '••••••••'}
|
|
|
+ {/if}
|
|
|
+ </Table.Cell>
|
|
|
+ <Table.Cell class="text-end select-none">
|
|
|
+ {#if editingIdx !== idx}
|
|
|
+ <Button size="icon-xs" title="Edit" variant="ghost" onclick={() => onEdit(idx)}>
|
|
|
+ <Pencil />
|
|
|
+ </Button>
|
|
|
+ {:else}
|
|
|
+ <Button
|
|
|
+ disabled={!canSave()}
|
|
|
+ size="icon-xs"
|
|
|
+ title="Save"
|
|
|
+ variant="ghost"
|
|
|
+ onclick={() => onSaveItem()}
|
|
|
+ >
|
|
|
+ <Save />
|
|
|
+ </Button>
|
|
|
+ {/if}
|
|
|
+ <Button
|
|
|
+ size="icon-xs"
|
|
|
+ title="Delete"
|
|
|
+ variant="ghost"
|
|
|
+ onclick={() => onDeleteItem(idx)}
|
|
|
+ >
|
|
|
+ <X />
|
|
|
+ </Button>
|
|
|
+ </Table.Cell>
|
|
|
+ </Table.Row>
|
|
|
+ {:else}
|
|
|
+ <Empty icon={StickyNote}>
|
|
|
+ {#snippet caption()}
|
|
|
+ <div>No items</div>
|
|
|
+ {/snippet}
|
|
|
+ </Empty>
|
|
|
+ {/each}
|
|
|
+ </Table.Body>
|
|
|
+ </Table.Root>
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
+</div>
|