Strip out the web UI (login + subscription filter management)
Remove the browser admin SPA under web/ that let users log in via
a Nostr signer and manage subscription filters. The server is now
a headless API only.
Changes:
- Delete web/ directory entirely (SPA source, config, deps)
- Remove express.static('web/dist') serving from server.ts
- Simplify GET / handler to always return JSON (no fallback)
- Remove build:web from package.json build pipeline
- Remove web build steps from Dockerfile
- Remove web references from build-in-production.sh
Kept: core subscription/unsubscribe/confirm/notify backend and
transactional src/pages/*.html (part of email flow, not the UI).
This commit is contained in:
parent
d5f54845b6
commit
75f5908039
16 changed files with 2 additions and 742 deletions
|
|
@ -25,12 +25,7 @@ COPY src/ ./src/
|
|||
COPY src/pages/ ./src/pages/
|
||||
COPY src/emails/ ./src/emails/
|
||||
|
||||
# Build web UI
|
||||
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml web/tsconfig.json web/vite.config.js web/index.html ./web/
|
||||
COPY web/src/ ./web/src/
|
||||
RUN cd web && pnpm install --frozen-lockfile && pnpm run build
|
||||
|
||||
# Build TypeScript (runs tsc && build:html && build:web)
|
||||
# Build TypeScript (runs tsc && build:html)
|
||||
RUN pnpm run build
|
||||
|
||||
# Production image
|
||||
|
|
@ -51,7 +46,6 @@ RUN pnpm install --frozen-lockfile --prod
|
|||
|
||||
# Copy build artifacts
|
||||
COPY --from=build /app/dist/ ./dist/
|
||||
COPY --from=build /app/web/dist/ ./web/dist/
|
||||
COPY --from=build /app/src/pages/ ./dist/pages/
|
||||
COPY --from=build /app/src/emails/ ./dist/emails/
|
||||
|
||||
|
|
|
|||
2
build-in-production.sh
Executable file → Normal file
2
build-in-production.sh
Executable file → Normal file
|
|
@ -2,11 +2,9 @@
|
|||
|
||||
# Remove link overrides
|
||||
node remove-pnpm-overrides.js package.json
|
||||
node remove-pnpm-overrides.js web/package.json
|
||||
|
||||
# When CI=true as it is on render.com, removing link overrides breaks the lockfile
|
||||
pnpm i --no-frozen-lockfile
|
||||
(cd web && pnpm i --no-frozen-lockfile)
|
||||
|
||||
# Build everything
|
||||
pnpm run build
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "tsc && pnpm run build:html && pnpm run build:web",
|
||||
"build:web": "cd web && pnpm run build",
|
||||
"build": "tsc && pnpm run build:html",
|
||||
"build:html": "cp -r src/pages dist/ && cp -r src/emails dist/",
|
||||
"check": "tsc --noEmit && eslint src",
|
||||
"format": "prettier --write \"src/**/*.{ts,js,json,html}\"",
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ server.use((req: Request, res: Response, next: NextFunction) => {
|
|||
|
||||
server.use(express.json())
|
||||
|
||||
server.use(express.static('web/dist'))
|
||||
|
||||
// Rate limit for registration endpoints
|
||||
server.use(
|
||||
'/subscription',
|
||||
|
|
@ -71,16 +69,6 @@ const addRoute = (method: 'get' | 'post' | 'put' | 'delete', path: string, handl
|
|||
}
|
||||
|
||||
addRoute('get', '/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {existsSync} = await import('fs')
|
||||
const webIndex = new URL('../web/dist/index.html', import.meta.url)
|
||||
if (existsSync(webIndex)) {
|
||||
return res.send(await render('../web/dist/index.html'))
|
||||
}
|
||||
} catch {
|
||||
// Fall through to JSON
|
||||
}
|
||||
|
||||
res.json({
|
||||
name: 'Mailship',
|
||||
description: 'Email notification server for Nostr',
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
VITE_NOTIFIER_PUBKEY=
|
||||
VITE_NOTIFIER_RELAY=
|
||||
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band
|
||||
24
web/.gitignore
vendored
24
web/.gitignore
vendored
|
|
@ -1,24 +0,0 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig } from "eslint/config";
|
||||
|
||||
|
||||
export default defineConfig([
|
||||
{ files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] },
|
||||
{ files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.browser } },
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{js,mjs,cjs,ts}"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{args: "none", destructuredArrayIgnorePattern: "^_d?$", caughtErrors: "none"},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Anchor Alerts</title>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div id="app" class="max-w-2xl mx-auto"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"check": "tsc --noEmit && eslint src",
|
||||
"format": "eslint src --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
"@types/mithril": "^2.2.7",
|
||||
"eslint": "^9.25.1",
|
||||
"globals": "^16.0.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.31.1",
|
||||
"vite": "^6.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@welshman/feeds": "^0.6.3",
|
||||
"@welshman/lib": "^0.6.3",
|
||||
"@welshman/net": "^0.6.3",
|
||||
"@welshman/signer": "^0.6.3",
|
||||
"@welshman/store": "^0.6.3",
|
||||
"@welshman/util": "^0.6.3",
|
||||
"events": "^3.3.0",
|
||||
"mithril": "^2.2.15",
|
||||
"svelte": "^5.27.2",
|
||||
"tailwindcss": "^4.1.4"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -1,2 +0,0 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
544
web/src/main.ts
544
web/src/main.ts
|
|
@ -1,544 +0,0 @@
|
|||
import './style.css'
|
||||
|
||||
import m from "mithril"
|
||||
import {writable} from 'svelte/store'
|
||||
import {getJson, removeNil, spec, parseJson, setJson, assoc, randomId, TIMEZONE, tryCatch, LOCALE} from '@welshman/lib'
|
||||
import {withGetter} from '@welshman/store'
|
||||
import {Router} from '@welshman/router'
|
||||
import {validateFeed, ValidationError, displayFeeds, Feed} from '@welshman/feeds'
|
||||
import {getAddress, getRelaysFromList, RelayMode, readList, asDecryptedEvent, normalizeRelayUrl, getTagValue, getTagValues, makeEvent, DELETE, TrustedEvent, StampedEvent, FEED, Address, getIdFilters, fromNostrURI, RELAYS} from '@welshman/util'
|
||||
import {load, publish, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net'
|
||||
import type {ISigner} from '@welshman/signer'
|
||||
import {Nip07Signer, decrypt} from '@welshman/signer'
|
||||
|
||||
// Constants
|
||||
|
||||
const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY
|
||||
|
||||
const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) : undefined
|
||||
|
||||
const INDEXER_RELAYS = (import.meta.env.VITE_INDEXER_RELAYS || 'purplepag.es,relay.damus.io,relay.nostr.band').split(',').map(normalizeRelayUrl)
|
||||
|
||||
const ALERT = 32830
|
||||
|
||||
const ALERT_STATUS = 32831
|
||||
|
||||
const TZ_OFFSET = parseInt(TIMEZONE.split(':')[0]!)
|
||||
|
||||
const CRON_DAILY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* \*$/
|
||||
|
||||
const CRON_WEEKLY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* 1$/
|
||||
|
||||
const PLUS_ICON = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
const TRASH_ICON = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.5 6H3.49991" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M18.8333 8.5L18.3734 15.3991C18.1964 18.054 18.1079 19.3815 17.2429 20.1907C16.3779 21 15.0475 21 12.3867 21H11.6133C8.95252 21 7.62212 21 6.75711 20.1907C5.8921 19.3815 5.80361 18.054 5.62661 15.3991L5.16667 8.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M6.5 6C6.55588 6 6.58382 6 6.60915 5.99936C7.43259 5.97849 8.15902 5.45491 8.43922 4.68032C8.44784 4.65649 8.45667 4.62999 8.47434 4.57697L8.57143 4.28571C8.65431 4.03708 8.69575 3.91276 8.75071 3.8072C8.97001 3.38607 9.37574 3.09364 9.84461 3.01877C9.96213 3 10.0932 3 10.3553 3H13.6447C13.9068 3 14.0379 3 14.1554 3.01877C14.6243 3.09364 15.03 3.38607 15.2493 3.8072C15.3043 3.91276 15.3457 4.03708 15.4286 4.28571L15.5257 4.57697C15.5433 4.62992 15.5522 4.65651 15.5608 4.68032C15.841 5.45491 16.5674 5.97849 17.3909 5.99936C17.4162 6 17.4441 6 17.5 6" stroke="#1C274C" stroke-width="1.5"/>
|
||||
</svg>`
|
||||
|
||||
const ARROW_LEFT_ICON = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
// Types and state
|
||||
|
||||
type Alert = {
|
||||
event: TrustedEvent
|
||||
tags: string[][]
|
||||
}
|
||||
|
||||
type AlertStatus = {
|
||||
event: TrustedEvent
|
||||
tags: string[][]
|
||||
}
|
||||
|
||||
type AlertValues = {
|
||||
feedAddress: string
|
||||
freq: string
|
||||
time: string,
|
||||
email: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
failedToLogin: boolean
|
||||
signer: ISigner
|
||||
pubkey: string | undefined
|
||||
alerts: Alert[]
|
||||
alertDraft?: AlertValues,
|
||||
alertStatuses: AlertStatus[]
|
||||
alertsLoading: boolean
|
||||
}
|
||||
|
||||
const state = withGetter(
|
||||
writable({
|
||||
failedToLogin: false,
|
||||
signer: new Nip07Signer(),
|
||||
pubkey: getJson('pubkey'),
|
||||
alerts: [],
|
||||
alertStatuses: [],
|
||||
alertsLoading: false,
|
||||
} as State)
|
||||
)
|
||||
|
||||
// Actions
|
||||
|
||||
const login = async () => {
|
||||
const {signer} = state.get()
|
||||
|
||||
try {
|
||||
const pubkey = await signer.getPubkey()
|
||||
|
||||
state.update(assoc('pubkey', pubkey))
|
||||
setJson('pubkey', pubkey)
|
||||
} catch (e) {
|
||||
state.update(assoc('failedToLogin', true))
|
||||
}
|
||||
}
|
||||
|
||||
const loadAlerts = async () => {
|
||||
const {signer, pubkey} = state.get()
|
||||
|
||||
if (!NOTIFIER_RELAY) {
|
||||
state.update(assoc('alertsLoading', false))
|
||||
return
|
||||
}
|
||||
|
||||
state.update(assoc('alertsLoading', true))
|
||||
|
||||
const events = await load({
|
||||
relays: [NOTIFIER_RELAY],
|
||||
filters: [
|
||||
{kinds: [ALERT], authors: [pubkey!]},
|
||||
{kinds: [ALERT_STATUS], "#p": [pubkey!]},
|
||||
],
|
||||
})
|
||||
|
||||
const alerts = await Promise.all(
|
||||
events
|
||||
.filter(spec({kind: ALERT}))
|
||||
.map(async event => {
|
||||
const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content))
|
||||
|
||||
return {event, tags}
|
||||
})
|
||||
)
|
||||
|
||||
const alertStatuses = await Promise.all(
|
||||
events
|
||||
.filter(spec({kind: ALERT_STATUS}))
|
||||
.map(async event => {
|
||||
const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content))
|
||||
|
||||
return {event, tags}
|
||||
})
|
||||
)
|
||||
|
||||
state.update($state => ({...$state, alertsLoading: false, alerts, alertStatuses}))
|
||||
}
|
||||
|
||||
const deleteAlert = async (alert: Alert) => {
|
||||
if (!NOTIFIER_RELAY) return
|
||||
|
||||
if (confirm("Are you sure you want to delete this alert?")) {
|
||||
state.update(assoc('alertsLoading', true))
|
||||
|
||||
await publish({
|
||||
relays: [NOTIFIER_RELAY],
|
||||
event: await state.get().signer!.sign(
|
||||
makeEvent(DELETE, {
|
||||
tags: [
|
||||
["k", String(alert.event.kind)],
|
||||
["a", getAddress(alert.event)]
|
||||
],
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
await loadAlerts()
|
||||
}
|
||||
}
|
||||
|
||||
export type AlertParams = {
|
||||
feeds: Feed[]
|
||||
freq: string
|
||||
time: string
|
||||
email: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
export const makeAlert = async ({freq, time, email, feeds, secret}: AlertParams) => {
|
||||
const {signer} = state.get()
|
||||
const [hour, minute] = time.split(':')
|
||||
const utcHour = (parseInt(hour) - TZ_OFFSET) % 24
|
||||
const dow = freq === 'daily' ? '*' : freq
|
||||
const cron = `0 ${minute} ${utcHour} * * ${dow}`
|
||||
|
||||
const tags = [
|
||||
["cron", cron],
|
||||
["email", email],
|
||||
["channel", "email"],
|
||||
["locale", LOCALE],
|
||||
["timezone", TIMEZONE],
|
||||
[
|
||||
"handler",
|
||||
"31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690",
|
||||
"wss://relay.nostr.band/",
|
||||
"web",
|
||||
],
|
||||
]
|
||||
|
||||
for (const feed of feeds) {
|
||||
tags.push(["feed", JSON.stringify(feed)])
|
||||
}
|
||||
|
||||
return signer.sign(
|
||||
makeEvent(ALERT, {
|
||||
content: await signer.nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)),
|
||||
tags: [
|
||||
["d", randomId()],
|
||||
["p", NOTIFIER_PUBKEY],
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const publishAlert = async (params: AlertParams) => {
|
||||
if (!NOTIFIER_RELAY) return
|
||||
|
||||
await publish({event: await makeAlert(params), relays: [NOTIFIER_RELAY]})
|
||||
}
|
||||
|
||||
// Components
|
||||
|
||||
const Loader = {
|
||||
view: () => m("div", { class: "flex justify-center py-4" }, [
|
||||
m("div", {
|
||||
class: "animate-spin rounded-full h-8 w-8 border-4 border-purple-200 border-t-purple-600"
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
const Login = {
|
||||
view: () =>
|
||||
m("button", {
|
||||
onclick: login,
|
||||
class: "w-full bg-purple-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors"
|
||||
}, "Connect with Nostr"),
|
||||
}
|
||||
|
||||
const AlertStatus: m.Component<{alert: Alert}> = {
|
||||
view: vnode => {
|
||||
const {alert} = vnode.attrs
|
||||
const {alertStatuses} = state.get()
|
||||
const address = getAddress(alert.event)
|
||||
const alertStatus = alertStatuses.find(s => getTagValue('d', s.event.tags) === address)
|
||||
const status = getTagValue('status', alertStatus?.tags || [])
|
||||
const message = getTagValue('message', alertStatus?.tags || [])
|
||||
|
||||
const getStatusClasses = () => {
|
||||
const baseClasses = "rounded-full px-3 py-1 text-sm border"
|
||||
if (status === 'ok') return `${baseClasses} border-green-500 text-green-500`
|
||||
if (status === 'pending') return `${baseClasses} border-yellow-500 text-yellow-500`
|
||||
return `${baseClasses} border-red-500 text-red-500`
|
||||
}
|
||||
|
||||
const getStatusDisplay = () => {
|
||||
if (!status) return 'Inactive'
|
||||
if (status === 'ok') return 'Active'
|
||||
if (status === 'pending') return 'Pending'
|
||||
return status.replace('-', ' ').replace(/^(.)/, x => x.toUpperCase())
|
||||
}
|
||||
|
||||
return m("div", {class: getStatusClasses(), tooltip: message}, getStatusDisplay())
|
||||
},
|
||||
}
|
||||
|
||||
const AlertListItem: m.Component<{alert: Alert}> = {
|
||||
view: vnode => {
|
||||
const {alert} = vnode.attrs
|
||||
const cron = getTagValue('cron', alert.tags)
|
||||
const feeds = getTagValues('feed', alert.tags)
|
||||
const channel = getTagValue('channel', alert.tags)
|
||||
const description = displayFeeds(feeds.map(feed => parseJson(feed))) || "[invalid feed]"
|
||||
|
||||
let frequency = cron || "Unknown"
|
||||
if (cron) {
|
||||
if (CRON_DAILY_PATTERN.test(cron)) {
|
||||
frequency = 'Daily'
|
||||
} else if (CRON_WEEKLY_PATTERN.test(cron)) {
|
||||
frequency = 'Weekly'
|
||||
}
|
||||
}
|
||||
|
||||
return m("div", { class: "flex items-start justify-between p-4" }, [
|
||||
m("button", {
|
||||
onclick: () => deleteAlert(alert),
|
||||
class: "mr-4 mt-1",
|
||||
tooltip: "Delete alert"
|
||||
}, [m.trust(TRASH_ICON)]),
|
||||
m("div", { class: "space-y-2 flex-grow" }, [
|
||||
m("div", { class: "text-gray-600" }, `${frequency} alert via ${channel}`),
|
||||
m("div", { class: "text-sm text-gray-500" }, `Events ${description}`)
|
||||
]),
|
||||
m(AlertStatus, {alert}),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const AlertList = {
|
||||
oninit: loadAlerts,
|
||||
view: () => {
|
||||
const {alerts, alertsLoading} = state.get()
|
||||
|
||||
const content = alertsLoading
|
||||
? m(Loader)
|
||||
: alerts.length > 0
|
||||
? alerts.map(alert => m(AlertListItem, {alert, key: alert.event.id}))
|
||||
: m("div", { class: "text-center text-gray-500 py-8" }, [
|
||||
"You don't have any alerts set up.",
|
||||
])
|
||||
|
||||
return m("div", { class: "space-y-4" }, [
|
||||
m("div", { class: "flex items-center justify-between mb-6" }, [
|
||||
m("h1", { class: "text-2xl font-bold text-gray-900" }, "Your Nostr Alerts"),
|
||||
m("a", {
|
||||
href: "#!/alerts/new",
|
||||
class: "flex items-center gap-2 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors",
|
||||
}, [
|
||||
m.trust(PLUS_ICON),
|
||||
"Add Alert"
|
||||
])
|
||||
]),
|
||||
m("div", { class: "bg-white shadow rounded-lg p-6" }, content)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const AlertCreate = {
|
||||
oninit: () => {
|
||||
state.update(assoc('alertDraft', {
|
||||
email: getTagValue('email', state.get().alerts[0]?.tags || []) || "",
|
||||
freq: 'daily',
|
||||
time: '17:00',
|
||||
feedAddress: "",
|
||||
secret: "",
|
||||
}))
|
||||
},
|
||||
view: () => {
|
||||
const {pubkey, alertDraft, alertsLoading} = state.get()
|
||||
const {email, feedAddress, freq, time, secret} = alertDraft!
|
||||
|
||||
const update = (newValues: Partial<AlertValues>) => {
|
||||
state.update(assoc('alertDraft', {...alertDraft, ...newValues}))
|
||||
}
|
||||
|
||||
const submit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
state.update(assoc('alertsLoading', true))
|
||||
|
||||
try {
|
||||
if (!email.includes("@")) return alert("Please provide a valid email address")
|
||||
|
||||
const address = tryCatch(() => Address.fromNaddr(fromNostrURI(feedAddress)))
|
||||
|
||||
if (!address) return alert("Please provide a valid feed address")
|
||||
if (address.kind !== FEED) return alert(`Please provide a valid feed address (kind ${FEED})`)
|
||||
|
||||
const selections = await load({
|
||||
relays: INDEXER_RELAYS,
|
||||
filters: [{kinds: [RELAYS], authors: [pubkey!, address.pubkey]}],
|
||||
})
|
||||
|
||||
const router = Router.get()
|
||||
const filters = getIdFilters([address.toString()])
|
||||
const scenario = router.merge([
|
||||
router.FromRelays(selections.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)), RelayMode.Write))),
|
||||
router.FromRelays(address.relays),
|
||||
router.FromRelays(INDEXER_RELAYS),
|
||||
])
|
||||
const relays = scenario.limit(10).getUrls()
|
||||
|
||||
const [event] = await load({relays, filters})
|
||||
|
||||
if (!event) return alert("Sorry, we weren't able to find that feed")
|
||||
|
||||
const feedStrings = getTagValues('feed', event.tags)
|
||||
|
||||
if (feedStrings.length === 0) return alert('At least one feed is required')
|
||||
|
||||
const feeds = removeNil(feedStrings.map(parseJson))
|
||||
|
||||
if (feeds.length < feedStrings.length) return alert("At least one feed is invalid (must be valid JSON)")
|
||||
|
||||
const feedError = feeds.map(validateFeed).find(e => e instanceof ValidationError)
|
||||
|
||||
if (feedError) return alert(`At least one feed is invalid (${feedError.data.toLowerCase()}).`)
|
||||
|
||||
await publishAlert({freq, time, email, feeds, secret})
|
||||
|
||||
m.route.set("/alerts")
|
||||
} catch (error) {
|
||||
alert("Failed to create alert. Please try again.")
|
||||
console.error('Error creating alert:', error)
|
||||
} finally {
|
||||
state.update(assoc('alertsLoading', false))
|
||||
}
|
||||
}
|
||||
|
||||
return m("div", { class: "space-y-4" }, [
|
||||
m("div", { class: "flex items-center gap-4 mb-6" }, [
|
||||
m("button", {
|
||||
onclick: () => m.route.set("/alerts"),
|
||||
class: "text-gray-600 hover:text-gray-900 cursor-pointer",
|
||||
tooltip: "Back to alerts"
|
||||
}, m.trust(ARROW_LEFT_ICON)),
|
||||
m("h1", { class: "text-2xl font-bold text-gray-900" }, "Create Alert")
|
||||
]),
|
||||
m("div", { class: "bg-white shadow rounded-lg p-6" }, [
|
||||
m("form", { class: "space-y-6", onsubmit: submit }, [
|
||||
m("div", [
|
||||
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Email"),
|
||||
m("input", {
|
||||
type: "email",
|
||||
placeholder: "Enter your email address",
|
||||
value: email,
|
||||
oninput: (e: InputEvent) => update({email: (e.target as HTMLInputElement).value}),
|
||||
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
})
|
||||
]),
|
||||
m("div", {class: "w-full flex gap-2"}, [
|
||||
m("div", {class: "flex-grow"}, [
|
||||
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Frequency"),
|
||||
m("select", {
|
||||
value: freq,
|
||||
onchange: (e: Event) => update({freq: (e.target as HTMLSelectElement).value}),
|
||||
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
}, [
|
||||
m("option", { value: 'daily' }, "Daily"),
|
||||
m("option", { value: '0' }, "Weekly on Sunday"),
|
||||
m("option", { value: '1' }, "Weekly on Monday"),
|
||||
m("option", { value: '2' }, "Weekly on Tuesday"),
|
||||
m("option", { value: '3' }, "Weekly on Wednesday"),
|
||||
m("option", { value: '4' }, "Weekly on Thursday"),
|
||||
m("option", { value: '5' }, "Weekly on Friday"),
|
||||
m("option", { value: '6' }, "Weekly on Saturday"),
|
||||
])
|
||||
]),
|
||||
m("div", [
|
||||
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Time"),
|
||||
m("input", {
|
||||
value: time,
|
||||
onchange: (e: Event) => update({time: (e.target as HTMLSelectElement).value}),
|
||||
type: "time",
|
||||
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
})
|
||||
]),
|
||||
]),
|
||||
m("div", [
|
||||
m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Feed Address"),
|
||||
m("div", { class: "space-y-2" }, [
|
||||
m("input", {
|
||||
type: "text",
|
||||
placeholder: "naddr1...",
|
||||
value: feedAddress,
|
||||
oninput: (e: InputEvent) => update({feedAddress: (e.target as HTMLInputElement).value}),
|
||||
class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500"
|
||||
}),
|
||||
m("p", { class: "text-sm text-gray-500" }, [
|
||||
"Visit ",
|
||||
m("a", {
|
||||
href: "https://coracle.social/feeds",
|
||||
target: "_blank",
|
||||
class: "text-purple-600 hover:text-purple-800"
|
||||
}, "coracle.social/feeds"),
|
||||
" to search for existing feeds or create a new one. Copy the feed address (starts with 'naddr1') and paste it here."
|
||||
])
|
||||
])
|
||||
]),
|
||||
m("div", { class: "flex justify-end" }, [
|
||||
m("button", {
|
||||
type: "submit",
|
||||
disabled: alertsLoading,
|
||||
class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
}, alertsLoading ? "Creating..." : "Create Alert")
|
||||
])
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const FailedToLogin = {
|
||||
view: () =>
|
||||
m("div", { class: "space-y-6 text-center" }, [
|
||||
m("div", { class: "bg-red-50 border border-red-200 rounded-lg p-6" }, [
|
||||
m("h2", { class: "text-red-800 font-semibold mb-2" }, "Unable to Connect"),
|
||||
m("p", { class: "text-red-600 mb-4" }, "To use Anchor Alerts, you need a Nostr signer extension installed in your browser."),
|
||||
m("div", { class: "space-y-3" }, [
|
||||
m("button", {
|
||||
onclick: () => window.location.reload(),
|
||||
class: "w-full bg-red-100 text-red-700 font-medium py-2 px-4 rounded-lg hover:bg-red-200 transition-colors"
|
||||
}, "Try Again"),
|
||||
m("a", {
|
||||
href: "https://nostrapps.com/#signers",
|
||||
target: "_blank",
|
||||
class: "block w-full bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors"
|
||||
}, "Install a Nostr Signer")
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
|
||||
const Layout: m.Component<{children: m.Children}> = {
|
||||
view: vnode => {
|
||||
const {children} = vnode.attrs
|
||||
const {failedToLogin, pubkey} = state.get()
|
||||
|
||||
if (failedToLogin) {
|
||||
return m(FailedToLogin)
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
return m("div", { class: "text-center space-y-4" }, [
|
||||
m("h1", { class: "text-2xl font-bold text-gray-900 mb-2" }, "Welcome to Anchor Alerts"),
|
||||
m("p", { class: "text-gray-600 mb-6" }, "Connect your Nostr signer to get started"),
|
||||
m(Login)
|
||||
])
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
}
|
||||
|
||||
m.route(document.querySelector('#app')!, "/alerts", {
|
||||
"/alerts": {
|
||||
view: () => {
|
||||
return m(Layout, {children: [m(AlertList)]})
|
||||
},
|
||||
},
|
||||
"/alerts/new": {
|
||||
view: () => {
|
||||
return m(Layout, {children: [m(AlertCreate)]})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
state.subscribe(() => m.redraw())
|
||||
|
||||
defaultSocketPolicies.push(
|
||||
makeSocketPolicyAuth({
|
||||
sign: (event: StampedEvent) => {
|
||||
return state.get().signer?.sign(event)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
Object.assign(window, {setJson, getJson})
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
a, button {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
/* Tooltip styling */
|
||||
[tooltip] {
|
||||
@apply cursor-pointer relative;
|
||||
}
|
||||
|
||||
[tooltip]:hover::after {
|
||||
content: attr(tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
|
||||
/* Animation properties */
|
||||
opacity: 0;
|
||||
animation: tooltipFadeIn 0.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Keyframes for fade in animation */
|
||||
@keyframes tooltipFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-3px);
|
||||
}
|
||||
}
|
||||
1
web/src/vite-env.d.ts
vendored
1
web/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 2893,
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
Loading…
Reference in a new issue