First beta

This commit is contained in:
Danny Morabito 2024-11-27 20:15:51 +01:00
commit a983fe669b
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
41 changed files with 3739 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
node_modules
# Output
.output
.vercel
/.svelte-kit
/build
build.sh
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# idea
.idea

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

17
.prettierrc Normal file
View file

@ -0,0 +1,17 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

2
README.md Normal file
View file

@ -0,0 +1,2 @@
# npub.email

BIN
bun.lockb Executable file

Binary file not shown.

33
package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "mail",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check ."
},
"devDependencies": {
"@iconify/svelte": "^4.0.2",
"@sveltejs/adapter-node": "^5.2.9",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/kit": "^2.8.4",
"@sveltejs/vite-plugin-svelte": "^4.0.2",
"prettier": "^3.4.1",
"prettier-plugin-svelte": "^3.3.2",
"svelte": "^5.2.9",
"svelte-check": "^4.1.0",
"typescript": "^5.7.2",
"vite": "^5.4.11"
},
"dependencies": {
"@arx/utils": "git+ssh://git@git.arx-ccn.com:222/Arx/ts-utils#v0.0.4",
"@nostr-dev-kit/ndk": "^2.10.7",
"@nostr-dev-kit/ndk-cache-dexie": "^2.5.8",
"@nostr-dev-kit/ndk-svelte": "^2.3.2"
}
}

13
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

15
src/app.html Normal file
View file

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link href="%sveltekit.assets%/favicon.png" rel="icon" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<link href="%sveltekit.assets%/base.css" rel="stylesheet" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
%sveltekit.body%
</div>
</body>
</html>

View file

@ -0,0 +1,203 @@
<script lang="ts">
let { checked = $bindable<boolean>(false), label = 'Toggle me' } = $props();
let isHovering = $state(false);
</script>
<label
class="checkbox-container"
class:is-hovering={isHovering}
onpointerenter={() => isHovering = true}
onpointerleave={() => isHovering = false}
>
<input
bind:checked
type="checkbox"
/>
<div class="checkbox-wrapper">
<div class="checkbox">
<div class="checkbox-glass"></div>
<div class="checkbox-fill"></div>
<div class="glow-effect"></div>
<div class="checkbox-border"></div>
<div class="checkbox-icon">
<svg fill="none" viewBox="0 0 24 24">
<path
class="check-path"
d="M4 12.6111L8.92308 17.5L20 6.5"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
/>
</svg>
</div>
</div>
</div>
<span class="label">{label}</span>
</label>
<style>
.checkbox-container {
--checkbox-size: max(32px, 2rem);
--highlight-size: 100%;
--highlight-offset: 20%;
position: relative;
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: calc(var(--spacing-sm) * 0.75);
cursor: pointer;
isolation: isolate;
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.checkbox-wrapper {
position: relative;
inline-size: var(--checkbox-size);
block-size: var(--checkbox-size);
}
.checkbox {
position: absolute;
inset: 0;
border-radius: 28%;
perspective: 500px;
transform-style: preserve-3d;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
}
.checkbox-glass {
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-strength));
transform: translateZ(0);
filter: var(--noise-filter);
}
.checkbox-border {
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid var(--glass-border);
background-image: var(--gradient-shine);
background-size: 200% 200%;
background-position: 100% 100%;
transition: all 0.4s ease;
}
.checkbox-fill {
position: absolute;
inset: 0;
border-radius: inherit;
background: color-mix(in oklch, var(--accent) 90%, transparent);
opacity: 0;
scale: 0.8;
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.glow-effect {
position: absolute;
inset: -20%;
background: radial-gradient(
circle at 50% 50%,
var(--glass-glow) 0%,
transparent 60%
);
opacity: 0;
transition: opacity 0.3s;
}
.checkbox-icon {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--text-primary);
transform: translateZ(2px);
}
.checkbox-icon svg {
inline-size: 60%;
block-size: 60%;
stroke-dasharray: 100;
stroke-dashoffset: 100;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.label {
color: var(--text-primary);
font-size: 1rem;
font-weight: 500;
transform: translateZ(0);
}
/* States */
.is-hovering .checkbox {
transform: translateZ(10px);
}
.is-hovering .glow-effect {
opacity: 0.5;
}
input:checked ~ .checkbox-wrapper .checkbox-fill {
opacity: 1;
scale: 1;
}
input:checked ~ .checkbox-wrapper .check-path {
animation: check-draw 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
input:checked ~ .checkbox-wrapper .checkbox-border {
border-color: var(--accent-light);
background-position: 0% 0%;
}
/* Hover */
@media (hover: hover) {
.checkbox-container:hover .checkbox-border {
border-color: var(--accent-light);
box-shadow: 0 0 calc(var(--checkbox-size) * 0.5) var(--glass-glow),
var(--depth-shadow);
}
}
/* Focus */
.checkbox-container:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--input-focus);
border-radius: 8px;
}
/* Active */
.checkbox-container:active .checkbox {
scale: 0.95;
}
@keyframes check-draw {
to {
stroke-dashoffset: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.checkbox-container *,
.checkbox-container {
animation: none !important;
transition: none !important;
}
}
</style>

View file

@ -0,0 +1,131 @@
<script lang="ts">
let { value = $bindable<number>() } = $props();
let min: number = -360;
let max: number = 360;
const percentage = $derived(((value - min) / (max - min)) * 100);
const backgroundColor = $derived(`oklch(75% 0.25 ${value})`);
const gradient = $derived(`linear-gradient(to right,
oklch(100% 0.25 ${value}) ${percentage}%,
rgba(255, 255, 255, 0.05) ${percentage}%
)`);
</script>
<div class="controls">
<input
bind:value
class="slider"
{max}
{min}
style:--track-gradient={gradient}
type="range"
/>
<div
class="color-square"
style:background-color={backgroundColor}
>
<div class="square-shine"></div>
<div class="glow"></div>
</div>
</div>
<style>
.controls {
display: flex;
align-items: center;
gap: 2rem;
}
.slider {
-webkit-appearance: none;
width: 100%;
height: 8px;
background: var(--track-gradient);
border-radius: 4px;
outline: none;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2),
0 1px 2px rgba(255, 255, 255, 0.08);
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 28px;
height: 28px;
background: linear-gradient(145deg, #ffffff, #f5f5f5);
border-radius: 50%;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15),
0 1px 3px rgba(0, 0, 0, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.05);
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25),
0 2px 6px rgba(0, 0, 0, 0.3);
}
.slider::-webkit-slider-thumb:active {
transform: scale(1.1);
}
.color-square {
position: relative;
width: 56px;
height: 56px;
border-radius: 16px;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15),
inset 0 2px 2px rgba(255, 255, 255, 0.4),
inset 0 -2px 2px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.square-shine {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 255, 255, 0.5) 40%,
rgba(255, 255, 255, 0.3) 50%,
transparent 60%
);
transform: rotate(45deg);
animation: shine 4s infinite cubic-bezier(0.4, 0, 0.2, 1);
}
.glow {
position: absolute;
inset: 0;
background: radial-gradient(
circle at 50% 50%,
rgba(255, 255, 255, 0.2),
transparent 70%
);
animation: pulse 3s infinite alternate;
}
@keyframes shine {
0% {
transform: translateX(-150%) rotate(45deg);
}
100% {
transform: translateX(150%) rotate(45deg);
}
}
@keyframes pulse {
0% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,86 @@
<script>
import { dateFormat, timeFormat } from '$lib/stores.svelte';
import { getReadableDate, getReadableTime } from '$lib/utils.svelte';
let previewDate = $state(getReadableDate(new Date()));
let previewTime = $state(getReadableTime(new Date()));
dateFormat.subscribe((v) => {
if (v === '') {
$dateFormat = 'y-m-d';
return;
}
previewDate = getReadableDate(new Date());
});
timeFormat.subscribe((v) => {
if (v === '') {
$timeFormat = 'h:m:s';
return;
}
previewTime = getReadableTime(new Date());
});
</script>
<div class="format-container">
<div class="format-group">
<label>Date Format</label>
Available format letters:
<ul>
<li><b>y</b>: year (2024)</li>
<li><b>m</b>: month (01-12)</li>
<li><b>d</b>: day (01-31)</li>
</ul>
<input
bind:value={$dateFormat}
placeholder="y-m-d"
type="text"
/>
<div class="preview">Preview: {previewDate}</div>
</div>
<div class="format-group">
<label>Time Format</label>
Available format letters:
<ul>
<li><b>h</b>: hour (00-23 or 01-12 if 'a' is used)</li>
<li><b>m</b>: minute (00-59)</li>
<li><b>s</b>: second (00-59)</li>
<li><b>a</b>: adds AM/PM and switches to 12h format</li>
</ul>
<input
bind:value={$timeFormat}
placeholder="h:m:s"
type="text"
/>
<div class="preview">Preview: {previewTime}</div>
</div>
</div>
<style>
li {
list-style: none;
}
.format-container {
display: flex;
gap: 2rem;
padding: 1rem;
}
.format-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
label {
font-weight: bold;
}
.preview {
font-size: 0.9rem;
font-style: italic;
}
</style>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { appendToBody } from '$lib/utils.svelte';
let {
open = $bindable<boolean>(false),
children,
onClose = $bindable(() => console.log('Closed'))
} = $props();
let dialog: HTMLDialogElement;
$effect(() => open ? dialog.showModal() : dialog.close());
</script>
<dialog
bind:this={dialog}
class="glass"
onclose={() => onClose()}
use:appendToBody
>
<div class="dialog-content">
{@render children?.()}
</div>
</dialog>
<style>
dialog {
position: fixed;
inset: 50%;
transform: translate(-50%, -50%);
width: 100%;
max-width: min(700px, 90vw);
height: 90vh;
background: var(--glass-bg);
border: none;
padding: 0;
overflow: hidden;
&::before {
content: '';
position: fixed;
inset: 0;
background-image: var(--noise-filter);
opacity: 0.15;
z-index: -1;
}
}
dialog::backdrop {
display: block;
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-strength));
}
.dialog-content {
overflow-y: auto;
height: 100%;
width: 100%;
padding: var(--spacing-md);
> :global(*:not(:last-child)) {
margin-bottom: var(--spacing-sm);
}
}
</style>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { activeUser, ndk } from '$lib/stores.svelte';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { NDKNip07Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { decode as decodeKey } from 'nostr-tools/nip19';
import { decrypt as decryptNsec, encrypt as encryptNsec } from 'nostr-tools/nip49';
import { goto } from '$app/navigation';
let ncryptsec = $state(browser ? localStorage.getItem('ncryptsec') || '' : '');
let nsec = $state('');
let nsecField = $state('');
let password = $state('');
let isLoggingIn = $state(false);
async function login() {
localStorage.setItem('useNip07', 'true');
$ndk.signer = new NDKNip07Signer();
await $ndk.connect(6000);
await $ndk.signer!.user();
activeUser.set($ndk.activeUser);
}
async function loginWithExistingNcryptSec() {
let decrypted;
try {
decrypted = decryptNsec(ncryptsec, password);
if (!decrypted) return alert('Invalid password');
} catch (e) {
return alert('Invalid password');
}
$ndk.signer = new NDKPrivateKeySigner(decrypted);
await $ndk.connect(6000);
activeUser.set(await $ndk.signer!.user());
activeUser.set($ndk.activeUser);
goto('/');
}
function loginWithNsec() {
if (password.length < 8) return alert('Password must be at least 8 characters long');
let nsecBytes = decodeKey(nsec);
if (nsecBytes.type !== 'nsec') return alert('Invalid nsec');
let encrypted = encryptNsec(nsecBytes.data, password);
localStorage.setItem('ncryptsec', encrypted);
$ndk.signer = new NDKPrivateKeySigner(nsec);
$ndk.connect(6000);
$ndk.signer!.user();
activeUser.set($ndk.activeUser);
goto('/');
}
function cancelLoginWithNsec() {
if (!ncryptsec)
nsec = '';
nsecField = '';
isLoggingIn = false;
}
function nsecOk() {
try {
let decoded = decodeKey(nsecField);
if (decoded.type !== 'nsec')
return alert('Invalid nsec');
nsec = nsecField;
} catch (e) {
return alert('Invalid nsec');
}
}
onMount(() => {
if ($ndk.activeUser)
activeUser.set($ndk.activeUser);
});
</script>
{#if isLoggingIn}
{#if ncryptsec || nsec}
Enter your password: <br />
If this is a new account make sure to remember the password in order to login later, if this is an existing account,
put in the same password you used to create the account. <br />
<input bind:value={password} type="password" placeholder="Password" />
<button onclick={cancelLoginWithNsec}>Cancel</button>
{#if ncryptsec}
<button onclick={loginWithExistingNcryptSec}>Login</button>
{:else}
<button onclick={loginWithNsec}>Login</button>
{/if}
{:else}
If you already have a nostr account, please enter your nsec below. If you don't have an nsec please use a nostr
client to create one, such as <a href="https://primal.net">Primal</a>.<br />
<input bind:value={nsecField} type="password" placeholder="nsec1..." /><br />
<button onclick={nsecOk}>Ok</button>
{/if}
{:else}
<button onclick={login}>Login with Nostr Extension</button>
<button onclick={() => isLoggingIn = true}>Login with Nsec</button>
{/if}
<style>
button {
display: block;
align-self: center;
}
</style>

View file

@ -0,0 +1,320 @@
<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';
let {
letters,
foldersList = $bindable<{ id: string; name: string; icon: string }[]>(),
folder = $bindable<{ id: string; name: string; icon: string }>()
} = $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 moveFolderDialogOpen = $state(false);
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}
<a class="letter-item" href="/letters/{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='icon-park-twotone:stamp' />
{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="mdi:calendar" />
{getReadableDate(letter.date)}
&nbsp;
<Icon icon="mdi:clock-outline" />
{getReadableTime(letter.date)}
</div>
<div class="letter-preview">{letter.preview}</div>
</a>
{/each}
</div>
{/snippet}
<Dialog
bind:open={moveFolderDialogOpen}
onClose={() => moveFolderDialogOpen = false}
>
<h3>Move Letters to Folder</h3>
<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}
<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='icon-park-twotone:stamp' />
{letter.stamps} sats
</div>
{/if}
</div>
<div class="letter-from">
<NostrIdentifier user={letter.from.npub} emailAddress={letter.emailAddress} />
</div>
</div>
{/each}
</div>
</div>
<div class="button-group">
<button onclick={() => moveFolderDialogOpen = false} type="button">Cancel</button>
<button onclick={doMove} type="submit">OK</button>
</div>
</Dialog>
<h2 id="page-title">
{folder.name}
</h2>
<div class="actions">
<button onclick={() => moveFolderDialogOpen = true}>
<Icon icon="mdi:folder-move-outline" />
</button>
</div>
<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;
}
}
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
}
</style>

View file

@ -0,0 +1,199 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { ndk } from '$lib/stores.svelte';
import { isValidNip05 } from '$lib/utils.svelte';
import { onMount } from 'svelte';
import type { NDKUser } from '@nostr-dev-kit/ndk';
import Tooltip from './Tooltip.svelte';
type Props = {
user: string;
extraButtons?: () => void;
emailAddress?: string;
};
let { user, extraButtons, emailAddress }: Props = $props();
const CACHE_DURATION = 5 * 60 * 1000;
let npub = $state('');
let ndkUser = $state<NDKUser>();
let displayString = $state(emailAddress || user);
let chipType = $state(emailAddress ? 'email' : isValidNpub(user) ? 'npub' : 'nip05');
let avatarUrl = $state<string | undefined>();
let isLoading = $state(true);
function isValidNpub(npub: string): boolean {
return npub.startsWith('npub');
}
function getCachedAvatar(identifier: string): string | null {
try {
const cached = localStorage.getItem(`avatar_${identifier}`);
if (!cached) return null;
const { url, timestamp } = JSON.parse(cached);
return Date.now() - timestamp > CACHE_DURATION ? null : url;
} catch {
return null;
}
}
function setCachedAvatar(identifier: string, url: string): void {
try {
localStorage.setItem(
`avatar_${identifier}`,
JSON.stringify({ url, timestamp: Date.now() })
);
} catch (error) {
console.error('Cache write error:', error);
}
}
onMount(async () => {
if (!user) {
isLoading = false;
return;
}
try {
ndkUser = isValidNpub(user)
? $ndk.getUser({ npub: user })
: await $ndk.getUserFromNip05(user);
if (!ndkUser) {
isLoading = false;
return;
}
npub = ndkUser.npub;
await ndkUser.fetchProfile();
if (ndkUser.profile?.nip05) {
const fetchFromNip05 = await $ndk.getUserFromNip05(ndkUser.profile.nip05);
if (fetchFromNip05?.pubkey === ndkUser.pubkey) {
displayString = ndkUser.profile.nip05;
chipType = 'nip05';
}
}
const cachedUrl = getCachedAvatar(user);
if (cachedUrl) {
avatarUrl = cachedUrl;
} else {
let avatarUser;
if (isValidNip05(user)) {
avatarUser = await $ndk.getUserFromNip05(user);
} else if (isValidNpub(user)) {
avatarUser = $ndk.getUser({ npub: user });
}
if (avatarUser) {
await avatarUser.fetchProfile();
avatarUrl = avatarUser.profile?.image;
if (avatarUrl) setCachedAvatar(user, avatarUrl);
}
}
} catch (error) {
console.error('Error loading user data:', error);
} finally {
isLoading = false;
}
});
</script>
<div class="user-chip" data-type={chipType}>
<div class="avatar">
{#if isLoading}
<Icon icon="eos-icons:loading" />
{:else if avatarUrl}
<img alt="User avatar" src={avatarUrl} loading="lazy" />
{:else}
<Icon icon="ph:user" />
{/if}
</div>
{#if npub !== displayString}
<Tooltip position="bottom" content={npub}>
<span class="user-text">{displayString}</span>
</Tooltip>
{:else}
<span class="user-text">{displayString}</span>
{/if}
{@render extraButtons?.()}
</div>
<style>
.user-chip {
--avatar-size: 32px;
--chip-padding-x: 12px;
--chip-padding-y: 8px;
--border-radius: 20px;
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--chip-padding-y) var(--chip-padding-x);
border-radius: var(--border-radius);
font-size: 0.95rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
box-shadow: var(--depth-shadow);
backdrop-filter: blur(var(--blur-strength));
border: 1px solid var(--glass-border);
&:hover {
transform: translateY(-1px);
box-shadow: var(--depth-shadow);
background: var(--glass-bg);
}
&[data-type="nip05"] {
background: var(--chip-success);
& .user-text::before {
content: 'nip05:';
color: var(--nip05-color);
font-weight: 600;
margin-inline-end: var(--spacing-xs);
}
}
&[data-type="npub"] {
background: var(--chip-warning);
}
&[data-type="email"] {
background: var(--chip-error);
& .user-text::before {
content: 'email:';
color: var(--email-color);
font-weight: 600;
margin-inline-end: var(--spacing-xs);
}
}
& .avatar {
inline-size: var(--avatar-size);
block-size: var(--avatar-size);
border-radius: 50%;
background: var(--glass-bg);
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid var(--glass-border);
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 0 12px var(--glass-glow);
}
& img {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
border-radius: 50%;
}
}
}
</style>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { onMount } from 'svelte';
import { appendToBody } from '$lib/utils.svelte';
let { title, children, duration }: {
title: string;
children?: () => any;
duration?: number
} = $props();
let showing = $state(false);
let notification: HTMLDivElement;
function hide() {
showing = false;
setTimeout(() => {
notification.remove();
});
}
onMount(() => {
showing = true;
if (duration)
setTimeout(hide, duration);
});
</script>
<div bind:this={notification} class="notification" class:show={showing} use:appendToBody>
<div class="notification-content">
<div class="notification-title">{title}</div>
<div class="notification-message">{@render children?.()}</div>
</div>
<button class="close-button" onclick={hide}>
<Icon icon="mdi:close" />
</button>
</div>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import NostrIdentifier from './NostrIdentifier.svelte';
import Icon from '@iconify/svelte';
let { recipient, onRemove } = $props();
</script>
<NostrIdentifier user={recipient}>
{#snippet extraButtons()}
<span class="remove" on:click={onRemove}>
<Icon icon="ph:x" />
</span>
{/snippet}
</NostrIdentifier>
<style>
.remove {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s ease;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.remove:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.1);
}
</style>

