feat: add settings panel with NIP-78 storage

- Create settings service using Kind 30078 for user preferences
- Add Settings component with UI for configuring app preferences
- Wire settings icon to open settings modal
- Store settings like default view mode, sidebar collapse states, etc.
- Use d tag: com.dergigi.boris.user-settings
This commit is contained in:
Gigi
2025-10-05 02:30:30 +01:00
parent 67f0a0b3b6
commit f8c8ab5402
6 changed files with 432 additions and 44 deletions

View File

@@ -16,6 +16,7 @@ interface BookmarkListProps {
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
selectedUrl?: string
onOpenSettings: () => void
}
export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -26,7 +27,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onLogout,
viewMode,
onViewModeChange,
selectedUrl
selectedUrl,
onOpenSettings
}) => {
if (isCollapsed) {
// Check if the selected URL is in bookmarks
@@ -57,6 +59,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onLogout={onLogout}
viewMode={viewMode}
onViewModeChange={onViewModeChange}
onOpenSettings={onOpenSettings}
/>
{bookmarks.length === 0 ? (

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import { Hooks } from 'applesauce-react'
import { Hooks, useEventStore } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay'
import { EventFactory } from 'applesauce-factory'
import { Bookmark } from '../types/bookmarks'
import { Highlight } from '../types/highlights'
import { BookmarkList } from './BookmarkList'
@@ -9,6 +10,8 @@ import { fetchHighlights } from '../services/highlightService'
import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel'
import { fetchReadableContent, ReadableContent } from '../services/readerService'
import Settings from './Settings'
import { UserSettings, loadSettings, saveSettings } from '../services/settingsService'
export type ViewMode = 'compact' | 'cards' | 'large'
@@ -17,6 +20,11 @@ interface BookmarksProps {
onLogout: () => void
}
const RELAY_URLS = [
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band',
'wss://relay.dergigi.com', 'wss://wot.dergigi.com'
]
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [loading, setLoading] = useState(true)
@@ -30,8 +38,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const [viewMode, setViewMode] = useState<ViewMode>('compact')
const [showUnderlines, setShowUnderlines] = useState(true)
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
const [showSettings, setShowSettings] = useState(false)
const [settings, setSettings] = useState<UserSettings>({})
const [isSavingSettings, setIsSavingSettings] = useState(false)
const activeAccount = Hooks.useActiveAccount()
const accountManager = Hooks.useAccountManager()
const eventStore = useEventStore()
useEffect(() => {
console.log('Bookmarks useEffect triggered')
@@ -41,10 +53,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
console.log('Starting to fetch bookmarks and highlights...')
handleFetchBookmarks()
handleFetchHighlights()
handleLoadSettings()
} else {
console.log('Not fetching bookmarks - missing dependencies')
}
}, [relayPool, activeAccount?.pubkey]) // Only depend on pubkey, not the entire activeAccount object
}, [relayPool, activeAccount?.pubkey])
useEffect(() => {
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
if (settings.showUnderlines !== undefined) setShowUnderlines(settings.showUnderlines)
if (settings.sidebarCollapsed !== undefined) setIsCollapsed(settings.sidebarCollapsed)
if (settings.highlightsCollapsed !== undefined) setIsHighlightsCollapsed(settings.highlightsCollapsed)
}, [settings])
const handleFetchBookmarks = async () => {
console.log('🔍 fetchBookmarks called, loading:', loading)
@@ -80,6 +100,35 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
}
const handleLoadSettings = async () => {
if (!relayPool || !activeAccount) return
try {
const loadedSettings = await loadSettings(relayPool, eventStore, activeAccount.pubkey, RELAY_URLS)
if (loadedSettings) {
setSettings(loadedSettings)
}
} catch (err) {
console.error('Failed to load settings:', err)
}
}
const handleSaveSettings = async (newSettings: UserSettings) => {
if (!relayPool || !activeAccount) return
setIsSavingSettings(true)
try {
const fullAccount = accountManager.getActive()
if (!fullAccount) throw new Error('No active account')
const factory = new EventFactory({ signer: fullAccount })
await saveSettings(relayPool, eventStore, factory, newSettings, RELAY_URLS)
setSettings(newSettings)
setShowSettings(false)
} catch (err) {
console.error('Failed to save settings:', err)
} finally {
setIsSavingSettings(false)
}
}
const handleSelectUrl = async (url: string) => {
setSelectedUrl(url)
setReaderLoading(true)
@@ -105,47 +154,58 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}
return (
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
<div className="pane sidebar">
<BookmarkList
bookmarks={bookmarks}
onSelectUrl={handleSelectUrl}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
viewMode={viewMode}
onViewModeChange={setViewMode}
selectedUrl={selectedUrl}
/>
<>
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
<div className="pane sidebar">
<BookmarkList
bookmarks={bookmarks}
onSelectUrl={handleSelectUrl}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
viewMode={viewMode}
onViewModeChange={setViewMode}
selectedUrl={selectedUrl}
onOpenSettings={() => setShowSettings(true)}
/>
</div>
<div className="pane main">
<ContentPanel
loading={readerLoading}
title={readerContent?.title}
html={readerContent?.html}
markdown={readerContent?.markdown}
selectedUrl={selectedUrl}
highlights={highlights}
showUnderlines={showUnderlines}
onHighlightClick={setSelectedHighlightId}
selectedHighlightId={selectedHighlightId}
/>
</div>
<div className="pane highlights">
<HighlightsPanel
highlights={highlights}
loading={highlightsLoading}
isCollapsed={isHighlightsCollapsed}
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
onSelectUrl={handleSelectUrl}
selectedUrl={selectedUrl}
onToggleUnderlines={setShowUnderlines}
selectedHighlightId={selectedHighlightId}
onRefresh={handleFetchHighlights}
onHighlightClick={setSelectedHighlightId}
/>
</div>
</div>
<div className="pane main">
<ContentPanel
loading={readerLoading}
title={readerContent?.title}
html={readerContent?.html}
markdown={readerContent?.markdown}
selectedUrl={selectedUrl}
highlights={highlights}
showUnderlines={showUnderlines}
onHighlightClick={setSelectedHighlightId}
selectedHighlightId={selectedHighlightId}
{showSettings && (
<Settings
settings={settings}
onSave={handleSaveSettings}
onClose={() => setShowSettings(false)}
isSaving={isSavingSettings}
/>
</div>
<div className="pane highlights">
<HighlightsPanel
highlights={highlights}
loading={highlightsLoading}
isCollapsed={isHighlightsCollapsed}
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
onSelectUrl={handleSelectUrl}
selectedUrl={selectedUrl}
onToggleUnderlines={setShowUnderlines}
selectedHighlightId={selectedHighlightId}
onRefresh={handleFetchHighlights}
onHighlightClick={setSelectedHighlightId}
/>
</div>
</div>
)}
</>
)
}

109
src/components/Settings.tsx Normal file
View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faTimes, faSave } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../services/settingsService'
import IconButton from './IconButton'
interface SettingsProps {
settings: UserSettings
onSave: (settings: UserSettings) => Promise<void>
onClose: () => void
isSaving: boolean
}
const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, isSaving }) => {
const [localSettings, setLocalSettings] = useState<UserSettings>(settings)
useEffect(() => {
setLocalSettings(settings)
}, [settings])
const handleSave = async () => {
await onSave(localSettings)
}
return (
<div className="settings-overlay">
<div className="settings-panel">
<div className="settings-header">
<h2>Settings</h2>
<IconButton
icon={faTimes}
onClick={onClose}
title="Close settings"
ariaLabel="Close settings"
variant="ghost"
/>
</div>
<div className="settings-content">
<div className="setting-group">
<label htmlFor="defaultViewMode">Default View Mode</label>
<select
id="defaultViewMode"
value={localSettings.defaultViewMode || 'compact'}
onChange={(e) => setLocalSettings({ ...localSettings, defaultViewMode: e.target.value as 'compact' | 'cards' | 'large' })}
className="setting-select"
>
<option value="compact">Compact</option>
<option value="cards">Cards</option>
<option value="large">Large</option>
</select>
</div>
<div className="setting-group">
<label htmlFor="showUnderlines" className="checkbox-label">
<input
id="showUnderlines"
type="checkbox"
checked={localSettings.showUnderlines !== false}
onChange={(e) => setLocalSettings({ ...localSettings, showUnderlines: e.target.checked })}
className="setting-checkbox"
/>
<span>Show highlight underlines</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="sidebarCollapsed" className="checkbox-label">
<input
id="sidebarCollapsed"
type="checkbox"
checked={localSettings.sidebarCollapsed === true}
onChange={(e) => setLocalSettings({ ...localSettings, sidebarCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with bookmarks sidebar collapsed</span>
</label>
</div>
<div className="setting-group">
<label htmlFor="highlightsCollapsed" className="checkbox-label">
<input
id="highlightsCollapsed"
type="checkbox"
checked={localSettings.highlightsCollapsed === true}
onChange={(e) => setLocalSettings({ ...localSettings, highlightsCollapsed: e.target.checked })}
className="setting-checkbox"
/>
<span>Start with highlights panel collapsed</span>
</label>
</div>
</div>
<div className="settings-footer">
<button
className="btn-primary"
onClick={handleSave}
disabled={isSaving}
>
<FontAwesomeIcon icon={faSave} />
{isSaving ? 'Saving...' : 'Save Settings'}
</button>
</div>
</div>
</div>
)
}
export default Settings

View File

@@ -12,9 +12,10 @@ interface SidebarHeaderProps {
onLogout: () => void
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
onOpenSettings: () => void
}
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, viewMode, onViewModeChange }) => {
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, viewMode, onViewModeChange, onOpenSettings }) => {
const activeAccount = Hooks.useActiveAccount()
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
@@ -52,7 +53,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
</div>
<IconButton
icon={faGear}
onClick={() => console.log('Settings clicked')}
onClick={onOpenSettings}
title="Settings"
ariaLabel="Settings"
variant="ghost"

View File

@@ -1420,3 +1420,122 @@ body {
border-color: rgba(100, 108, 255, 0.4);
}
}
/* Settings Panel */
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.settings-panel {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #333;
}
.settings-header h2 {
margin: 0;
font-size: 1.5rem;
}
.settings-content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
.setting-group {
margin-bottom: 1.5rem;
}
.setting-group label {
display: block;
margin-bottom: 0.5rem;
color: #ccc;
font-weight: 500;
}
.setting-select {
width: 100%;
padding: 0.5rem;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #fff;
font-size: 1rem;
}
.setting-select:focus {
outline: none;
border-color: #646cff;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
user-select: none;
}
.setting-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
}
.checkbox-label span {
color: #ccc;
}
.settings-footer {
padding: 1.5rem;
border-top: 1px solid #333;
display: flex;
justify-content: flex-end;
}
.settings-footer .btn-primary {
background: #646cff;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.settings-footer .btn-primary:hover:not(:disabled) {
background: #535bf2;
}
.settings-footer .btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,96 @@
import { EventStore } from 'applesauce-core'
import { APP_DATA_KIND, getAppDataContent } from 'applesauce-core/helpers/app-data'
import { AppDataBlueprint } from 'applesauce-factory/blueprints'
import { EventFactory } from 'applesauce-factory'
import { RelayPool, onlyEvents, mapEventsToStore } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { Account } from 'applesauce-accounts'
const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings'
export interface UserSettings {
defaultViewMode?: 'compact' | 'cards' | 'large'
showUnderlines?: boolean
sidebarCollapsed?: boolean
highlightsCollapsed?: boolean
}
export async function loadSettings(
relayPool: RelayPool,
eventStore: EventStore,
pubkey: string,
relays: string[]
): Promise<UserSettings | null> {
return new Promise((resolve) => {
let hasResolved = false
const timeout = setTimeout(() => {
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}, 5000)
const sub = relayPool
.subscription(relays, {
kinds: [APP_DATA_KIND],
authors: [pubkey],
'#d': [SETTINGS_IDENTIFIER]
})
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
complete: () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
const event = eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER).value
if (event) {
const content = getAppDataContent<UserSettings>(event)
resolve(content || null)
} else {
resolve(null)
}
}
},
error: () => {
clearTimeout(timeout)
if (!hasResolved) {
hasResolved = true
resolve(null)
}
}
})
setTimeout(() => {
sub.unsubscribe()
}, 5000)
})
}
export async function saveSettings(
relayPool: RelayPool,
eventStore: EventStore,
factory: EventFactory,
settings: UserSettings,
relays: string[]
): Promise<void> {
const draft = await factory.create(AppDataBlueprint, SETTINGS_IDENTIFIER, settings, false)
const signed = await factory.sign(draft)
eventStore.add(signed)
await relayPool.publish(relays, signed)
}
export function watchSettings(
eventStore: EventStore,
pubkey: string,
callback: (settings: UserSettings | null) => void
) {
return eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER).subscribe((event: NostrEvent | undefined) => {
if (event) {
const content = getAppDataContent<UserSettings>(event)
callback(content || null)
} else {
callback(null)
}
})
}