mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ae74268fd | ||
|
|
52e959a7f5 | ||
|
|
4f03a2c276 | ||
|
|
bc4c96ee35 | ||
|
|
a866040fc1 | ||
|
|
c90fad268a | ||
|
|
8ef1f775f9 | ||
|
|
90af87339c | ||
|
|
9007b1ca71 | ||
|
|
0b7e6145de | ||
|
|
bf1b608d96 | ||
|
|
7db0f2a05c | ||
|
|
165b4d4b9f | ||
|
|
a7106138c4 | ||
|
|
a498bfab38 | ||
|
|
3dd2980283 | ||
|
|
2e2a1a2c9d | ||
|
|
b9666bf037 |
88
CHANGELOG.md
88
CHANGELOG.md
@@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.12] - 2025-10-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Horizontal dividers (`<hr>`) in blog posts now display with more subtle styling
|
||||
- Reduced visual weight with 69% opacity for better readability
|
||||
- Added increased vertical padding (2.5rem) above and below dividers
|
||||
- Improved visual separation without disrupting reading flow
|
||||
|
||||
## [0.6.11] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Colored borders to blog post and highlight cards based on relationship
|
||||
- Mine: yellow border
|
||||
- Friends: orange border
|
||||
- Nostrverse: purple border
|
||||
- Visual distinction helps identify content source at a glance
|
||||
- Mobile sidebar toggle buttons on explore page
|
||||
- Bookmark and highlights buttons now visible on explore page
|
||||
- Improves mobile navigation UX
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile bookmarks sidebar opening and closing immediately
|
||||
- Memoized `toggleSidebar` function to prevent unnecessary re-renders
|
||||
- Updated route-change effect to only close sidebar on actual pathname changes
|
||||
- Sidebar now stays open when opened on mobile PWA
|
||||
|
||||
## [0.6.10] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Support page (`/support`) displaying zappers with avatar grid
|
||||
- Shows "Absolute Legends" (69420+ sats) and regular supporters (2100+ sats)
|
||||
- Clickable supporter avatars link to profiles
|
||||
- Bolt icon button in sidebar navigation
|
||||
- Thank-you illustration and call-to-action
|
||||
- Links to pricing page and Boris profile
|
||||
- Refresh button to explore page
|
||||
- Positioned next to filter buttons
|
||||
- Spinning animation during loading and pull-to-refresh
|
||||
- Unified event publishing and querying services
|
||||
- `publishEvent` service for highlights and settings
|
||||
- `queryEvents` helper with local-first fetching
|
||||
- Centralized relay timeouts configuration
|
||||
- FEATURES.md documentation file
|
||||
- MIT License
|
||||
|
||||
### Changed
|
||||
|
||||
- Explore page improvements
|
||||
- Filter defaults to friends only (instead of all)
|
||||
- Tabs moved below filter buttons
|
||||
- Filter buttons positioned on the right
|
||||
- Writings tab now uses newspaper icon
|
||||
- Subtitle removed for cleaner layout
|
||||
- Pull-to-refresh library
|
||||
- Replaced custom implementation with `use-pull-to-refresh`
|
||||
- Updated HighlightsPanel to use new library
|
||||
- Loading states now show progressive loading with skeletons instead of blocking error screens
|
||||
- All event fetching services migrated to unified `queryEvents` helper
|
||||
- `nostrverseService`, `bookmarkService`, `libraryService`
|
||||
- `exploreService`, `fetchHighlightsFromAuthors`
|
||||
- Contact streaming with extended timeout and partial results
|
||||
|
||||
### Fixed
|
||||
|
||||
- All ESLint and TypeScript linting errors
|
||||
- Removed all `eslint-disable` statements
|
||||
- Fixed `react-hooks/exhaustive-deps` warnings
|
||||
- Resolved all type errors
|
||||
- Explore page refresh loop and false empty-follows error
|
||||
- Zap receipt scanning with applesauce helpers and more relays
|
||||
- Support page theme colors for proper readability
|
||||
|
||||
### Refactored
|
||||
|
||||
- Event publishing to use unified `publishEvent` service
|
||||
- Event fetching to use unified `queryEvents` helper
|
||||
- Image cache and bookmark components (removed unused settings parameter)
|
||||
- Support page spacing and visual hierarchy
|
||||
|
||||
## [0.6.9] - 2025-10-14
|
||||
|
||||
### Documentation
|
||||
@@ -1299,7 +1382,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.9...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.12...HEAD
|
||||
[0.6.12]: https://github.com/dergigi/boris/compare/v0.6.11...v0.6.12
|
||||
[0.6.11]: https://github.com/dergigi/boris/compare/v0.6.10...v0.6.11
|
||||
[0.6.10]: https://github.com/dergigi/boris/compare/v0.6.9...v0.6.10
|
||||
[0.6.9]: https://github.com/dergigi/boris/compare/v0.6.8...v0.6.9
|
||||
[0.6.8]: https://github.com/dergigi/boris/compare/v0.6.7...v0.6.8
|
||||
[0.6.7]: https://github.com/dergigi/boris/compare/v0.6.6...v0.6.7
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.10",
|
||||
"version": "0.6.13",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -10,9 +10,10 @@ import { Models } from 'applesauce-core'
|
||||
interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
@@ -25,7 +26,7 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="blog-post-card"
|
||||
className={`blog-post-card ${level ? `level-${level}` : ''}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<div className="blog-post-card-image">
|
||||
|
||||
@@ -58,16 +58,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
// Extract tab from profile routes
|
||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||
|
||||
// Decode npub to pubkey for profile view
|
||||
// Decode npub or nprofile to pubkey for profile view
|
||||
let profilePubkey: string | undefined
|
||||
if (npub && showProfile) {
|
||||
try {
|
||||
const decoded = nip19.decode(npub)
|
||||
if (decoded.type === 'npub') {
|
||||
profilePubkey = decoded.data
|
||||
} else if (decoded.type === 'nprofile') {
|
||||
profilePubkey = decoded.data.pubkey
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode npub:', err)
|
||||
console.error('Failed to decode npub/nprofile:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +128,13 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
} = useBookmarksUI({ settings })
|
||||
|
||||
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
|
||||
const prevPathnameRef = useRef<string>(location.pathname)
|
||||
useEffect(() => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
// Only close if pathname actually changed, not on initial render or other state changes
|
||||
if (isMobile && isSidebarOpen && prevPathnameRef.current !== location.pathname) {
|
||||
toggleSidebar()
|
||||
}
|
||||
prevPathnameRef.current = location.pathname
|
||||
}, [location.pathname, isMobile, isSidebarOpen, toggleSidebar])
|
||||
|
||||
// Handle highlight navigation from explore page
|
||||
|
||||
@@ -278,25 +278,33 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
})
|
||||
}, [highlights, activeAccount?.pubkey, followedPubkeys, visibility])
|
||||
|
||||
// Filter blog posts by future dates and visibility
|
||||
// Filter blog posts by future dates and visibility, and add level classification
|
||||
const filteredBlogPosts = useMemo(() => {
|
||||
const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now
|
||||
return blogPosts.filter(post => {
|
||||
// Filter out future dates
|
||||
const publishedTime = post.published || post.event.created_at
|
||||
if (publishedTime > maxFutureTime) return false
|
||||
|
||||
// Apply visibility filters
|
||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||
const isFriend = followedPubkeys.has(post.author)
|
||||
const isNostrverse = !isMine && !isFriend
|
||||
|
||||
if (isMine && !visibility.mine) return false
|
||||
if (isFriend && !visibility.friends) return false
|
||||
if (isNostrverse && !visibility.nostrverse) return false
|
||||
|
||||
return true
|
||||
})
|
||||
return blogPosts
|
||||
.filter(post => {
|
||||
// Filter out future dates
|
||||
const publishedTime = post.published || post.event.created_at
|
||||
if (publishedTime > maxFutureTime) return false
|
||||
|
||||
// Apply visibility filters
|
||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||
const isFriend = followedPubkeys.has(post.author)
|
||||
const isNostrverse = !isMine && !isFriend
|
||||
|
||||
if (isMine && !visibility.mine) return false
|
||||
if (isFriend && !visibility.friends) return false
|
||||
if (isNostrverse && !visibility.nostrverse) return false
|
||||
|
||||
return true
|
||||
})
|
||||
.map(post => {
|
||||
// Add level classification
|
||||
const isMine = activeAccount && post.author === activeAccount.pubkey
|
||||
const isFriend = followedPubkeys.has(post.author)
|
||||
const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse'
|
||||
return { ...post, level }
|
||||
})
|
||||
}, [blogPosts, activeAccount, followedPubkeys, visibility])
|
||||
|
||||
const renderTabContent = () => {
|
||||
@@ -322,6 +330,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
level={post.level}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { format } from 'date-fns'
|
||||
import { useImageCache } from '../hooks/useImageCache'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -70,11 +70,18 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
}
|
||||
}, [highlights, highlightVisibility, settings])
|
||||
|
||||
if (cachedImage) {
|
||||
// Show hero section if we have an image OR a title
|
||||
if (cachedImage || title) {
|
||||
return (
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{cachedImage ? (
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
) : (
|
||||
<div className="reader-hero-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
</div>
|
||||
)}
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
{formattedDate}
|
||||
|
||||
@@ -16,7 +16,7 @@ const PWASettings: React.FC = () => {
|
||||
if (isInstalled) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<h3 className="section-title">Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
|
||||
@@ -36,41 +36,19 @@ const PWASettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<h3 className="section-title">Progressive Web App</h3>
|
||||
<div className="setting-group">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
|
||||
<span>Install Boris as an app</span>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Install Boris on your device for a native app experience with offline support.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="install-button"
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '8px 16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
className="zap-preset-btn"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
Install App
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -105,13 +105,33 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect scroll direction to hide/show mobile buttons
|
||||
// Now using window scroll (document scroll) instead of pane scroll
|
||||
// Detect scroll direction and position to hide/show mobile buttons
|
||||
// Only hide on scroll down when viewing article content
|
||||
const isViewingArticle = !!(props.selectedUrl)
|
||||
const scrollDirection = useScrollDirection({
|
||||
threshold: 10,
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && isViewingArticle
|
||||
})
|
||||
const showMobileButtons = scrollDirection !== 'down'
|
||||
|
||||
// Track if we're at the top of the page
|
||||
const [isAtTop, setIsAtTop] = useState(true)
|
||||
useEffect(() => {
|
||||
if (!isMobile || !isViewingArticle) return
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsAtTop(window.scrollY <= 10)
|
||||
}
|
||||
|
||||
handleScroll() // Check initial position
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [isMobile, isViewingArticle])
|
||||
|
||||
// Bookmark button: hide only when scrolling down
|
||||
const showBookmarkButton = scrollDirection !== 'down'
|
||||
// Highlights button: hide when scrolling down OR at the top
|
||||
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
useEffect(() => {
|
||||
@@ -229,11 +249,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
{/* Mobile bookmark button - always show except on settings page */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && (
|
||||
<button
|
||||
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
showBookmarkButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
@@ -249,11 +269,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
{/* Mobile highlights button - only show when viewing article content */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && isViewingArticle && (
|
||||
<button
|
||||
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
showHighlightsButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
@@ -402,7 +422,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
)}
|
||||
<RelayStatusIndicator
|
||||
relayPool={props.relayPool}
|
||||
showOnMobile={showMobileButtons}
|
||||
showOnMobile={showBookmarkButton}
|
||||
/>
|
||||
{props.toastMessage && (
|
||||
<Toast
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -47,9 +47,9 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
})
|
||||
}, [settings])
|
||||
|
||||
const toggleSidebar = () => {
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setIsSidebarOpen(prev => !prev)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
|
||||
@@ -57,10 +57,10 @@
|
||||
|
||||
/* Large preview view */
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
|
||||
.large-preview-image { width: 100%; height: 180px; background: var(--color-bg); 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 var(--color-border); position: relative; }
|
||||
.large-preview-image { width: 100%; height: 180px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); 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 var(--color-border); 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: var(--color-border-subtle); }
|
||||
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; }
|
||||
.large-content { padding: 1.25rem; }
|
||||
.large-text { color: var(--color-text); 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: var(--color-text-secondary); padding-top: 0.75rem; border-top: 1px solid var(--color-border); }
|
||||
@@ -81,10 +81,13 @@
|
||||
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
|
||||
.blog-post-card { background: var(--color-bg); border: 1px solid var(--color-border); 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: var(--color-primary); transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: var(--color-bg-subtle); display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }
|
||||
.blog-post-card.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); }
|
||||
.blog-post-card.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); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
.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-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||
.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: var(--color-text); 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: var(--color-text-secondary); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
|
||||
|
||||
@@ -128,6 +128,13 @@
|
||||
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
|
||||
.reader-markdown blockquote p:first-child, .reader-html blockquote p:first-child { margin-top: 0; }
|
||||
.reader-markdown blockquote p:last-child, .reader-html blockquote p:last-child { margin-bottom: 0; }
|
||||
/* Horizontal rule - subtle divider */
|
||||
.reader-markdown hr, .reader-html hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
opacity: 0.69;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.reader-markdown a { color: var(--color-primary); text-decoration: none; }
|
||||
.reader-markdown a:hover { text-decoration: underline; }
|
||||
.reader-markdown code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
@@ -214,8 +221,9 @@
|
||||
.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 { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 25%, var(--color-bg-elevated) 50%, var(--color-bg-subtle) 75%, var(--color-bg-elevated) 100%); }
|
||||
.reader-hero-image img { width: 100%; height: auto; max-height: 500px; object-fit: cover; display: block; }
|
||||
.reader-hero-placeholder { width: 100%; height: 300px; display: flex; align-items: center; justify-content: center; font-size: 4rem; color: var(--color-border-subtle); opacity: 0.3; }
|
||||
.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; font-size: 2.5rem; font-weight: 700; line-height: 1.2; }
|
||||
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.2rem; line-height: 1.6; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }
|
||||
|
||||
Reference in New Issue
Block a user