10 changed files with 603 additions and 201 deletions
@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
|
||||
import { |
||||
getRssFeedUrlHostname, |
||||
getStandardRssFeedProfile, |
||||
type StandardRssFeedIcon, |
||||
type StandardRssFeedProfile |
||||
} from '@/lib/standard-rss-feed-url' |
||||
import { cn } from '@/lib/utils' |
||||
import { BookOpen, Flame, Mail, Music2, Newspaper, Rss, Youtube } from 'lucide-react' |
||||
import type { LucideIcon } from 'lucide-react' |
||||
import type { ReactNode } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
const ICONS: Record<StandardRssFeedIcon, LucideIcon> = { |
||||
music: Music2, |
||||
youtube: Youtube, |
||||
feedburner: Flame, |
||||
reddit: Newspaper, |
||||
substack: Mail, |
||||
medium: BookOpen, |
||||
rss: Rss |
||||
} |
||||
|
||||
function ProfileIcon({ profile }: { profile: StandardRssFeedProfile | null }) { |
||||
const Icon = profile ? ICONS[profile.icon] : Rss |
||||
return ( |
||||
<div |
||||
className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border/80 bg-muted/40 text-muted-foreground" |
||||
aria-hidden |
||||
> |
||||
<Icon className="size-4" strokeWidth={2} /> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
type Props = { |
||||
feedUrl: string |
||||
className?: string |
||||
/** Trailing actions (e.g. remove button) */ |
||||
actions?: ReactNode |
||||
} |
||||
|
||||
/** |
||||
* Settings-style row: icon, friendly name for known feed URLs, optional id line, full URL link. |
||||
*/ |
||||
export default function StandardRssFeedUrlRow({ feedUrl, className, actions }: Props) { |
||||
const { t } = useTranslation() |
||||
const profile = getStandardRssFeedProfile(feedUrl) |
||||
const host = getRssFeedUrlHostname(feedUrl) |
||||
const title = profile |
||||
? t(profile.labelKey, { defaultValue: profile.defaultLabel }) |
||||
: host |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
'flex items-start justify-between gap-3 rounded-lg border p-3', |
||||
className |
||||
)} |
||||
> |
||||
<div className="flex min-w-0 flex-1 gap-3"> |
||||
<ProfileIcon profile={profile} /> |
||||
<div className="min-w-0 flex-1 space-y-1"> |
||||
<p className="text-sm font-medium leading-tight">{title}</p> |
||||
{profile?.detail ? ( |
||||
<p className="text-xs text-muted-foreground font-mono">{profile.detail}</p> |
||||
) : null} |
||||
<a |
||||
href={feedUrl} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="block break-all text-xs text-primary hover:underline" |
||||
onClick={(e) => e.stopPropagation()} |
||||
> |
||||
{feedUrl} |
||||
</a> |
||||
</div> |
||||
</div> |
||||
{actions ? <div className="flex shrink-0 flex-col items-end gap-1">{actions}</div> : null} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
type InlineProps = { |
||||
feedUrl: string |
||||
/** Prefer RSS channel title when we have it */ |
||||
title?: string |
||||
/** Truncate display label (full string still in `title` tooltip) */ |
||||
maxLength?: number |
||||
className?: string |
||||
} |
||||
|
||||
const INLINE_ICONS: Record<StandardRssFeedIcon, LucideIcon> = ICONS |
||||
|
||||
/** |
||||
* Compact icon + label for filter menus (single line, truncated). |
||||
*/ |
||||
export function StandardRssFeedUrlInline({ feedUrl, title, maxLength, className }: InlineProps) { |
||||
const { t } = useTranslation() |
||||
const profile = getStandardRssFeedProfile(feedUrl) |
||||
const host = getRssFeedUrlHostname(feedUrl) |
||||
const label = |
||||
title?.trim() || |
||||
(profile |
||||
? t(profile.labelKey, { defaultValue: profile.defaultLabel }) |
||||
: host) |
||||
const display = |
||||
maxLength !== undefined && label.length > maxLength |
||||
? `${label.slice(0, maxLength)}…` |
||||
: label |
||||
const Icon = profile ? INLINE_ICONS[profile.icon] : Rss |
||||
|
||||
return ( |
||||
<span className={cn('inline-flex min-w-0 max-w-full items-center gap-2', className)}> |
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" strokeWidth={2} aria-hidden /> |
||||
<span className="truncate" title={label}> |
||||
{display} |
||||
</span> |
||||
</span> |
||||
) |
||||
} |
||||
@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
|
||||
/** |
||||
* Recognize well-known RSS / Atom feed URL shapes (Spotifeed, YouTube, FeedBurner, etc.) |
||||
* for friendlier labels in the UI. Not exhaustive — unknown URLs return null. |
||||
*/ |
||||
|
||||
export type StandardRssFeedIcon = 'music' | 'youtube' | 'feedburner' | 'reddit' | 'substack' | 'medium' | 'rss' |
||||
|
||||
export type StandardRssFeedProfile = { |
||||
icon: StandardRssFeedIcon |
||||
/** i18n key under translation */ |
||||
labelKey: string |
||||
/** English fallback if catalog missing */ |
||||
defaultLabel: string |
||||
/** Short secondary line (e.g. truncated id) */ |
||||
detail?: string |
||||
} |
||||
|
||||
function truncateId(id: string, max = 14): string { |
||||
const t = id.trim() |
||||
if (t.length <= max) return t |
||||
return `${t.slice(0, max)}…` |
||||
} |
||||
|
||||
/** |
||||
* Returns a profile when `feedUrl` matches a known pattern; otherwise null. |
||||
*/ |
||||
export function getStandardRssFeedProfile(feedUrl: string): StandardRssFeedProfile | null { |
||||
let u: URL |
||||
try { |
||||
u = new URL(feedUrl.trim()) |
||||
} catch { |
||||
return null |
||||
} |
||||
|
||||
const host = u.hostname.replace(/^www\./, '').toLowerCase() |
||||
|
||||
if (host === 'spotifeed.timdorr.com') { |
||||
const id = u.pathname.replace(/^\//, '').split('/').filter(Boolean)[0] ?? '' |
||||
return { |
||||
icon: 'music', |
||||
labelKey: 'standardRssFeed_spotifeed', |
||||
defaultLabel: 'Spotify playlist (Spotifeed)', |
||||
detail: id ? truncateId(id, 22) : undefined |
||||
} |
||||
} |
||||
|
||||
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') { |
||||
if (u.pathname.includes('/feeds/videos.xml')) { |
||||
const ch = u.searchParams.get('channel_id') |
||||
const pl = u.searchParams.get('playlist_id') |
||||
if (ch) { |
||||
return { |
||||
icon: 'youtube', |
||||
labelKey: 'standardRssFeed_youtubeChannel', |
||||
defaultLabel: 'YouTube channel feed', |
||||
detail: truncateId(ch, 18) |
||||
} |
||||
} |
||||
if (pl) { |
||||
return { |
||||
icon: 'youtube', |
||||
labelKey: 'standardRssFeed_youtubePlaylist', |
||||
defaultLabel: 'YouTube playlist feed', |
||||
detail: truncateId(pl, 18) |
||||
} |
||||
} |
||||
return { |
||||
icon: 'youtube', |
||||
labelKey: 'standardRssFeed_youtube', |
||||
defaultLabel: 'YouTube feed' |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (host.endsWith('feedburner.com') || host.endsWith('feedburner.google.com')) { |
||||
return { |
||||
icon: 'feedburner', |
||||
labelKey: 'standardRssFeed_feedburner', |
||||
defaultLabel: 'FeedBurner' |
||||
} |
||||
} |
||||
|
||||
if (host === 'reddit.com' || host.endsWith('.reddit.com')) { |
||||
const p = u.pathname |
||||
if (p.endsWith('.rss') || p.includes('/.rss') || p.endsWith('/rss') || p.includes('/rss/')) { |
||||
return { |
||||
icon: 'reddit', |
||||
labelKey: 'standardRssFeed_reddit', |
||||
defaultLabel: 'Reddit RSS' |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (host.endsWith('.substack.com') && u.pathname.startsWith('/feed')) { |
||||
return { |
||||
icon: 'substack', |
||||
labelKey: 'standardRssFeed_substack', |
||||
defaultLabel: 'Substack' |
||||
} |
||||
} |
||||
|
||||
if (host === 'medium.com' || host.endsWith('.medium.com')) { |
||||
if (u.pathname.startsWith('/feed') || u.pathname.startsWith('/@')) { |
||||
return { |
||||
icon: 'medium', |
||||
labelKey: 'standardRssFeed_medium', |
||||
defaultLabel: 'Medium' |
||||
} |
||||
} |
||||
} |
||||
|
||||
return null |
||||
} |
||||
|
||||
/** Hostname for display when there is no known profile. */ |
||||
export function getRssFeedUrlHostname(feedUrl: string): string { |
||||
try { |
||||
return new URL(feedUrl.trim()).hostname.replace(/^www\./, '') |
||||
} catch { |
||||
return feedUrl.trim().slice(0, 80) |
||||
} |
||||
} |
||||
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
/** |
||||
* Builds the browser fetch URL for Jumble's server-side fetch proxy (`VITE_PROXY_SERVER`). |
||||
* Shared by OG/HTML fetches and RSS so both hit the same proxy contract. |
||||
*/ |
||||
export function buildViteProxySitesFetchUrl(originalUrl: string, proxyServer: string): string { |
||||
const base = proxyServer.trim() |
||||
if (base.startsWith('http://') || base.startsWith('https://')) { |
||||
const withSlash = base.endsWith('/') ? base : `${base}/` |
||||
return `${withSlash}sites/?url=${encodeURIComponent(originalUrl)}` |
||||
} |
||||
const basePath = base.endsWith('/') ? base : `${base}/` |
||||
return `${basePath}?url=${encodeURIComponent(originalUrl)}` |
||||
} |
||||
|
||||
export function urlLooksLikeViteProxyRequest(url: string): boolean { |
||||
return url.includes('/sites/') || url.includes('/sites/?url=') |
||||
} |
||||
Loading…
Reference in new issue