Merge pull request #4 from dergigi/me-page

feat(me): two-pane layout, tabs, refined highlight cards, and mark-as-read
This commit is contained in:
Gigi
2025-10-13 15:31:44 +01:00
committed by GitHub
28 changed files with 1872 additions and 452 deletions

View File

@@ -7,6 +7,19 @@ 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

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.5.3",
"version": "0.5.4",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View 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

View File

@@ -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>
)}

View File

@@ -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">

View File

@@ -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>
)

1
src/icons/books.svg Normal file
View 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
View 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'
})

View File

@@ -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: 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;
@@ -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;
@@ -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) {

View 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 []
}
}

View File

@@ -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
}
}

View 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;
}

View 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;
}
}

View 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; }
}

View 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; }

View 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; }
}

View 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;
}
}

View 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; }

View 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; }
}

View 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; }
}

View 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; }

View 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
View 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; } }
}

View 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; }

View 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; }

View 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); }
}

View 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
View File

@@ -3,3 +3,8 @@
interface ImportMetaEnv {
readonly VITE_DEFAULT_ARTICLE_NADDR: string
}
declare module '*.svg?raw' {
const content: string
export default content
}