First beta
This commit is contained in:
commit
a983fe669b
41 changed files with 3739 additions and 0 deletions
203
src/components/Checkbox.svelte
Normal file
203
src/components/Checkbox.svelte
Normal 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>
|
131
src/components/ColorPicker.svelte
Normal file
131
src/components/ColorPicker.svelte
Normal 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>
|
86
src/components/DateFormatBuilder.svelte
Normal file
86
src/components/DateFormatBuilder.svelte
Normal 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>
|
69
src/components/Dialog.svelte
Normal file
69
src/components/Dialog.svelte
Normal 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>
|
105
src/components/LoginWithNostr.svelte
Normal file
105
src/components/LoginWithNostr.svelte
Normal 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>
|
320
src/components/MailboxFolderItems.svelte
Normal file
320
src/components/MailboxFolderItems.svelte
Normal 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)}
|
||||
|
||||
<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>
|
199
src/components/NostrIdentifier.svelte
Normal file
199
src/components/NostrIdentifier.svelte
Normal 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>
|
38
src/components/Notification.svelte
Normal file
38
src/components/Notification.svelte
Normal 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>
|
33
src/components/RecipientChip.svelte
Normal file
33
src/components/RecipientChip.svelte
Normal 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>
|
203
src/components/Select.svelte
Normal file
203
src/components/Select.svelte
Normal 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>
|
42
src/components/SettingsLine.svelte
Normal file
42
src/components/SettingsLine.svelte
Normal 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>
|
80
src/components/TimeCountdown.svelte
Normal file
80
src/components/TimeCountdown.svelte
Normal 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>
|
177
src/components/Tooltip.svelte
Normal file
177
src/components/Tooltip.svelte
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue