mirror of
https://github.com/dergigi/boris.git
synced 2026-02-20 14:34:29 +01:00
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:
@@ -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 ? (
|
||||
|
||||
@@ -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
109
src/components/Settings.tsx
Normal 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
|
||||
@@ -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"
|
||||
|
||||
119
src/index.css
119
src/index.css
@@ -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;
|
||||
}
|
||||
|
||||
96
src/services/settingsService.ts
Normal file
96
src/services/settingsService.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user