mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5bb8496f | ||
|
|
9264a78c95 | ||
|
|
326d571871 | ||
|
|
744e86b290 | ||
|
|
e46b68da7e | ||
|
|
811a962632 | ||
|
|
eb82e8762a | ||
|
|
d919da153f | ||
|
|
8389d5811a | ||
|
|
0aa0c44441 | ||
|
|
49ea7504a1 | ||
|
|
6602fb9359 | ||
|
|
731eb6915a | ||
|
|
3459179310 | ||
|
|
b1f951daf5 | ||
|
|
caebcec0af | ||
|
|
5f50f4b8d6 | ||
|
|
3039208ba0 | ||
|
|
397c956e87 | ||
|
|
cf47ceb74b | ||
|
|
da7aa2c115 | ||
|
|
c0046bc04c | ||
|
|
2f8f6a0652 | ||
|
|
9a6f788b98 | ||
|
|
c1a628260c | ||
|
|
7b0bd7077c | ||
|
|
7d47f0a86e | ||
|
|
44fcd74cbe | ||
|
|
5ac0e7ed87 | ||
|
|
743968f7fb | ||
|
|
e1a3ae4b4d | ||
|
|
acf13448ae | ||
|
|
a5daa8b56c | ||
|
|
267169c5c1 | ||
|
|
89272dd9a3 | ||
|
|
d059212238 | ||
|
|
0d8a3576a6 | ||
|
|
8910c2750a | ||
|
|
12393d6df4 | ||
|
|
6c0a2439ad | ||
|
|
d83712127b | ||
|
|
55325cd7ad | ||
|
|
82e508fca6 | ||
|
|
8ff32e9363 | ||
|
|
477308632b | ||
|
|
9ffd06f5e3 | ||
|
|
a89c87819a | ||
|
|
b09ae3bae3 | ||
|
|
6ea8c0d40e | ||
|
|
079501337c | ||
|
|
5bf0382227 |
70
CHANGELOG.md
70
CHANGELOG.md
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.4] - 2025-10-13
|
||||
|
||||
### Changed
|
||||
- Refactor CSS into modular structure
|
||||
- Split 3600+ line monolithic `index.css` into organized modules
|
||||
- Created `src/styles/` directory with base, layout, components, and utils subdirectories
|
||||
- Each file kept under 210 lines for maintainability
|
||||
- Preserved cascade order and selector specificity via ordered `@import` statements
|
||||
- No functional changes to styling
|
||||
|
||||
### Fixed
|
||||
- Mobile button positioning now uses safe area insets for symmetrical layout on notched devices
|
||||
|
||||
## [0.5.3] - 2025-10-13
|
||||
|
||||
### Changed
|
||||
- Relay status indicator is now more compact
|
||||
- Smaller padding and font sizes on desktop
|
||||
- Auto-collapsed on mobile (icon-only by default, tap to expand)
|
||||
- Matches size of sidebar toggle buttons (44px touch target)
|
||||
- Hides when scrolling down, shows when scrolling up (consistent with other mobile controls)
|
||||
|
||||
### Fixed
|
||||
- Invalid bookmarks without IDs no longer appear in bookmark list
|
||||
- Previously showed as "Now" timestamp with no content
|
||||
- Bookmarks without valid IDs are now filtered out entirely
|
||||
- Use bookmark's original timestamp instead of always generating new ones
|
||||
- Profile icon size when logged out now matches other icon buttons in sidebar header
|
||||
|
||||
## [0.5.2] - 2025-10-12
|
||||
|
||||
### Added
|
||||
- Three-dot menu to highlight cards for more compact UI
|
||||
- Combines "Open on Nostr" and "Delete" actions into dropdown menu
|
||||
- Uses horizontal ellipsis icon (⋯)
|
||||
- Click-outside functionality to close menu
|
||||
|
||||
### Changed
|
||||
- Switch Nostr gateway from njump.me/search.dergigi.com to ants.sh
|
||||
- Centralized gateway URLs in config file
|
||||
- All profile and event links now use ants.sh
|
||||
- Automatic detection of identifier type (profile vs event) for proper routing
|
||||
- Remove loading text from Explore and Me pages (spinner only)
|
||||
- "Open on Nostr" now links to the highlight event itself instead of the article
|
||||
|
||||
### Fixed
|
||||
- Gateway URL routing for ants.sh requirements (/p/ for profiles, /e/ for events)
|
||||
- Linting errors in HighlightItem component
|
||||
|
||||
## [0.5.1] - 2025-10-12
|
||||
|
||||
### Added
|
||||
- Highlight color customization to UI elements
|
||||
- Apply user's "my highlights" color to highlight creation buttons
|
||||
- Apply highlight group colors to highlight count indicators
|
||||
- Apply "my highlights" color to collapsed highlights panel button
|
||||
|
||||
### Fixed
|
||||
- Highlight count indicator styling to match reading-time element
|
||||
- Brightness and border styling for highlight count indicator
|
||||
- User highlight color now applies to both marker and arrow icons
|
||||
- Highlight group color properly applied to count indicator background
|
||||
|
||||
### Removed
|
||||
- MOBILE_IMPLEMENTATION.md documentation file
|
||||
|
||||
## [0.5.0] - 2025-10-12
|
||||
|
||||
### Added
|
||||
@@ -692,7 +758,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.0...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.2...HEAD
|
||||
[0.5.2]: https://github.com/dergigi/boris/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://github.com/dergigi/boris/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/dergigi/boris/compare/v0.4.3...v0.5.0
|
||||
[0.4.0]: https://github.com/dergigi/boris/compare/v0.3.8...v0.4.0
|
||||
[0.3.8]: https://github.com/dergigi/boris/compare/v0.3.7...v0.3.8
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.5",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
41
src/components/CompactButton.tsx
Normal file
41
src/components/CompactButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
interface CompactButtonProps {
|
||||
icon?: IconDefinition
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
title?: string
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const CompactButton: React.FC<CompactButtonProps> = ({
|
||||
icon,
|
||||
onClick,
|
||||
title,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
spin = false,
|
||||
className = '',
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`compact-button ${className}`.trim()}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon && <FontAwesomeIcon icon={icon} spin={spin} />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompactButton
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useMemo, useState, useEffect } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faBook } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner, faCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
@@ -15,8 +15,14 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { createEventReaction, createWebsiteReaction } from '../services/reactionService'
|
||||
import {
|
||||
createEventReaction,
|
||||
createWebsiteReaction,
|
||||
hasMarkedEventAsRead,
|
||||
hasMarkedWebsiteAsRead
|
||||
} from '../services/reactionService'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -70,7 +76,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
}) => {
|
||||
const [isMarkingAsRead, setIsMarkingAsRead] = useState(false)
|
||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||
@@ -105,39 +113,82 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||
|
||||
const handleMarkAsRead = async () => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
console.warn('Cannot mark as read: no account or relay pool')
|
||||
// Check if article is already marked as read when URL/article changes
|
||||
useEffect(() => {
|
||||
const checkReadStatus = async () => {
|
||||
if (!activeAccount || !relayPool || !selectedUrl) {
|
||||
setIsMarkedAsRead(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsCheckingReadStatus(true)
|
||||
|
||||
try {
|
||||
let hasRead = false
|
||||
if (isNostrArticle && currentArticle) {
|
||||
hasRead = await hasMarkedEventAsRead(
|
||||
currentArticle.id,
|
||||
activeAccount.pubkey,
|
||||
relayPool
|
||||
)
|
||||
} else {
|
||||
hasRead = await hasMarkedWebsiteAsRead(
|
||||
selectedUrl,
|
||||
activeAccount.pubkey,
|
||||
relayPool
|
||||
)
|
||||
}
|
||||
setIsMarkedAsRead(hasRead)
|
||||
} catch (error) {
|
||||
console.error('Failed to check read status:', error)
|
||||
} finally {
|
||||
setIsCheckingReadStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkReadStatus()
|
||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsMarkingAsRead(true)
|
||||
// Instantly update UI with checkmark animation
|
||||
setIsMarkedAsRead(true)
|
||||
setShowCheckAnimation(true)
|
||||
|
||||
try {
|
||||
if (isNostrArticle && currentArticle) {
|
||||
// Kind 7 reaction for nostr-native articles
|
||||
await createEventReaction(
|
||||
currentArticle.id,
|
||||
currentArticle.pubkey,
|
||||
currentArticle.kind,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked nostr article as read')
|
||||
} else if (selectedUrl) {
|
||||
// Kind 17 reaction for external websites
|
||||
await createWebsiteReaction(
|
||||
selectedUrl,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked website as read')
|
||||
// Reset animation after it completes
|
||||
setTimeout(() => {
|
||||
setShowCheckAnimation(false)
|
||||
}, 600)
|
||||
|
||||
// Fire-and-forget: publish in background without blocking UI
|
||||
;(async () => {
|
||||
try {
|
||||
if (isNostrArticle && currentArticle) {
|
||||
await createEventReaction(
|
||||
currentArticle.id,
|
||||
currentArticle.pubkey,
|
||||
currentArticle.kind,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked nostr article as read')
|
||||
} else if (selectedUrl) {
|
||||
await createWebsiteReaction(
|
||||
selectedUrl,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked website as read')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
// Revert UI state on error
|
||||
setIsMarkedAsRead(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
} finally {
|
||||
setIsMarkingAsRead(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
if (!selectedUrl) {
|
||||
@@ -215,13 +266,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className="mark-as-read-btn"
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkingAsRead}
|
||||
title="Mark as Read"
|
||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
||||
>
|
||||
<FontAwesomeIcon icon={isMarkingAsRead ? faSpinner : faBook} spin={isMarkingAsRead} />
|
||||
<span>{isMarkingAsRead ? 'Marking...' : 'Mark as Read'}</span>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheck : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||
import { createDeletionRequest } from '../services/deletionService'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import CompactButton from './CompactButton'
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
@@ -303,20 +304,25 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="highlight-quote-icon">
|
||||
<FontAwesomeIcon icon={faQuoteLeft} />
|
||||
{relayIndicator && (
|
||||
<div
|
||||
className="highlight-relay-indicator"
|
||||
title={relayIndicator.tooltip}
|
||||
onClick={handleRebroadcast}
|
||||
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
|
||||
</div>
|
||||
)}
|
||||
<div className="highlight-header">
|
||||
<CompactButton
|
||||
className="highlight-timestamp"
|
||||
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</CompactButton>
|
||||
</div>
|
||||
|
||||
<CompactButton
|
||||
className="highlight-quote-button"
|
||||
icon={faQuoteLeft}
|
||||
title="Quote"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* relay indicator lives in footer for consistent padding/alignment */}
|
||||
|
||||
<div className="highlight-content">
|
||||
<blockquote className="highlight-text">
|
||||
{highlight.content}
|
||||
@@ -329,23 +335,30 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
)}
|
||||
|
||||
|
||||
<div className="highlight-meta">
|
||||
<span className="highlight-author">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
<span className="highlight-meta-separator">•</span>
|
||||
<span className="highlight-time">
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</span>
|
||||
<div className="highlight-footer">
|
||||
<div className="highlight-footer-left">
|
||||
{relayIndicator && (
|
||||
<CompactButton
|
||||
className="highlight-relay-indicator"
|
||||
icon={relayIndicator.icon}
|
||||
spin={relayIndicator.spin}
|
||||
title={relayIndicator.tooltip}
|
||||
onClick={handleRebroadcast}
|
||||
disabled={!relayPool || !eventStore}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="highlight-author">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||
<button
|
||||
className="highlight-menu-btn"
|
||||
<CompactButton
|
||||
icon={faEllipsisH}
|
||||
onClick={handleMenuToggle}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
/>
|
||||
|
||||
{showMenu && (
|
||||
<div className="highlight-menu">
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faUser, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchReadArticlesWithData } from '../services/libraryService'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'archive'
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [activeTab, setActiveTab] = useState<TabType>('highlights')
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||
|
||||
const getUserDisplayName = () => {
|
||||
if (!activeAccount) return 'Unknown User'
|
||||
if (profile?.name) return profile.name
|
||||
if (profile?.display_name) return profile.display_name
|
||||
if (profile?.nip05) return profile.nip05
|
||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadHighlights = async () => {
|
||||
const loadData = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to view your highlights')
|
||||
setError('Please log in to view your data')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -41,33 +42,47 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Fetch highlights created by the user
|
||||
const userHighlights = await fetchHighlights(
|
||||
relayPool,
|
||||
activeAccount.pubkey
|
||||
)
|
||||
|
||||
if (userHighlights.length === 0) {
|
||||
setError('No highlights yet. Start highlighting content to see them here!')
|
||||
}
|
||||
// Fetch highlights and read articles
|
||||
const [userHighlights, userReadArticles] = await Promise.all([
|
||||
fetchHighlights(relayPool, activeAccount.pubkey),
|
||||
fetchReadArticlesWithData(relayPool, activeAccount.pubkey)
|
||||
])
|
||||
|
||||
setHighlights(userHighlights)
|
||||
setReadArticles(userReadArticles)
|
||||
|
||||
// Fetch bookmarks using callback pattern
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, setBookmarks)
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
setBookmarks([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load highlights:', err)
|
||||
setError('Failed to load highlights. Please try again.')
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load data. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadHighlights()
|
||||
loadData()
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
// Remove highlight from local state
|
||||
setHighlights(prev => prev.filter(h => h.id !== highlightId))
|
||||
}
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
@@ -89,26 +104,101 @@ const Me: React.FC<MeProps> = ({ relayPool }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
return highlights.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No highlights yet. Start highlighting content to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list me-highlights-list">
|
||||
{highlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={{ ...highlight, level: 'mine' }}
|
||||
relayPool={relayPool}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'reading-list':
|
||||
return bookmarks.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No bookmarks yet. Bookmark articles to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
{bookmarks.map((bookmark) => (
|
||||
<div key={bookmark.id} className="bookmark-item">
|
||||
<a href={bookmark.url} target="_blank" rel="noopener noreferrer">
|
||||
<h3>{bookmark.title || 'Untitled'}</h3>
|
||||
{bookmark.content && <p>{bookmark.content.slice(0, 150)}...</p>}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'archive':
|
||||
return readArticles.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No read articles yet. Mark articles as read to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{readArticles.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
{getUserDisplayName()}
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
<FontAwesomeIcon icon={faHighlighter} /> {highlights.length} highlight{highlights.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => setActiveTab('highlights')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
Highlights ({highlights.length})
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => setActiveTab('reading-list')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
Reading List ({bookmarks.length})
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||
data-tab="archive"
|
||||
onClick={() => setActiveTab('archive')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
Archive ({readArticles.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="highlights-list me-highlights-list">
|
||||
{highlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={{ ...highlight, level: 'mine' }}
|
||||
relayPool={relayPool}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="me-tab-content">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,9 +8,13 @@ import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
|
||||
interface RelayStatusIndicatorProps {
|
||||
relayPool: RelayPool | null
|
||||
showOnMobile?: boolean // Control visibility based on scroll
|
||||
}
|
||||
|
||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||
relayPool,
|
||||
showOnMobile = true
|
||||
}) => {
|
||||
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
||||
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
||||
const [isConnecting, setIsConnecting] = useState(true)
|
||||
@@ -70,7 +74,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''}`}
|
||||
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
|
||||
title={
|
||||
!isMobile ? (
|
||||
isConnecting
|
||||
|
||||
@@ -366,7 +366,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
highlightColor={props.settings.highlightColorMine || '#ffff00'}
|
||||
/>
|
||||
)}
|
||||
<RelayStatusIndicator relayPool={props.relayPool} />
|
||||
<RelayStatusIndicator
|
||||
relayPool={props.relayPool}
|
||||
showOnMobile={showMobileButtons}
|
||||
/>
|
||||
{props.toastMessage && (
|
||||
<Toast
|
||||
message={props.toastMessage}
|
||||
|
||||
1
src/icons/books.svg
Normal file
1
src/icons/books.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M510.354 435.363L402.686 35.422C396.939 14.078 377.547 0 356.354 0C352.242 0 348.059 0.531 343.896 1.641L282.078 18.125C276.193 19.695 270.939 22.383 266.295 25.758C258.254 10.508 242.436 0 224 0H160C151.213 0 143.084 2.531 136 6.656C128.916 2.531 120.787 0 112 0H48C21.49 0 0 21.492 0 48V464C0 490.508 21.49 512 48 512H112C120.787 512 128.916 509.469 136 505.344C143.084 509.469 151.213 512 160 512H224C250.51 512 272 490.508 272 464V165.281L355.805 476.578C361.553 497.926 380.945 512 402.139 512C406.25 512 410.432 511.469 414.594 510.359L476.412 493.875C502.018 487.043 517.215 460.848 510.354 435.363ZM224 48V96H160V48H224ZM160 144H224V368H160V144ZM112 368H48V144H112V368ZM112 48V96H48V48H112ZM48 464V416H112V464H48ZM160 464V416H224V464H160ZM294.445 64.504L356.271 48.02L356.361 48L368.742 93.93L306.828 110.445L294.445 64.504ZM319.266 156.586L381.18 140.074L439.223 355.41L377.309 371.922L319.266 156.586ZM402.154 464.102L389.746 418.066L451.66 401.555L464.045 447.496L402.154 464.102Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
52
src/icons/customIcons.ts
Normal file
52
src/icons/customIcons.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { IconDefinition, IconPrefix, IconName } from '@fortawesome/fontawesome-svg-core'
|
||||
import booksSvg from './books.svg?raw'
|
||||
|
||||
/**
|
||||
* Custom icon definitions for FontAwesome Pro icons
|
||||
* or any custom SVG icons that aren't in the free tier
|
||||
*/
|
||||
|
||||
function parseSvgToIconDefinition(svg: string, options: { prefix: IconPrefix, iconName: IconName, unicode?: string }): IconDefinition {
|
||||
const { prefix, iconName, unicode = 'e002' } = options
|
||||
|
||||
// Extract viewBox first; fallback to width/height
|
||||
const viewBoxMatch = svg.match(/viewBox\s*=\s*"([^"]+)"/i)
|
||||
let width = 512
|
||||
let height = 512
|
||||
if (viewBoxMatch) {
|
||||
const parts = viewBoxMatch[1].trim().split(/\s+/)
|
||||
if (parts.length === 4) {
|
||||
const w = Number(parts[2])
|
||||
const h = Number(parts[3])
|
||||
if (!Number.isNaN(w)) width = Math.round(w)
|
||||
if (!Number.isNaN(h)) height = Math.round(h)
|
||||
}
|
||||
} else {
|
||||
const widthMatch = svg.match(/\bwidth\s*=\s*"(\d+(?:\.\d+)?)"/i)
|
||||
const heightMatch = svg.match(/\bheight\s*=\s*"(\d+(?:\.\d+)?)"/i)
|
||||
if (widthMatch) width = Math.round(Number(widthMatch[1]))
|
||||
if (heightMatch) height = Math.round(Number(heightMatch[1]))
|
||||
}
|
||||
|
||||
// Collect all path d attributes
|
||||
const pathDs: string[] = []
|
||||
const pathRegex = /<path[^>]*\sd=\s*"([^"]+)"[^>]*>/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = pathRegex.exec(svg)) !== null) {
|
||||
pathDs.push(m[1])
|
||||
}
|
||||
|
||||
const pathData = pathDs.length <= 1 ? (pathDs[0] || '') : pathDs
|
||||
|
||||
return {
|
||||
prefix,
|
||||
iconName,
|
||||
icon: [width, height, [], unicode, pathData]
|
||||
}
|
||||
}
|
||||
|
||||
export const faBooks: IconDefinition = parseSvgToIconDefinition(booksSvg, {
|
||||
prefix: 'far',
|
||||
iconName: 'books'
|
||||
})
|
||||
|
||||
439
src/index.css
439
src/index.css
@@ -1,299 +1,20 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
@import './styles/base/variables.css';
|
||||
@import './styles/base/global.css';
|
||||
@import './styles/layout/app.css';
|
||||
@import './styles/layout/sidebar.css';
|
||||
@import './styles/layout/highlights.css';
|
||||
@import './styles/components/icon-button.css';
|
||||
@import './styles/components/profile.css';
|
||||
@import './styles/components/cards.css';
|
||||
@import './styles/components/modals.css';
|
||||
@import './styles/components/toast.css';
|
||||
@import './styles/components/forms.css';
|
||||
@import './styles/components/reader.css';
|
||||
@import './styles/components/settings.css';
|
||||
@import './styles/components/me.css';
|
||||
@import './styles/utils/animations.css';
|
||||
@import './styles/utils/utilities.css';
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
--reading-font: 'Source Serif 4', serif;
|
||||
--reading-font-size: 18px;
|
||||
/* Layout variables */
|
||||
--sidebar-width: 320px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--highlights-width: 360px;
|
||||
--highlights-collapsed-width: 56px;
|
||||
--main-max-width: 900px;
|
||||
--main-horizontal-padding: 1rem;
|
||||
|
||||
/* Mobile breakpoints */
|
||||
--mobile-breakpoint: 768px;
|
||||
--tablet-breakpoint: 1024px;
|
||||
|
||||
/* Mobile touch target minimum */
|
||||
--min-touch-target: 44px;
|
||||
|
||||
/* Safe area insets for notched devices */
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Use dynamic viewport height if supported */
|
||||
@supports (height: 100dvh) {
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
body.mobile-sidebar-open {
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#root {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.app {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0;
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.app header p {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Bookmarks Styles */
|
||||
.bookmarks-container {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container .view-mode-controls {
|
||||
margin-top: auto;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #333;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container .bookmarks-list {
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 900;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #ddd;
|
||||
width: var(--min-touch-target);
|
||||
height: var(--min-touch-target);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.mobile-close-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hamburger-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-close-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.view-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
flex-shrink: 0;
|
||||
color: #ddd;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-avatar svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn.with-icon {
|
||||
width: auto;
|
||||
padding: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn .glow-blue {
|
||||
color: #646cff;
|
||||
filter: drop-shadow(0 0 4px rgba(100, 108, 255, 0.6));
|
||||
}
|
||||
|
||||
.user-info {
|
||||
margin: 0.5rem 0 0 0;
|
||||
@@ -360,55 +81,6 @@ body.mobile-sidebar-open {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
/* Generic IconButton styling */
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
min-width: 33px;
|
||||
min-height: 33px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon-button:hover { background: #333; }
|
||||
.icon-button:active { transform: translateY(1px); }
|
||||
|
||||
.icon-button.primary { background: #646cff; color: white; border-color: #646cff; }
|
||||
.icon-button.primary:hover { filter: brightness(1.05); }
|
||||
|
||||
.icon-button.success { background: #28a745; color: white; border-color: #28a745; }
|
||||
.icon-button.success:hover { filter: brightness(1.05); }
|
||||
|
||||
.icon-button.ghost { background: #2a2a2a; }
|
||||
|
||||
/* Mobile touch target improvements */
|
||||
@media (max-width: 768px) {
|
||||
.icon-button {
|
||||
min-width: var(--min-touch-target);
|
||||
min-height: var(--min-touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
/* Disable hover effects on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.icon-button:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.icon-button.ghost:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.icon-button:active {
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-events {
|
||||
margin: 1rem 0;
|
||||
@@ -732,8 +404,8 @@ body.mobile-sidebar-open {
|
||||
.mobile-highlights-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
top: calc(1rem + env(safe-area-inset-top));
|
||||
right: calc(1rem + env(safe-area-inset-right));
|
||||
z-index: 900;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
@@ -1005,6 +677,39 @@ body.mobile-sidebar-open {
|
||||
|
||||
.mark-as-read-btn svg {
|
||||
font-size: 1.1rem;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* Marked as read state */
|
||||
.mark-as-read-btn.marked {
|
||||
background: #1a4d1a;
|
||||
border-color: #2d662d;
|
||||
color: #90ee90;
|
||||
}
|
||||
|
||||
.mark-as-read-btn.marked:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Checkmark animation */
|
||||
.mark-as-read-btn.animating svg {
|
||||
animation: checkmarkPop 0.6s ease;
|
||||
}
|
||||
|
||||
@keyframes checkmarkPop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -3182,13 +2887,13 @@ body.mobile-sidebar-open {
|
||||
/* Relay Status Indicator */
|
||||
.relay-status-indicator {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 1.5rem;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(245, 158, 11, 0.95);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
border-radius: 8px;
|
||||
@@ -3196,25 +2901,27 @@ body.mobile-sidebar-open {
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.3s ease;
|
||||
cursor: default;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Mobile compact mode - just show icon */
|
||||
/* Mobile compact mode - just show icon, match sidebar button size */
|
||||
@media (max-width: 768px) {
|
||||
.relay-status-indicator.mobile {
|
||||
padding: 0.5rem;
|
||||
padding: 0;
|
||||
width: var(--min-touch-target);
|
||||
height: var(--min-touch-target);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||
left: calc(1rem + env(safe-area-inset-left));
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.relay-status-indicator.mobile.expanded {
|
||||
width: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.relay-status-indicator.mobile .relay-status-icon {
|
||||
@@ -3224,6 +2931,18 @@ body.mobile-sidebar-open {
|
||||
.relay-status-indicator.mobile:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Hide/show on scroll */
|
||||
.relay-status-indicator.mobile.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.relay-status-indicator.mobile.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.relay-status-indicator.connecting {
|
||||
@@ -3250,7 +2969,7 @@ body.mobile-sidebar-open {
|
||||
}
|
||||
|
||||
.relay-status-icon {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1rem;
|
||||
color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3260,18 +2979,18 @@ body.mobile-sidebar-open {
|
||||
.relay-status-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.relay-status-title {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.relay-status-subtitle {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: rgba(26, 26, 26, 0.8);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -60,8 +60,27 @@ export const processApplesauceBookmarks = (
|
||||
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
|
||||
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
|
||||
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
|
||||
return allItems.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
||||
return allItems
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
@@ -70,23 +89,8 @@ export const processApplesauceBookmarks = (
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
// Types and guards around signer/decryption APIs
|
||||
|
||||
222
src/services/libraryService.ts
Normal file
222
src/services/libraryService.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
export interface ReadArticle {
|
||||
id: string
|
||||
url?: string
|
||||
eventId?: string
|
||||
eventAuthor?: string
|
||||
eventKind?: number
|
||||
markedAt: number
|
||||
reactionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all articles that the user has marked as read
|
||||
* Returns both nostr-native articles (kind:7) and external URLs (kind:17)
|
||||
*/
|
||||
export async function fetchReadArticles(
|
||||
relayPool: RelayPool,
|
||||
userPubkey: string
|
||||
): Promise<ReadArticle[]> {
|
||||
try {
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch kind:7 reactions (nostr-native articles)
|
||||
const kind7Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind7Local$, kind7Remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
// Fetch kind:17 reactions (external URLs)
|
||||
const kind17Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind17Local$, kind17Remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
const readArticles: ReadArticle[] = []
|
||||
|
||||
// Process kind:7 reactions (nostr-native articles)
|
||||
for (const event of kind7Events) {
|
||||
if (event.content === MARK_AS_READ_EMOJI) {
|
||||
const eTag = event.tags.find((t) => t[0] === 'e')
|
||||
const pTag = event.tags.find((t) => t[0] === 'p')
|
||||
const kTag = event.tags.find((t) => t[0] === 'k')
|
||||
|
||||
if (eTag && eTag[1]) {
|
||||
readArticles.push({
|
||||
id: eTag[1],
|
||||
eventId: eTag[1],
|
||||
eventAuthor: pTag?.[1],
|
||||
eventKind: kTag?.[1] ? parseInt(kTag[1]) : undefined,
|
||||
markedAt: event.created_at,
|
||||
reactionId: event.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process kind:17 reactions (external URLs)
|
||||
for (const event of kind17Events) {
|
||||
if (event.content === MARK_AS_READ_EMOJI) {
|
||||
const rTag = event.tags.find((t) => t[0] === 'r')
|
||||
|
||||
if (rTag && rTag[1]) {
|
||||
readArticles.push({
|
||||
id: rTag[1],
|
||||
url: rTag[1],
|
||||
markedAt: event.created_at,
|
||||
reactionId: event.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by markedAt (most recent first) and dedupe
|
||||
const deduped = new Map<string, ReadArticle>()
|
||||
readArticles
|
||||
.sort((a, b) => b.markedAt - a.markedAt)
|
||||
.forEach((article) => {
|
||||
if (!deduped.has(article.id)) {
|
||||
deduped.set(article.id, article)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(deduped.values())
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch read articles:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches full article data for read nostr-native articles
|
||||
* and converts them to BlogPostPreview format for rendering
|
||||
*/
|
||||
export async function fetchReadArticlesWithData(
|
||||
relayPool: RelayPool,
|
||||
userPubkey: string
|
||||
): Promise<BlogPostPreview[]> {
|
||||
try {
|
||||
// First get all read articles
|
||||
const readArticles = await fetchReadArticles(relayPool, userPubkey)
|
||||
|
||||
// Filter to only nostr-native articles (kind 30023)
|
||||
const nostrArticles = readArticles.filter(
|
||||
article => article.eventKind === 30023 && article.eventId
|
||||
)
|
||||
|
||||
if (nostrArticles.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch the actual article events
|
||||
const eventIds = nostrArticles.map(a => a.eventId!).filter(Boolean)
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const articleEvents: NostrEvent[] = await lastValueFrom(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
// Deduplicate article events by ID
|
||||
const uniqueArticleEvents = new Map<string, NostrEvent>()
|
||||
articleEvents.forEach(event => {
|
||||
if (!uniqueArticleEvents.has(event.id)) {
|
||||
uniqueArticleEvents.set(event.id, event)
|
||||
}
|
||||
})
|
||||
|
||||
// Convert to BlogPostPreview format
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueArticleEvents.values()).map(event => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled Article',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}))
|
||||
|
||||
// Sort by when they were marked as read (most recent first)
|
||||
const articlesMap = new Map(nostrArticles.map(a => [a.eventId, a]))
|
||||
blogPosts.sort((a, b) => {
|
||||
const markedAtA = articlesMap.get(a.event.id)?.markedAt || 0
|
||||
const markedAtB = articlesMap.get(b.event.id)?.markedAt || 0
|
||||
return markedAtB - markedAtA
|
||||
})
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch read articles with data:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
const MARK_AS_READ_EMOJI = '📚'
|
||||
|
||||
export { MARK_AS_READ_EMOJI }
|
||||
|
||||
/**
|
||||
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
|
||||
* @param eventId The ID of the event being reacted to
|
||||
@@ -101,3 +104,96 @@ export async function createWebsiteReaction(
|
||||
return signed
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has already marked a nostr event as read
|
||||
* @param eventId The ID of the event to check
|
||||
* @param userPubkey The user's pubkey
|
||||
* @param relayPool The relay pool for querying
|
||||
* @returns True if the user has already reacted with the mark-as-read emoji
|
||||
*/
|
||||
export async function hasMarkedEventAsRead(
|
||||
eventId: string,
|
||||
userPubkey: string,
|
||||
relayPool: RelayPool
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const filter = {
|
||||
kinds: [7],
|
||||
authors: [userPubkey],
|
||||
'#e': [eventId]
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(2000)),
|
||||
toArray()
|
||||
)
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
|
||||
// Check if any reaction has our mark-as-read emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
||||
|
||||
return hasReadReaction
|
||||
} catch (error) {
|
||||
console.error('Error checking read status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has already marked a website as read
|
||||
* @param url The URL to check
|
||||
* @param userPubkey The user's pubkey
|
||||
* @param relayPool The relay pool for querying
|
||||
* @returns True if the user has already reacted with the mark-as-read emoji
|
||||
*/
|
||||
export async function hasMarkedWebsiteAsRead(
|
||||
url: string,
|
||||
userPubkey: string,
|
||||
relayPool: RelayPool
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Normalize URL the same way as when creating reactions
|
||||
let normalizedUrl = url
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
parsed.hash = ''
|
||||
normalizedUrl = parsed.toString()
|
||||
if (normalizedUrl.endsWith('/')) {
|
||||
normalizedUrl = normalizedUrl.slice(0, -1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize URL:', error)
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [17],
|
||||
authors: [userPubkey],
|
||||
'#r': [normalizedUrl]
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(2000)),
|
||||
toArray()
|
||||
)
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
|
||||
// Check if any reaction has our mark-as-read emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
||||
|
||||
return hasReadReaction
|
||||
} catch (error) {
|
||||
console.error('Error checking read status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
src/styles/base/global.css
Normal file
68
src/styles/base/global.css
Normal file
@@ -0,0 +1,68 @@
|
||||
/* Global element styles and app container */
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Use dynamic viewport height if supported */
|
||||
@supports (height: 100dvh) {
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
body.mobile-sidebar-open {
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#root {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.app {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.app header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0;
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.app header p {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
48
src/styles/base/variables.css
Normal file
48
src/styles/base/variables.css
Normal file
@@ -0,0 +1,48 @@
|
||||
/* CSS variables and color-scheme */
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
--reading-font: 'Source Serif 4', serif;
|
||||
--reading-font-size: 18px;
|
||||
/* Layout variables */
|
||||
--sidebar-width: 320px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--highlights-width: 360px;
|
||||
--highlights-collapsed-width: 56px;
|
||||
--main-max-width: 900px;
|
||||
--main-horizontal-padding: 1rem;
|
||||
|
||||
/* Mobile breakpoints */
|
||||
--mobile-breakpoint: 768px;
|
||||
--tablet-breakpoint: 1024px;
|
||||
|
||||
/* Mobile touch target minimum */
|
||||
--min-touch-target: 44px;
|
||||
|
||||
/* Safe area insets for notched devices */
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
97
src/styles/components/cards.css
Normal file
97
src/styles/components/cards.css
Normal file
@@ -0,0 +1,97 @@
|
||||
/* Bookmark item and blog post cards */
|
||||
.bookmark-item { background: #1a1a1a; padding: 1.5rem; border-radius: 12px; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
|
||||
.bookmark-item:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); }
|
||||
.bookmark-item h3 { margin: 0 0 0.5rem 0; color: #fff; font-size: 1.2rem; }
|
||||
.bookmark-url { color: #646cff; text-decoration: none; display: block; margin-bottom: 0.5rem; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
|
||||
.bookmark-url:hover { text-decoration: underline; }
|
||||
.bookmark-content { color: #ccc; margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.bookmark-meta { color: #888; font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
|
||||
.individual-bookmarks { margin: 1rem 0; }
|
||||
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: #fff; }
|
||||
|
||||
.bookmarks-grid { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
|
||||
.bookmarks-grid.bookmarks-compact { gap: 0.5rem; }
|
||||
.bookmarks-grid.bookmarks-large { gap: 1.5rem; }
|
||||
@media (max-width: 768px) {
|
||||
.bookmarks-grid { gap: 0.75rem; }
|
||||
.bookmarks-grid.bookmarks-compact { gap: 0.25rem; }
|
||||
.bookmarks-grid.bookmarks-large { gap: 1rem; }
|
||||
}
|
||||
|
||||
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid transparent; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
|
||||
.individual-bookmark:hover { border-color: transparent; background: #2a2a2a; }
|
||||
|
||||
/* Compact view */
|
||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid #2a2a2a; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||
.individual-bookmark.compact:hover { background: #252525; border-bottom-color: #333; transform: none; box-shadow: none; }
|
||||
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
|
||||
.compact-row.clickable { cursor: pointer; }
|
||||
.compact-row.clickable:active { opacity: 0.8; }
|
||||
.bookmark-type-compact { display: flex; align-items: center; gap: 0.25rem; color: #646cff; font-size: 0.85rem; flex-shrink: 0; }
|
||||
.compact-text { flex: 1; min-width: 0; color: #ccc; font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookmark-date-compact { font-size: 0.7rem; color: #666; flex-shrink: 0; white-space: nowrap; }
|
||||
.compact-read-btn { background: transparent; color: #888; border: none; padding: 0; border-radius: 4px; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; width: 24px; height: 22px; flex-shrink: 0; transition: color 0.2s ease; }
|
||||
.compact-read-btn:hover { color: #ccc; }
|
||||
.compact-read-btn:active { transform: translateY(1px); }
|
||||
|
||||
.bookmark-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.bookmark-type { color: #646cff; font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
|
||||
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: #888; background: #1a1a1a; padding: 0.25rem 0.5rem; border-radius: 4px; }
|
||||
.bookmark-date { font-size: 0.8rem; color: #666; }
|
||||
.bookmark-date-link { font-size: 0.8rem; color: #666; text-decoration: none; transition: color 0.2s ease; }
|
||||
.bookmark-date-link:hover { color: #8ab4f8; text-decoration: underline; }
|
||||
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: #ccc; line-height: 1.6; font-size: 0.9rem; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.expand-toggle { margin: 0.25rem 0; background: transparent; border: none; color: #888; cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
|
||||
.expand-toggle:hover { color: #bbb; }
|
||||
.bookmark-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; gap: 0.75rem; }
|
||||
.bookmark-meta-minimal { font-size: 0.8rem; color: #888; }
|
||||
.author-link-minimal { color: #888; text-decoration: none; transition: color 0.2s ease; }
|
||||
.author-link-minimal:hover { color: #aaa; }
|
||||
.read-now-button-minimal { background: #28a745; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; white-space: nowrap; }
|
||||
.read-now-button-minimal:hover { background: #218838; }
|
||||
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: #646cff; cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
|
||||
.expand-toggle-urls:hover { color: #8088ff; }
|
||||
|
||||
/* Large preview view */
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.large-preview-image { width: 100%; height: 180px; background: #1a1a1a; background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid #333; position: relative; }
|
||||
.large-preview-image:hover { opacity: 0.9; }
|
||||
.large-preview-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%); pointer-events: none; }
|
||||
.preview-placeholder { font-size: 3rem; color: #444; }
|
||||
.large-content { padding: 1.25rem; }
|
||||
.large-text { color: #ccc; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: #888; padding-top: 0.75rem; border-top: 1px solid #333; }
|
||||
.large-author { flex: 1; }
|
||||
.large-read-button { background: #28a745; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.large-read-button:hover { background: #218838; }
|
||||
|
||||
/* Blog cards (Explore) */
|
||||
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; }
|
||||
.explore-header { text-align: center; margin-bottom: 3rem; }
|
||||
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: #646cff; display: flex; align-items: center; justify-content: center; gap: 1rem; }
|
||||
.explore-subtitle { font-size: 1.125rem; color: rgba(255, 255, 255, 0.7); margin: 0; }
|
||||
.explore-loading, .explore-error { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: rgba(255, 255, 255, 0.7); }
|
||||
.explore-loading { min-height: 0; padding: 0.25rem 0; }
|
||||
.explore-error { color: #ff6b6b; }
|
||||
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
|
||||
.blog-post-card { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
|
||||
.blog-post-card:hover { border-color: #646cff; transform: translateY(-4px); box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: #0f0f0f; }
|
||||
.blog-post-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; }
|
||||
.blog-post-card:hover .blog-post-card-image img { transform: scale(1.05); }
|
||||
.blog-post-card-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; flex: 1; }
|
||||
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: rgba(255, 255, 255, 0.95); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.blog-post-card-summary { font-size: 0.875rem; color: rgba(255, 255, 255, 0.6); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
|
||||
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid #333; font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); flex-wrap: wrap; }
|
||||
.blog-post-card-author, .blog-post-card-date { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
|
||||
@media (max-width: 768px) {
|
||||
.explore-container { padding: 1rem; }
|
||||
.explore-header h1 { font-size: 2rem; }
|
||||
.explore-grid { grid-template-columns: 1fr; gap: 1.5rem; }
|
||||
.blog-post-card-summary { -webkit-line-clamp: 2; font-size: 0.8rem; }
|
||||
.blog-post-card-content { padding: 1rem; }
|
||||
}
|
||||
|
||||
|
||||
30
src/styles/components/forms.css
Normal file
30
src/styles/components/forms.css
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Forms and controls for settings */
|
||||
.setting-group { margin-bottom: 1.5rem; text-align: left; }
|
||||
.setting-group.setting-inline { display: flex; align-items: center; gap: 1rem; }
|
||||
.setting-label { text-align: left; flex: 1; }
|
||||
.setting-control { display: flex; justify-content: flex-end; align-items: center; }
|
||||
.setting-group.setting-inline label { margin-bottom: 0; }
|
||||
.setting-group label { display: block; margin-bottom: 0.5rem; color: #ccc; font-weight: 500; text-align: left; }
|
||||
.setting-buttons { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-picker { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-swatch { width: 33px; height: 33px; border: 1px solid #444; border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
|
||||
.color-swatch:hover { border-color: #888; }
|
||||
.color-swatch.active { border-color: #646cff; box-shadow: 0 0 0 2px #646cff; }
|
||||
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #000; font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px #fff; }
|
||||
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid #444; border-radius: 6px; color: #ccc; cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
|
||||
.font-size-btn:hover { background: #333; border-color: #666; }
|
||||
.font-size-btn.active { background: #646cff; border-color: #646cff; color: white; }
|
||||
.setting-preview { margin: 1.5rem 0; padding: 1rem; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; }
|
||||
.preview-label { font-size: 0.875rem; color: #999; margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.preview-content { color: #ddd; line-height: 1.7; }
|
||||
.preview-content h3 { margin: 0 0 1rem 0; font-size: 1.5em; color: #fff; }
|
||||
.preview-content p { margin: 0.75rem 0; }
|
||||
.setting-select { width: 100%; padding: 0.5rem; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; color: #fff; font-size: 1rem; }
|
||||
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
||||
.setting-select:focus { outline: none; border-color: #646cff; }
|
||||
.font-select option { padding: 0.5rem; font-size: 1rem; }
|
||||
.checkbox-label { display: flex !important; align-items: center; gap: 0.75rem; cursor: pointer; user-select: none; text-align: left; justify-content: flex-start; margin-bottom: 0 !important; font-weight: normal !important; }
|
||||
.setting-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin: 0; accent-color: #646cff; }
|
||||
.checkbox-label span { color: #ddd; text-align: left; font-weight: 500; }
|
||||
|
||||
|
||||
43
src/styles/components/icon-button.css
Normal file
43
src/styles/components/icon-button.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/* Generic IconButton styling */
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
min-width: 33px;
|
||||
min-height: 33px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon-button:hover { background: #333; }
|
||||
.icon-button:active { transform: translateY(1px); }
|
||||
|
||||
.icon-button.primary { background: #646cff; color: white; border-color: #646cff; }
|
||||
.icon-button.primary:hover { filter: brightness(1.05); }
|
||||
|
||||
.icon-button.success { background: #28a745; color: white; border-color: #28a745; }
|
||||
.icon-button.success:hover { filter: brightness(1.05); }
|
||||
|
||||
.icon-button.ghost { background: #2a2a2a; }
|
||||
|
||||
/* Mobile touch target improvements */
|
||||
@media (max-width: 768px) {
|
||||
.icon-button {
|
||||
min-width: var(--min-touch-target);
|
||||
min-height: var(--min-touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
/* Disable hover effects on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.icon-button:hover { background: #2a2a2a; }
|
||||
.icon-button.ghost:hover { background: #2a2a2a; }
|
||||
.icon-button:active { background: #333; }
|
||||
}
|
||||
|
||||
|
||||
126
src/styles/components/me.css
Normal file
126
src/styles/components/me.css
Normal file
@@ -0,0 +1,126 @@
|
||||
/* Me page tabs */
|
||||
.me-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow-x: auto;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.me-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.me-tab:hover {
|
||||
color: var(--text-primary, #ddd);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.me-tab.active {
|
||||
color: var(--primary-color, #8b5cf6);
|
||||
border-bottom-color: var(--primary-color, #8b5cf6);
|
||||
}
|
||||
|
||||
/* Highlights tab uses the user's custom "my highlights" color */
|
||||
.me-tab[data-tab="highlights"].active {
|
||||
color: var(--highlight-color-mine, #ffff00);
|
||||
border-bottom-color: var(--highlight-color-mine, #ffff00);
|
||||
}
|
||||
|
||||
.me-tab svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.me-tab-content {
|
||||
padding: 1.5rem 0;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Align highlight list width with profile card width on /me */
|
||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||
|
||||
/* Bookmarks list */
|
||||
.bookmarks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
padding: 1rem;
|
||||
background: var(--card-bg, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bookmark-item:hover {
|
||||
border-color: var(--primary-color, #8b5cf6);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bookmark-item a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bookmark-item h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary, #000);
|
||||
}
|
||||
|
||||
.bookmark-item p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary, #666);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
/* Add top breathing room so floating sidebar buttons don't overlap header */
|
||||
.explore-container .explore-header {
|
||||
margin-top: 2.25rem;
|
||||
}
|
||||
|
||||
.me-tabs {
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.me-tab {
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.me-tab svg {
|
||||
font-size: 0.9rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.me-tab-content {
|
||||
padding: 1.25rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
28
src/styles/components/modals.css
Normal file
28
src/styles/components/modals.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/* Add Bookmark Modal */
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.75); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 1rem; }
|
||||
.modal-content { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); box-sizing: border-box; }
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay { padding: 0; align-items: flex-end; }
|
||||
.modal-content { max-width: 100%; max-height: 95vh; max-height: 95dvh; border-radius: 16px 16px 0 0; margin: 0; padding-bottom: var(--safe-area-bottom); }
|
||||
}
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1.5rem; border-bottom: 1px solid #333; }
|
||||
.modal-header h2 { margin: 0; font-size: 1.5rem; color: #fff; }
|
||||
.modal-form { padding: 1.5rem; }
|
||||
.form-group { margin-bottom: 1.25rem; }
|
||||
.form-group label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; color: #ccc; font-size: 0.9rem; font-weight: 500; }
|
||||
.fetching-indicator { font-size: 0.8rem; color: #999; font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: #2a2a2a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 1rem; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; }
|
||||
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: #646cff; }
|
||||
.form-group input:disabled, .form-group textarea:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.form-group textarea { resize: vertical; min-height: 80px; }
|
||||
.form-helper-text { margin-top: 0.25rem; font-size: 0.8rem; color: #999; line-height: 1.4; }
|
||||
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid #dc3545; border-radius: 6px; color: #dc3545; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
|
||||
.btn-secondary { padding: 0.75rem 1.5rem; background: #2a2a2a; border: 1px solid #444; border-radius: 6px; color: #ccc; font-size: 1rem; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #333; border-color: #646cff; color: white; }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-primary { padding: 0.75rem 1.5rem; background: #646cff; border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
|
||||
.btn-primary:hover:not(:disabled) { background: #535bf2; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
|
||||
20
src/styles/components/profile.css
Normal file
20
src/styles/components/profile.css
Normal file
@@ -0,0 +1,20 @@
|
||||
/* Profile UI fragments */
|
||||
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; }
|
||||
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: #1a1a1a; border: 1px solid #333; border-radius: 12px; max-width: 600px; width: 100%; }
|
||||
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: #2a2a2a; display: flex; align-items: center; justify-content: center; color: #666; }
|
||||
.author-card-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.author-card-avatar svg { font-size: 2.5rem; }
|
||||
.author-card-content { flex: 1; min-width: 0; text-align: left; }
|
||||
.author-card-name { font-size: 1rem; font-weight: 600; color: #ddd; margin-bottom: 0.5rem; text-align: left; }
|
||||
.author-card-bio { font-size: 0.9rem; color: #999; line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.author-card-container { padding: 1.5rem 1rem; }
|
||||
.author-card { padding: 1rem; }
|
||||
.author-card-avatar { width: 48px; height: 48px; }
|
||||
.author-card-avatar svg { font-size: 2rem; }
|
||||
.author-card-name { font-size: 0.95rem; }
|
||||
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
|
||||
}
|
||||
|
||||
|
||||
69
src/styles/components/reader.css
Normal file
69
src/styles/components/reader.css
Normal file
@@ -0,0 +1,69 @@
|
||||
/* Reader view */
|
||||
.reader { background: #1a1a1a; border: 1px solid #333; border-radius: 8px; padding: 0.75rem; text-align: left; overflow: hidden; contain: layout style; }
|
||||
.reader.empty { color: #888; }
|
||||
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: #888; }
|
||||
.loading-spinner svg { font-size: 1.2rem; }
|
||||
.reader-header { margin-bottom: 2rem; position: relative; }
|
||||
.reader-title { margin: 0 0 0.75rem 0; font-family: var(--reading-font); }
|
||||
.reader-summary { color: #aaa; font-size: 1.1rem; line-height: 1.5; margin: 0 0 1rem 0; font-family: var(--reading-font); }
|
||||
.reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: rgba(136, 136, 136, 0.7); opacity: 0.85; }
|
||||
.publish-date svg { font-size: 0.75rem; opacity: 0.6; }
|
||||
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: #fff; padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; }
|
||||
.reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(136, 136, 136, 0.1); border: 1px solid rgba(136, 136, 136, 0.3); border-radius: 6px; font-size: 0.875rem; color: #888; }
|
||||
.reading-time svg { font-size: 0.875rem; }
|
||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(100, 108, 255, 0.1); border: 1px solid rgba(100, 108, 255, 0.3); border-radius: 6px; font-size: 0.875rem; color: #646cff; }
|
||||
.highlight-indicator svg { font-size: 0.875rem; }
|
||||
.reader-html { color: #ddd; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
.reader-markdown { color: #ddd; line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
/* Ensure content is left-aligned even if source markup uses center */
|
||||
.reader .reader-html *, .reader .reader-markdown * { text-align: left !important; font-family: inherit !important; }
|
||||
.reader center, .reader [align="center"] { text-align: left !important; }
|
||||
/* Tame images from external content */
|
||||
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
|
||||
.reader-markdown h1, .reader-markdown h2, .reader-markdown h3, .reader-markdown h4 { margin-top: 1.2rem; }
|
||||
.reader-markdown p { margin: 0.5rem 0; }
|
||||
.reader-html p, .reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
|
||||
.reader-markdown a { color: #8ab4f8; text-decoration: none; }
|
||||
.reader-markdown a:hover { text-decoration: underline; }
|
||||
.reader-markdown pre, .reader-markdown code { background: #111; border: 1px solid #333; border-radius: 6px; }
|
||||
.reader-markdown pre { padding: 0.75rem; overflow: auto; }
|
||||
.reader-markdown code { padding: 0.1rem 0.3rem; }
|
||||
/* Mark as Read button */
|
||||
.mark-as-read-container { display: flex; justify-content: center; align-items: center; padding: 2rem 1rem; margin-top: 2rem; border-top: 1px solid #333; }
|
||||
.mark-as-read-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; background: #2a2a2a; color: #ddd; border: 1px solid #444; border-radius: 8px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; min-width: 160px; justify-content: center; }
|
||||
.mark-as-read-btn:hover:not(:disabled) { background: #333; border-color: #555; transform: translateY(-1px); }
|
||||
.mark-as-read-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.mark-as-read-btn svg { font-size: 1.1rem; }
|
||||
@media (max-width: 768px) {
|
||||
.mark-as-read-container { padding: 1.5rem 1rem; }
|
||||
.mark-as-read-btn { width: 100%; max-width: 300px; }
|
||||
}
|
||||
|
||||
/* Hero image in reader/card views */
|
||||
.article-hero-image { width: 100%; height: 200px; background-size: cover; background-position: center; background-repeat: no-repeat; cursor: pointer; transition: all 0.2s ease; border-radius: 8px 8px 0 0; position: relative; }
|
||||
.article-hero-image:hover { opacity: 0.9; }
|
||||
.article-hero-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.4) 100%); pointer-events: none; border-radius: 8px 8px 0 0; }
|
||||
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; }
|
||||
.reader-hero-image img { width: 100%; height: auto; max-height: 500px; object-fit: cover; display: block; }
|
||||
.reader-header-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: 2rem 2rem 1.5rem; background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%); }
|
||||
.reader-header-overlay .reader-title { color: #fff; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); margin-bottom: 0.75rem; }
|
||||
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.1rem; line-height: 1.5; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }
|
||||
.reader-header-overlay .reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.reader-header-overlay .publish-date { color: rgba(255, 255, 255, 0.65); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); opacity: 1; }
|
||||
.reader-header-overlay .publish-date svg { opacity: 0.7; }
|
||||
.reader-header-overlay .reading-time, .reader-header-overlay .highlight-indicator { background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.25); color: #fff; }
|
||||
.reader-header-overlay .highlight-indicator { background: rgba(100, 108, 255, 0.25); border: 1px solid rgba(100, 108, 255, 0.4); }
|
||||
.reader-summary-below-image { display: none; }
|
||||
@media (max-width: 768px) {
|
||||
.reader-header-overlay .reader-summary.hide-on-mobile { display: none; }
|
||||
.reader-summary-below-image { display: block; padding: 0 0 1.5rem 0; margin-top: -1rem; }
|
||||
.reader-summary-below-image .reader-summary { color: #aaa; font-size: 1rem; line-height: 1.6; margin: 0; }
|
||||
.reader-hero-image { min-height: 280px; max-height: 400px; height: 50vh; }
|
||||
.reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
|
||||
.reader-header-overlay { padding: 1.5rem 1rem 1rem; }
|
||||
.reader-header-overlay .reader-title { font-size: 1.5rem; line-height: 1.3; }
|
||||
}
|
||||
|
||||
|
||||
15
src/styles/components/settings.css
Normal file
15
src/styles/components/settings.css
Normal file
@@ -0,0 +1,15 @@
|
||||
/* Settings view containers */
|
||||
.settings-view { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding: 0.75rem 1rem; text-align: left; }
|
||||
.settings-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; padding: 0; }
|
||||
.settings-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; text-align: left; }
|
||||
.settings-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.settings-content { overflow-y: auto; flex: 1; margin-bottom: 1rem; text-align: left; padding: 0 0.25rem 2rem 0.25rem; }
|
||||
.settings-section { margin-bottom: 2.5rem; }
|
||||
.settings-section:last-child { margin-bottom: 0; }
|
||||
.section-title { font-size: 1rem; font-weight: 600; color: #fff; margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid #333; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.settings-footer { display: flex; justify-content: flex-start; padding: 1rem 0 0.5rem 0; flex-shrink: 0; }
|
||||
.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; }
|
||||
|
||||
|
||||
13
src/styles/components/toast.css
Normal file
13
src/styles/components/toast.css
Normal file
@@ -0,0 +1,13 @@
|
||||
/* Toast Notification */
|
||||
.toast { position: fixed; top: 2rem; right: 2rem; background: #1a1a1a; color: #fff; padding: 1rem 1.5rem; border-radius: 8px; border: 1px solid #333; display: flex; align-items: center; gap: 0.75rem; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); animation: toast-slide-in 0.3s ease-out; z-index: 9999; font-size: 0.95rem; }
|
||||
@media (max-width: 768px) {
|
||||
.toast { top: auto; bottom: calc(1rem + var(--safe-area-bottom)); right: 1rem; left: 1rem; max-width: calc(100% - 2rem); }
|
||||
@keyframes toast-slide-in { from { transform: translateY(100px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
}
|
||||
.toast-success { border-color: #28a745; }
|
||||
.toast-success svg { color: #28a745; }
|
||||
.toast-error { border-color: #dc3545; }
|
||||
.toast-error svg { color: #dc3545; }
|
||||
@keyframes toast-slide-in { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
|
||||
145
src/styles/layout/app.css
Normal file
145
src/styles/layout/app.css
Normal file
@@ -0,0 +1,145 @@
|
||||
/* App-level layout and panes */
|
||||
.bookmarks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Two-pane layout (legacy support) */
|
||||
.two-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 4rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.two-pane.sidebar-collapsed { grid-template-columns: 60px 1fr; }
|
||||
|
||||
/* Three-pane layout */
|
||||
.three-pane {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-width);
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 2rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.three-pane { height: calc(100dvh - 2rem); }
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed { grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-width); }
|
||||
.three-pane.highlights-collapsed { grid-template-columns: var(--sidebar-width) 1fr var(--highlights-collapsed-width); }
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed { grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width); }
|
||||
|
||||
/* Mobile three-pane layout */
|
||||
@media (max-width: 768px) {
|
||||
.three-pane {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
.three-pane.sidebar-collapsed,
|
||||
.three-pane.highlights-collapsed,
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.pane.sidebar { overflow-y: auto; height: 100%; }
|
||||
.pane.main {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-width: var(--main-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--main-horizontal-padding);
|
||||
overflow-x: hidden;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* Remove padding when sidebar is collapsed for zero gap */
|
||||
.three-pane.sidebar-collapsed .pane.main { padding-left: 0; }
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed .pane.main { padding-left: 0; }
|
||||
.pane.highlights { overflow-y: auto; height: 100%; }
|
||||
|
||||
/* Ensure panes are stacked in the correct order on desktop */
|
||||
@media (min-width: 769px) {
|
||||
/* Desktop stacking to keep highlights above main without overlap */
|
||||
.three-pane .pane.sidebar { z-index: 1; }
|
||||
.three-pane .pane.main { z-index: 1; }
|
||||
.three-pane .pane.highlights { z-index: 2; }
|
||||
}
|
||||
|
||||
/* Mobile pane styles */
|
||||
@media (max-width: 768px) {
|
||||
/* Both sidepanes slide in as overlays */
|
||||
.pane.sidebar,
|
||||
.pane.highlights {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 85%;
|
||||
max-width: 320px;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: #1a1a1a;
|
||||
z-index: 1001; /* Above backdrop */
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Ensure content fills the mobile sidepanes */
|
||||
.pane.sidebar > *,
|
||||
.pane.highlights > * { width: 100%; height: 100%; }
|
||||
/* Remove borders from containers in mobile overlays */
|
||||
.pane.sidebar .bookmarks-container,
|
||||
.pane.highlights .highlights-container { border: none; border-radius: 0; flex: 1; min-height: 0; }
|
||||
/* Bookmarks sidebar from left */
|
||||
.pane.sidebar { left: 0; transform: translateX(-100%); }
|
||||
.pane.sidebar.mobile-open { transform: translateX(0); box-shadow: 4px 0 12px rgba(0, 0, 0, 0.5); }
|
||||
/* Highlights sidebar from right */
|
||||
.pane.highlights { right: 0; transform: translateX(100%); }
|
||||
.pane.highlights.mobile-open { transform: translateX(0); box-shadow: -4px 0 12px rgba(0, 0, 0, 0.5); }
|
||||
.pane.main { grid-column: 1; grid-row: 1; padding: 0.5rem; max-width: 100%; transition: opacity 0.2s ease; }
|
||||
/* Hide main content when sidepanes are open on mobile */
|
||||
.three-pane .pane.main.mobile-hidden { opacity: 0; pointer-events: none; }
|
||||
.mobile-sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 999; /* Below sidepanes */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.mobile-sidebar-backdrop.visible { display: block; opacity: 1; }
|
||||
.mobile-highlights-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: calc(1rem + env(safe-area-inset-top));
|
||||
right: calc(1rem + env(safe-area-inset-right));
|
||||
z-index: 900;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #ddd;
|
||||
width: var(--min-touch-target);
|
||||
height: var(--min-touch-target);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
.mobile-highlights-btn.hidden { opacity: 0; visibility: hidden; pointer-events: none; }
|
||||
.mobile-highlights-btn.visible { opacity: 1; visibility: visible; }
|
||||
@media (max-width: 768px) { .mobile-highlights-btn { display: flex; } }
|
||||
}
|
||||
|
||||
|
||||
175
src/styles/layout/highlights.css
Normal file
175
src/styles/layout/highlights.css
Normal file
@@ -0,0 +1,175 @@
|
||||
/* Highlights panel layout and interactions */
|
||||
.highlights-container {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed .toggle-highlights-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed .toggle-highlights-btn:hover { background: #333; color: #fff; }
|
||||
.highlights-container.collapsed .toggle-highlights-btn:active { transform: translateY(1px); }
|
||||
.highlights-container.collapsed .toggle-highlights-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; }
|
||||
|
||||
.highlights-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.highlights-actions { display: flex; align-items: center; justify-content: space-between; width: 100%; }
|
||||
.highlights-actions-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.highlights-title { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.highlights-title h3 { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||
.highlights-title .count { color: #888; font-size: 0.875rem; }
|
||||
|
||||
.highlight-mode-toggle { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
|
||||
.highlight-mode-toggle .mode-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
|
||||
.highlight-mode-toggle .mode-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.highlight-mode-toggle .mode-btn.active { background: #646cff; color: #fff; }
|
||||
|
||||
/* Three-level highlight toggles */
|
||||
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
|
||||
.highlight-level-toggles .level-toggle-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
|
||||
.highlight-level-toggles .level-toggle-btn:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
.highlight-level-toggles .level-toggle-btn.active { background: rgba(255, 255, 255, 0.1); opacity: 1; }
|
||||
.highlight-level-toggles .level-toggle-btn:not(.active) { opacity: 0.4; }
|
||||
.highlight-level-toggles .level-toggle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.highlight-level-toggles .level-toggle-btn:disabled:hover { background: none; }
|
||||
|
||||
.refresh-highlights-btn,
|
||||
.toggle-highlight-display-btn,
|
||||
.toggle-highlights-btn {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:hover,
|
||||
.toggle-highlight-display-btn:hover,
|
||||
.toggle-highlights-btn:hover { background: #2a2a2a; color: #fff; }
|
||||
.refresh-highlights-btn:active,
|
||||
.toggle-highlight-display-btn:active,
|
||||
.toggle-highlights-btn:active { transform: translateY(1px); }
|
||||
.refresh-highlights-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.refresh-highlights-btn:disabled:hover { background: transparent; color: #ddd; }
|
||||
|
||||
.highlights-loading,
|
||||
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: #888; text-align: center; gap: 0.5rem; }
|
||||
.highlights-empty svg { color: #555; margin-bottom: 0.5rem; }
|
||||
.empty-hint { font-size: 0.875rem; color: #666; margin-top: 0.5rem; }
|
||||
|
||||
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.highlight-item { background: #1e1e1e; border-left: 1px solid #333; border-right: 1px solid #333; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
|
||||
.highlight-item:hover { border-color: #646cff; }
|
||||
.highlight-item:hover .highlight-header,
|
||||
.highlight-item:hover .highlight-footer { border-color: #646cff; }
|
||||
.highlight-item.selected { border-color: #646cff; background: #252525; box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.3); }
|
||||
.highlight-item.selected .highlight-header,
|
||||
.highlight-item.selected .highlight-footer { border-color: #646cff; }
|
||||
|
||||
/* Compact button for highlight cards */
|
||||
.compact-button { background: none; border: none; color: #888; cursor: pointer; padding: 0.25rem; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; gap: 0.25rem; transition: all 0.2s ease; border-radius: 4px; min-width: 20px; min-height: 20px; }
|
||||
.compact-button:hover { color: #aaa; background: rgba(255, 255, 255, 0.05); }
|
||||
.compact-button:active { transform: scale(0.95); }
|
||||
.compact-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.compact-button:disabled:hover { background: none; color: #888; transform: none; }
|
||||
|
||||
.highlight-header { position: absolute; top: 0; left: 0; right: 0; padding: 0.25rem 0.5rem; display: flex; align-items: center; justify-content: flex-end; pointer-events: none; border-top: 1px solid #333; border-top-left-radius: 8px; border-top-right-radius: 8px; transition: border-color 0.2s ease; }
|
||||
.highlight-header .compact-button { pointer-events: auto; }
|
||||
.highlight-timestamp { font-size: 0.75rem; font-weight: 500; white-space: nowrap; }
|
||||
|
||||
/* Level colors in sidebar items */
|
||||
.highlight-item.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent); }
|
||||
.highlight-item.level-mine .highlight-header,
|
||||
.highlight-item.level-mine .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333); }
|
||||
.highlight-item.level-friends { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
|
||||
.highlight-item.level-friends .highlight-header,
|
||||
.highlight-item.level-friends .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); }
|
||||
.highlight-item.level-nostrverse { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
|
||||
.highlight-item.level-nostrverse .highlight-header,
|
||||
.highlight-item.level-nostrverse .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); }
|
||||
|
||||
.highlight-quote-button { position: absolute; top: 0.25rem; left: 0.5rem; z-index: 10; }
|
||||
.highlight-item.level-mine .highlight-quote-button { color: var(--highlight-color-mine, #ffff00); }
|
||||
.highlight-item.level-friends .highlight-quote-button { color: var(--highlight-color-friends, #f97316); }
|
||||
.highlight-item.level-nostrverse .highlight-quote-button { color: var(--highlight-color-nostrverse, #9333ea); }
|
||||
.highlight-relay-indicator { flex-shrink: 0; }
|
||||
.highlight-relay-indicator:hover { opacity: 1; }
|
||||
|
||||
/* Mobile: Larger touch targets and better spacing */
|
||||
@media (max-width: 768px) {
|
||||
.highlight-relay-indicator { padding: 8px; min-width: var(--min-touch-target); min-height: var(--min-touch-target); }
|
||||
.compact-button { padding: 0.5rem; min-width: var(--min-touch-target); min-height: var(--min-touch-target); }
|
||||
}
|
||||
|
||||
/* Level-colored quote icon */
|
||||
.highlight-item.level-mine .highlight-quote-icon { color: var(--highlight-color-mine, #ffff00); }
|
||||
.highlight-item.level-friends .highlight-quote-icon { color: var(--highlight-color-friends, #f97316); }
|
||||
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
|
||||
|
||||
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 1.75rem 0.75rem; }
|
||||
.highlight-text { margin: 0; padding: 0; font-style: italic; color: #ddd; line-height: 1.6; border-left: none; font-size: 0.95rem; }
|
||||
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; background: rgba(100, 108, 255, 0.1); border-left: 3px solid #646cff; border-radius: 4px; font-size: 0.875rem; color: #ddd; line-height: 1.5; }
|
||||
|
||||
.highlight-footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; font-size: 0.8rem; color: #888; border-bottom: 1px solid #333; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; }
|
||||
.highlight-footer-left { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
|
||||
.highlight-author { color: #aaa; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-flex; align-items: center; min-height: 28px; }
|
||||
|
||||
/* Ensure relay indicator in footer uses normal flow and matches CompactButton spacing */
|
||||
.highlight-item .highlight-footer .highlight-relay-indicator {
|
||||
position: static; /* override any absolute rules from global styles */
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
margin: 0; /* rely on footer gap */
|
||||
padding: 0.25rem; /* CompactButton base */
|
||||
}
|
||||
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
|
||||
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: #2a2a2a; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
|
||||
.highlight-menu-item { width: 100%; background: none; border: none; color: #ddd; padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
|
||||
.highlight-menu-item:hover { background: rgba(100, 108, 255, 0.15); color: #fff; }
|
||||
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.highlight-menu-item-danger:hover { background: rgba(255, 68, 68, 0.15); color: #ff4444; }
|
||||
.highlight-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
|
||||
|
||||
|
||||
183
src/styles/layout/sidebar.css
Normal file
183
src/styles/layout/sidebar.css
Normal file
@@ -0,0 +1,183 @@
|
||||
/* Bookmarks and sidebar layout */
|
||||
.bookmarks-container {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container .view-mode-controls {
|
||||
margin-top: auto;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #333;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container .bookmarks-list {
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: calc(1rem + env(safe-area-inset-top));
|
||||
left: calc(1rem + env(safe-area-inset-left));
|
||||
z-index: 900;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #ddd;
|
||||
width: var(--min-touch-target);
|
||||
height: var(--min-touch-target);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s ease, opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.mobile-close-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hamburger-btn { display: flex; }
|
||||
.sidebar-header-bar .toggle-sidebar-btn { display: none; }
|
||||
.mobile-close-btn { display: flex; }
|
||||
}
|
||||
|
||||
.view-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
flex-shrink: 0;
|
||||
color: #ddd;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-avatar svg { font-size: 1.25rem; }
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn:hover { background: #2a2a2a; color: #fff; }
|
||||
.sidebar-header-bar .toggle-sidebar-btn:active { transform: translateY(1px); }
|
||||
|
||||
.bookmarks-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:hover { background: #333; color: #fff; }
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:active { transform: translateY(1px); }
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; }
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn .glow-blue { color: #646cff; filter: drop-shadow(0 0 4px rgba(100, 108, 255, 0.6)); }
|
||||
|
||||
.user-info { margin: 0.5rem 0 0 0; color: #888; font-size: 0.9rem; font-family: monospace; }
|
||||
.bookmark-count { color: #666; font-size: 0.9rem; margin: 0.5rem 0; }
|
||||
.event-link { color: #8ab4f8; text-decoration: none; font-weight: 500; }
|
||||
.event-link:hover { text-decoration: underline; }
|
||||
|
||||
.bookmark-urls { margin: 0.75rem 0; }
|
||||
.bookmark-url { display: block; margin: 0.25rem 0; color: #007bff; text-decoration: none; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
|
||||
.bookmark-url:hover { text-decoration: underline; }
|
||||
|
||||
.url-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.read-inline-btn { background: #28a745; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
|
||||
.read-inline-btn:hover { background: #218838; }
|
||||
|
||||
|
||||
24
src/styles/utils/animations.css
Normal file
24
src/styles/utils/animations.css
Normal file
@@ -0,0 +1,24 @@
|
||||
/* Reusable keyframes */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 0.8; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes toast-slide-in {
|
||||
from { transform: translateX(400px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes highlight-pulse-animation {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
|
||||
25% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
|
||||
50% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
|
||||
75% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
|
||||
}
|
||||
|
||||
|
||||
42
src/styles/utils/utilities.css
Normal file
42
src/styles/utils/utilities.css
Normal file
@@ -0,0 +1,42 @@
|
||||
/* Inline content highlights - utilities */
|
||||
.content-highlight, .content-highlight-marker { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.35); padding: 0.125rem 0.25rem; cursor: pointer; transition: all 0.2s ease; position: relative; border-radius: 2px; box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); contain: layout style; }
|
||||
.content-highlight:hover, .content-highlight-marker:hover { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.5); box-shadow: 0 0 12px rgba(var(--highlight-rgb, 255, 255, 0), 0.3); }
|
||||
.content-highlight-underline { background: transparent; padding: 0; cursor: pointer; transition: all 0.2s ease; position: relative; text-decoration: underline; text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8); text-decoration-thickness: 2px; text-underline-offset: 2px; contain: layout style; }
|
||||
.content-highlight-underline:hover { text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1); text-decoration-thickness: 3px; }
|
||||
.content-highlight.highlight-pulse, .content-highlight-marker.highlight-pulse, .content-highlight-underline.highlight-pulse { animation: highlight-pulse-animation 1.5s ease-in-out; }
|
||||
.reader-html .content-highlight, .reader-markdown .content-highlight, .reader-html .content-highlight-marker, .reader-markdown .content-highlight-marker, .reader-html .content-highlight-underline, .reader-markdown .content-highlight-underline { color: inherit; }
|
||||
.reader-html .content-highlight, .reader-markdown .content-highlight, .reader-html .content-highlight-marker, .reader-markdown .content-highlight-marker { text-decoration: none; }
|
||||
/* Three-level highlight colors */
|
||||
.content-highlight-marker.level-mine, .content-highlight.level-mine { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 35%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 20%, transparent); }
|
||||
.content-highlight-marker.level-mine:hover, .content-highlight.level-mine:hover { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 50%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 30%, transparent); }
|
||||
.content-highlight-marker.level-friends, .content-highlight.level-friends { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-friends, #f97316) 20%, transparent); }
|
||||
.content-highlight-marker.level-friends:hover, .content-highlight.level-friends:hover { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 50%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-friends, #f97316) 30%, transparent); }
|
||||
.content-highlight-marker.level-nostrverse, .content-highlight.level-nostrverse { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 20%, transparent); }
|
||||
.content-highlight-marker.level-nostrverse:hover, .content-highlight.level-nostrverse:hover { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 50%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 30%, transparent); }
|
||||
/* Underline styles for three levels */
|
||||
.content-highlight-underline.level-mine { text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 80%, transparent); }
|
||||
.content-highlight-underline.level-mine:hover { text-decoration-color: var(--highlight-color-mine, #ffff00); }
|
||||
.content-highlight-underline.level-friends { text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 80%, transparent); }
|
||||
.content-highlight-underline.level-friends:hover { text-decoration-color: var(--highlight-color-friends, #f97316); }
|
||||
.content-highlight-underline.level-nostrverse { text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 80%, transparent); }
|
||||
.content-highlight-underline.level-nostrverse:hover { text-decoration-color: var(--highlight-color-nostrverse, #9333ea); }
|
||||
/* Ensure highlights work in both light and dark mode */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.content-highlight, .content-highlight-marker { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.4); box-shadow: 0 0 6px rgba(var(--highlight-rgb, 255, 255, 0), 0.15); }
|
||||
.content-highlight:hover, .content-highlight-marker:hover { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.55); box-shadow: 0 0 10px rgba(var(--highlight-rgb, 255, 255, 0), 0.25); }
|
||||
.content-highlight-underline { text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.9); }
|
||||
.content-highlight-underline:hover { text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1); }
|
||||
/* Three-level overrides for light mode */
|
||||
.content-highlight-marker.level-mine, .content-highlight.level-mine { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 40%, transparent); box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 15%, transparent); }
|
||||
.content-highlight-marker.level-mine:hover, .content-highlight.level-mine:hover { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 55%, transparent); box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent); }
|
||||
.content-highlight-marker.level-friends, .content-highlight.level-friends { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 40%, transparent); box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-friends, #f97316) 15%, transparent); }
|
||||
.content-highlight-marker.level-friends:hover, .content-highlight.level-friends:hover { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 55%, transparent); box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
|
||||
.content-highlight-marker.level-nostrverse, .content-highlight.level-nostrverse { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 40%, transparent); box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 15%, transparent); }
|
||||
.content-highlight-marker.level-nostrverse:hover, .content-highlight.level-nostrverse:hover { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 55%, transparent); box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
|
||||
.content-highlight-underline.level-mine { text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 90%, transparent); }
|
||||
.content-highlight-underline.level-friends { text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 90%, transparent); }
|
||||
.content-highlight-underline.level-nostrverse { text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 90%, transparent); }
|
||||
.highlight-indicator { background: rgba(100, 108, 255, 0.15); border-color: rgba(100, 108, 255, 0.4); }
|
||||
}
|
||||
|
||||
|
||||
5
src/vite-env.d.ts
vendored
5
src/vite-env.d.ts
vendored
@@ -3,3 +3,8 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEFAULT_ARTICLE_NADDR: string
|
||||
}
|
||||
|
||||
declare module '*.svg?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user