312 lines
7.9 KiB
Svelte
312 lines
7.9 KiB
Svelte
<script lang="ts">
|
|
import { groupByStamps, sortBy } from '$lib/stores.svelte';
|
|
import Icon from '@iconify/svelte';
|
|
import NostrIdentifier from './NostrIdentifier.svelte';
|
|
import { getReadableDate, getReadableTime, moveMessageToFolder } from '$lib/utils.svelte.js';
|
|
import Dialog from './Dialog.svelte';
|
|
import Select from './Select.svelte';
|
|
import type { Letter } from '$lib/letter';
|
|
import { slide } from 'svelte/transition';
|
|
|
|
let {
|
|
letters,
|
|
foldersList = $bindable<{ id: string; name: string; icon: string }[]>(),
|
|
folder = $bindable<{ id: string; name: string; icon: string }>(),
|
|
moveFolderDialogOpen = $bindable<boolean>()
|
|
} = $props();
|
|
|
|
function formatRange(start, end) {
|
|
if (start === 0) return '0';
|
|
if (end === Infinity) return `${start}+`;
|
|
return `${start}-${end}`;
|
|
}
|
|
|
|
let sortedInbox = $derived(
|
|
letters.toSorted((a, b) => {
|
|
switch ($sortBy) {
|
|
case 'stamps':
|
|
return b.stamps - a.stamps;
|
|
case 'sender':
|
|
return a.from.localeCompare(b.from);
|
|
case 'subject':
|
|
return a.subject.localeCompare(b.subject);
|
|
case 'date':
|
|
return b.date - a.date;
|
|
}
|
|
return 0;
|
|
})
|
|
);
|
|
|
|
let groupedInbox = $derived.by(() => {
|
|
const maxStamps = Math.max(...sortedInbox.map(letter => letter.stamps));
|
|
const ranges = [
|
|
10000,
|
|
1000,
|
|
210,
|
|
100,
|
|
42,
|
|
21,
|
|
10,
|
|
0
|
|
];
|
|
|
|
if (!ranges.includes(maxStamps)) {
|
|
ranges.push(maxStamps);
|
|
ranges.sort((a, b) => b - a);
|
|
}
|
|
|
|
const groups = {};
|
|
|
|
for (let i = 0; i < ranges.length; i++) {
|
|
const start = ranges[i];
|
|
const end = ranges[i + 1] || Infinity;
|
|
const rangeKey = formatRange(start, end);
|
|
groups[rangeKey] = [];
|
|
}
|
|
|
|
sortedInbox.forEach(letter => {
|
|
for (let i = 0; i < ranges.length; i++) {
|
|
const start = ranges[i];
|
|
const end = ranges[i + 1] || Infinity;
|
|
|
|
if (letter.stamps >= start && letter.stamps < end) {
|
|
const rangeKey = formatRange(start, end);
|
|
groups[rangeKey].push(letter);
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
return Object.entries(groups)
|
|
.filter(([_, letters]) => letters.length > 0)
|
|
.reverse();
|
|
});
|
|
|
|
let selectedLetters = $state<string[]>([]);
|
|
let moveFolderName = $state('');
|
|
|
|
function clickedLetter(letter: Letter) {
|
|
if (selectedLetters.includes(letter.id))
|
|
selectedLetters = selectedLetters.filter(id => id !== letter.id);
|
|
else
|
|
selectedLetters = [...selectedLetters, letter.id];
|
|
}
|
|
|
|
async function doMove() {
|
|
if (!moveFolderName) return alert('Please select a folder');
|
|
if (selectedLetters.length === 0) return alert('Please select at least one letter');
|
|
const events = [];
|
|
for (let letter of selectedLetters)
|
|
events.push(await moveMessageToFolder(
|
|
letter,
|
|
moveFolderName
|
|
));
|
|
for (let event of events)
|
|
await event.publish();
|
|
selectedLetters = [];
|
|
moveFolderName = '';
|
|
moveFolderDialogOpen = false;
|
|
window.location.reload();
|
|
}
|
|
</script>
|
|
|
|
{#snippet letterGroup(letters)}
|
|
<div class="letter-group">
|
|
{#each letters as letter}
|
|
{#key letter.id}
|
|
<a in:slide out:slide class="letter-item" href="/letters/{letter.id}">
|
|
<div class="subject-line">
|
|
<div class="letter-subject">
|
|
{#if !letter.emailAddress}
|
|
<img src="/nostr-lock.svg" width="32" />
|
|
{/if}
|
|
{letter.subject}
|
|
</div>
|
|
{#if letter.stamps}
|
|
<div class="stamps-count">
|
|
<Icon icon="fxemoji:stampedenvelope" />
|
|
{letter.stamps} sats
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="letter-from">
|
|
{#if folder.id === 'sent'}
|
|
{#each letter.recipients as recipient}
|
|
<NostrIdentifier user={recipient.npub} />
|
|
{/each}
|
|
{:else}
|
|
<NostrIdentifier user={letter.from.npub} emailAddress={letter.emailAddress} />
|
|
{/if}
|
|
</div>
|
|
<div class="letter-meta">
|
|
<Icon icon="ph:calendar-duotone" />
|
|
{getReadableDate(letter.date)}
|
|
|
|
<Icon icon="ph:clock-duotone" />
|
|
{getReadableTime(letter.date)}
|
|
</div>
|
|
<div class="letter-preview">{letter.preview}</div>
|
|
</a>
|
|
{/key}
|
|
{/each}
|
|
</div>
|
|
{/snippet}
|
|
|
|
<Dialog
|
|
bind:open={moveFolderDialogOpen}
|
|
>
|
|
<div class="section-title">
|
|
<h3>Move Letters to Folder</h3>
|
|
</div>
|
|
<p class="text-secondary">Select a folder to move the selected letters to:</p>
|
|
|
|
<Select bind:value={moveFolderName} label="Select folder" options={
|
|
foldersList.filter(cf => cf.id !== folder.id && cf.id != 'sent').map(folder => ({ value: folder.id, label: folder.name }))
|
|
} />
|
|
|
|
<h4>Letters ({selectedLetters.length} selected)</h4>
|
|
|
|
<div class="letter-list">
|
|
<div class="letter-group">
|
|
{#each letters as letter}
|
|
{#key letter.id}
|
|
<div class="letter-item"
|
|
onclick={() => clickedLetter(letter)}
|
|
class:selected={selectedLetters.includes(letter.id)}
|
|
>
|
|
<div class="subject-line">
|
|
<div class="letter-subject">
|
|
<Icon icon="mdi:letter-outline" /> {letter.subject}
|
|
</div>
|
|
{#if letter.stamps}
|
|
<div class="stamps-count">
|
|
<Icon icon="fxemoji:stampedenvelope" />
|
|
{letter.stamps} sats
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="letter-from">
|
|
<NostrIdentifier user={letter.from.npub} emailAddress={letter.emailAddress} />
|
|
</div>
|
|
</div>
|
|
{/key}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="button-group">
|
|
<button onclick={() => moveFolderDialogOpen = false} type="button">Cancel</button>
|
|
<button onclick={doMove} type="submit">OK</button>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<main class="letter-list">
|
|
{#if $groupByStamps}
|
|
{#each groupedInbox as [range, letters]}
|
|
<div class="stamp-group">
|
|
{#if range == 0}
|
|
<h3>No stamps</h3>
|
|
{:else}
|
|
<h3>{range} sats stamp</h3>
|
|
{/if}
|
|
<div class="letter-group">
|
|
{@render letterGroup(letters)}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{:else}
|
|
{@render letterGroup(sortedInbox)}
|
|
{/if}
|
|
</main>
|
|
|
|
<style>
|
|
@layer layout {
|
|
.letter-list {
|
|
display: grid;
|
|
gap: var(--spacing-sm);
|
|
overflow: hidden;
|
|
|
|
.stamp-group {
|
|
display: grid;
|
|
gap: var(--spacing-md);
|
|
}
|
|
|
|
.letter-group {
|
|
display: grid;
|
|
gap: var(--spacing-sm);
|
|
}
|
|
|
|
.letter-meta {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
margin-bottom: var(--spacing-xs);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-xs);
|
|
}
|
|
|
|
& .letter-item {
|
|
text-decoration: none;
|
|
color: var(--text-primary);
|
|
position: relative;
|
|
overflow: hidden;
|
|
padding: var(--spacing-md);
|
|
border-radius: 12px;
|
|
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
|
|
border: 1px solid var(--glass-border);
|
|
transition: all 0.3s ease;
|
|
cursor: pointer;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
background: var(--gradient-shine);
|
|
background-size: 200% 200%;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
&:hover, &.selected {
|
|
transform: translateY(-2px) scale(1.01);
|
|
background: color-mix(in srgb, var(--glass-bg) 90%, var(--accent));
|
|
box-shadow: 0 4px 16px var(--glass-shadow);
|
|
|
|
&::before {
|
|
opacity: 1;
|
|
animation: shine 2s linear infinite;
|
|
}
|
|
}
|
|
|
|
& .subject-line {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: var(--spacing-xs);
|
|
}
|
|
|
|
& .letter-subject, .letter-from {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
flex-direction: row;
|
|
align-content: center;
|
|
align-items: center;
|
|
font-weight: 600;
|
|
}
|
|
|
|
& .stamps-count {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-xs);
|
|
}
|
|
|
|
& .letter-preview {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|