View file

@ -0,0 +1,203 @@
<script lang="ts">
let {
value = $bindable<string>(''),
label = 'Select option',
options = [],
placeholder = 'Choose an option...'
} = $props();
let isOpen = $state(false);
let isHovering = $state(false);
</script>
<div
class="select-container"
class:is-hovering={isHovering}
class:is-open={isOpen}
onpointerenter={() => isHovering = true}
onpointerleave={() => isHovering = false}
>
<label class="label">{label}</label>
<div class="select-wrapper">
<select
bind:value
onblur={() => isOpen = false}
onfocus={() => isOpen = true}
>
<option disabled hidden selected value="">{placeholder}</option>
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<div class="select-glass">
<div class="select-fill" />
<div class="glow-effect" />
<div class="select-border" />
<div class="select-value">
{value ? options.find(opt => opt.value === value)?.label : placeholder}
</div>
<div class="select-icon">
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M6 9L12 15L18 9"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
</div>
</div>
</div>
<style>
.select-container {
--select-height: max(48px, 3rem);
--highlight-size: 100%;
--highlight-offset: 20%;
position: relative;
display: flex;
flex-direction: column;
gap: calc(var(--spacing-sm) * 0.5);
width: 100%;
max-width: 320px;
}
.label {
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
padding-left: calc(var(--spacing-sm) * 0.75);
}
.select-wrapper {
position: relative;
height: var(--select-height);
perspective: 1000px;
}
select {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
font-size: 1rem;
z-index: 1;
}
.select-glass {
position: absolute;
inset: 0;
border-radius: 12px;
transform-style: preserve-3d;
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
}
.select-fill {
position: absolute;
inset: 0;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-strength));
transform: translateZ(0);
filter: var(--noise-filter);
}
.glow-effect {
position: absolute;
inset: -20%;
background: radial-gradient(
circle at 50% 50%,
var(--glass-glow) 0%,
transparent 60%
);
opacity: 0;
transition: opacity 0.3s;
}
.select-border {
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid var(--glass-border);
background-image: var(--gradient-shine);
background-size: 200% 200%;
background-position: 100% 100%;
transition: all 0.4s ease;
}
.select-value {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding: 0 var(--spacing-md);
color: var(--text-primary);
font-size: 1rem;
pointer-events: none;
transform: translateZ(1px);
}
.select-icon {
position: absolute;
right: var(--spacing-sm);
top: 50%;
transform: translateY(-50%) translateZ(1px);
color: var(--text-secondary);
width: 20px;
height: 20px;
transition: transform 0.3s ease;
}
/* States */
.is-hovering .select-glass {
transform: translateZ(10px) rotateX(var(--rot-x)) rotateY(var(--rot-y));
}
.is-hovering .glow-effect {
opacity: 0.5;
}
.is-open .select-icon {
transform: translateY(-50%) translateZ(1px) rotate(180deg);
}
.is-open .select-border {
border-color: var(--accent-light);
background-position: 0 0;
box-shadow: 0 0 calc(var(--select-height) * 0.25) var(--glass-glow),
var(--depth-shadow);
}
/* Hover */
@media (hover: hover) {
.select-container:hover .select-border {
border-color: var(--accent-light);
box-shadow: 0 0 calc(var(--select-height) * 0.25) var(--glass-glow),
var(--depth-shadow);
}
}
/* Focus */
select:focus + .select-glass .select-border {
border-color: var(--accent-light);
box-shadow: 0 0 calc(var(--select-height) * 0.25) var(--glass-glow),
var(--depth-shadow);
}
@media (prefers-reduced-motion: reduce) {
.select-container *,
.select-container {
animation: none !important;
transition: none !important;
}
}
</style>

View file

@ -0,0 +1,42 @@
<script>
let { children, title } = $props();
</script>
<div class="glass-panel">
<div class="content">
<h3>{title}</h3>
{@render children()}
</div>
</div>
<style>
.glass-panel {
padding: 2rem 2.5rem;
width: 100%;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
border-radius: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 24px -1px rgba(0, 0, 0, 0.2),
0 12px 48px -6px rgba(0, 0, 0, 0.15),
inset 0 1px 1px rgba(255, 255, 255, 0.12),
inset 0 -1px 1px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
margin-bottom: 2rem;
}
.glass-panel:hover {
transform: translateY(-2px);
box-shadow: 0 8px 32px -2px rgba(0, 0, 0, 0.25),
0 16px 64px -8px rgba(0, 0, 0, 0.2),
inset 0 1px 1px rgba(255, 255, 255, 0.15);
}
.content {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View file

@ -0,0 +1,80 @@
<script lang="ts">
let { time = $bindable<Date>(), isStopped = false } = $props();
let diff = $state(time.getTime() - Date.now());
let days = $derived(Math.floor(diff / (1000 * 60 * 60 * 24)));
let hours = $derived(Math.floor((diff / (1000 * 60 * 60)) % 24));
let minutes = $derived(Math.floor((diff / 1000 / 60) % 60));
let seconds = $derived(Math.floor((diff / 1000) % 60));
$effect(() => {
if (isStopped) return;
const interval = setInterval(() => {
diff = time.getTime() - Date.now();
if (diff <= 0) {
time = new Date(0); // quick way to just update the ui
clearInterval(interval);
}
}, 1000);
return () => clearInterval(interval);
});
</script>
<div class="time-countdown">
{#if days > 0}
<div class="time-countdown-item">
<div class="time-countdown-item-value">{days}</div>
<div class="time-countdown-item-label">days</div>
</div>
{/if}
{#if days > 0 || hours > 0}
<div class="time-countdown-item">
<div class="time-countdown-item-value">{hours}</div>
<div class="time-countdown-item-label">hours</div>
</div>
{/if}
{#if days > 0 || hours > 0 || minutes > 0}
<div class="time-countdown-item">
<div class="time-countdown-item-value">{minutes}</div>
<div class="time-countdown-item-label">minutes</div>
</div>
{/if}
<div class="time-countdown-item">
<div class="time-countdown-item-value">{seconds}</div>
<div class="time-countdown-item-label">seconds</div>
</div>
</div>
<style>
.time-countdown {
display: flex;
gap: 0.5rem;
}
.time-countdown-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-strength));
border-radius: 50%;
padding: 1.5rem;
aspect-ratio: 1;
width: 100px;
height: 100px;
}
.time-countdown-item-value {
font-size: 2rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
}
.time-countdown-item-label {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.2rem;
}
</style>

View file

@ -0,0 +1,177 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
export let content: string;
export let position: 'top' | 'bottom' | 'left' | 'right' = 'top';
export let offset = 8;
export let show = false;
let triggerElement: HTMLElement;
let tooltipElement: HTMLElement;
let tooltipContainer: HTMLElement;
let portal: HTMLElement;
function calculatePosition() {
if (!tooltipElement || !triggerElement) return;
const triggerRect = triggerElement.getBoundingClientRect();
const tooltipRect = tooltipElement.getBoundingClientRect();
let left = 0;
let top = 0;
switch (position) {
case 'top':
left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2);
top = triggerRect.top - tooltipRect.height - offset;
break;
case 'bottom':
left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2);
top = triggerRect.bottom + offset;
break;
case 'left':
left = triggerRect.left - tooltipRect.width - offset;
top = triggerRect.top + (triggerRect.height / 2) - (tooltipRect.height / 2);
break;
case 'right':
left = triggerRect.right + offset;
top = triggerRect.top + (triggerRect.height / 2) - (tooltipRect.height / 2);
break;
}
// Prevent tooltip from going off screen
left = Math.max(offset, Math.min(left, window.innerWidth - tooltipRect.width - offset));
top = Math.max(offset, Math.min(top, window.innerHeight - tooltipRect.height - offset));
tooltipElement.style.left = `${left}px`;
tooltipElement.style.top = `${top}px`;
}
function handleMouseEnter() {
show = true;
setTimeout(calculatePosition, 0);
}
function handleMouseLeave() {
show = false;
}
onMount(() => {
tooltipContainer = document.createElement('div');
tooltipContainer.setAttribute('class', 'tooltip-container');
document.body.appendChild(tooltipContainer);
tooltipContainer.appendChild(portal);
window.addEventListener('scroll', calculatePosition, true);
window.addEventListener('resize', calculatePosition);
});
onDestroy(() => {
if (tooltipContainer && tooltipContainer.parentNode) {
tooltipContainer.parentNode.removeChild(tooltipContainer);
}
window.removeEventListener('scroll', calculatePosition, true);
window.removeEventListener('resize', calculatePosition);
});
</script>
<div
bind:this={triggerElement}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
on:mousemove={calculatePosition}
>
<slot />
</div>
<div bind:this={portal}>
{#if show}
<div
class="tooltip"
class:show
class:top={position === 'top'}
class:bottom={position === 'bottom'}
class:left={position === 'left'}
class:right={position === 'right'}
bind:this={tooltipElement}
role="tooltip"
>
{content}
<div class="arrow" />
</div>
{/if}
</div>
<style>
.tooltip-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 99999;
}
.tooltip {
position: fixed;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--glass-bg);
color: var(--text-primary);
border: 1px solid var(--glass-border);
border-radius: 8px;
font-size: 0.85rem;
white-space: nowrap;
backdrop-filter: blur(var(--blur-strength));
box-shadow: var(--depth-shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.tooltip.show {
opacity: 1;
pointer-events: auto;
}
.arrow {
position: absolute;
width: 8px;
height: 8px;
background: var(--glass-bg);
transform: rotate(45deg);
border: 1px solid var(--glass-border);
}
.tooltip.top .arrow {
bottom: -5px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
border-top: none;
border-left: none;
}
.tooltip.bottom .arrow {
top: -5px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
border-bottom: none;
border-right: none;
}
.tooltip.left .arrow {
right: -5px;
top: 50%;
transform: translateY(-50%) rotate(45deg);
border-left: none;
border-bottom: none;
}
.tooltip.right .arrow {
left: -5px;
top: 50%;
transform: translateY(-50%) rotate(45deg);
border-right: none;
border-top: none;
}
</style>

28
src/lib/folderLabel.ts Normal file
View file

@ -0,0 +1,28 @@
import { NDKEvent, type NDKEventId, NDKKind } from '@nostr-dev-kit/ndk';
export class FolderLabel {
id: NDKEventId;
name: string;
icon: string;
constructor() {
this.id = '';
this.name = '';
this.icon = '';
}
static async fromDecryptedMessage(
decryptedMessage: NDKEvent,
encryptedMessage: NDKEvent
): Promise<FolderLabel> {
if (decryptedMessage.kind !== NDKKind.Label) throw new Error('Not a label');
let labelType = decryptedMessage.tags.find((t) => t[0] === 'label-type')?.[1];
if (!labelType) throw new Error('No label type');
if (labelType !== 'folder') throw new Error('Not a folder');
let label = new FolderLabel();
label.id = encryptedMessage.id;
label.name = decryptedMessage.tags.find((t) => t[0] === 'name')?.[1] ?? 'No name';
label.icon = decryptedMessage.tags.find((t) => t[0] === 'icon')?.[1] ?? '';
return label;
}
}

1
src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

58
src/lib/letter.ts Normal file
View file

@ -0,0 +1,58 @@
import NDK, { NDKEvent, type NDKEventId, NDKKind, type NDKUser } from '@nostr-dev-kit/ndk';
import { isValidNip05 } from '$lib/utils.svelte';
export class Letter {
id: NDKEventId;
subject: string;
from: NDKUser;
content: string;
date: Date;
recipients: Set<NDKUser>;
stamps: number;
emailAddress?: string;
private constructor() {
this.id = '';
this.subject = '';
this.from = {} as NDKUser;
this.content = '';
this.date = new Date();
this.recipients = new Set<NDKUser>();
this.stamps = 0;
}
static async fromDecryptedMessage(
decryptedMessage: NDKEvent,
encryptedMessage: NDKEvent,
ndk: NDK
): Promise<Letter> {
if (decryptedMessage.kind !== NDKKind.Article) throw new Error('Not a letter');
const letter = new Letter();
letter.id = encryptedMessage.id;
letter.subject = decryptedMessage.tags.find((t) => t[0] === 'subject')?.[1] ?? 'No subject';
letter.from = decryptedMessage.author;
letter.content = decryptedMessage.content;
letter.date = new Date(decryptedMessage.created_at! * 1000);
letter.emailAddress = decryptedMessage.tags.find((t) => t[0] === 'email:from')?.[1];
for (const tag of decryptedMessage.tags) {
if (tag[0] === 'p') {
try {
if (isValidNip05(tag[1])) {
const parsed = await ndk.getUserFromNip05(tag[1]);
if (parsed) letter.recipients.add(parsed);
} else if (tag[1].startsWith('npub')) {
letter.recipients.add(ndk.getUser({ npub: tag[1] }));
} else {
letter.recipients.add(ndk.getUser({ pubkey: tag[1] }));
}
} catch (error) {
console.error('Error processing recipient:', error);
}
}
}
return letter;
}
}

View file

@ -0,0 +1,25 @@
import { NDKEvent, type NDKEventId, NDKKind } from '@nostr-dev-kit/ndk';
export class LetterToFolderMapping {
message: NDKEventId;
folder: NDKEventId;
constructor() {
this.message = '';
this.folder = '';
}
static async fromDecryptedMessage(
decryptedMessage: NDKEvent,
encryptedMessage: NDKEvent
): Promise<LetterToFolderMapping> {
if (decryptedMessage.kind !== NDKKind.Label) throw new Error('Not a label');
let labelType = decryptedMessage.tags.find((t) => t[0] === 'label-type')?.[1];
if (!labelType) throw new Error('No label type');
if (labelType !== 'letter-to-folder-mapping') throw new Error('Not a letter-to-folder-mapping');
let mapping = new LetterToFolderMapping();
mapping.message = decryptedMessage.tags.find((t) => t[0] === 'message')?.[1] ?? '';
mapping.folder = decryptedMessage.tags.find((t) => t[0] === 'folder')?.[1] ?? '';
return mapping;
}
}

71
src/lib/stores.svelte.ts Normal file
View file

@ -0,0 +1,71 @@
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
import { NDKNip07Signer, NDKUser } from '@nostr-dev-kit/ndk';
import NDKSvelte from '@nostr-dev-kit/ndk-svelte';
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'arxmail-cache' });
const nip07signer = new NDKNip07Signer();
export const _ndk = new NDKSvelte({
explicitRelayUrls: [
'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://offchain.pub',
'wss://relay.snort.social'
],
autoConnectUserRelays: false,
relayAuthDefaultPolicy: async (r) => true,
enableOutboxModel: false,
// signer: nip07signer,
cacheAdapter: dexieAdapter
});
if (browser && localStorage.getItem('useNip07')) _ndk.signer = nip07signer;
export const ndk = writable(_ndk);
export const activeUser = writable<NDKUser | undefined>(undefined);
if (browser && _ndk.activeUser) activeUser.set(_ndk.activeUser);
export const validSortOptions = ['stamps', 'date', 'sender', 'subject'];
export const baseHue = writable(
browser ? parseInt(<string>localStorage.getItem('baseHue')) || 200 : 200
);
export const groupByStamps = writable(
browser ? localStorage.getItem('groupByStamps') === 'true' : false
);
export const sortBy = writable(browser ? localStorage.getItem('sortBy') || 'date' : 'date');
export const dateFormat = writable(
browser ? localStorage.getItem('dateFormat') || 'y-m-d' : 'y-m-d'
);
export const timeFormat = writable(
browser ? localStorage.getItem('timeFormat') || 'h:m:s' : 'h:m:s'
);
if (browser) {
baseHue.subscribe((value) => {
document.documentElement.style.setProperty('--base-hue', value.toString());
localStorage.setItem('baseHue', value.toString());
});
groupByStamps.subscribe((value) => {
localStorage.setItem('groupByStamps', value.toString());
});
sortBy.subscribe((value) => {
if (!validSortOptions.includes(value)) value = 'date';
localStorage.setItem('sortBy', value);
});
dateFormat.subscribe((value) => {
localStorage.setItem('dateFormat', value);
});
timeFormat.subscribe((value) => {
localStorage.setItem('timeFormat', value);
});
}

