First beta
This commit is contained in:
commit
a983fe669b
41 changed files with 3739 additions and 0 deletions
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
engine-strict=true
|
4
.prettierignore
Normal file
4
.prettierignore
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
17
.prettierrc
Normal file
17
.prettierrc
Normal 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
2
README.md
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# npub.email
|
||||||
|
|
BIN
bun.lockb
Executable file
BIN
bun.lockb
Executable file
Binary file not shown.
33
package.json
Normal file
33
package.json
Normal 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
13
src/app.d.ts
vendored
Normal 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
15
src/app.html
Normal 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>
|
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>
|
28
src/lib/folderLabel.ts
Normal file
28
src/lib/folderLabel.ts
Normal 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
1
src/lib/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
58
src/lib/letter.ts
Normal file
58
src/lib/letter.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
25
src/lib/letterToFolderMapping.ts
Normal file
25
src/lib/letterToFolderMapping.ts
Normal 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
71
src/lib/stores.svelte.ts
Normal 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
244
src/lib/utils.svelte.ts
Normal 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
43
src/routes/+layout.svelte
Normal 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
1
src/routes/+layout.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export const ssr = false;
|
189
src/routes/+page.svelte
Normal file
189
src/routes/+page.svelte
Normal 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>
|
24
src/routes/.well-known/nostr.json/+server.ts
Normal file
24
src/routes/.well-known/nostr.json/+server.ts
Normal 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);
|
||||||
|
}
|
255
src/routes/compose/+page.svelte
Normal file
255
src/routes/compose/+page.svelte
Normal 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>
|
5
src/routes/letters/[id]/+page.server.ts
Normal file
5
src/routes/letters/[id]/+page.server.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export function load({ params }) {
|
||||||
|
return {
|
||||||
|
id: params.id
|
||||||
|
};
|
||||||
|
}
|
272
src/routes/letters/[id]/+page.svelte
Normal file
272
src/routes/letters/[id]/+page.svelte
Normal 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>
|
261
src/routes/settings/+page.svelte
Normal file
261
src/routes/settings/+page.svelte
Normal 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
422
static/base.css
Normal 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
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
19
svelte.config.js
Normal file
19
svelte.config.js
Normal 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
19
tsconfig.json
Normal 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
6
vite.config.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
Loading…
Reference in a new issue