mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 13:04:59 +01:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86de9c9f1f | ||
|
|
974cecb85f | ||
|
|
9b245b3d29 | ||
|
|
4fe9fd5470 | ||
|
|
18af2d02ea | ||
|
|
a80352d8d3 | ||
|
|
6652694304 | ||
|
|
728c269a29 | ||
|
|
91c68a9d48 | ||
|
|
f9d381e451 | ||
|
|
81a48bd0f6 | ||
|
|
386a821c6b | ||
|
|
d10e12b8df | ||
|
|
c3eb29445e | ||
|
|
e0450385ed | ||
|
|
a2620caa29 | ||
|
|
609e15a738 | ||
|
|
fdb8511c87 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "markr",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "markr",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "markr",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import {
|
||||
faBookmark,
|
||||
faUserLock,
|
||||
faCircleUser,
|
||||
faFeather,
|
||||
faRetweet,
|
||||
faHeart,
|
||||
faImage,
|
||||
faVideo,
|
||||
faFile,
|
||||
faLaptopCode,
|
||||
faCodePullRequest,
|
||||
faBug,
|
||||
faExclamationTriangle,
|
||||
faBolt,
|
||||
faCloudBolt,
|
||||
faHighlighter,
|
||||
faNewspaper,
|
||||
faEyeSlash,
|
||||
faThumbtack
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBookmark, faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronDown, faChevronUp, faBookOpen } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
||||
import { IndividualBookmark } from '../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
|
||||
import { getKindIcon } from './kindIcon'
|
||||
import ContentWithResolvedProfiles from './ContentWithResolvedProfiles'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
|
||||
@@ -35,6 +19,8 @@ interface BookmarkItemProps {
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
// removed copy-to-clipboard buttons
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -42,9 +28,14 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
// Extract URLs from bookmark content
|
||||
const extractedUrls = extractUrlsFromContent(bookmark.content)
|
||||
const hasUrls = extractedUrls.length > 0
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
|
||||
// Resolve author profile using applesauce
|
||||
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
||||
const authorNpub = npubEncode(bookmark.pubkey)
|
||||
const isHexId = /^[0-9a-f]{64}$/i.test(bookmark.id)
|
||||
const eventNevent = isHexId ? neventEncode({ id: bookmark.id }) : undefined
|
||||
|
||||
// Get display name for author
|
||||
const getAuthorDisplayName = () => {
|
||||
@@ -54,31 +45,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
return short(bookmark.pubkey) // fallback to short pubkey
|
||||
}
|
||||
|
||||
// Map kind numbers to FontAwesome icons
|
||||
const getKindIcon = (kind: number) => {
|
||||
const iconMap: Record<number, import('@fortawesome/fontawesome-svg-core').IconDefinition> = {
|
||||
0: faCircleUser,
|
||||
1: faFeather,
|
||||
6: faRetweet,
|
||||
7: faHeart,
|
||||
20: faImage,
|
||||
21: faVideo,
|
||||
22: faVideo,
|
||||
1063: faFile,
|
||||
1337: faLaptopCode,
|
||||
1617: faCodePullRequest,
|
||||
1621: faBug,
|
||||
1984: faExclamationTriangle,
|
||||
9735: faBolt,
|
||||
9321: faCloudBolt,
|
||||
9802: faHighlighter,
|
||||
30023: faNewspaper,
|
||||
10000: faEyeSlash,
|
||||
10001: faThumbtack,
|
||||
10003: faBookmark
|
||||
}
|
||||
return iconMap[kind] || faFile // fallback to file icon
|
||||
}
|
||||
// use helper from kindIcon.ts
|
||||
|
||||
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!hasUrls) return
|
||||
@@ -104,44 +71,97 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
)}
|
||||
</span>
|
||||
<span className="bookmark-id">
|
||||
{short(bookmark.id)}
|
||||
</span>
|
||||
|
||||
<span className="bookmark-date">{formatDate(bookmark.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{extractedUrls.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
<h4>URLs:</h4>
|
||||
{extractedUrls.map((url, urlIndex) => (
|
||||
<a
|
||||
key={urlIndex}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bookmark-url"
|
||||
onClick={(e) => { if (onSelectUrl) { e.preventDefault(); onSelectUrl(url) } }}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 3)).map((url, urlIndex) => (
|
||||
<div key={urlIndex} className="url-row">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bookmark-url"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
<IconButton
|
||||
icon={faBookOpen}
|
||||
ariaLabel="Read now"
|
||||
title="Read now"
|
||||
variant="success"
|
||||
size={36}
|
||||
onClick={(e) => { e.preventDefault(); onSelectUrl?.(url) }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{extractedUrls.length > 3 && (
|
||||
<button
|
||||
className="expand-toggle"
|
||||
onClick={() => setUrlsExpanded(v => !v)}
|
||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
>
|
||||
<FontAwesomeIcon icon={urlsExpanded ? faChevronUp : faChevronDown} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{renderParsedContent(bookmark.parsedContent)}
|
||||
{shouldTruncate && bookmark.content
|
||||
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}…`} />
|
||||
: renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<ContentWithResolvedProfiles content={bookmark.content} />
|
||||
<div className="bookmark-content">
|
||||
<ContentWithResolvedProfiles content={shouldTruncate ? `${bookmark.content.slice(0, 210).trimEnd()}…` : bookmark.content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contentLength > 210 && (
|
||||
<button
|
||||
className="expand-toggle"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="bookmark-meta">
|
||||
<span className="kind-icon">
|
||||
<FontAwesomeIcon icon={getKindIcon(bookmark.kind)} />
|
||||
</span>
|
||||
{eventNevent ? (
|
||||
<a
|
||||
href={`https://search.dergigi.com/e/${eventNevent}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="kind-icon-link"
|
||||
title="Open event in search"
|
||||
>
|
||||
<span className="kind-icon">
|
||||
<FontAwesomeIcon icon={getKindIcon(bookmark.kind)} />
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="kind-icon">
|
||||
<FontAwesomeIcon icon={getKindIcon(bookmark.kind)} />
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
Author: {getAuthorDisplayName()}
|
||||
<a
|
||||
href={`https://search.dergigi.com/p/${authorNpub}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="author-link"
|
||||
title="Open author in search"
|
||||
>
|
||||
by: {getAuthorDisplayName()}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
60
src/components/IconButton.tsx
Normal file
60
src/components/IconButton.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
interface IconButtonProps {
|
||||
icon: IconDefinition
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
title?: string
|
||||
ariaLabel?: string
|
||||
variant?: 'primary' | 'success' | 'ghost'
|
||||
size?: number
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
icon,
|
||||
onClick,
|
||||
title,
|
||||
ariaLabel,
|
||||
variant = 'ghost',
|
||||
size = 44,
|
||||
href,
|
||||
target,
|
||||
rel
|
||||
}) => {
|
||||
const commonProps = {
|
||||
className: `icon-button ${variant}`,
|
||||
title,
|
||||
'aria-label': ariaLabel || title,
|
||||
style: { width: size, height: size }
|
||||
} as const
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
{...(commonProps as any)}
|
||||
href={href}
|
||||
target={target || '_blank'}
|
||||
rel={rel || 'noopener noreferrer'}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...(commonProps as any)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default IconButton
|
||||
|
||||
|
||||
42
src/components/ResolvedMention.tsx
Normal file
42
src/components/ResolvedMention.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { decode, npubEncode } from 'nostr-tools/nip19'
|
||||
import { getPubkeyFromDecodeResult } from 'applesauce-core/helpers'
|
||||
|
||||
interface ResolvedMentionProps {
|
||||
encoded?: string
|
||||
}
|
||||
|
||||
const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
|
||||
if (!encoded) return null
|
||||
let pubkey: string | undefined
|
||||
try {
|
||||
pubkey = getPubkeyFromDecodeResult(decode(encoded))
|
||||
} catch {
|
||||
// ignore; will fallback to showing the encoded value
|
||||
}
|
||||
|
||||
const profile = pubkey ? useEventModel(Models.ProfileModel, [pubkey]) : undefined
|
||||
const display = profile?.name || profile?.display_name || profile?.nip05 || (pubkey ? `${pubkey.slice(0, 8)}...` : encoded)
|
||||
const npub = pubkey ? npubEncode(pubkey) : undefined
|
||||
|
||||
if (npub) {
|
||||
return (
|
||||
<a
|
||||
href={`https://search.dergigi.com/p/${npub}`}
|
||||
className="nostr-mention"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@{display}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className="nostr-mention">{encoded}</span>
|
||||
}
|
||||
|
||||
export default ResolvedMention
|
||||
|
||||
|
||||
49
src/components/kindIcon.ts
Normal file
49
src/components/kindIcon.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faCircleUser,
|
||||
faFeather,
|
||||
faRetweet,
|
||||
faHeart,
|
||||
faImage,
|
||||
faVideo,
|
||||
faFile,
|
||||
faLaptopCode,
|
||||
faCodePullRequest,
|
||||
faBug,
|
||||
faExclamationTriangle,
|
||||
faBolt,
|
||||
faCloudBolt,
|
||||
faHighlighter,
|
||||
faNewspaper,
|
||||
faEyeSlash,
|
||||
faThumbtack,
|
||||
faBookmark
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
const iconMap: Record<number, IconDefinition> = {
|
||||
0: faCircleUser,
|
||||
1: faFeather,
|
||||
6: faRetweet,
|
||||
7: faHeart,
|
||||
20: faImage,
|
||||
21: faVideo,
|
||||
22: faVideo,
|
||||
1063: faFile,
|
||||
1337: faLaptopCode,
|
||||
1617: faCodePullRequest,
|
||||
1621: faBug,
|
||||
1984: faExclamationTriangle,
|
||||
9735: faBolt,
|
||||
9321: faCloudBolt,
|
||||
9802: faHighlighter,
|
||||
30023: faNewspaper,
|
||||
10000: faEyeSlash,
|
||||
10001: faThumbtack,
|
||||
10003: faBookmark
|
||||
}
|
||||
|
||||
export function getKindIcon(kind: number): IconDefinition {
|
||||
return iconMap[kind] || faFile
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,50 @@ body {
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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: 44px; /* mobile tap target */
|
||||
min-height: 44px; /* mobile tap target */
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.bookmark-events {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
@@ -376,13 +420,11 @@ body {
|
||||
background: #1a1a1a;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #333;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bookmark-item:hover {
|
||||
border-color: #646cff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@@ -441,7 +483,6 @@ body {
|
||||
background: #2a2a2a;
|
||||
padding: 1.25rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
word-wrap: break-word;
|
||||
@@ -451,7 +492,6 @@ body {
|
||||
}
|
||||
|
||||
.individual-bookmark:hover {
|
||||
border-color: #646cff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@@ -501,6 +541,23 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.expand-toggle {
|
||||
margin: 0.25rem 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
height: 22px; /* half of default icon button */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.expand-toggle:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.individual-bookmark .bookmark-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -517,13 +574,23 @@ body {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.author-link {
|
||||
color: #8ab4f8;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.author-link:hover { text-decoration: underline; }
|
||||
|
||||
.kind-icon {
|
||||
background: #1a1a1a;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.kind-icon svg {
|
||||
@@ -531,6 +598,10 @@ body {
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.kind-icon-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.read-now {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
@@ -559,13 +630,11 @@ body {
|
||||
|
||||
/* Private Bookmark Styles */
|
||||
.private-bookmark {
|
||||
border-left: 4px solid #ff6b6b;
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1f1f1f 100%);
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.private-bookmark:hover {
|
||||
border-color: #ff6b6b;
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.private-indicator {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react'
|
||||
import { ParsedContent, ParsedNode } from '../types/bookmarks'
|
||||
import { ContentWithResolvedProfiles } from '../components/ContentWithResolvedProfiles'
|
||||
import ResolvedMention from '../components/ResolvedMention'
|
||||
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
|
||||
|
||||
export const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
}
|
||||
|
||||
// Component to render content with resolved nprofile names
|
||||
export { default as ContentWithResolvedProfiles } from '../components/ContentWithResolvedProfiles'
|
||||
// Intentionally no exports except components and render helpers
|
||||
|
||||
// Component to render parsed content using applesauce-content
|
||||
export const renderParsedContent = (parsedContent: ParsedContent) => {
|
||||
@@ -21,17 +22,7 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
|
||||
}
|
||||
|
||||
if (node.type === 'mention') {
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={`nostr:${node.encoded}`}
|
||||
className="nostr-mention"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{node.encoded}
|
||||
</a>
|
||||
)
|
||||
return <ResolvedMention key={index} encoded={node.encoded} />
|
||||
}
|
||||
|
||||
if (node.type === 'link') {
|
||||
|
||||
Reference in New Issue
Block a user