244
src/lib/utils.svelte.ts Normal file
View file

@ -0,0 +1,244 @@
import NDK, {
NDKEvent,
type NDKEventId,
NDKKind,
NDKPrivateKeySigner,
type NDKUser
} from '@nostr-dev-kit/ndk';
import {
dateFormat as dateFormatStore,
ndk as ndkStore,
timeFormat as timeFormatStore
} from './stores.svelte';
import { generateSecretKey } from 'nostr-tools';
import { Letter } from '$lib/letter';
import { FolderLabel } from '$lib/folderLabel';
import { LetterToFolderMapping } from '$lib/letterToFolderMapping';
let ndk: NDK;
let dateFormat: string;
let timeFormat: string;
ndkStore.subscribe((n: NDK) => (ndk = n));
dateFormatStore.subscribe((d: string) => (dateFormat = d));
timeFormatStore.subscribe((t: string) => (timeFormat = t));
async function waitForNDK() {
if (ndk) return;
await new Promise((resolve) => setTimeout(resolve, 1000));
await waitForNDK();
}
export function randomTimeUpTo2DaysInThePast() {
const now = Date.now();
const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000 - 3600 * 1000; // 1 hour buffer in case of clock skew
return Math.floor((Math.floor(Math.random() * (now - twoDaysAgo)) + twoDaysAgo) / 1000);
}
export async function decryptSealedMessage(message: NDKEvent): Promise<NDKEvent> {
await waitForNDK();
const sealedMessage = JSON.parse(await ndk.signer!.nip44Decrypt(message.author, message.content));
const author = ndk.getUser({ pubkey: sealedMessage.pubkey });
const msg = JSON.parse(await ndk.signer!.nip44Decrypt(author, sealedMessage.content));
const event = new NDKEvent(ndk, msg);
if (event.pubkey === '') event.pubkey = author.pubkey;
return event;
}
export async function decryptSealedMessageIntoReadableType(
encryptedMessage: NDKEvent
): Promise<Letter | FolderLabel | LetterToFolderMapping | undefined> {
await waitForNDK();
let rawDecrypted = await decryptSealedMessage(encryptedMessage);
switch (rawDecrypted.kind) {
case NDKKind.Article:
return getLetterFromDecryptedMessage(rawDecrypted, encryptedMessage);
case NDKKind.Label:
let labelType = rawDecrypted.tags.find((t) => t[0] === 'label-type')?.[1];
if (labelType === 'folder')
return getLabelFromDecryptedMessage(rawDecrypted, encryptedMessage);
if (labelType === 'letter-to-folder-mapping')
return getLetterToFolderMappingFromDecryptedMessage(rawDecrypted, encryptedMessage);
}
}
async function getLabelFromDecryptedMessage(
msg: NDKEvent,
encryptedMessage: NDKEvent
): Promise<FolderLabel | undefined> {
await waitForNDK();
if (msg.kind != NDKKind.Label) return;
let labelType = msg.tags.find((t) => t[0] === 'label-type')?.[1];
if (!labelType) return;
if (labelType !== 'folder') return;
return FolderLabel.fromDecryptedMessage(msg, encryptedMessage);
}
async function getLetterToFolderMappingFromDecryptedMessage(
msg: NDKEvent,
encryptedMessage: NDKEvent
): Promise<LetterToFolderMapping | undefined> {
await waitForNDK();
if (msg.kind != NDKKind.Label) return;
let labelType = msg.tags.find((t) => t[0] === 'label-type')?.[1];
if (!labelType) return;
if (labelType !== 'letter-to-folder-mapping') return;
return LetterToFolderMapping.fromDecryptedMessage(msg, encryptedMessage);
}
export async function moveMessageToFolder(id: NDKEventId, folder: NDKEventId | string) {
if (folder === 'sent') throw new Error('Cannot move message to sent folder');
await waitForNDK();
const user = await ndk.signer!.user();
const rawMessage = new NDKEvent();
rawMessage.author = user;
rawMessage.created_at = Math.ceil(Date.now() / 1000);
rawMessage.kind = NDKKind.Label;
rawMessage.content = '';
rawMessage.tags.push(['label-type', 'letter-to-folder-mapping']);
rawMessage.tags.push(['message', id]);
rawMessage.tags.push(['folder', folder]);
return encryptEventForRecipient(rawMessage, user);
}
export async function createFolder(name: string, icon: string) {
await waitForNDK();
const user = await ndk.signer!.user();
const rawMessage = new NDKEvent();
rawMessage.author = user;
rawMessage.created_at = Math.ceil(Date.now() / 1000);
rawMessage.kind = NDKKind.Label;
rawMessage.content = '';
rawMessage.tags.push(['label-type', 'folder']);
rawMessage.tags.push(['name', name]);
rawMessage.tags.push(['icon', icon]);
return encryptEventForRecipient(rawMessage, user);
}
export async function createSealedLetter(
from: NDKUser,
to: NDKUser,
subject: string,
content: string,
replyTo?: string
) {
await waitForNDK();
const rawMessage = new NDKEvent();
rawMessage.author = from;
rawMessage.created_at = Math.ceil(Date.now() / 1000);
rawMessage.kind = NDKKind.Article;
rawMessage.content = content;
rawMessage.tags.push(['subject', subject]);
if (typeof replyTo !== 'undefined' && replyTo) rawMessage.tags.push(['e', replyTo, 'reply']);
rawMessage.tags.push(['p', to.pubkey]);
return encryptEventForRecipient(rawMessage, to);
}
export async function createCarbonCopyLetter(
sender: NDKUser,
recipients: NDKUser[],
subject: string,
content: string,
replyTo?: string
) {
await waitForNDK();
const rawMessage = new NDKEvent();
rawMessage.author = sender;
rawMessage.created_at = Math.ceil(Date.now() / 1000);
rawMessage.kind = NDKKind.Article;
rawMessage.content = content;
rawMessage.tags.push(['subject', subject]);
if (typeof replyTo !== 'undefined' && replyTo) rawMessage.tags.push(['e', replyTo, 'reply']);
for (const recipient of recipients) rawMessage.tags.push(['p', recipient.pubkey]);
return encryptEventForRecipient(rawMessage, sender);
}
export async function encryptEventForRecipient(
event: NDKEvent,
recipient: NDKUser
): Promise<NDKEvent> {
await waitForNDK();
let randomKey = generateSecretKey();
const randomKeySinger = new NDKPrivateKeySigner(randomKey);
const seal = new NDKEvent();
seal.pubkey = recipient.pubkey;
seal.kind = 13;
seal.content = await ndk.signer!.nip44Encrypt(recipient, JSON.stringify(event));
seal.created_at = randomTimeUpTo2DaysInThePast();
await seal.sign(ndk.signer);
const giftWrap = new NDKEvent();
giftWrap.kind = 1059;
giftWrap.created_at = randomTimeUpTo2DaysInThePast();
giftWrap.content = await randomKeySinger.nip44Encrypt(recipient, JSON.stringify(seal));
giftWrap.tags.push(['p', recipient.pubkey]);
await giftWrap.sign(randomKeySinger);
giftWrap.ndk = ndk;
return giftWrap;
}
export function isValidNip05(nip05: string): boolean {
let parts = nip05.split('@');
if (parts.length !== 2) return false;
let domain = parts[1];
return domain.includes('.');
}
let letterCache: {
[id: string]: Letter;
} = $state({});
export async function getLetterFromDecryptedMessage(
msg: NDKEvent,
encryptedMessage: NDKEvent
): Promise<Letter | undefined> {
if (letterCache[encryptedMessage.id]) return letterCache[encryptedMessage.id];
await waitForNDK();
if (msg.kind != NDKKind.Article) return;
letterCache[encryptedMessage.id] = await Letter.fromDecryptedMessage(msg, encryptedMessage, ndk);
return letterCache[encryptedMessage.id];
}
export function getReadableDate(date: Date): string {
const map = {
y: date.getFullYear(),
m: String(date.getMonth() + 1).padStart(2, '0'),
d: String(date.getDate()).padStart(2, '0')
};
return dateFormat.replace(/[ymd]/g, (char) => map[char]);
}
export function getReadableTime(date: Date) {
let hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const use12Hour = timeFormat.includes('a');
let period = '';
if (use12Hour) {
period = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12;
hours = hours ? hours : 12;
}
const map = {
h: String(hours).padStart(2, '0'),
m: minutes,
s: seconds,
a: period
};
return timeFormat.replace(/[hmsa]/g, (char) => map[char]);
}
export function appendToBody(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
if (node.parentNode) {
node.parentNode.removeChild(node);
}
}
};
}

