Files
pulse/lib/utils/icons.tsx

345 lines
17 KiB
TypeScript

import React, { type ReactNode } from 'react'
import {
Globe,
Question,
DeviceMobile,
DeviceTablet,
Desktop,
Link,
CursorClick,
} from '@phosphor-icons/react'
import {
SiGoogle,
SiFacebook,
SiInstagram,
SiGithub,
SiYoutube,
SiReddit,
SiWhatsapp,
SiTelegram,
SiSnapchat,
SiPinterest,
SiThreads,
SiDuckduckgo,
SiBrave,
SiPerplexity,
SiAnthropic,
SiGooglegemini,
SiGithubcopilot,
SiDiscord,
} from '@icons-pack/react-simple-icons'
// Inline SVG icons for brands not in @icons-pack/react-simple-icons
function XIcon({ size = 16, color = '#fff' }: { size?: number; color?: string }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/></svg>
}
function LinkedInIcon({ size = 16, color = '#0A66C2' }: { size?: number; color?: string }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
}
function OpenAIIcon({ size = 16, color = '#fff' }: { size?: number; color?: string }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>
}
function BingIcon({ size = 16, color = '#258FFA' }: { size?: number; color?: string }) {
return <svg width={size} height={size} viewBox="0 0 24 24" fill={color}><path d="M5.71 0v18.39l4.44 2.46 8.14-4.69v-4.71l-8.14-2.84V4.09L5.71 0zm4.44 11.19l4.39 1.53v2.78l-4.39 2.53v-6.84z"/></svg>
}
import { FAVICON_SERVICE_URL } from './favicon'
export { FAVICON_SERVICE_URL }
// ─── Browser, OS, Device icons (unchanged) ───────────────────────────────────
const BROWSER_ICON_MAP: Record<string, { file: string; ext: 'svg' | 'png' }> = {
'chrome': { file: 'chrome', ext: 'svg' },
'firefox': { file: 'firefox', ext: 'svg' },
'safari': { file: 'safari', ext: 'svg' },
'edge': { file: 'edge', ext: 'svg' },
'opera': { file: 'opera', ext: 'svg' },
'brave': { file: 'brave', ext: 'svg' },
'vivaldi': { file: 'vivaldi', ext: 'svg' },
'samsung internet': { file: 'samsung-internet', ext: 'svg' },
'uc browser': { file: 'uc-browser', ext: 'svg' },
'yandex browser': { file: 'yandex', ext: 'png' },
'waterfox': { file: 'waterfox', ext: 'png' },
'pale moon': { file: 'pale-moon', ext: 'png' },
'duckduckgo': { file: 'duckduckgo', ext: 'png' },
'maxthon': { file: 'maxthon', ext: 'png' },
'silk': { file: 'silk', ext: 'png' },
'puffin': { file: 'puffin', ext: 'png' },
'arc': { file: 'arc', ext: 'png' },
'tor': { file: 'tor', ext: 'png' },
'opera mini': { file: 'opera-mini', ext: 'png' },
}
export function getBrowserIcon(browserName: string) {
if (!browserName) return <Globe className="text-neutral-400" />
const entry = BROWSER_ICON_MAP[browserName.toLowerCase()]
if (!entry) return <Globe className="text-neutral-500" />
const src = `/icons/browsers/${entry.file}.${entry.ext}`
return <img src={src} alt={browserName} width={18} height={18} className="inline-block" style={{ verticalAlign: '-0.125em' }} />
}
const OS_DARK_INVERT = new Set(['macos', 'playstation'])
const OS_ICON_MAP: Record<string, string> = {
'windows': 'windows',
'macos': 'macos',
'linux': 'linux',
'android': 'android',
'ios': 'ios',
'chromeos': 'chromeos',
'harmonyos': 'harmonyos',
'kaios': 'kaios',
'tizen': 'tizen',
'webos': 'webos',
'freebsd': 'freebsd',
'openbsd': 'openbsd',
'netbsd': 'netbsd',
'playstation': 'playstation',
'xbox': 'xbox',
'nintendo': 'nintendo',
}
export function getOSIcon(osName: string) {
if (!osName) return <Question className="text-neutral-400" />
const file = OS_ICON_MAP[osName.toLowerCase()]
if (!file) return <Question className="text-neutral-400" />
const cls = OS_DARK_INVERT.has(file) ? 'inline-block dark:invert' : 'inline-block'
return <img src={`/icons/os/${file}.png`} alt={osName} width={18} height={18} className={cls} style={{ verticalAlign: '-0.125em' }} />
}
export function getDeviceIcon(deviceName: string) {
if (!deviceName) return <Question className="text-neutral-400" />
const lower = deviceName.toLowerCase()
if (lower.includes('mobile') || lower.includes('phone')) return <DeviceMobile className="text-neutral-500" />
if (lower.includes('tablet') || lower.includes('ipad')) return <DeviceTablet className="text-neutral-500" />
if (lower.includes('desktop') || lower.includes('laptop')) return <Desktop className="text-neutral-500" />
return <Question className="text-neutral-400" />
}
// ─── Referrer Registry ───────────────────────────────────────────────────────
const SI = { size: 16 } as const
interface ReferrerEntry {
display: string
icon: () => ReactNode
hostnames?: string[]
aliases?: string[]
}
/**
* Single source of truth for all known referrer brands.
* Key = canonical label (what getReferrerLabel extracts from hostnames).
* Adding a new brand = adding one entry here. Nothing else.
*/
const REFERRER_REGISTRY: Record<string, ReferrerEntry> = {
// ── Special ──
direct: { display: 'Direct', icon: () => <CursorClick className="text-neutral-500" /> },
'shared link': { display: 'Shared Link', icon: () => <Link className="text-neutral-500" /> },
// ── Social / platforms ──
google: { display: 'Google', icon: () => <SiGoogle size={SI.size} color="#4285F4" /> },
facebook: { display: 'Facebook', icon: () => <SiFacebook size={SI.size} color="#0866FF" />, aliases: ['fb'] },
x: { display: 'X', icon: () => <XIcon />, hostnames: ['t.co', 'x.com', 'twitter.com'] },
linkedin: { display: 'LinkedIn', icon: () => <LinkedInIcon /> },
instagram: { display: 'Instagram', icon: () => <SiInstagram size={SI.size} color="#E4405F" />, aliases: ['ig'] },
github: { display: 'GitHub', icon: () => <SiGithub size={SI.size} color="#fff" /> },
youtube: { display: 'YouTube', icon: () => <SiYoutube size={SI.size} color="#FF0000" />, aliases: ['yt'] },
reddit: { display: 'Reddit', icon: () => <SiReddit size={SI.size} color="#FF4500" /> },
whatsapp: { display: 'WhatsApp', icon: () => <SiWhatsapp size={SI.size} color="#25D366" /> },
telegram: { display: 'Telegram', icon: () => <SiTelegram size={SI.size} color="#26A5E4" />, hostnames: ['t.me'] },
snapchat: { display: 'Snapchat', icon: () => <SiSnapchat size={SI.size} color="#FFFC00" /> },
pinterest: { display: 'Pinterest', icon: () => <SiPinterest size={SI.size} color="#BD081C" /> },
threads: { display: 'Threads', icon: () => <SiThreads size={SI.size} color="#fff" /> },
discord: { display: 'Discord', icon: () => <SiDiscord size={SI.size} color="#5865F2" /> },
tumblr: { display: 'Tumblr', icon: () => <Globe className="text-neutral-400" /> },
quora: { display: 'Quora', icon: () => <Globe className="text-neutral-400" /> },
// ── Search engines ──
bing: { display: 'Bing', icon: () => <BingIcon /> },
duckduckgo: { display: 'DuckDuckGo', icon: () => <SiDuckduckgo size={SI.size} color="#DE5833" /> },
brave: { display: 'Brave', icon: () => <SiBrave size={SI.size} color="#FB542B" /> },
// ── AI assistants ──
chatgpt: { display: 'ChatGPT', icon: () => <OpenAIIcon />, hostnames: ['chat.openai.com', 'openai.com'] },
perplexity: { display: 'Perplexity', icon: () => <SiPerplexity size={SI.size} color="#1FB8CD" /> },
claude: { display: 'Claude', icon: () => <SiAnthropic size={SI.size} color="#D97757" />, hostnames: ['anthropic.com'] },
gemini: { display: 'Gemini', icon: () => <SiGooglegemini size={SI.size} color="#8E75B2" />, hostnames: ['gemini.google.com'] },
copilot: { display: 'Copilot', icon: () => <SiGithubcopilot size={SI.size} color="#fff" />, hostnames: ['copilot.microsoft.com'] },
deepseek: { display: 'DeepSeek', icon: () => <OpenAIIcon color="#4D6BFE" />, hostnames: ['chat.deepseek.com'] },
grok: { display: 'Grok', icon: () => <XIcon />, hostnames: ['grok.x.ai', 'x.ai'] },
you: { display: 'You.com', icon: () => <Globe className="text-neutral-400" /> },
phind: { display: 'Phind', icon: () => <Globe className="text-neutral-400" /> },
// ── Browsers as referrers ──
googlechrome: { display: 'Google Chrome', icon: () => <img src="/icons/browsers/chrome.svg" alt="Chrome" width={16} height={16} className="inline-block" />, hostnames: ['googlechrome.github.io'] },
// ── Ciphera products ──
pulse: { display: 'Pulse', icon: () => <img src="/pulse_icon_no_margins.png" alt="Pulse" width={16} height={16} className="inline-block" />, hostnames: ['pulse.ciphera.net', 'pulse-staging.ciphera.net'] },
}
// ── Derived lookup maps (built once at module load) ──
/** alias → registry key (e.g. "ig" → "instagram", "fb" → "facebook") */
const ALIAS_TO_KEY: Record<string, string> = {}
/** exact hostname → registry key (e.g. "t.co" → "x", "t.me" → "telegram") */
const HOSTNAME_TO_KEY: Record<string, string> = {}
/** All known hostnames — union of auto-derived (key + ".com") and explicit hostnames */
const ALL_KNOWN_HOSTNAMES = new Set<string>()
for (const [key, entry] of Object.entries(REFERRER_REGISTRY)) {
if (entry.aliases) {
for (const alias of entry.aliases) {
ALIAS_TO_KEY[alias] = key
}
}
if (entry.hostnames) {
for (const hostname of entry.hostnames) {
HOSTNAME_TO_KEY[hostname] = key
ALL_KNOWN_HOSTNAMES.add(hostname)
}
}
// Auto-derive common hostnames from the key itself
ALL_KNOWN_HOSTNAMES.add(`${key}.com`)
ALL_KNOWN_HOSTNAMES.add(`www.${key}.com`)
}
// ── Referrer resolution ──
/** Common subdomains to skip when deriving the main label (e.g. l.instagram.com → instagram). */
const REFERRER_SUBDOMAIN_SKIP = new Set([
'www', 'm', 'l', 'app', 'mobile', 'search', 'mail', 'drive', 'maps', 'docs',
'sub', 'api', 'static', 'cdn', 'blog', 'shop', 'support', 'help', 'link',
])
const REFERRER_NO_FAVICON = new Set(['direct', 'shared link', 'unknown', ''])
/**
* Derives the main label from a hostname (e.g. "l.instagram.com" → "instagram", "google.com" → "google").
*/
function getReferrerLabel(hostname: string): string {
const withoutWww = hostname.startsWith('www.') ? hostname.slice(4) : hostname
const parts = withoutWww.split('.')
if (parts.length >= 2 && REFERRER_SUBDOMAIN_SKIP.has(parts[0])) {
return parts[1]
}
return parts[0] ?? withoutWww
}
function getReferrerHostname(referrer: string): string | null {
if (!referrer || typeof referrer !== 'string') return null
const trimmed = referrer.trim()
if (REFERRER_NO_FAVICON.has(trimmed.toLowerCase())) return null
try {
const url = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`)
return url.hostname.toLowerCase()
} catch {
return null
}
}
/**
* Resolves a raw referrer string to a registry entry.
* Returns null if no known brand matches (unknown domain → use favicon service).
*/
function resolveReferrer(referrer: string): ReferrerEntry | null {
if (!referrer || typeof referrer !== 'string') return null
const lower = referrer.trim().toLowerCase()
// 1. Exact registry key match (e.g. "Direct", "Reddit", "Google")
if (REFERRER_REGISTRY[lower]) return REFERRER_REGISTRY[lower]
// 2. Alias match (e.g. "ig" → instagram, "fb" → facebook)
const aliasKey = ALIAS_TO_KEY[lower]
if (aliasKey) return REFERRER_REGISTRY[aliasKey]
// 3. Hostname-based matching
const hostname = getReferrerHostname(referrer)
if (!hostname) return null
// 3a. Exact hostname match (e.g. "t.co" → x, "t.me" → telegram)
const hostnameKey = HOSTNAME_TO_KEY[hostname]
if (hostnameKey) return REFERRER_REGISTRY[hostnameKey]
// 3b. Label-based lookup (e.g. "old.reddit.com" → label "reddit" → registry hit)
const label = getReferrerLabel(hostname)
if (REFERRER_REGISTRY[label]) return REFERRER_REGISTRY[label]
// 3c. Check alias from label (e.g. hostname "ig.something.com" → label "ig" → alias → instagram)
const labelAliasKey = ALIAS_TO_KEY[label]
if (labelAliasKey) return REFERRER_REGISTRY[labelAliasKey]
return null
}
// ── Public API (same signatures as before) ──
export function getReferrerIcon(referrerName: string): ReactNode {
if (!referrerName) return <Globe className="text-neutral-400" />
const entry = resolveReferrer(referrerName)
if (entry) return entry.icon()
return <Globe className="text-neutral-400" />
}
function capitalizeLabel(label: string): string {
if (!label) return label
return label.charAt(0).toUpperCase() + label.slice(1).toLowerCase()
}
export function getReferrerDisplayName(referrer: string): string {
if (!referrer || typeof referrer !== 'string') return referrer || ''
const trimmed = referrer.trim()
if (trimmed === '') return ''
const entry = resolveReferrer(trimmed)
if (entry) return entry.display
// Unknown referrer — derive display name from hostname
const hostname = getReferrerHostname(trimmed)
if (!hostname) return trimmed
return capitalizeLabel(getReferrerLabel(hostname))
}
export function getReferrerFavicon(referrer: string): string | null {
if (!referrer || typeof referrer !== 'string') return null
const normalized = referrer.trim().toLowerCase()
if (REFERRER_NO_FAVICON.has(normalized)) return null
if (!normalized.includes('.')) return null
// Known brand → skip favicon service, use registry icon
if (resolveReferrer(referrer)) return null
try {
const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`)
return `${FAVICON_SERVICE_URL}?domain=${url.hostname.toLowerCase()}&sz=32`
} catch {
return null
}
}
/**
* Merges referrer rows that share the same display name (e.g. chatgpt.com and https://chatgpt.com/...),
* summing pageviews and keeping one referrer per group for icon/tooltip. Sorted by pageviews desc.
*/
export function mergeReferrersByDisplayName(
items: Array<{ referrer: string; pageviews: number }>
): Array<{ referrer: string; pageviews: number; allReferrers: string[] }> {
const byDisplayName = new Map<string, { referrer: string; pageviews: number; maxSingle: number; allReferrers: Set<string> }>()
for (const ref of items) {
const name = getReferrerDisplayName(ref.referrer)
const existing = byDisplayName.get(name)
if (!existing) {
byDisplayName.set(name, { referrer: ref.referrer, pageviews: ref.pageviews, maxSingle: ref.pageviews, allReferrers: new Set([ref.referrer]) })
} else {
existing.pageviews += ref.pageviews
existing.allReferrers.add(ref.referrer)
if (ref.pageviews > existing.maxSingle) {
existing.maxSingle = ref.pageviews
existing.referrer = ref.referrer
}
}
}
return Array.from(byDisplayName.values())
.map(({ referrer, pageviews, allReferrers }) => ({ referrer, pageviews, allReferrers: Array.from(allReferrers) }))
.sort((a, b) => b.pageviews - a.pageviews)
}