Compare commits

..

18 Commits

Author SHA1 Message Date
Gigi
86de9c9f1f chore(release): bump version to 0.1.1 2025-10-03 01:04:20 +02:00
Gigi
974cecb85f style(ui): use full-width slim chevron toggle; keep IconButton square for actions 2025-10-03 01:02:52 +02:00
Gigi
9b245b3d29 style(ui): make kind icon square to match IconButton sizing 2025-10-03 01:01:37 +02:00
Gigi
4fe9fd5470 refactor(ui): use IconButton for kind icon (square, link-capable) 2025-10-03 00:59:45 +02:00
Gigi
18af2d02ea style(ui): remove colored borders and gradients; keep neutral cards 2025-10-03 00:56:58 +02:00
Gigi
a80352d8d3 refactor(ui): use IconButton for all icon-only actions to enforce square sizing 2025-10-03 00:55:51 +02:00
Gigi
6652694304 refactor(ui): extract kind icon mapping to helper and keep BookmarkItem under 210 lines 2025-10-03 00:53:38 +02:00
Gigi
728c269a29 feat(ui): make IconButton square and mobile-tappable (44px min) 2025-10-03 00:50:12 +02:00
Gigi
91c68a9d48 feat(ui): show bookmarked event date top-right; remove event id display 2025-10-03 00:48:48 +02:00
Gigi
f9d381e451 feat(ui): add reusable IconButton component with square styling 2025-10-03 00:47:37 +02:00
Gigi
81a48bd0f6 feat(ui): resolve nprofile/npub mentions to names in content
- Add ResolvedMention component using applesauce ProfileModel
- Update parsed content renderer to use ResolvedMention for mentions
- Mentions now show @name and link to search page
2025-10-03 00:46:11 +02:00
Gigi
386a821c6b feat(ui): make kind icon open event on search.dergigi.com\n\n- Wrap kind icon with link to nevent-encoded event\n- Adds fallback when id is not hex 2025-10-03 00:44:40 +02:00
Gigi
d10e12b8df feat(ui): link author to search.dergigi.com with npub\n\n- Clickable 'by: <author>' opens profile search in new tab\n- Styles for author link 2025-10-03 00:43:19 +02:00
Gigi
c3eb29445e feat(ui): add chevron toggle for URL list (show 3 by default) 2025-10-03 00:40:37 +02:00
Gigi
e0450385ed fix(ui): enforce 210-char truncation for both plain and parsed content\n\n- Show truncated plain text when parsedContent exists and not expanded\n- Render full parsed content only when expanded\n- Keep chevron toggle below content 2025-10-03 00:35:55 +02:00
Gigi
a2620caa29 feat(ui): add 'Read now' button next to each URL in bookmarks\n\n- Display inline book-open icon button per URL\n- Clicking loads readability content in the right panel\n- Added styles for url rows and inline button 2025-10-03 00:32:16 +02:00
Gigi
609e15a738 feat(ui): truncate long bookmark text with expand/collapse chevron\n\n- Show first 210 chars by default\n- Toggle expansion with FontAwesome chevrons\n- Add minimal styles for the toggle 2025-10-03 00:27:31 +02:00
Gigi
fdb8511c87 chore(ui): change 'Author:' label to 'by:' in bookmark cards 2025-10-03 00:26:16 +02:00
8 changed files with 326 additions and 95 deletions

4
package-lock.json generated
View File

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

View File

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

View File

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

View 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

View 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

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

View File

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

View File

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