added nftcache functionality and cleaned up the settings page
This commit is contained in:
@@ -78,6 +78,23 @@ export default function DappPage() {
|
||||
const { writeContractAsync, isPending: writePending } = useWriteContract();
|
||||
const { sendTransactionAsync, isPending: txPending } = useSendTransaction();
|
||||
|
||||
// NFTCache settings (from localStorage; defaults to env or '/nftcache')
|
||||
const [nftcacheEnabled, setNftcacheEnabled] = useState<boolean>(false);
|
||||
const [nftcacheBaseUrl, setNftcacheBaseUrl] = useState<string>((process.env.NEXT_PUBLIC_NFTCACHE_URL || process.env.NFTCACHE_URL || '/nftcache').replace(/\/$/, ''));
|
||||
const [nftcacheApiKey, setNftcacheApiKey] = useState<string>((process.env.NEXT_PUBLIC_NFTCACHE_API_KEY || process.env.NFTCACHE_API_KEY || '').trim());
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
const ls = window.localStorage;
|
||||
setNftcacheEnabled(ls.getItem('nftcache:enabled') === '1');
|
||||
const nurl = (ls.getItem('nftcache:baseUrl') || '').trim();
|
||||
if (nurl) setNftcacheBaseUrl(nurl.replace(/\/$/, ''));
|
||||
const nkey = (ls.getItem('nftcache:apiKey') || '').trim();
|
||||
if (nkey) setNftcacheApiKey(nkey);
|
||||
}
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// Notification settings (global) and per-position preferences
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
@@ -203,6 +220,26 @@ export default function DappPage() {
|
||||
return `nftScan:v1:${selectedChainId}:${nftAddress.toLowerCase()}`;
|
||||
}, [nftAddress, selectedChainId]);
|
||||
|
||||
// Resolve network key used by NFTCache API
|
||||
const nftcacheNetworkKey = useMemo(() => {
|
||||
if (selectedChainId === base.id) return 'base';
|
||||
if (selectedChainId === arbitrum.id) return 'arb';
|
||||
if (selectedChainId === mainnet.id) return 'eth';
|
||||
return String(selectedChainId);
|
||||
}, [selectedChainId]);
|
||||
|
||||
// Map known contracts to nftcache slug keys
|
||||
const nftcacheContractSlug = useMemo(() => {
|
||||
const addr = (nftAddress || '').toLowerCase();
|
||||
// base
|
||||
if (addr === '0xcc9a350c5b1e1c9ecd23d376e6618cdfd6bbbdbe') return 'cbbtc';
|
||||
if (addr === '0xab825f45e9e5d2459fb7a1527a8d0ca082c582f4') return 'weth';
|
||||
// arbitrum
|
||||
if (addr === '0xede6f5f8a9d6b90b1392dcc9e7fd8a5b0192bfe1') return 'wbbtc';
|
||||
// fallback: return empty to use raw address
|
||||
return '';
|
||||
}, [nftAddress]);
|
||||
|
||||
const loadCache = (): ContractCache | null => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !cacheKey) return null;
|
||||
@@ -242,6 +279,41 @@ export default function DappPage() {
|
||||
}
|
||||
}, [effectiveWallet, cacheKey]);
|
||||
|
||||
// Try NFTCache fetch first when enabled; fallback to local cache/scan
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
if (!nftcacheEnabled) return; // disabled => keep current behavior
|
||||
if (!effectiveWallet || !nftAddress) return;
|
||||
try {
|
||||
setScanBusy(true);
|
||||
const contractParam = nftcacheContractSlug || nftAddress;
|
||||
const url = `${nftcacheBaseUrl}/nfts?network=${encodeURIComponent(nftcacheNetworkKey)}&nft_contract=${encodeURIComponent(contractParam)}&user_wallet=${encodeURIComponent(effectiveWallet)}`;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), 12000);
|
||||
const headers: Record<string, string> = { 'Accept': 'application/json' };
|
||||
if (nftcacheApiKey) headers['X-API-Key'] = nftcacheApiKey;
|
||||
const res = await fetch(url, { signal: ctrl.signal, headers });
|
||||
clearTimeout(t);
|
||||
if (!res.ok) throw new Error(`nftcache status ${res.status}`);
|
||||
const data = await res.json();
|
||||
let ids: string[] = [];
|
||||
if (Array.isArray(data)) ids = data.map(String);
|
||||
else if (data && Array.isArray((data as any).tokenIds)) ids = (data as any).tokenIds.map(String);
|
||||
else if (data && Array.isArray((data as any).token_ids)) ids = (data as any).token_ids.map(String);
|
||||
const bigs = ids.map((s) => BigInt(s)).filter((x, i, arr) => (arr.indexOf(x) === i));
|
||||
setDetectedTokenIds(bigs);
|
||||
setScanComplete(true);
|
||||
console.log('[NFTCache] Loaded owner tokens', { count: bigs.length });
|
||||
} catch (e) {
|
||||
console.warn('[NFTCache] Fetch failed; falling back to local scan', e);
|
||||
} finally {
|
||||
setScanBusy(false);
|
||||
}
|
||||
};
|
||||
run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nftcacheEnabled, nftcacheBaseUrl, nftcacheApiKey, nftcacheNetworkKey, effectiveWallet, nftAddress, nftcacheContractSlug]);
|
||||
|
||||
// OwnerOf scan (batch of 12)
|
||||
const scanMore = async () => {
|
||||
if (!publicClient || !effectiveWallet || !nftAddress) return;
|
||||
@@ -442,11 +514,16 @@ export default function DappPage() {
|
||||
|
||||
// Resolve tokenIds: either detected or manual entry
|
||||
const tokenIds = useMemo(() => {
|
||||
if (deeplinkTokenId.trim()) {
|
||||
try { return [BigInt(deeplinkTokenId.trim())]; } catch { return []; }
|
||||
// Show all sources together: detected, manual, and deep-linked (deduped)
|
||||
const set = new Set<string>();
|
||||
for (const id of detectedTokenIds) set.add(id.toString());
|
||||
if (manualTokenId.trim()) {
|
||||
try { set.add(BigInt(manualTokenId.trim()).toString()); } catch {}
|
||||
}
|
||||
const manual = manualTokenId.trim() ? [BigInt(manualTokenId.trim())] : [];
|
||||
return [...detectedTokenIds, ...manual];
|
||||
if (deeplinkTokenId.trim()) {
|
||||
try { set.add(BigInt(deeplinkTokenId.trim()).toString()); } catch {}
|
||||
}
|
||||
return Array.from(set).map((s) => BigInt(s));
|
||||
}, [detectedTokenIds, manualTokenId, deeplinkTokenId]);
|
||||
|
||||
// Build reads for debt contract
|
||||
|
||||
@@ -40,6 +40,11 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
|
||||
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 [nftcacheEnabled, setNftcacheEnabled] = useState<boolean>(false);
|
||||
const [nftcacheBaseUrl, setNftcacheBaseUrl] = useState<string>(ENV_NFTCACHE);
|
||||
const [nftcacheApiKey, setNftcacheApiKey] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -58,6 +63,13 @@ 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 || ''));
|
||||
} catch {}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -118,35 +130,91 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
|
||||
<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 and scheduler credentials.</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-sm">
|
||||
<label className="col-span-2">
|
||||
<span className="text-gray-300">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"
|
||||
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>
|
||||
<p className="text-sm text-gray-300">Configure your notification provider, scheduler, RPC and NFTCache.</p>
|
||||
|
||||
<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>
|
||||
{/* Section: Basics */}
|
||||
<details className="mt-3" open>
|
||||
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">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>
|
||||
<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={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>
|
||||
</details>
|
||||
|
||||
{/* Section: Email & Timing */}
|
||||
<details className="mt-3" open>
|
||||
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">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>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
placeholder="you@example.com"
|
||||
value={form.email || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="text-gray-300">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"
|
||||
placeholder="backup@example.com"
|
||||
value={form.backupEmail || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, backupEmail: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="text-gray-300">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"
|
||||
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>
|
||||
<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"
|
||||
value={form.backupDelayDays ?? 1}
|
||||
onChange={(e) => setForm((f) => ({ ...f, backupDelayDays: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
</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
|
||||
@@ -165,175 +233,177 @@ export default function SettingsModal({ open, initial, onClose, onSave }: Settin
|
||||
onChange={(e) => setForm((f) => ({ ...f, schedyApiKey: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
<span className="text-gray-300">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"
|
||||
placeholder="you@example.com"
|
||||
value={form.email || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="text-gray-300">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"
|
||||
placeholder="backup@example.com"
|
||||
value={form.backupEmail || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, backupEmail: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="text-gray-300">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"
|
||||
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>
|
||||
<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"
|
||||
value={form.backupDelayDays ?? 1}
|
||||
onChange={(e) => setForm((f) => ({ ...f, backupDelayDays: Number(e.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{provider === 'ntfy' && (
|
||||
<>
|
||||
<label>
|
||||
<span className="text-gray-300">ntfy Server</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
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>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
placeholder="topic-name"
|
||||
value={form.ntfyTopic || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider === 'gotify' && (
|
||||
<>
|
||||
<label>
|
||||
<span className="text-gray-300">Gotify Server</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
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>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
placeholder="token"
|
||||
value={form.gotifyToken || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, gotifyToken: e.target.value }))}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* RPC settings */}
|
||||
<div className="col-span-2 mt-2 border-t border-gray-700 pt-2">
|
||||
<div className="text-sm font-semibold text-gray-200 mb-1">RPC endpoints (per network)</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label>
|
||||
<span className="text-gray-300">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"
|
||||
placeholder="https://base.llamarpc.com"
|
||||
value={rpcBase}
|
||||
onChange={(e) => setRpcBase(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="text-gray-300">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"
|
||||
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>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
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>
|
||||
)}
|
||||
</details>
|
||||
|
||||
{/* Section: Provider-specific */}
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">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>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
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>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
placeholder="topic-name"
|
||||
value={form.ntfyTopic || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{provider === 'gotify' && (
|
||||
<>
|
||||
<label>
|
||||
<span className="text-gray-300">Gotify Server</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
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>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
placeholder="token"
|
||||
value={form.gotifyToken || ''}
|
||||
onChange={(e) => setForm((f) => ({ ...f, gotifyToken: e.target.value }))}
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
placeholder="https://base.llamarpc.com"
|
||||
value={rpcBase}
|
||||
onChange={(e) => setRpcBase(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="text-gray-300">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"
|
||||
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>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-gray-700 bg-gray-800 px-2 py-1 text-gray-100"
|
||||
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>
|
||||
</details>
|
||||
|
||||
{/* Section: NFTCache */}
|
||||
<details className="mt-3">
|
||||
<summary className="cursor-pointer select-none text-sm font-semibold text-gray-200">NFTCache</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={nftcacheEnabled}
|
||||
onChange={(e) => setNftcacheEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="text-gray-300">Enable NFTCache</span>
|
||||
</label>
|
||||
<label className="col-span-2">
|
||||
<span className="text-gray-300">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"
|
||||
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>
|
||||
</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>
|
||||
@@ -367,6 +437,12 @@ 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');
|
||||
const nkey = (nftcacheApiKey || '').trim();
|
||||
if (nkey) ls.setItem('nftcache:apiKey', nkey); else ls.removeItem('nftcache:apiKey');
|
||||
}
|
||||
} catch {}
|
||||
onSave(form);
|
||||
|
||||
Reference in New Issue
Block a user