43
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,43 @@
<script lang="ts">
import { activeUser, baseHue, ndk } from '$lib/stores.svelte';
import LoginWithNostr from '../components/LoginWithNostr.svelte';
import { onMount } from 'svelte';
import { NDKNip07Signer } from '@nostr-dev-kit/ndk';
onMount(async () => {
if (localStorage.getItem('useNip07')) {
$ndk.signer = new NDKNip07Signer();
await $ndk.connect(6000);
await $ndk.signer!.user();
if ($ndk.activeUser)
activeUser.set($ndk.activeUser);
}
});
</script>
<div id="app" style:--base-hue={$baseHue}>
<header id="header">
<nav class="actions">
{#if $activeUser}
<a class="button" href="/compose">Compose</a>
{/if}
</nav>
<a class="title" href="/">npub.email</a>
<nav class="actions">
{#if $activeUser}
<a class="button" href="/settings">Settings</a>
{/if}
</nav>
</header>
<div id="content">
{#if $activeUser}
<slot></slot>
{:else}
<LoginWithNostr />
{/if}
</div>
</div>

1
src/routes/+layout.ts Normal file
View file

@ -0,0 +1 @@
export const ssr = false;

189
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,189 @@
<script lang="ts">
import { ndk } from '$lib/stores.svelte';
import Icon from '@iconify/svelte';
import MailboxFolderItems from '../components/MailboxFolderItems.svelte';
import { createFolder, decryptSealedMessageIntoReadableType } from '$lib/utils.svelte';
import { Letter } from '$lib/letter';
import Dialog from '../components/Dialog.svelte';
import { FolderLabel } from '$lib/folderLabel';
import type { NDKEventId } from '@nostr-dev-kit/ndk';
import { LetterToFolderMapping } from '$lib/letterToFolderMapping';
let sidebarOpen = $state(false);
let messages = $ndk.storeSubscribe([{
kinds: [1059],
'#p': [$ndk.activeUser.pubkey]
}], {
closeOnEose: false,
groupable: false,
groupableDelay: 0
});
let letters = $state<Letter[]>([]);
let activeFolder = $state('inbox');
let folders = $state([
{ id: 'inbox', name: 'Inbox', icon: 'solar:inbox-bold-duotone' },
{ id: 'sent', name: 'Sent', icon: 'fa:send' }
]);
async function decryptMessages(messages) {
const currentMessages = messages;
const decryptedFolders = [
{ id: 'inbox', name: 'Inbox', icon: 'solar:inbox-bold-duotone' },
{ id: 'sent', name: 'Sent', icon: 'fa:send' }
];
const allLetters = [];
let allLettersInFolders: NDKEventId[] = [];
const letterLabels = new Map<string, NDKEventId[]>();
for (const message of currentMessages) {
const msg = await decryptSealedMessageIntoReadableType(message);
if ((msg instanceof Letter)) {
allLetters.push(msg);
} else if (msg instanceof FolderLabel) {
decryptedFolders.push(msg);
} else if (msg instanceof LetterToFolderMapping) {
if (!letterLabels.has(msg.folder))
letterLabels.set(msg.folder, []);
for (const folderId of letterLabels.keys()) {
// check if letter is in a folder, if so remove it and move it to the new folder
if (letterLabels.get(folderId)!.includes(msg.message)) {
letterLabels.get(folderId)!.splice(letterLabels.get(folderId)!.indexOf(msg.message), 1);
break;
}
}
allLettersInFolders.push(msg.message);
letterLabels.set(msg.folder, letterLabels.get(msg.folder)!.concat(msg.message));
}
}
letters = allLetters.filter(letter => {
if (activeFolder === 'sent')
return letter.from.pubkey === $ndk.activeUser.pubkey;
if (activeFolder === 'inbox' && letter.from.pubkey !== $ndk.activeUser.pubkey)
return !allLettersInFolders.includes(letter.id);
if (!letterLabels.has(activeFolder))
return false;
return letterLabels.get(activeFolder).includes(letter.id);
});
folders = decryptedFolders;
}
messages.subscribe(m => decryptMessages(m));
function changeFolder(newFolder) {
letters = [];
activeFolder = newFolder;
decryptMessages($messages);
}
async function addNewFolderPressed() {
const newFolder = await createFolder(newFolderName, '');
if (!newFolder) return;
await newFolder.publish();
folders = [...folders, { id: newFolder.id, name: newFolderName, icon: 'eos-icons:plus' }];
newFolderName = '';
isAddingFolder = false;
}
function newFolderDialogClosed() {
isAddingFolder = false;
newFolderName = '';
}
let isAddingFolder = $state(false);
let newFolderName = $state('');
</script>
<button class="sidebar-button" onclick={() => sidebarOpen = !sidebarOpen}>
<Icon icon="mynaui:sidebar-solid" />
</button>
<Dialog
bind:open={isAddingFolder}
onClose={newFolderDialogClosed}
>
<h3>Create Folder</h3>
<p class="text-secondary">Please enter a name for the new folder:</p>
<form onsubmit={addNewFolderPressed}>
<input
autofocus
bind:value={newFolderName}
placeholder="Folder name"
type="text"
/>
<div class="button-group">
<button onclick={newFolderDialogClosed} type="button">Cancel</button>
<button type="submit">OK</button>
</div>
</form>
</Dialog>
<div class="content">
<div class="sidebar-container" class:open={sidebarOpen}>
<div class="glass sidebar">
{#each folders as folder}
<button class="folder-item" onclick={() => changeFolder(folder.id)}>
<Icon icon={folder.icon} />
{folder.name}
</button>
{/each}
<hr />
<button class="folder-item" onclick={() => isAddingFolder = true}>
<Icon icon="eos-icons:plus" />
Add Folder
</button>
</div>
</div>
<div class="folder-view">
{#if letters.length === 0}
<Icon icon="eos-icons:loading" width="5em" />
{:else}
<MailboxFolderItems foldersList={folders} folder={folders.find(f => f.id === activeFolder)} {letters} />
{/if}
</div>
</div>
<style>
.folder-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.sidebar-button {
position: absolute;
top: 0;
right: 0;
}
.content {
display: flex;
gap: var(--spacing-sm);
}
.sidebar-container {
width: 0;
overflow: hidden;
transition: width 0.3s ease-in-out;
&.open {
width: clamp(16rem, 20vw, 20rem);
}
}
.folder-view {
flex-grow: 1;
}
.sidebar {
display: flex;
gap: var(--spacing-sm);
flex-direction: column;
height: 100%;
}
</style>

View file

@ -0,0 +1,24 @@
import { error, json } from '@sveltejs/kit';
import { PUBLIC_API_BASE_URL } from '$env/static/public';
import { npubToPubKeyString } from '@arx/utils/nostr.ts';
export async function GET({ url }: { url: URL }) {
const name = url.searchParams.get('name');
if (!name) throw error(400, 'Name parameter is required');
const aliasGetURL = `${PUBLIC_API_BASE_URL}/alias/${name}`;
const aliasResponse = await fetch(aliasGetURL);
if (aliasResponse.ok) {
const npub = await aliasResponse.text();
const hex = npubToPubKeyString(npub);
return json({
names: {
[name]: hex
}
});
}
const errorText = await aliasResponse.text();
if (errorText === '') throw error(404, 'Alias not found');
throw error(500, errorText);
}

View file

@ -0,0 +1,255 @@
<script lang="ts">
import RecipientChip from '../../components/RecipientChip.svelte';
import { onMount } from 'svelte';
import { ndk } from '$lib/stores.svelte';
import Notification from '../../components/Notification.svelte';
import { createCarbonCopyLetter, createSealedLetter } from '$lib/utils.svelte';
import type { NDKUser } from '@nostr-dev-kit/ndk';
let isSending = $state(false);
let letterSent = $state(0);
let recipients = $state([]);
let replyTo = $state('');
let toField = $state('');
let subject = $state('');
let content = $state('');
function isValidNpub(npub: string): boolean {
return npub.startsWith('npub');
}
function isPossibleNip05(nip05: string): boolean {
let parts = nip05.split('@');
if (parts.length !== 2) return false;
let domain = parts[1];
return domain.includes('.');
}
async function addRecipient(e: KeyboardEvent) {
if (e.key !== 'Enter') return;
if (!isValidNpub(toField) && !isPossibleNip05(toField)) return alert('Invalid recipient');
if (isPossibleNip05(toField)) {
const user = await $ndk.getUserFromNip05(toField);
if (!user)
return alert('Invalid recipient (nip05 does not match any npub)');
}
if (recipients.includes(toField)) toField = '';
recipients = [...recipients, toField];
toField = '';
}
async function send() {
if (!confirm('Are you sure you want to send this message?')) return;
if (recipients.length === 0) return alert('Please add at least one recipient');
if (subject.length === 0) return alert('Please add a subject');
if (content.length === 0) return alert('Please add a message');
isSending = true;
try {
const notesToSend = [];
const alreadyCheckedPublicKeys: string[] = [];
const realRecipients: NDKUser[] = [];
for (let r of recipients) {
let recipient;
if (isPossibleNip05(r))
recipient = await $ndk.getUserFromNip05(r);
else
recipient = $ndk.getUser({
npub: r
});
if (!recipient) return alert(`Invalid recipient: ${r}`);
if (alreadyCheckedPublicKeys.includes(recipient.pubkey)) continue;
alreadyCheckedPublicKeys.push(recipient.pubkey);
notesToSend.push(await createSealedLetter(
$ndk.activeUser!,
recipient,
subject,
content,
replyTo
));
realRecipients.push(recipient);
}
notesToSend.push(await createCarbonCopyLetter(
$ndk.activeUser!,
realRecipients,
subject,
content
));
await Promise.all(notesToSend.map(n => n.publish()));
letterSent = Date.now();
} finally {
isSending = false;
}
}
onMount(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('replyTo'))
replyTo = urlParams.get('replyTo');
if (urlParams.has('to'))
recipients.push(urlParams.get('to'));
if (urlParams.has('subject'))
subject = urlParams.get('subject');
if (urlParams.has('content'))
content = urlParams.get('content');
});
</script>
{#if letterSent}
<Notification title="Letter Sent" duration={5000}>
Letter sent to {recipients.join(', ')}.
</Notification>
{/if}
{#if isSending}
<div class="compose-container">
<div class="compose-header">
<div id="page-title">Sending Letter...</div>
</div>
</div>
{:else}
<div class="compose-container">
<div class="compose-header">
<div id="page-title">New Message</div>
<button class="button">Close</button>
</div>
<div class="compose-fields">
<div class="field">
<label>To:</label>
<div class="to">
{#each recipients as r}
<RecipientChip recipient={r}
onRemove={() => recipients = recipients.filter((recipient) => recipient !== r)} />
{/each}
<input
bind:value={toField}
class="input"
onkeyup={addRecipient}
placeholder="Enter npub or nip05..."
type="text"
/>
</div>
</div>
<div class="field">
<label>Subject:</label>
<input bind:value={subject} class="input" placeholder="Enter subject..." type="text">
</div>
</div>
<div class="compose-editor">
<textarea bind:value={content} class="editor-content"></textarea>
</div>
<div class="compose-actions">
<button class="send-button" onclick={send}>Send</button>
</div>
</div>
{/if}
<style>
.compose-container {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: var(--spacing-md);
}
.compose-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--glass-border);
}
.compose-fields {
display: grid;
gap: var(--spacing-sm);
& .field {
position: relative;
display: grid;
grid-template-columns: 80px 1fr;
gap: var(--spacing-sm);
& .to {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
align-items: center;
width: 100%;
}
& label {
color: var(--text-secondary);
}
& input {
width: 100%;
flex-basis: 100%;
}
}
}
.compose-editor {
position: relative;
background: var(--editor-bg);
border-radius: 12px;
border: 1px solid var(--input-border);
overflow: hidden;
transition: all 0.3s ease;
&:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--input-focus);
}
& .editor-content {
background: transparent;
resize: none;
border: 0;
width: 100%;
padding: var(--spacing-md);
min-height: 300px;
}
}
.compose-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
}
@layer components {
.input {
flex-grow: 1;
background: transparent;
border: 1px solid var(--input-border);
border-radius: 8px;
padding: 0.5rem;
color: var(--text-primary);
transition: all 0.3s ease;
&:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--input-focus);
outline: none;
}
}
.send-button {
background: var(--accent);
color: white;
padding: 0.75rem 2rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
&:hover {
background: var(--accent-light);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
}
}
</style>

