feat(ui): Ghibli/Miyazaki reskin + Obsidian docs vault + project audit

UI: warm daylight design system (Tailwind v4 @theme palette, gh-* component
classes, watercolor grain, Zen Maru Gothic + Klee One fonts), animated SSR-safe
GhibliBackground (drifting clouds, meadow hills, soot sprites), and a full reskin
of navbar, connect button, dapp page, loan cards, settings modal, and readme.
Fixes the bg-white-on-dark loan-card inconsistency. Web3/business logic untouched.

Docs: converted docs/ into an Obsidian vault (frontmatter, [[wikilinks]],
callouts, Home MOC, folders Architecture/Operations/Audits) and added a
full-project audit note (Project Audit 2026-06). Redacted a real leaked Schedy
key value from the security audit example (rotate it at Schedy).

Also commits the previously-untracked server layer: app/api (cron + tasks routes)
and lib (redis, ssrf-guard, task-store).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-06-14 08:13:53 +04:00
parent cf76322008
commit 6ae581ab2e
25 changed files with 4245 additions and 369 deletions

View File

@@ -17,10 +17,9 @@ export function ConnectButton() {
if (isConnected && address) {
return (
<div className="flex items-center space-x-4">
<span className="text-sm font-medium text-gray-700">
{formatAddress(address)}
</span>
<div className="inline-flex items-center gap-2 rounded-full border border-line bg-cloud/80 px-3 py-1.5 text-sm font-medium text-forest-deep">
<span className="h-2 w-2 rounded-full bg-forest animate-breathe" aria-hidden />
{formatAddress(address)}
{/* Disconnect can be done from the wallet UI; no WalletConnect here */}
</div>
);
@@ -29,9 +28,9 @@ export function ConnectButton() {
return (
<button
onClick={handleConnect}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="gh-btn gh-btn-primary text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-deep"
>
Connect Wallet
🍃 Connect Wallet
</button>
);
}

View File

@@ -0,0 +1,108 @@
// Purely decorative, server-rendered Ghibli landscape that sits behind all
// content. No JS, no state — just layered CSS-animated SVG/divs. Positions and
// timings are hardcoded so SSR and client markup match (no hydration drift).
const clouds = [
{ top: "8%", scale: 1.0, duration: "70s", delay: "0s", opacity: 0.9 },
{ top: "18%", scale: 0.7, duration: "95s", delay: "-30s", opacity: 0.75 },
{ top: "30%", scale: 1.3, duration: "120s", delay: "-70s", opacity: 0.85 },
{ top: "46%", scale: 0.55, duration: "85s", delay: "-15s", opacity: 0.6 },
];
// Soot sprites (susuwatari) drifting up from the meadow
const sprites = [
{ left: "12%", size: 10, duration: "16s", delay: "0s" },
{ left: "26%", size: 7, duration: "21s", delay: "-6s" },
{ left: "44%", size: 12, duration: "18s", delay: "-11s" },
{ left: "61%", size: 8, duration: "23s", delay: "-3s" },
{ left: "78%", size: 9, duration: "19s", delay: "-14s" },
{ left: "89%", size: 6, duration: "25s", delay: "-9s" },
];
function Cloud({ opacity }: { opacity: number }) {
return (
<svg viewBox="0 0 200 80" width="200" height="80" style={{ opacity }}>
<g fill="#fffdf7">
<ellipse cx="60" cy="50" rx="55" ry="26" />
<ellipse cx="105" cy="40" rx="45" ry="34" />
<ellipse cx="145" cy="52" rx="42" ry="24" />
<ellipse cx="95" cy="58" rx="70" ry="20" />
</g>
</svg>
);
}
export default function GhibliBackground() {
return (
<div aria-hidden className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
{/* warm sun glow, lower-left */}
<div
className="gh-anim absolute rounded-full"
style={{
left: "-6rem",
top: "-6rem",
width: "26rem",
height: "26rem",
background:
"radial-gradient(circle, rgba(244,210,122,0.55) 0%, rgba(244,210,122,0) 68%)",
animation: "breathe 9s ease-in-out infinite",
}}
/>
{/* drifting clouds */}
{clouds.map((c, i) => (
<div
key={i}
className="gh-anim absolute left-0"
style={{
top: c.top,
transform: `scale(${c.scale})`,
animation: `drift ${c.duration} linear infinite`,
animationDelay: c.delay,
}}
>
<Cloud opacity={c.opacity} />
</div>
))}
{/* layered meadow hills along the bottom */}
<svg
className="absolute bottom-0 left-0 w-full"
viewBox="0 0 1440 320"
preserveAspectRatio="none"
style={{ height: "38vh", minHeight: "220px" }}
>
<path
fill="#9cb87f"
fillOpacity="0.55"
d="M0,224 C240,160 420,272 720,224 C1020,176 1200,272 1440,208 L1440,320 L0,320 Z"
/>
<path
fill="#6f9a5a"
fillOpacity="0.7"
d="M0,272 C260,224 480,304 760,272 C1040,240 1240,304 1440,272 L1440,320 L0,320 Z"
/>
<path
fill="#466a3a"
fillOpacity="0.85"
d="M0,300 C300,276 520,320 820,300 C1120,280 1280,316 1440,300 L1440,320 L0,320 Z"
/>
</svg>
{/* soot sprites rising from the meadow */}
{sprites.map((s, i) => (
<span
key={i}
className="gh-soot gh-anim"
style={{
left: s.left,
width: `${s.size}px`,
height: `${s.size}px`,
animation: `rise ${s.duration} ease-in-out infinite`,
animationDelay: s.delay,
}}
/>
))}
</div>
);
}

View File

@@ -1,7 +1,6 @@
'use client';
import { Fragment } from 'react';
import { Disclosure, Menu, Transition } from '@headlessui/react';
import { Disclosure } from '@headlessui/react';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import { ConnectButton } from '@/components/ConnectButton';
@@ -17,29 +16,29 @@ function classNames(...classes: string[]) {
export default function Navbar() {
return (
<Disclosure as="nav" className="bg-gray-900 border-b border-gray-800">
<Disclosure
as="nav"
className="sticky top-0 z-40 border-b border-line/70 bg-cloud/70 backdrop-blur-md"
>
{({ open }) => (
<>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<span className="text-xl font-bold text-indigo-400">MortgageFi</span>
<div className="flex-shrink-0 flex items-center gap-2">
<span className="text-2xl" aria-hidden>🌱</span>
<span className="font-display text-xl font-semibold text-forest-deep">
MortgageFi
</span>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<div className="hidden sm:ml-8 sm:flex sm:space-x-2">
{navigation.map((item) => (
<a
key={item.name}
href={item.href}
target={item.external ? "_blank" : "_self"}
rel={item.external ? "noopener noreferrer" : ""}
className={classNames(
item.current
? 'border-indigo-500 text-gray-100'
: 'border-transparent text-gray-300 hover:border-gray-700 hover:text-white',
'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium'
)}
aria-current={item.current ? 'page' : undefined}
className="inline-flex items-center rounded-full px-3 py-1.5 my-3 text-sm font-medium text-ink-soft transition hover:bg-sun/30 hover:text-forest-deep"
>
{item.name}
</a>
@@ -50,8 +49,7 @@ export default function Navbar() {
<ConnectButton />
</div>
<div className="-mr-2 flex items-center sm:hidden">
{/* Mobile menu button */}
<Disclosure.Button className="inline-flex items-center justify-center p-2 rounded-md text-gray-300 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500">
<Disclosure.Button className="inline-flex items-center justify-center p-2 rounded-full text-ink-soft hover:text-forest-deep hover:bg-sun/30 focus:outline-none focus:ring-2 focus:ring-sky-deep">
<span className="sr-only">Open main menu</span>
{open ? (
<XMarkIcon className="block h-6 w-6" aria-hidden="true" />
@@ -64,7 +62,7 @@ export default function Navbar() {
</div>
<Disclosure.Panel className="sm:hidden">
<div className="pt-2 pb-3 space-y-1">
<div className="pt-2 pb-3 space-y-1 px-2">
{navigation.map((item) => (
<Disclosure.Button
key={item.name}
@@ -72,19 +70,13 @@ export default function Navbar() {
href={item.href}
target={item.external ? "_blank" : "_self"}
rel={item.external ? "noopener noreferrer" : ""}
className={classNames(
item.current
? 'bg-gray-800 border-indigo-500 text-gray-100'
: 'border-transparent text-gray-300 hover:bg-gray-800 hover:border-gray-700 hover:text-white',
'block pl-3 pr-4 py-2 border-l-4 text-base font-medium'
)}
aria-current={item.current ? 'page' : undefined}
className="block rounded-xl px-3 py-2 text-base font-medium text-ink-soft hover:bg-sun/30 hover:text-forest-deep"
>
{item.name}
</Disclosure.Button>
))}
<div className="pt-4 pb-3 border-t border-gray-800">
<div className="px-4">
<div className="pt-4 pb-2 border-t border-line/60">
<div className="px-2">
<ConnectButton />
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { NotificationSettings, NotificationProvider, SchedulerProvider } from "@/types/notifications";
import { NotificationSettings, NotificationProvider } from "@/types/notifications";
import { scheduleTestNotification, scheduleTestBackupNotification } from "@/utils/scheduler";
export interface SettingsModalProps {
@@ -10,23 +10,14 @@ export interface SettingsModalProps {
onSave: (settings: NotificationSettings) => void;
}
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/$/, '');
const ENV_SCHEDY = (process.env.NEXT_PUBLIC_SCHEDY_URL || process.env.SCHEDY_URL || 'http://localhost:8080').replace(/\/$/, '');
const ENV_SCHEDY_API_KEY = (process.env.NEXT_PUBLIC_SCHEDY_API_KEY || process.env.SCHEDY_API_KEY || '').trim();
const ENV_NTFY = (process.env.NEXT_PUBLIC_NTFY_URL || '/ntfy').replace(/\/$/, '');
const defaultSettings: NotificationSettings = {
provider: "",
scheduler: 'schedy',
ntfyServer: ENV_NTFY,
ntfyTopic: "",
gotifyServer: "",
gotifyToken: "",
snsRegion: "",
snsTopicArn: "",
snsAccessKeyId: "",
snsSecretAccessKey: "",
schedyBaseUrl: ENV_SCHEDY,
schedyApiKey: ENV_SCHEDY_API_KEY,
email: "",
backupEmail: "",
backupDelayDays: 1,
@@ -36,25 +27,19 @@ const defaultSettings: NotificationSettings = {
export default function SettingsModal({ open, initial, onClose, onSave }: SettingsModalProps) {
const [form, setForm] = useState<NotificationSettings>(initial || defaultSettings);
const [testStatus, setTestStatus] = useState<string>("");
// RPC runtime overrides (stored in localStorage)
const [rpcBase, setRpcBase] = useState<string>("");
const [rpcArbitrum, setRpcArbitrum] = useState<string>("");
const [rpcMainnet, setRpcMainnet] = useState<string>("");
// NFTCache settings (stored in localStorage)
const ENV_NFTCACHE = (process.env.NEXT_PUBLIC_NFTCACHE_URL || process.env.NFTCACHE_URL || '/nftcache').replace(/\/$/, '');
const ENV_NFTCACHE = (process.env.NEXT_PUBLIC_NFTCACHE_URL || '/nftcache').replace(/\/$/, '');
const [nftcacheEnabled, setNftcacheEnabled] = useState<boolean>(false);
const [nftcacheBaseUrl, setNftcacheBaseUrl] = useState<string>(ENV_NFTCACHE);
const [nftcacheApiKey, setNftcacheApiKey] = useState<string>('');
useEffect(() => {
if (open) {
// Merge defaults to ensure newly added fields (e.g., backupDelayDays) are populated
const merged = { ...defaultSettings, ...(initial || {}) } as NotificationSettings;
// Re-apply env defaults if user hasn't set values
if (!merged.ntfyServer) merged.ntfyServer = ENV_NTFY;
if (!merged.schedyBaseUrl) merged.schedyBaseUrl = ENV_SCHEDY;
setForm(merged);
// Load RPC overrides from localStorage
try {
const ls = typeof window !== 'undefined' ? window.localStorage : undefined;
const b = ls?.getItem('rpc:base') || '';
@@ -63,45 +48,38 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
setRpcBase(b || 'https://base.llamarpc.com');
setRpcArbitrum(a || '');
setRpcMainnet(m || '');
// Load NFTCache settings
const nEn = ls?.getItem('nftcache:enabled');
const nUrl = ls?.getItem('nftcache:baseUrl');
const nKey = ls?.getItem('nftcache:apiKey');
setNftcacheEnabled(nEn === '1');
setNftcacheBaseUrl((nUrl && nUrl.trim()) || ENV_NFTCACHE);
setNftcacheApiKey(nKey || (process.env.NEXT_PUBLIC_NFTCACHE_API_KEY || process.env.NFTCACHE_API_KEY || ''));
setNftcacheApiKey(nKey || '');
} catch {}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initial]);
const provider: NotificationProvider | '' = form.provider || '';
const scheduler: SchedulerProvider | '' = form.scheduler || '';
const saveDisabled = useMemo(() => {
if (!form.email) return true;
const db = form.daysBefore ?? 10;
if (Number(db) < 0) return true;
// if backupEmail provided, enforce min backupDelayDays >= 1 (treat empty/NaN as invalid)
const hasBackup = Boolean((form.backupEmail || '').trim());
if (hasBackup) {
const bdd = Number(form.backupDelayDays);
if (!Number.isFinite(bdd) || bdd < 1) return true;
}
if (!provider) return true;
if (!scheduler) return true;
if (provider === 'ntfy') return !(form.ntfyServer && form.ntfyTopic);
if (provider === 'gotify') return !(form.gotifyServer && form.gotifyToken);
if (provider === 'sns') return !(form.snsRegion && form.snsTopicArn && form.snsAccessKeyId && form.snsSecretAccessKey);
if (scheduler === 'schedy') return !(form.schedyBaseUrl && form.schedyApiKey);
return true;
}, [form, provider, scheduler]);
return false;
}, [form, provider]);
const canTest = useMemo(() => {
// Basic same checks as save + provider must be ntfy
if (saveDisabled) return false;
return provider === 'ntfy' && scheduler === 'schedy';
}, [saveDisabled, provider, scheduler]);
return provider === 'ntfy';
}, [saveDisabled, provider]);
const onTest = async () => {
setTestStatus('Scheduling test…');
@@ -126,38 +104,29 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div className="relative z-10 w-full max-w-xl rounded-lg border border-gray-700 bg-gray-900 p-4 text-gray-100 shadow-lg">
<div className="text-lg font-semibold">Notification Settings</div>
<p className="text-sm text-gray-300">Configure your notification provider, scheduler, RPC and NFTCache.</p>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-ink/40 backdrop-blur-sm" onClick={onClose} />
<div className="gh-card relative z-10 w-full max-w-xl p-5 text-ink shadow-float max-h-[90vh] overflow-y-auto">
<div className="font-display text-xl font-semibold text-forest-deep flex items-center gap-2"><span aria-hidden>🔔</span>Notification Settings</div>
<p className="text-sm text-ink-soft">Configure your notification provider, RPC and NFTCache.</p>
<div className="gh-note gh-note-warn mt-2 text-xs">
Settings are stored locally in your browser. Do not use this on shared computers.
</div>
{/* Section: Basics */}
<details className="mt-3" open>
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Basics</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">Basics</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label className="col-span-2">
<span className="text-gray-300">Provider</span>
<span className="text-earth">Provider</span>
<select
className="mt-1 w-full rounded-none border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100 appearance-none shadow-none focus:outline-none focus:ring-0"
className="gh-select appearance-none"
value={provider}
onChange={(e) => setForm((f) => ({ ...f, provider: e.target.value as NotificationProvider }))}
>
<option value="">Select</option>
<option value="ntfy">ntfy.sh</option>
<option value="gotify">Gotify</option>
<option value="sns">Amazon SNS</option>
</select>
</label>
<label className="col-span-2">
<span className="text-gray-300">Scheduler</span>
<select
className="mt-1 w-full rounded-none border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100 appearance-none shadow-none focus:outline-none focus:ring-0"
value={scheduler}
onChange={(e) => setForm((f) => ({ ...f, scheduler: e.target.value as SchedulerProvider }))}
>
<option value="schedy">Schedy</option>
</select>
</label>
</div>
@@ -165,12 +134,12 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
{/* Section: Email & Timing */}
<details className="mt-3" open>
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Email & Timing</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">Email & Timing</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label>
<span className="text-gray-300">Email to notify</span>
<span className="text-earth">Email to notify</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="you@example.com"
value={form.email || ''}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
@@ -178,9 +147,9 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</label>
<label>
<span className="text-gray-300">Backup Email (optional)</span>
<span className="text-earth">Backup Email (optional)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="backup@example.com"
value={form.backupEmail || ''}
onChange={(e) => setForm((f) => ({ ...f, backupEmail: e.target.value }))}
@@ -188,21 +157,23 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</label>
<label>
<span className="text-gray-300">Days before liquidation</span>
<span className="text-earth">Days before liquidation</span>
<input
type="number"
min={0}
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
max={365}
className="gh-input"
value={form.daysBefore ?? 10}
onChange={(e) => setForm((f) => ({ ...f, daysBefore: Number(e.target.value) }))}
/>
</label>
<label>
<span className="text-gray-300">Backup delay (days after first email)</span>
<span className="text-earth">Backup delay (days after first email)</span>
<input
type="number"
min={1}
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
max={90}
className="gh-input"
value={form.backupDelayDays ?? 1}
onChange={(e) => setForm((f) => ({ ...f, backupDelayDays: Number(e.target.value) }))}
/>
@@ -210,52 +181,25 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</div>
</details>
{/* Section: Scheduler (Schedy) */}
<details className="mt-3">
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Scheduler</summary>
{scheduler === 'schedy' && (
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label>
<span className="text-gray-300">Schedy Base URL</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="http://localhost:8080"
value={form.schedyBaseUrl || ''}
onChange={(e) => setForm((f) => ({ ...f, schedyBaseUrl: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">Schedy API Key</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="your-secret"
value={form.schedyApiKey || ''}
onChange={(e) => setForm((f) => ({ ...f, schedyApiKey: e.target.value }))}
/>
</label>
</div>
)}
</details>
{/* Section: Provider-specific */}
<details className="mt-3">
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">Provider Settings</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">Provider Settings</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
{provider === 'ntfy' && (
<>
<label>
<span className="text-gray-300">ntfy Server</span>
<span className="text-earth">ntfy Server</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://ntfy.sh"
value={form.ntfyServer || ''}
onChange={(e) => setForm((f) => ({ ...f, ntfyServer: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">ntfy Topic</span>
<span className="text-earth">ntfy Topic</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="topic-name"
value={form.ntfyTopic || ''}
onChange={(e) => setForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
@@ -267,18 +211,18 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
{provider === 'gotify' && (
<>
<label>
<span className="text-gray-300">Gotify Server</span>
<span className="text-earth">Gotify Server</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://gotify.example.com"
value={form.gotifyServer || ''}
onChange={(e) => setForm((f) => ({ ...f, gotifyServer: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">Gotify App Token</span>
<span className="text-earth">Gotify App Token</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="token"
value={form.gotifyToken || ''}
onChange={(e) => setForm((f) => ({ ...f, gotifyToken: e.target.value }))}
@@ -286,92 +230,49 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</label>
</>
)}
{provider === 'sns' && (
<>
<label>
<span className="text-gray-300">AWS Region</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="us-east-1"
value={form.snsRegion || ''}
onChange={(e) => setForm((f) => ({ ...f, snsRegion: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">SNS Topic ARN</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="arn:aws:sns:..."
value={form.snsTopicArn || ''}
onChange={(e) => setForm((f) => ({ ...f, snsTopicArn: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">AWS Access Key ID</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="AKIA..."
value={form.snsAccessKeyId || ''}
onChange={(e) => setForm((f) => ({ ...f, snsAccessKeyId: e.target.value }))}
/>
</label>
<label>
<span className="text-gray-300">AWS Secret Access Key</span>
<input
type="password"
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="••••••••"
value={form.snsSecretAccessKey || ''}
onChange={(e) => setForm((f) => ({ ...f, snsSecretAccessKey: e.target.value }))}
/>
</label>
<div className="col-span-2 text-xs text-amber-300">
Storing AWS credentials in the browser is insecure. Prefer a server-side relay.
</div>
</>
)}
</div>
</details>
{/* Section: RPC endpoints */}
<details className="mt-3">
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">RPC endpoints</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">RPC endpoints</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label>
<span className="text-gray-300">Base RPC (default https://base.llamarpc.com)</span>
<span className="text-earth">Base RPC (default https://base.llamarpc.com)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://base.llamarpc.com"
value={rpcBase}
onChange={(e) => setRpcBase(e.target.value)}
/>
</label>
<label>
<span className="text-gray-300">Arbitrum RPC (optional)</span>
<span className="text-earth">Arbitrum RPC (optional)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://arb.yourrpc.example"
value={rpcArbitrum}
onChange={(e) => setRpcArbitrum(e.target.value)}
/>
</label>
<label className="col-span-2">
<span className="text-gray-300">Mainnet RPC (optional)</span>
<span className="text-earth">Mainnet RPC (optional)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="https://mainnet.yourrpc.example"
value={rpcMainnet}
onChange={(e) => setRpcMainnet(e.target.value)}
/>
</label>
<div className="col-span-2 text-xs text-gray-400">Changes apply immediately after Save without rebuild.</div>
<div className="col-span-2 text-xs text-danger">
Only use HTTPS RPC endpoints you trust. Malicious RPCs can show fake balances and hide liquidation warnings.
</div>
</div>
</details>
{/* Section: NFTCache */}
<details className="mt-3">
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">NFTCache</summary>
<summary className="cursor-pointer select-none gh-eyebrow text-sm">NFTCache</summary>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<label className="flex items-center gap-2">
<input
@@ -380,35 +281,25 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
checked={nftcacheEnabled}
onChange={(e) => setNftcacheEnabled(e.target.checked)}
/>
<span className="text-gray-300">Enable NFTCache</span>
<span className="text-earth">Enable NFTCache</span>
</label>
<label className="col-span-2">
<span className="text-gray-300">NFTCache Base URL</span>
<span className="text-earth">NFTCache Base URL</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
className="gh-input"
placeholder="/nftcache"
value={nftcacheBaseUrl}
onChange={(e) => setNftcacheBaseUrl(e.target.value)}
/>
<div className="text-xs text-gray-400 mt-1">Proxied default via nginx: /nftcache. Endpoint used in-app: {nftcacheBaseUrl || ENV_NFTCACHE}/nfts</div>
</label>
<label className="col-span-2">
<span className="text-gray-300">NFTCache API Key (X-API-Key)</span>
<input
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
placeholder="optional, can also be set via NEXT_PUBLIC_NFTCACHE_API_KEY"
value={nftcacheApiKey}
onChange={(e) => setNftcacheApiKey(e.target.value)}
/>
<div className="text-xs text-gray-400 mt-1">If empty, the app will try NEXT_PUBLIC_NFTCACHE_API_KEY from the environment.</div>
<div className="text-xs text-earth mt-1">Proxied default via nginx: /nftcache. Endpoint used in-app: {nftcacheBaseUrl || ENV_NFTCACHE}/nfts</div>
</label>
</div>
</details>
<div className="mt-4 flex justify-end gap-2">
<button className="rounded bg-gray-700 px-3 py-1.5" onClick={onClose}>Cancel</button>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<button className="gh-btn gh-btn-ghost" onClick={onClose}>Cancel</button>
<button
className="rounded bg-emerald-600 px-3 py-1.5 disabled:opacity-50"
className="gh-btn gh-btn-sun"
disabled={!canTest}
onClick={onTest}
title={provider !== 'ntfy' ? 'Test currently supports ntfy provider only' : ''}
@@ -416,7 +307,7 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
Send test alert
</button>
<button
className="rounded bg-teal-600 px-3 py-1.5 disabled:opacity-50"
className="gh-btn gh-btn-sky"
disabled={!canTest || !(form.backupEmail || '').trim()}
onClick={onTestBackup}
title={!((form.backupEmail || '').trim()) ? 'Set a backup email first' : ''}
@@ -424,10 +315,9 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
Send backup test
</button>
<button
className="rounded bg-indigo-600 px-3 py-1.5 disabled:opacity-50"
className="gh-btn gh-btn-primary"
disabled={saveDisabled}
onClick={() => {
// Persist RPC overrides
try {
if (typeof window !== 'undefined' && window.localStorage) {
const ls = window.localStorage;
@@ -437,7 +327,6 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
if (b) ls.setItem('rpc:base', b); else ls.removeItem('rpc:base');
if (a) ls.setItem('rpc:arbitrum', a); else ls.removeItem('rpc:arbitrum');
if (m) ls.setItem('rpc:mainnet', m); else ls.removeItem('rpc:mainnet');
// Persist NFTCache settings
ls.setItem('nftcache:enabled', nftcacheEnabled ? '1' : '0');
const nurl = (nftcacheBaseUrl || '').trim();
if (nurl) ls.setItem('nftcache:baseUrl', nurl); else ls.removeItem('nftcache:baseUrl');
@@ -448,7 +337,6 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
onSave(form);
try {
if (typeof window !== 'undefined') {
// Ensure new RPCs are applied by reloading the app
setTimeout(() => window.location.reload(), 150);
}
} catch {}
@@ -458,7 +346,7 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
</button>
</div>
{testStatus && (
<div className="mt-2 text-xs text-gray-300">{testStatus}</div>
<div className="mt-2 text-xs text-ink-soft">{testStatus}</div>
)}
</div>
</div>