From 75f5908039773422606e56848879e74c42451248 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 10:08:14 -0400 Subject: [PATCH 1/4] 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). --- Dockerfile | 8 +- build-in-production.sh | 2 - package.json | 3 +- src/server.ts | 12 - web/.env.template | 3 - web/.gitignore | 24 -- web/eslint.config.js | 21 -- web/index.html | 15 -- web/package.json | 34 --- web/pnpm-lock.yaml | Bin 78215 -> 0 bytes web/pnpm-workspace.yaml | 2 - web/src/main.ts | 544 ---------------------------------------- web/src/style.css | 42 ---- web/src/vite-env.d.ts | 1 - web/tsconfig.json | 22 -- web/vite.config.js | 11 - 16 files changed, 2 insertions(+), 742 deletions(-) mode change 100755 => 100644 build-in-production.sh delete mode 100644 web/.env.template delete mode 100644 web/.gitignore delete mode 100644 web/eslint.config.js delete mode 100644 web/index.html delete mode 100644 web/package.json delete mode 100644 web/pnpm-lock.yaml delete mode 100644 web/pnpm-workspace.yaml delete mode 100644 web/src/main.ts delete mode 100644 web/src/style.css delete mode 100644 web/src/vite-env.d.ts delete mode 100644 web/tsconfig.json delete mode 100644 web/vite.config.js diff --git a/Dockerfile b/Dockerfile index dc3287a..f5feb6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/ diff --git a/build-in-production.sh b/build-in-production.sh old mode 100755 new mode 100644 index 37bf10d..639fba3 --- a/build-in-production.sh +++ b/build-in-production.sh @@ -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 diff --git a/package.json b/package.json index b2b588f..9ac4de2 100644 --- a/package.json +++ b/package.json @@ -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}\"", diff --git a/src/server.ts b/src/server.ts index eb3fe92..4e6e8fa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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', diff --git a/web/.env.template b/web/.env.template deleted file mode 100644 index 9f0b1f1..0000000 --- a/web/.env.template +++ /dev/null @@ -1,3 +0,0 @@ -VITE_NOTIFIER_PUBKEY= -VITE_NOTIFIER_RELAY= -VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/web/.gitignore +++ /dev/null @@ -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? diff --git a/web/eslint.config.js b/web/eslint.config.js deleted file mode 100644 index d785258..0000000 --- a/web/eslint.config.js +++ /dev/null @@ -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"}, - ], - }, - }, -]); diff --git a/web/index.html b/web/index.html deleted file mode 100644 index b477b77..0000000 --- a/web/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Anchor Alerts - - -
-
-
- - - diff --git a/web/package.json b/web/package.json deleted file mode 100644 index c783cf3..0000000 --- a/web/package.json +++ /dev/null @@ -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" - } -} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml deleted file mode 100644 index dc6d6add4e6f81f33c0ef9c19c53403b022976f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78215 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 - -` - -const TRASH_ICON = ` - - - - -` - -const ARROW_LEFT_ICON = ` - - -` - -// 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) => { - 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}) diff --git a/web/src/style.css b/web/src/style.css deleted file mode 100644 index 1c1b184..0000000 --- a/web/src/style.css +++ /dev/null @@ -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); - } -} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts deleted file mode 100644 index 11f02fe..0000000 --- a/web/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/web/tsconfig.json b/web/tsconfig.json deleted file mode 100644 index 9d2104f..0000000 --- a/web/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/web/vite.config.js b/web/vite.config.js deleted file mode 100644 index bd48de3..0000000 --- a/web/vite.config.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite' -import tailwindcss from '@tailwindcss/vite' - -export default defineConfig({ - server: { - port: 2893, - }, - plugins: [ - tailwindcss(), - ], -}) From abc993e595ec8ca1fc4aaa820dad5c1730cd85d2 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 10:09:13 -0400 Subject: [PATCH 2/4] Remove orphaned web/dist/ reference from .dockerignore --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index 49ad795..4b7fa98 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ node_modules/ dist/ -web/dist/ .git/ .gitattributes .gitignore From 2fb738981e65fad6a2af2d52bd5f9854f23cc9d2 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 10:36:54 -0400 Subject: [PATCH 3/4] Fix pre-existing eslint unused-import errors to pass script/checks gate Remove 20 no-unused-vars violations across 5 files: - src/actions.ts: unused Subscription type import, getCronExpression - src/alert.ts: unused cron-parser and @welshman/lib imports - src/digest.ts: unused now, nth, nthEq, dateToSeconds, getIdFilters, getReplyFilters, Loader, AdapterContext, makeLoader, SocketAdapter, call, loadRelaySelections - src/env.ts: unused netContext import - src/worker/email.ts: assigned-but-unused purgeJob variable These were pre-existing errors unrelated to the web UI removal. --- src/actions.ts | 2 -- src/alert.ts | 3 +-- src/digest.ts | 9 --------- src/env.ts | 1 - src/worker/email.ts | 2 +- 5 files changed, 2 insertions(+), 15 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index ceec6fe..661044a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,6 +1,4 @@ import { instrument } from 'succinct-async' -import type { Subscription } from './alert.js' -import { getCronExpression } from './alert.js' import * as mailer from './mailer.js' import * as worker from './worker/index.js' import * as db from './database.js' diff --git a/src/alert.ts b/src/alert.ts index 01df785..62f5bf8 100644 --- a/src/alert.ts +++ b/src/alert.ts @@ -1,5 +1,4 @@ -import { CronExpressionParser } from 'cron-parser' -import { tryCatch, int, HOUR } from '@welshman/lib' + export type Subscription = { id: string diff --git a/src/digest.ts b/src/digest.ts index 334a2c8..334c00b 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -1,13 +1,9 @@ import { neventEncode, decode } from 'nostr-tools/nip19' import { spec, - now, sortBy, groupBy, displayList, - nth, - nthEq, - dateToSeconds, secondsToDate, } from '@welshman/lib' import { parse, truncate, renderAsHtml } from '@welshman/content' @@ -15,8 +11,6 @@ import { TrustedEvent, normalizeRelayUrl, getParentId, - getIdFilters, - getReplyFilters, NOTE, COMMENT, REACTION, @@ -24,15 +18,12 @@ import { displayPubkey, getTagValue, } from '@welshman/util' -import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' -import { call } from '@welshman/lib' import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' import { EVENT_VIEWER_URL } from './env.js' import { profilesByPubkey, - loadRelaySelections, loadProfile, } from './repository.js' diff --git a/src/env.ts b/src/env.ts index 3588110..7795c40 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..df776a5 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -65,7 +65,7 @@ export const removeJob = (sub: Subscription) => { } // Daily purge of events older than 7 days -const purgeJob = CronJob.from({ +CronJob.from({ cronTime: '0 0 3 * * *', // 3am UTC daily onTick: async () => { const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600 From 4497a7dee95815fc8c6afb6bb349808058c1b07c Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 10 Sep 2026 11:58:05 -0400 Subject: [PATCH 4/4] fix: remove pre-eslint unused-variable errors across src/ Remove 21 unused imports/variables that caused eslint failures: - actions.ts (2): Subscription type import, getCronExpression import - alert.ts (4): CronExpressionParser, tryCatch, int, HOUR - digest.ts (12): now, nth, nthEq, dateToSeconds, getIdFilters, getReplyFilters, Loader, AdapterContext, makeLoader, SocketAdapter, call, loadRelaySelections - env.ts (1): netContext - worker/email.ts (1): purgeJob variable (kept CronJob side-effect) All unused symbols were pre-existing, not related to changed files. tsc --noEmit passes; script/checks now returns 0 on the src check. --- src/actions.ts | 2 -- src/alert.ts | 3 +-- src/digest.ts | 10 +--------- src/env.ts | 1 - src/worker/email.ts | 2 +- 5 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/actions.ts b/src/actions.ts index ceec6fe..661044a 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -1,6 +1,4 @@ import { instrument } from 'succinct-async' -import type { Subscription } from './alert.js' -import { getCronExpression } from './alert.js' import * as mailer from './mailer.js' import * as worker from './worker/index.js' import * as db from './database.js' diff --git a/src/alert.ts b/src/alert.ts index 01df785..62f5bf8 100644 --- a/src/alert.ts +++ b/src/alert.ts @@ -1,5 +1,4 @@ -import { CronExpressionParser } from 'cron-parser' -import { tryCatch, int, HOUR } from '@welshman/lib' + export type Subscription = { id: string diff --git a/src/digest.ts b/src/digest.ts index 334a2c8..daccaf2 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -1,13 +1,9 @@ import { neventEncode, decode } from 'nostr-tools/nip19' import { spec, - now, sortBy, groupBy, displayList, - nth, - nthEq, - dateToSeconds, secondsToDate, } from '@welshman/lib' import { parse, truncate, renderAsHtml } from '@welshman/content' @@ -15,8 +11,6 @@ import { TrustedEvent, normalizeRelayUrl, getParentId, - getIdFilters, - getReplyFilters, NOTE, COMMENT, REACTION, @@ -24,15 +18,13 @@ import { displayPubkey, getTagValue, } from '@welshman/util' -import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' -import { call } from '@welshman/lib' + import { displayDuration, createElement } from './util.js' import type { Subscription } from './alert.js' import { sendDigest } from './mailer.js' import { EVENT_VIEWER_URL } from './env.js' import { profilesByPubkey, - loadRelaySelections, loadProfile, } from './repository.js' diff --git a/src/env.ts b/src/env.ts index 3588110..7795c40 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,7 +1,6 @@ import 'dotenv/config' import { always } from '@welshman/lib' import { normalizeRelayUrl } from '@welshman/util' -import { netContext } from '@welshman/net' import { Nip01Signer } from '@welshman/signer' import { routerContext } from '@welshman/router' diff --git a/src/worker/email.ts b/src/worker/email.ts index d60e4a3..df776a5 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -65,7 +65,7 @@ export const removeJob = (sub: Subscription) => { } // Daily purge of events older than 7 days -const purgeJob = CronJob.from({ +CronJob.from({ cronTime: '0 0 3 * * *', // 3am UTC daily onTick: async () => { const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600