View file

@ -0,0 +1,5 @@
export function load({ params }) {
return {
id: params.id
};
}

View file

@ -0,0 +1,272 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import NostrIdentifier from '../../../components/NostrIdentifier.svelte';
import { onMount } from 'svelte';
import { ndk } from '$lib/stores.svelte';
import { decryptSealedMessageIntoReadableType, getReadableDate, getReadableTime } from '$lib/utils.svelte';
import { goto } from '$app/navigation';
import { Letter } from '$lib/letter';
const { data } = $props();
const { id } = data;
let error = $state('');
let loading = $state(true);
let letter = $state<Letter>();
onMount(async () => {
let letterEvent = await $ndk.fetchEvent(id);
if (!letterEvent) {
loading = false;
error = 'Error fetching letter';
return;
}
letter = await decryptSealedMessageIntoReadableType(letterEvent);
if (!(letter instanceof Letter)) {
error = 'Message is not a letter';
loading = false;
}
loading = false;
});
$inspect(letter);
function handleReply() {
if (!letter) return;
const params = {
replyTo: letter.id,
subject: `Re: ${letter.subject}`,
to: letter.from.npub,
content: `\n\nOn ${getReadableDate(letter.date)} at ${getReadableTime(letter.date)}, ${letter.from.npub} wrote:\n${letter.content.split('\n').map(line => `> ${line}`).join('\n')}`
};
const queryString = new URLSearchParams(params).toString();
const url = `/compose?${queryString}`;
goto(url);
}
</script>
{#if loading}
<div class="loading-state">
<Icon icon="eos-icons:loading" width="5em" class="spin" />
</div>
{:else if error}
<div class="error-state">
<Icon icon="eos-icons:close" width="5em" />
<span>{error}</span>
</div>
{:else if letter}
<div class="letter-card">
<div class="header">
<div class="header-main">
<div class="header-info">
<div class="subject">
<Icon icon="mdi:letter-outline" />
<h1>{letter.subject}</h1>
</div>
<div class="sender-recipient">
<div class="user-row">
<span class="label">From:</span>
<NostrIdentifier user={letter.from.npub} />
</div>
<div class="user-row">
<span class="label">To:</span>
<div class="recipients">
{#each letter.recipients as recipient}
<NostrIdentifier user={recipient.npub} />
{/each}
</div>
</div>
</div>
</div>
<div class="actions">
{#if letter.stamps}
<div class="stamps-badge">
<Icon icon='icon-park-twotone:stamp' />
<span>{letter.stamps} sats</span>
</div>
{/if}
<button class="reply-btn" on:click={handleReply}>
<Icon icon="mdi:reply" />
<span>Reply</span>
</button>
</div>
</div>
</div>
<div class="metadata">
<div class="timestamp">
<Icon icon="mdi:calendar" />
<span>{getReadableDate(letter.date)}</span>
<div class="separator" />
<Icon icon="mdi:clock-outline" />
<span>{getReadableTime(letter.date)}</span>
</div>
</div>
<pre class="content">{letter.content}</pre>
</div>
{/if}
<style>
.loading-state,
.error-state {
display: grid;
place-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg);
color: var(--text-secondary);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.letter-card {
container-type: inline-size;
width: 100%;
padding: var(--spacing-md);
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-strength));
border: 1px solid var(--glass-border);
border-radius: 24px;
box-shadow: var(--depth-shadow);
position: relative;
overflow: hidden;
}
.letter-card::before {
content: "";
position: absolute;
inset: 0;
background: var(--gradient-shine);
mask: var(--noise-filter);
pointer-events: none;
opacity: 0.5;
}
.header {
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--glass-border);
}
.subject {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.subject h1 {
font-size: 1.5rem;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.sender-recipient {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.user-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.label {
color: var(--text-secondary);
min-width: 3em;
}
.recipients {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.stamps-badge {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
color: var(--text-primary);
}
.reply-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding-inline: var(--spacing-md);
margin-block-start: var(--spacing-sm);
&:hover {
transform: translateY(1px);
text-decoration: none;
}
}
.metadata {
padding: var(--spacing-sm) 0;
color: var(--text-secondary);
}
.timestamp {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.875rem;
}
.separator {
width: 1px;
height: 1em;
background: var(--glass-border);
margin: 0 var(--spacing-xs);
}
.content {
margin-top: var(--spacing-md);
padding: var(--spacing-md);
background: var(--editor-bg);
border-radius: 12px;
color: var(--text-primary);
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
}
@container (max-width: 480px) {
.header-main {
flex-direction: column;
gap: var(--spacing-sm);
}
.actions {
align-self: flex-end;
}
.subject h1 {
font-size: 1.25rem;
}
}
</style>

View file

@ -0,0 +1,261 @@
<script lang="ts">
import { activeUser, baseHue, groupByStamps, ndk, sortBy, validSortOptions } from '$lib/stores.svelte';
import ColorPicker from '../../components/ColorPicker.svelte';
import SettingsLine from '../../components/SettingsLine.svelte';
import Checkbox from '../../components/Checkbox.svelte';
import Select from '../../components/Select.svelte';
import DateFormatBuilder from '../../components/DateFormatBuilder.svelte';
import { onMount } from 'svelte';
import * as nip98 from 'nostr-tools/nip98';
import { NDKEvent, type NostrEvent } from '@nostr-dev-kit/ndk';
import { PUBLIC_API_BASE_URL } from '$env/static/public';
import TimeCountdown from '../../components/TimeCountdown.svelte';
import { TokenInfoWithMailSubscriptionDuration } from '@arx/utils';
let subscribed = $state(false);
let hasUnlimitedSubscription = $state(false);
let subscriptionTill = $state(new Date(0));
let aliases = $state([]);
let newAlias = $state('');
let cashuTokenForBuy = $state('');
let tokenInfo = $state();
let tokenInfoError = $state('');
let buyTimeForNpub = $state('');
$effect(() => {
try {
tokenInfo = new TokenInfoWithMailSubscriptionDuration(cashuTokenForBuy);
tokenInfoError = '';
} catch (e) {
tokenInfoError = (e as Error).message;
}
});
async function buyTime() {
if (!tokenInfo) return;
if (tokenInfoError) return;
if (!buyTimeForNpub) return;
try {
const response = await fetch(PUBLIC_API_BASE_URL + '/addTime/' + buyTimeForNpub, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
tokenString: cashuTokenForBuy
})
});
if (!response.ok) throw new Error(await response.json().then(r => r.message));
reloadSubscriptionStatus();
} catch (e) {
alert(e);
}
}
async function reloadSubscriptionStatus() {
const subscriptionStatusURL = PUBLIC_API_BASE_URL + '/subscription/' + $ndk.activeUser.npub;
const subscriptionStatus = await fetch(subscriptionStatusURL).then(r => r.json<{
subscribed: boolean,
subscribedUntil: number | null
}>());
if (subscriptionStatus.subscribed) {
subscribed = true;
if (subscriptionStatus.subscribedUntil == null)
hasUnlimitedSubscription = true;
else {
subscriptionTill = new Date(subscriptionStatus.subscribedUntil * 1000);
}
}
}
async function reloadAliases() {
const aliasesURL = PUBLIC_API_BASE_URL + '/aliases/' + $ndk.activeUser.npub;
const auth = await nip98.getToken(aliasesURL, 'post', async (e: NostrEvent) => {
const event = new NDKEvent($ndk, e);
await event.sign();
return event.rawEvent();
},
true
);
aliases = await fetch(aliasesURL, {
headers: {
Authorization: auth
}
}).then(r => r.json());
}
async function addAlias() {
const addAliasURL = PUBLIC_API_BASE_URL + '/addAlias';
const auth = await nip98.getToken(addAliasURL, 'post', async (e: NostrEvent) => {
const event = new NDKEvent($ndk, e);
await event.sign();
return event.rawEvent();
},
true
);
await fetch(addAliasURL, {
method: 'POST',
headers: {
Authorization: auth,
'Content-Type': 'application/json'
},
body: JSON.stringify({
alias: newAlias
})
}).then(r => r.json()).then(console.log);
newAlias = '';
await reloadAliases();
alert('Alias added');
}
function randomAlias() {
const adjectives = [
// Colors
'crimson', 'azure', 'golden', 'silver', 'emerald', 'violet', 'cobalt', 'scarlet',
'obsidian', 'jade', 'amber', 'coral', 'indigo', 'sapphire', 'ruby', 'onyx',
// Elements
'frost', 'flame', 'storm', 'thunder', 'crystal', 'shadow', 'lunar', 'solar',
'plasma', 'terra', 'aether', 'void', 'cosmic', 'astral', 'nebula', 'nova',
// Power
'mega', 'ultra', 'hyper', 'super', 'prime', 'apex', 'elite', 'omega',
'alpha', 'delta', 'sigma', 'gamma', 'beta', 'epsilon', 'zeta', 'theta',
// Tech
'cyber', 'techno', 'digital', 'binary', 'neural', 'crypto', 'matrix', 'vector',
'quantum', 'nano', 'laser', 'cyber', 'data', 'pixel', 'sonic', 'hyper',
// Mystical
'mystic', 'arcane', 'ethereal', 'divine', 'phantom', 'spirit', 'ancient', 'chaos',
'astral', 'eldritch', 'occult', 'mythic', 'sacred', 'cursed', 'blessed', 'doom',
// Nature
'savage', 'primal', 'feral', 'wild', 'fierce', 'rapid', 'swift', 'silent',
'deadly', 'stealth', 'shadow', 'night', 'dark', 'light', 'bright', 'dawn',
// Epic
'epic', 'legendary', 'mythic', 'eternal', 'immortal', 'infinite', 'supreme', 'ultimate',
'grand', 'mighty', 'noble', 'royal', 'heroic', 'valor', 'glory', 'honor'
];
const animals = [
// Classic Predators
'wolf', 'tiger', 'lion', 'eagle', 'hawk', 'bear', 'panther', 'falcon',
'jaguar', 'leopard', 'lynx', 'cobra', 'viper', 'python', 'raptor', 'shark',
// Mythical Dragons
'dragon', 'wyvern', 'drake', 'wyrm', 'hydra', 'basilisk', 'tiamat', 'ryuu',
'fafnir', 'bahamut', 'ryu', 'draco', 'naga', 'ouroboros', 'lindworm', 'lung',
// Fantasy
'phoenix', 'griffin', 'unicorn', 'pegasus', 'chimera', 'manticore', 'sphinx', 'kraken',
'behemoth', 'leviathan', 'titan', 'giant', 'colossus', 'golem', 'gargoyle', 'djinn',
// Norse
'fenrir', 'jormungandr', 'sleipnir', 'valkyrie', 'einherjar', 'huginn', 'muninn', 'garmr',
'nidhogg', 'ratatoskr', 'hraesvelgr', 'gullinkambi', 'eikthyrnir', 'duneyrr', 'dvalinn', 'dainn',
// Eastern
'kitsune', 'kirin', 'byakko', 'suzaku', 'genbu', 'seiryu', 'oni', 'tengu',
'raiju', 'baku', 'nekomata', 'tanuki', 'kappa', 'tsukumogami', 'yokai', 'orochi',
// Ancient
'cyclops', 'minotaur', 'cerberus', 'scylla', 'typhon', 'charybdis', 'medusa', 'harpy',
'siren', 'gorgon', 'centaur', 'satyr', 'triton', 'echidna', 'lamia', 'sphinx',
// Cosmic
'nova', 'pulsar', 'quasar', 'nebula', 'vortex', 'cosmic', 'astral', 'celestial',
'starborn', 'solaris', 'lunaris', 'eclipse', 'meteor', 'comet', 'galaxy', 'cosmos'
];
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const animal = animals[Math.floor(Math.random() * animals.length)];
const digits = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
newAlias = `${adjective}-${animal}${digits}`;
}
onMount(async () => {
await reloadAliases();
await reloadSubscriptionStatus();
});
</script>
<h2 id="page-title">Settings</h2>
<SettingsLine title="Your Nostr Public Key">
<code>{$activeUser?.npub}</code>
</SettingsLine>
<SettingsLine title="Email Alias Time Blocks">
<p>
npub.email offers email aliases that connect to your nostr account, converting incoming emails into Letters.<br />
These aliases can also serve as your nip 05 identifier.
</p>
<h3>Pricing:</h3>
<ul>
<li><b>210 sats</b> per day</li>
<li>Minimum purchase: <b>21 sats</b> (2.4 hours)</li>
<li>Flexible duration: Purchase any length of time you need</li>
</ul>
<p>
Purchase time blocks to activate your email alias service for yourself or gift them to another user. Once the time
expires, you'll need to purchase additional time to continue using the service. Note: emails received while the
service is inactive will not be processed.
</p>
<SettingsLine title="Available time">
{#if hasUnlimitedSubscription}
<h4 style:text-align="center" style:width="100%">You are officially awesome!</h4>
<p>
The Arx team has granted you an unlimited subscription to npub.email for your valuable contributions to Arx,
nostr or bitcoin. <br />
Keep up your great work and thank you!
</p>
{:else if subscribed && subscriptionTill.getTime() > 1000}
Your subscription will end in: <br />
<TimeCountdown bind:time={subscriptionTill} />
{:else}
You are not currently subscribed to npub.email
{/if}
</SettingsLine>
<SettingsLine title="Buy time">
{#if cashuTokenForBuy !== ''}
{#if tokenInfoError}
<p class="error">{tokenInfoError}</p>
{:else}
<h4>{tokenInfo.amount} sats</h4>
<TimeCountdown time={new Date(Date.now() + tokenInfo.duration * 1000)} isStopped />
{/if}
{/if}
<input bind:value={cashuTokenForBuy} placeholder="Enter cashu token" type="text" />
<h4>Buy for:</h4>
<input bind:value={buyTimeForNpub} placeholder="Enter npub" type="text" />
<button onclick={() => buyTimeForNpub = $ndk.activeUser.npub}>Set to your own</button>
<button onclick={buyTime}>Buy</button>
</SettingsLine>
{#if subscribed}
<SettingsLine title="Your aliases">
{#each aliases as alias}
<code>{alias}@npub.email</code>
{:else}
<p>No aliases yet</p>
{/each}
<input bind:value={newAlias} placeholder="Enter alias" type="text" />
<button onclick={randomAlias}>Random</button>
<button onclick={addAlias}>Add</button>
</SettingsLine>
{/if}
</SettingsLine>
<SettingsLine title="Sorting and Grouping">
<Checkbox bind:checked={$groupByStamps} label="Group by stamps" />
<Select bind:value={$sortBy} label="Sort by"
options={validSortOptions.map(o => ({ value: o, label: o.charAt(0).toUpperCase() + o.slice(1).toLowerCase() }))} />
</SettingsLine>
<SettingsLine title="Date and Time">
<DateFormatBuilder />
</SettingsLine>
<SettingsLine title="Base Color">
<ColorPicker bind:value={$baseHue} />
</SettingsLine>

422
static/base.css Normal file
View file

@ -0,0 +1,422 @@
@layer reset {
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
::selection {
background: var(--accent);
color: white;
}
:focus {
outline: none;
}
html {
color-scheme: dark;
scroll-behavior: smooth;
}
body {
min-height: 100dvh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img, picture, svg, video {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
}
@layer base {
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
:root {
--glass-bg: color-mix(in srgb, white 8%, transparent);
--glass-border: color-mix(in srgb, white 12%, transparent);
--glass-shadow: rgb(0 0 0 / 0.1);
--glass-glow: oklch(67% 0.2 var(--base-hue) / 0.15);
--depth-shadow: 0 8px 32px -8px rgb(0 0 0 / 0.3);
--noise-filter: url("data:image/svg+xml,%3Csvg width='1000' height='1000' viewBox='0 0 1000 1000' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch' seed='2' result='noise'/%3E%3CfeColorMatrix type='saturate' values='0' in='noise' result='desaturatedNoise'/%3E%3CfeBlend in='SourceGraphic' in2='desaturatedNoise' mode='overlay' result='blend'/%3E%3CfeComposite operator='in' in2='SourceGraphic'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E#noiseFilter");
--gradient-shine: linear-gradient(
135deg,
transparent 25%,
rgb(255 255 255 / 0.1) 50%,
transparent 75%
);
--base-hue: 200;
--accent: oklch(67% 0.2 var(--base-hue));
--accent-light: oklch(75% 0.2 var(--base-hue));
--text-primary: oklch(100% 0 0);
--text-secondary: oklch(100% 0 0 / 0.7);
--blur-strength: 16px;
--spacing-xs: clamp(0.25rem, 0.5vw, 0.5rem);
--spacing-sm: clamp(0.5rem, 1vw, 1rem);
--spacing-md: clamp(1rem, 2vw, 2rem);
--spacing-lg: clamp(2rem, 4vw, 4rem);
--editor-bg: oklch(10% 0.02 var(--base-hue) / 0.6);
--toolbar-bg: oklch(15% 0.02 var(--base-hue) / 0.8);
--input-border: oklch(100% 0.02 var(--base-hue) / 0.15);
--input-focus: oklch(60% 0.2 var(--base-hue) / 0.3);
--chip-bg: oklch(60% 0.2 var(--base-hue) / 0.15);
--npub-color: oklch(70% 0.2 calc(var(--base-hue) + 60));
--nip05-color: oklch(85% 0.3 calc(var(--base-hue) - 30));
--email-color: oklch(70% 0.2 calc(var(--base-hue) - 60));
--verified-color: oklch(70% 0.2 calc(var(--base-hue) + 180));
--chip-success: oklch(60% 0.2 calc(var(--base-hue) + 180) / 0.5);
--chip-warning: oklch(60% 0.2 calc(var(--base-hue) + 60) / 0.5);
--chip-error: oklch(60% 0.2 calc(var(--base-hue) + 30) / 0.5);
}
.loading {
display: none;
opacity: 0;
transition: opacity 500ms ease-in;
position: fixed;
inset: 50%;
transform: translate(-50%, -50%);
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-strength));
border: 1px solid var(--glass-border);
border-radius: 16px;
padding: var(--spacing-md);
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
box-shadow: var(--depth-shadow),
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
0 0 30px var(--glass-glow);
width: 50vmin;
height: 50vmin;
justify-content: center;
}
.htmx-request .loading, .htmx-request.loading {
display: flex;
opacity: 1;
}
}
@layer layout {
body {
background: linear-gradient(
var(--gradient-angle),
oklch(10% 0.2 calc(var(--base-hue) - 3)),
oklch(12.5% 0.2 calc(var(--base-hue) - 2)),
oklch(15% 0.2 calc(var(--base-hue) - 1)),
oklch(17.5% 0.2 var(--base-hue)),
oklch(20% 0.2 calc(var(--base-hue) + 1)),
oklch(25% 0.2 calc(var(--base-hue) + 2)),
oklch(30% 0.2 calc(var(--base-hue) + 3))
),
radial-gradient(
circle at top left,
rgb(99 102 241 / 0.15),
transparent 50%
);
background-blend-mode: normal, overlay;
font-family: 'Inter', system-ui;
color: var(--text-primary);
@media (prefers-reduced-motion: no-preference) {
animation: rotate-gradient 20s linear infinite;
}
&::before {
content: '';
position: fixed;
inset: 0;
background-image: var(--noise-filter);
opacity: 0.05;
z-index: -1;
pointer-events: none;
}
}
#app {
display: grid;
grid-template-rows: auto 1fr;
min-height: 100dvh;
padding: var(--spacing-md);
gap: var(--spacing-md);
}
#header {
position: sticky;
top: var(--spacing-md);
backdrop-filter: blur(var(--blur-strength)) saturate(180%);
background: var(--glass-bg);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: var(--spacing-md);
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--spacing-md);
align-items: center;
box-shadow: var(--depth-shadow),
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
0 0 30px var(--glass-glow);
&::before {
content: '';
position: absolute;
inset: 0;
background: var(--gradient-shine);
background-size: 200% 200%;
border-radius: inherit;
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover::before {
opacity: 1;
animation: shine 3s linear infinite;
}
& .title {
position: relative;
text-shadow: 0 0 20px var(--glass-glow);
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 700;
text-align: center;
background: linear-gradient(to right, var(--text-primary), var(--accent-light));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
&::after {
content: '';
position: absolute;
left: 0;
bottom: -4px;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
transform: scaleX(0);
transition: transform 0.3s ease;
}
&:hover::after {
transform: scaleX(1);
}
}
}
#content {
height: 100%;
backdrop-filter: blur(var(--blur-strength));
background: var(--glass-bg);
border-radius: 16px;
border: 1px solid var(--glass-border);
padding: var(--spacing-md);
box-shadow: var(--depth-shadow);
}
#page-title {
width: fit-content;
font-size: 2.5rem;
font-weight: 600;
background: linear-gradient(to right, var(--text-primary), var(--accent-light));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
padding-left: 1rem;
padding-bottom: 1rem;
}
h3, h4 {
width: fit-content;
background: linear-gradient(to right, var(--text-primary), var(--accent-light));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 600;
}
h3 {
font-size: 1.5rem;
margin-bottom: 0;
}
}
@layer components {
button, .button {
text-decoration: none;
padding: 0.5rem 1rem;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
isolation: isolate;
&::before {
content: '';
position: absolute;
inset: -2px;
background: linear-gradient(45deg, var(--accent), transparent);
border-radius: 10px;
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
&:active {
transform: translatey(1px);
}
&:hover {
text-decoration: underline;
transform: translatey(-2px);
box-shadow: 0 0 15px var(--glass-glow);
&::before {
opacity: 1;
}
}
}
input[type="text"], input[type="password"] {
background: var(--editor-bg);
border: 1px solid var(--input-border);
color: var(--text-primary);
padding: var(--spacing-sm);
border-radius: 8px;
width: 100%;
transition: all 0.3s ease;
&:focus {
border-color: var(--input-focus);
box-shadow: 0 0 0 2px var(--input-focus),
0 0 15px var(--glass-glow);
}
&::placeholder {
color: var(--text-secondary);
}
}
.notification {
isolation: isolate;
contain: none;
position: fixed;
top: var(--spacing-md);
right: var(--spacing-md);
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-strength));
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: var(--spacing-md);
min-width: 300px;
max-width: 400px;
display: flex;
gap: var(--spacing-sm);
align-items: flex-start;
box-shadow: var(--depth-shadow),
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
0 0 30px var(--glass-glow);
transform: translateX(120%);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
z-index: 1000;
&.show {
transform: translateX(0);
}
&::before {
content: '';
position: absolute;
inset: 0;
background: var(--gradient-shine);
background-size: 200% 200%;
border-radius: inherit;
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover::before {
opacity: 1;
animation: shine 3s linear infinite;
}
.notification-content {
flex: 1;
.notification-title {
font-weight: 600;
margin-bottom: var(--spacing-xs);
background: linear-gradient(to right, var(--text-primary), var(--accent-light));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.notification-message {
color: var(--text-secondary);
font-size: 0.9rem;
}
}
.close-button {
background: transparent;
border: none;
color: var(--text-secondary);
padding: 4px;
cursor: pointer;
transition: color 0.3s ease;
&:hover {
color: var(--text-primary);
}
}
}
.glass {
backdrop-filter: blur(var(--blur-strength));
background: var(--glass-bg);
border-radius: 16px;
border: 1px solid var(--glass-border);
padding: var(--spacing-md);
box-shadow: var(--depth-shadow);
}
p.error {
color: oklch(60% 50% 35);
font-weight: 700;
font-size: 0.9rem;
text-align: center;
text-shadow: -1.5px -1.5px 0 oklch(15% 10% 35),
1.5px -1.5px 0 oklch(15% 10% 35),
-1.5px 1.5px 0 oklch(15% 10% 35),
1.5px 1.5px 0 oklch(15% 10% 35);
letter-spacing: 1px;
}
}
@keyframes shine {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

19
svelte.config.js Normal file
View file

@ -0,0 +1,19 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
precompress: false,
envPrefix: ''
})
}
};
export default config;

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

6
vite.config.ts Normal file
View file

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});