Skip to content

Commit 24f4148

Browse files
committed
Add icon upload functionality to relay info settings
- Implement dual icon upload: URL input or file upload with Blossom server integration - Add IconUpload component with drag-and-drop and URL validation - Integrate NIP-98 HTTP authentication for Blossom uploads - Add Kind 117 file metadata event publishing before upload - Update RelayInfoSettings to use new IconUpload component - Add relay_icon field to both frontend and backend type definitions - Make software and version fields read-only as per requirements - Fix form value synchronization for dynamic icon loading Icon uploads now support both direct URL entry and file uploads via Blossom server with proper Nostr authentication.
1 parent f4d1ead commit 24f4148

7 files changed

Lines changed: 491 additions & 37 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import React, { useState, useRef, useEffect } from 'react';
2+
import { Input, Upload, Button, message, Tabs, Avatar } from 'antd';
3+
import { UploadOutlined, LinkOutlined, LoadingOutlined } from '@ant-design/icons';
4+
import { uploadToBlossom, isValidUrl, isImageUrl } from '@app/utils/blossomUpload';
5+
import type { RcFile } from 'antd/es/upload/interface';
6+
7+
interface IconUploadProps {
8+
value?: string;
9+
onChange?: (url: string) => void;
10+
placeholder?: string;
11+
maxSize?: number; // in MB
12+
}
13+
14+
const IconUpload: React.FC<IconUploadProps> = ({
15+
value = '',
16+
onChange,
17+
placeholder = 'https://example.com/icon.png',
18+
maxSize = 5
19+
}) => {
20+
const [uploading, setUploading] = useState(false);
21+
const [urlInput, setUrlInput] = useState(value);
22+
const [activeTab, setActiveTab] = useState('url');
23+
const fileInputRef = useRef<HTMLInputElement>(null);
24+
25+
// Update local state when value prop changes (when form data loads)
26+
useEffect(() => {
27+
setUrlInput(value || '');
28+
}, [value]);
29+
30+
// Handle URL input change
31+
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
32+
const newUrl = e.target.value;
33+
setUrlInput(newUrl);
34+
35+
// Validate and update parent component
36+
if (newUrl === '' || isValidUrl(newUrl)) {
37+
onChange?.(newUrl);
38+
}
39+
};
40+
41+
// Handle file upload
42+
const handleFileUpload = async (file: RcFile): Promise<boolean> => {
43+
try {
44+
setUploading(true);
45+
46+
// Validate file type
47+
if (!file.type.startsWith('image/')) {
48+
message.error('Please select an image file');
49+
return false;
50+
}
51+
52+
// Validate file size
53+
if (file.size > maxSize * 1024 * 1024) {
54+
message.error(`File size must be less than ${maxSize}MB`);
55+
return false;
56+
}
57+
58+
// Upload to Blossom server
59+
const result = await uploadToBlossom(file);
60+
61+
// Update URL input and parent component
62+
setUrlInput(result.url);
63+
onChange?.(result.url);
64+
65+
message.success('Icon uploaded successfully!');
66+
67+
// Switch to URL tab to show the uploaded URL
68+
setActiveTab('url');
69+
70+
return true;
71+
} catch (error) {
72+
console.error('Upload failed:', error);
73+
message.error(error instanceof Error ? error.message : 'Upload failed. Please try again.');
74+
return false;
75+
} finally {
76+
setUploading(false);
77+
}
78+
};
79+
80+
// Custom upload handler that prevents default upload behavior
81+
const beforeUpload = (file: RcFile) => {
82+
handleFileUpload(file);
83+
return false; // Prevent default upload
84+
};
85+
86+
// Handle file input change (for custom file input)
87+
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
88+
const file = e.target.files?.[0];
89+
if (file) {
90+
handleFileUpload(file as RcFile);
91+
}
92+
};
93+
94+
// Trigger file input click
95+
const triggerFileInput = () => {
96+
fileInputRef.current?.click();
97+
};
98+
99+
// Clear the current icon
100+
const handleClear = () => {
101+
setUrlInput('');
102+
onChange?.('');
103+
};
104+
105+
const tabItems = [
106+
{
107+
key: 'url',
108+
label: (
109+
<span>
110+
<LinkOutlined />
111+
URL
112+
</span>
113+
),
114+
children: (
115+
<div style={{ padding: '16px 0' }}>
116+
<Input
117+
value={urlInput}
118+
onChange={handleUrlChange}
119+
placeholder={placeholder}
120+
prefix={<LinkOutlined />}
121+
suffix={
122+
urlInput && (
123+
<Button
124+
type="text"
125+
size="small"
126+
onClick={handleClear}
127+
style={{ color: '#999' }}
128+
>
129+
Clear
130+
</Button>
131+
)
132+
}
133+
/>
134+
{urlInput && !isValidUrl(urlInput) && (
135+
<div style={{ color: '#ff4d4f', fontSize: '12px', marginTop: '4px' }}>
136+
Please enter a valid URL
137+
</div>
138+
)}
139+
{urlInput && isValidUrl(urlInput) && !isImageUrl(urlInput) && (
140+
<div style={{ color: '#faad14', fontSize: '12px', marginTop: '4px' }}>
141+
Warning: URL may not point to an image file
142+
</div>
143+
)}
144+
</div>
145+
)
146+
},
147+
{
148+
key: 'upload',
149+
label: (
150+
<span>
151+
{uploading ? <LoadingOutlined /> : <UploadOutlined />}
152+
Upload
153+
</span>
154+
),
155+
children: (
156+
<div style={{ padding: '16px 0', textAlign: 'center' }}>
157+
<input
158+
ref={fileInputRef}
159+
type="file"
160+
accept="image/*"
161+
onChange={handleFileInputChange}
162+
style={{ display: 'none' }}
163+
disabled={uploading}
164+
/>
165+
166+
<Upload.Dragger
167+
beforeUpload={beforeUpload}
168+
showUploadList={false}
169+
accept="image/*"
170+
disabled={uploading}
171+
style={{ marginBottom: '16px' }}
172+
>
173+
<div style={{ padding: '20px' }}>
174+
{uploading ? (
175+
<>
176+
<LoadingOutlined style={{ fontSize: '24px', marginBottom: '8px' }} />
177+
<p>Uploading to Blossom server...</p>
178+
</>
179+
) : (
180+
<>
181+
<UploadOutlined style={{ fontSize: '24px', marginBottom: '8px' }} />
182+
<p>Click or drag image to upload</p>
183+
<p style={{ color: '#999', fontSize: '12px' }}>
184+
Supports: JPG, PNG, GIF, WebP (max {maxSize}MB)
185+
</p>
186+
</>
187+
)}
188+
</div>
189+
</Upload.Dragger>
190+
191+
<div style={{ marginBottom: '16px' }}>OR</div>
192+
193+
<Button
194+
icon={uploading ? <LoadingOutlined /> : <UploadOutlined />}
195+
onClick={triggerFileInput}
196+
disabled={uploading}
197+
loading={uploading}
198+
>
199+
Choose File
200+
</Button>
201+
</div>
202+
)
203+
}
204+
];
205+
206+
return (
207+
<div>
208+
<Tabs
209+
activeKey={activeTab}
210+
onChange={setActiveTab}
211+
items={tabItems}
212+
size="small"
213+
/>
214+
215+
{/* Preview */}
216+
{urlInput && isValidUrl(urlInput) && (
217+
<div style={{ marginTop: '16px', textAlign: 'center' }}>
218+
<div style={{ marginBottom: '8px', fontSize: '12px', color: '#666' }}>
219+
Preview:
220+
</div>
221+
<Avatar
222+
src={urlInput}
223+
size={64}
224+
shape="square"
225+
style={{ border: '1px solid #d9d9d9' }}
226+
/>
227+
</div>
228+
)}
229+
</div>
230+
);
231+
};
232+
233+
export default IconUpload;

