10 changed files with 603 additions and 201 deletions
@ -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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
/** |
||||||
|
* 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