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

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>