src/components/settings/RelayInfoSettings.tsx

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useEffect } from 'react';
22
import { Form, Input, Select, Tooltip } from 'antd';
33
import {
44
QuestionCircleOutlined,
55
InfoCircleOutlined,
66
UserOutlined,
77
KeyOutlined,
8-
UploadOutlined,
98
} from '@ant-design/icons';
9+
import IconUpload from '@app/components/common/IconUpload';
1010
import useGenericSettings from '@app/hooks/useGenericSettings';
1111
import { SettingsGroupType } from '@app/types/settings.types';
1212
import BaseSettingsForm from './BaseSettingsForm';
1313
import * as S from './Settings.styles';
14-
import { ComingSoonWrapper } from '@app/styles/themes/reusableComponentStyles';
1514
const { Option } = Select;
1615
const { TextArea } = Input;
1716

1817
const RelayInfoSettings: React.FC = () => {
1918
const { settings, loading, error, fetchSettings, updateSettings, saveSettings } = useGenericSettings('relay_info');
20-
const [image, setImage] = useState<string | null>(null);
2119
const [form] = Form.useForm();
2220

2321
// Update form values when settings change
@@ -84,34 +82,22 @@ const RelayInfoSettings: React.FC = () => {
8482
>
8583
<S.InputFieldWithPrefix prefix={<InfoCircleOutlined />} placeholder="My Nostr Relay" />
8684
</Form.Item>
87-
<ComingSoonWrapper>
88-
<Form.Item
89-
name="relayIcon"
90-
label={
91-
<span>
92-
Relay Icon&nbsp;
93-
<Tooltip title="An icon representing your relay">
94-
<QuestionCircleOutlined />
95-
</Tooltip>
96-
&nbsp;(Coming Soon)
97-
</span>
98-
}
99-
>
100-
{
101-
<S.InputFieldWithPrefix
102-
disabled={true}
103-
placeholder="https://example.com/relay-icon.png"
104-
// suffix={<S.UploadButton size='small' aria-label='Upload Relay Icon' icon={<UploadOutlined />}
105-
// > Upload Relay Icon</S.UploadButton>}
106-
/>
107-
}
108-
{image && (
109-
<S.UploadedImageWrapper>
110-
<img src={image} alt="" />
111-
</S.UploadedImageWrapper>
112-
)}
113-
</Form.Item>
114-
</ComingSoonWrapper>
85+
<Form.Item
86+
name="relayicon"
87+
label={
88+
<span>
89+
Relay Icon&nbsp;
90+
<Tooltip title="URL to an icon representing your relay (will be shown in relay lists). You can paste a URL or upload an image file.">
91+
<QuestionCircleOutlined />
92+
</Tooltip>
93+
</span>
94+
}
95+
>
96+
<IconUpload
97+
placeholder="https://example.com/relay-icon.png"
98+
maxSize={5}
99+
/>
100+
</Form.Item>
115101
<Form.Item
116102
name="relaydescription"
117103
label={
@@ -174,27 +160,29 @@ const RelayInfoSettings: React.FC = () => {
174160
label={
175161
<span>
176162
Relay Software&nbsp;
177-
<Tooltip title="The software used to run the relay">
163+
<Tooltip title="The software used to run the relay (read-only)">
178164
<QuestionCircleOutlined />
179165
</Tooltip>
166+
&nbsp;(Read-only)
180167
</span>
181168
}
182169
>
183-
<S.InputField placeholder="HORNETS Relay" />
170+
<S.InputField placeholder="HORNETS Relay" disabled />
184171
</Form.Item>
185172

186173
<Form.Item
187174
name="relayversion"
188175
label={
189176
<span>
190177
Version&nbsp;
191-
<Tooltip title="The version of the relay software">
178+
<Tooltip title="The version of the relay software (read-only)">
192179
<QuestionCircleOutlined />
193180
</Tooltip>
181+
&nbsp;(Read-only)
194182
</span>
195183
}
196184
>
197-
<S.InputField placeholder="1.0.0" />
185+
<S.InputField placeholder="1.0.0" disabled />
198186
</Form.Item>
199187
<S.StyledOption>
200188
<Form.Item

src/hooks/useGenericSettings.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ const extractSettingsForGroup = (settings: any, groupName: string) => {
170170
'relayname': 'name',
171171
'relaydescription': 'description',
172172
'relaycontact': 'contact',
173+
'relayicon': 'icon',
173174
'relaypubkey': 'public_key', // Backend sends 'public_key'
174175
'relaydhtkey': 'dht_key',
175176
'relaysoftware': 'software',
@@ -181,11 +182,17 @@ const extractSettingsForGroup = (settings: any, groupName: string) => {
181182
Object.entries(relayInfoMappings).forEach(([frontendKey, backendKey]) => {
182183
if (rawData[backendKey] !== undefined) {
183184
processedData[frontendKey] = rawData[backendKey];
185+
if (frontendKey === 'relayicon') {
186+
console.log(`Icon mapping: ${frontendKey} = ${rawData[backendKey]}`);
187+
}
184188
} else {
185189
// Set default values for missing fields
186190
if (frontendKey === 'relaysupportednips') {
187191
processedData[frontendKey] = []; // Default empty array
188192
}
193+
if (frontendKey === 'relayicon') {
194+
console.log(`Icon field '${backendKey}' not found in rawData:`, Object.keys(rawData));
195+
}
189196
}
190197
});
191198

@@ -255,7 +262,8 @@ const buildNestedUpdate = (groupName: string, data: any) => {
255262
const relayFieldMappings: Record<string, string> = {
256263
'name': 'relayname',
257264
'description': 'relaydescription',
258-
'contact': 'relaycontact',
265+
'contact': 'relaycontact',
266+
'icon': 'relayicon',
259267
'public_key': 'relaypubkey', // Frontend 'relaypubkey' -> backend 'public_key'
260268
'dht_key': 'relaydhtkey',
261269
'software': 'relaysoftware',

0 commit comments

Comments
 (0)