mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 13:04:59 +01:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c6232e029 | ||
|
|
f6c562e9be | ||
|
|
a92b14e877 | ||
|
|
b69a956247 | ||
|
|
82a8dcf6eb | ||
|
|
8e19e22289 | ||
|
|
e167b57810 | ||
|
|
ba3b82e6b5 | ||
|
|
b5edfbb2c9 | ||
|
|
48048f877a | ||
|
|
bd1afc54c3 | ||
|
|
a2c4bed0f5 | ||
|
|
9bad49fe5f | ||
|
|
2aa6536496 | ||
|
|
bd6d8a0342 | ||
|
|
dc8e86bc57 | ||
|
|
32b843908e | ||
|
|
5a71480459 | ||
|
|
17455aa47b | ||
|
|
4cc32c27de | ||
|
|
99bfe209a5 | ||
|
|
0a28bfbd50 | ||
|
|
ba9fb109f6 | ||
|
|
ec9d2fcb49 | ||
|
|
f841043e03 | ||
|
|
94dc95e1f0 | ||
|
|
32a5145d8f | ||
|
|
a856e8ca26 |
114
CHANGELOG.md
114
CHANGELOG.md
@@ -7,6 +7,116 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.23] - 2025-01-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Deep-link refresh redirect issue for nostr-native articles
|
||||
- Limited `/a/:naddr` rewrite to bot user-agents only in Vercel configuration
|
||||
- Real browsers now hit the SPA directly, preventing redirect to root path
|
||||
- Bot crawlers still receive proper OpenGraph metadata for social sharing
|
||||
|
||||
### Added
|
||||
|
||||
- Version and git commit information in Settings footer
|
||||
- Displays app version and short commit hash with link to GitHub
|
||||
- Build-time metadata injection via Vite configuration
|
||||
- Subtle footer styling with selectable text
|
||||
|
||||
### Changed
|
||||
|
||||
- Article OG handler now uses proper RelayPool.request() API
|
||||
- Aligned with applesauce RelayPool interface
|
||||
- Removed deprecated open/close methods
|
||||
- Fixed TypeScript linting errors
|
||||
|
||||
### Technical
|
||||
|
||||
- Added debug logging for route state and article OG handler
|
||||
- Gated by `?debug=1` query parameter for production testing
|
||||
- Structured logging for troubleshooting deep-link issues
|
||||
- Temporary debug components for validation
|
||||
|
||||
## [0.6.22] - 2025-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Dynamic OpenGraph and Twitter Card meta tags for article deep-links
|
||||
- Social media platforms display article title, author, cover image, and summary when sharing `/a/{naddr}` links
|
||||
- Serverless endpoint fetches article metadata from Nostr relays (kind:30023) and author profiles (kind:0)
|
||||
- User-agent detection serves appropriate content to crawlers vs browsers
|
||||
- Falls back to default social preview image when articles have no cover image
|
||||
- Social preview image for homepage and article links
|
||||
- Added `boris-social-1200.png` as default OpenGraph image (1200x630)
|
||||
- Homepage now includes social preview image in meta tags
|
||||
|
||||
### Changed
|
||||
|
||||
- Article deep-links now properly preserve URL when loading in browser
|
||||
- Uses `history.replaceState()` to maintain correct article path
|
||||
- Browser navigation works correctly on refresh and new tab opens
|
||||
|
||||
### Fixed
|
||||
|
||||
- Vercel rewrite configuration for article routes
|
||||
- Routes `/a/:naddr` to serverless OG endpoint for dynamic meta tags
|
||||
- Regular SPA routing preserved for browser navigation
|
||||
|
||||
## [0.6.21] - 2025-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Reading position sync across devices using Nostr Kind 30078 (NIP-78)
|
||||
- Automatically saves and syncs reading position as you scroll
|
||||
- Visual reading progress indicator on article cards
|
||||
- Reading progress shown in Explore and Bookmarks sidebar
|
||||
- Auto-scroll to last reading position setting (configurable in Settings)
|
||||
- Reading position displayed as colored progress bar on cards
|
||||
- Reading progress filters for organizing articles
|
||||
- Filter by reading state: Unopened, Started (0-10%), Reading (11-94%), Completed (95-100% or marked as read)
|
||||
- Filter icons colored when active (blue for most, green for completed)
|
||||
- URL routing support for reading progress filters
|
||||
- Reading progress filters available in Archive tab and bookmarks sidebar
|
||||
- Reads and Links tabs on `/me` page
|
||||
- Reads tab shows nostr-native articles with reading progress
|
||||
- Links tab shows external URLs with reading progress
|
||||
- Both tabs populate instantly from bookmarks for fast loading
|
||||
- Lazy loading for improved performance
|
||||
- Auto-mark as read at 100% reading progress
|
||||
- Articles automatically marked as read when scrolled to end
|
||||
- Marked-as-read articles treated as 100% progress
|
||||
- Fancy checkmark animation on Mark as Read button
|
||||
- Click-to-open article navigation on highlights
|
||||
- Clicking highlights in Explore and Me pages opens the source article
|
||||
- Automatically scrolls to highlighted text position
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed Archive to Reads with expanded functionality
|
||||
- Merged 'Completed' and 'Marked as Read' filters into one unified filter
|
||||
- Simplified filter icon colors to blue (except green for completed)
|
||||
- Started reading progress state (0-10%) uses neutral text color
|
||||
- Replace spinners with skeleton placeholders during refresh in Archive/Reads/Links tabs
|
||||
- Removed unused IEventStore import in ContentPanel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position calculation now accurately reaches 100%
|
||||
- Reading position filters work correctly in bookmarks sidebar
|
||||
- Filter out reads without timestamps or 'Untitled' items
|
||||
- Show skeleton placeholders correctly during initial tab load
|
||||
- External URLs in Reads tab only shown if they have reading progress
|
||||
- Reading progress merges even when timestamp is older than bookmark
|
||||
- Resolved all linter errors and TypeScript type issues
|
||||
|
||||
### Refactored
|
||||
|
||||
- Renamed ArchiveFilters component to ReadingProgressFilters
|
||||
- Extracted shared utilities from readsFromBookmarks for DRY code
|
||||
- Use setState callback pattern for background enrichment
|
||||
- Use naddr format for article IDs to match reading positions
|
||||
- Extract article titles, images, summaries from bookmark tags using applesauce helpers
|
||||
|
||||
## [0.6.20] - 2025-10-15
|
||||
|
||||
### Added
|
||||
@@ -1641,7 +1751,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.23...HEAD
|
||||
[0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23
|
||||
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
|
||||
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
||||
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
||||
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||
|
||||
304
api/article-og.ts
Normal file
304
api/article-og.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent, Filter } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
||||
|
||||
// Relay configuration (from src/config/relays.ts)
|
||||
const RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net'
|
||||
]
|
||||
|
||||
type CacheEntry = {
|
||||
html: string
|
||||
expires: number
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
}
|
||||
|
||||
interface ArticleMetadata {
|
||||
title: string
|
||||
summary: string
|
||||
image: string
|
||||
author: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
async function fetchEventsFromRelays(
|
||||
relayPool: RelayPool,
|
||||
relayUrls: string[],
|
||||
filter: Filter,
|
||||
timeoutMs: number
|
||||
): Promise<NostrEvent[]> {
|
||||
const events: NostrEvent[] = []
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(), timeoutMs)
|
||||
|
||||
// `request` emits NostrEvent objects directly
|
||||
relayPool.request(relayUrls, filter).subscribe({
|
||||
next: (event) => {
|
||||
events.push(event)
|
||||
},
|
||||
error: () => resolve(),
|
||||
complete: () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Sort by created_at and return most recent first
|
||||
return events.sort((a, b) => b.created_at - a.created_at)
|
||||
}
|
||||
|
||||
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
|
||||
const relayPool = new RelayPool()
|
||||
|
||||
try {
|
||||
// Decode naddr
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type !== 'naddr') {
|
||||
return null
|
||||
}
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Determine relay URLs
|
||||
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
||||
|
||||
// Fetch article and profile in parallel
|
||||
const [articleEvents, profileEvents] = await Promise.all([
|
||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||
kinds: [pointer.kind],
|
||||
authors: [pointer.pubkey],
|
||||
'#d': [pointer.identifier || '']
|
||||
}, 5000),
|
||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||
kinds: [0],
|
||||
authors: [pointer.pubkey]
|
||||
}, 3000)
|
||||
])
|
||||
|
||||
if (articleEvents.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const article = articleEvents[0]
|
||||
|
||||
// Extract article metadata
|
||||
const title = getArticleTitle(article) || 'Untitled Article'
|
||||
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
||||
const image = getArticleImage(article) || '/boris-social-1200.png'
|
||||
|
||||
// Extract author name from profile
|
||||
let authorName = pointer.pubkey.slice(0, 8) + '...'
|
||||
if (profileEvents.length > 0) {
|
||||
try {
|
||||
const profileData = JSON.parse(profileEvents[0].content)
|
||||
authorName = profileData.display_name || profileData.name || authorName
|
||||
} catch {
|
||||
// Use fallback
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
author: authorName,
|
||||
published: article.created_at
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch article metadata:', err)
|
||||
return null
|
||||
} finally {
|
||||
// No explicit close needed; pool manages connections internally
|
||||
}
|
||||
}
|
||||
|
||||
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||
const baseUrl = 'https://read.withboris.com'
|
||||
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||
|
||||
const title = meta?.title || 'Boris – Nostr Bookmarks'
|
||||
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||
const author = meta?.author || 'Boris'
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<meta name="description" content="${escapeHtml(description)}" />
|
||||
<link rel="canonical" href="${articleUrl}" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:url" content="${articleUrl}" />
|
||||
<meta property="og:title" content="${escapeHtml(title)}" />
|
||||
<meta property="og:description" content="${escapeHtml(description)}" />
|
||||
<meta property="og:image" content="${escapeHtml(image)}" />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
||||
<meta property="article:author" content="${escapeHtml(author)}" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="${articleUrl}" />
|
||||
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
||||
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
||||
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<p>Redirecting to <a href="/">Boris</a>...</p>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function isCrawler(userAgent: string | undefined): boolean {
|
||||
if (!userAgent) return false
|
||||
const crawlers = [
|
||||
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
|
||||
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
|
||||
]
|
||||
const ua = userAgent.toLowerCase()
|
||||
return crawlers.some(crawler => ua.includes(crawler))
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||
|
||||
if (!naddr) {
|
||||
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||
}
|
||||
|
||||
const userAgent = req.headers['user-agent'] as string | undefined
|
||||
const isCrawlerRequest = isCrawler(userAgent)
|
||||
|
||||
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] request', JSON.stringify({
|
||||
naddr,
|
||||
ua: userAgent || null,
|
||||
isCrawlerRequest,
|
||||
path: req.url || null
|
||||
}))
|
||||
res.setHeader('X-Boris-Debug', '1')
|
||||
}
|
||||
|
||||
// If it's a regular browser (not a bot), serve HTML that loads SPA
|
||||
// Use history.replaceState to set the URL before the SPA boots
|
||||
if (!isCrawlerRequest) {
|
||||
const articlePath = `/a/${naddr}`
|
||||
// Serve a minimal HTML that sets up the URL and loads the SPA
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Boris - Loading Article...</title>
|
||||
<script>
|
||||
// Set the URL to the article path before SPA loads
|
||||
if (window.location.pathname !== '${articlePath}') {
|
||||
history.replaceState(null, '', '${articlePath}');
|
||||
}
|
||||
</script>
|
||||
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</script>` : ''}
|
||||
<script>
|
||||
// Redirect to index.html which will load the SPA
|
||||
// The history state is already set, so SPA will see the correct URL
|
||||
window.location.replace('/');
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
|
||||
}
|
||||
return res.status(200).send(html)
|
||||
}
|
||||
|
||||
// Check cache for bots/crawlers
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(naddr)
|
||||
if (cached && cached.expires > now) {
|
||||
setCacheHeaders(res)
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
|
||||
}
|
||||
return res.status(200).send(cached.html)
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch metadata
|
||||
const meta = await fetchArticleMetadata(naddr)
|
||||
|
||||
// Generate HTML
|
||||
const html = generateHtml(naddr, meta)
|
||||
|
||||
// Cache the result
|
||||
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
|
||||
|
||||
// Send response
|
||||
setCacheHeaders(res)
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
|
||||
}
|
||||
return res.status(200).send(html)
|
||||
} catch (err) {
|
||||
console.error('Error generating article OG HTML:', err)
|
||||
|
||||
// Fallback to basic HTML with SPA boot
|
||||
const html = generateHtml(naddr, null)
|
||||
setCacheHeaders(res, 3600)
|
||||
if (debugEnabled) {
|
||||
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
|
||||
}
|
||||
return res.status(200).send(html)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<meta property="og:url" content="https://read.withboris.com/" />
|
||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
@@ -25,6 +26,7 @@
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
|
||||
<!-- Default to system theme until settings load from Nostr -->
|
||||
<script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.21",
|
||||
"version": "0.6.24",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
BIN
public/boris-social-1200.png
Normal file
BIN
public/boris-social-1200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 819 KiB |
@@ -9,6 +9,7 @@ import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import RouteDebug from './components/RouteDebug'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
@@ -303,6 +304,7 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
<RouteDebug />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
|
||||
30
src/components/RouteDebug.tsx
Normal file
30
src/components/RouteDebug.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation, useMatch } from 'react-router-dom'
|
||||
|
||||
export default function RouteDebug() {
|
||||
const location = useLocation()
|
||||
const matchArticle = useMatch('/a/:naddr')
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (params.get('debug') !== '1') return
|
||||
|
||||
const info: Record<string, unknown> = {
|
||||
pathname: location.pathname,
|
||||
search: location.search || null,
|
||||
matchedArticleRoute: Boolean(matchArticle),
|
||||
referrer: document.referrer || null
|
||||
}
|
||||
|
||||
if (location.pathname === '/') {
|
||||
// Unexpected during deep-link refresh tests
|
||||
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||
} else {
|
||||
console.debug('[RouteDebug]', info)
|
||||
}
|
||||
}, [location, matchArticle])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global __APP_VERSION__, __GIT_COMMIT__, __GIT_COMMIT_URL__ */
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { faTimes, faUndo } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -167,6 +168,21 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
</div>
|
||||
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
|
||||
<span>Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}</span>
|
||||
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
|
||||
<span>
|
||||
{' '}·
|
||||
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
|
||||
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
|
||||
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||
</a>
|
||||
) : (
|
||||
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
7
src/vite-env.d.ts
vendored
7
src/vite-env.d.ts
vendored
@@ -8,3 +8,10 @@ declare module '*.svg?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
// Build-time defines injected by Vite in vite.config.ts
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __GIT_COMMIT__: string
|
||||
declare const __GIT_BRANCH__: string
|
||||
declare const __BUILD_TIME__: string
|
||||
declare const __GIT_COMMIT_URL__: string
|
||||
|
||||
11
vercel.json
11
vercel.json
@@ -1,5 +1,16 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/a/:naddr",
|
||||
"has": [
|
||||
{
|
||||
"type": "header",
|
||||
"key": "user-agent",
|
||||
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*"
|
||||
}
|
||||
],
|
||||
"destination": "/api/article-og?naddr=:naddr"
|
||||
},
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
|
||||
@@ -1,8 +1,75 @@
|
||||
/* eslint-env node */
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { execSync } from 'node:child_process'
|
||||
|
||||
function getGitMetadata() {
|
||||
const envSha = process.env.VERCEL_GIT_COMMIT_SHA || ''
|
||||
const envRef = process.env.VERCEL_GIT_COMMIT_REF || ''
|
||||
let commit = envSha
|
||||
let branch = envRef
|
||||
try {
|
||||
if (!commit) commit = execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (!branch) branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { commit, branch }
|
||||
}
|
||||
|
||||
function getPackageVersion() {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)).toString())
|
||||
return pkg.version as string
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
const { commit, branch } = getGitMetadata()
|
||||
const version = getPackageVersion()
|
||||
const buildTime = new Date().toISOString()
|
||||
|
||||
function getCommitUrl(commit: string): string {
|
||||
if (!commit) return ''
|
||||
const provider = process.env.VERCEL_GIT_PROVIDER || ''
|
||||
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
|
||||
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
|
||||
if (provider.toLowerCase() === 'github' && owner && slug) {
|
||||
return `https://github.com/${owner}/${slug}/commit/${commit}`
|
||||
}
|
||||
try {
|
||||
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||
if (remote.includes('github.com')) {
|
||||
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
|
||||
const https = remote.startsWith('git@')
|
||||
? `https://github.com/${remote.split(':')[1]}`
|
||||
: remote
|
||||
const cleaned = https.replace(/\.git$/, '')
|
||||
return `${cleaned}/commit/${commit}`
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const commitUrl = getCommitUrl(commit)
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
__GIT_COMMIT__: JSON.stringify(commit),
|
||||
__GIT_BRANCH__: JSON.stringify(branch),
|
||||
__BUILD_TIME__: JSON.stringify(buildTime),
|
||||
__GIT_COMMIT_URL__: JSON.stringify(commitUrl)
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
|
||||
Reference in New Issue
Block a user