feat: PWA install prompt, brand-colored channel icons, image load fix, semantic connection indicator

- Fix image display bug: remove loading='lazy' + use opacity instead of hidden (#66)
- PWA: add id/scope to manifest, fix SW cache name, add install prompt hook + button (#64)
- Colorize channel icons with brand colors (Discord blurple, Telegram blue, etc.) (#68)
- Use semantic green for connection indicator instead of theme accent (#70)
- Replace Sparkles icon with PinchChat logo in sidebar (#65)
- Replace decorative dots in sidebar footer with GitHub link (#71)
This commit is contained in:
Nicolas Varrot
2026-02-14 13:58:18 +00:00
parent cf6512043c
commit 143e9486c2
8 changed files with 193 additions and 35 deletions

View File

@@ -81,7 +81,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
<span className="hidden sm:contents"><LanguageSelector /></span>
{status === 'connected' ? (
<div className="flex items-center gap-2 rounded-2xl border border-pc-border bg-pc-elevated/30 px-3 py-1.5">
<span className="w-2 h-2 rounded-full bg-[var(--pc-accent)] shadow-[0_0_12px_var(--pc-accent-dim)]" />
<span className="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_12px_rgba(52,211,153,0.4)]" />
<span className="text-xs text-pc-text hidden sm:inline">{t('header.connected')}</span>
</div>
) : status === 'connecting' ? (

View File

@@ -64,17 +64,18 @@ export function ImageBlock({ src, alt }: ImageBlockProps) {
aria-label={`View ${alt || 'image'} full size`}
className="block rounded-xl border border-pc-border cursor-pointer hover:brightness-110 transition-all focus:outline-none focus:ring-2 focus:ring-[var(--pc-accent-dim)]"
>
{loading && (
<div className="w-48 h-32 rounded-xl bg-pc-elevated/50 animate-pulse" />
)}
<img
src={src}
alt={alt || 'Image'}
className={`max-w-full max-h-80 rounded-xl${loading ? ' hidden' : ''}`}
loading="lazy"
onLoad={() => setLoading(false)}
onError={() => { setLoading(false); setError(true); }}
/>
<div className="relative">
{loading && (
<div className="w-48 h-32 rounded-xl bg-pc-elevated/50 animate-pulse" />
)}
<img
src={src}
alt={alt || 'Image'}
className={`max-w-full max-h-80 rounded-xl transition-opacity duration-200${loading ? ' absolute top-0 left-0 opacity-0 pointer-events-none' : ' opacity-100'}`}
onLoad={() => setLoading(false)}
onError={() => { setLoading(false); setError(true); }}
/>
</div>
</button>
</div>
{lightbox && <Lightbox src={src} alt={alt} onClose={() => setLightbox(false)} />}

View File

@@ -46,44 +46,63 @@ function SlackIcon({ size = 15, className = '' }: { size?: number; className?: s
* Returns the appropriate icon for a session based on its channel or kind.
* Detects cron sessions from the session key pattern.
*/
export function SessionIcon({ session, isActive, isCurrentSession }: {
export function SessionIcon({ session, isCurrentSession }: {
session: Session;
isActive?: boolean;
isActive?: boolean; // kept for API compat, brand colors used instead
isCurrentSession?: boolean;
}) {
const size = 15;
const baseClass = isCurrentSession ? 'text-pc-accent-light/70' : isActive ? 'text-violet-400/70' : '';
// Brand colors for each channel
const brandColor = (() => {
const isCron = session.key.includes(':cron:');
if (isCron) return 'text-amber-400/80';
const isSubAgent = session.key.includes(':spawn:') || session.key.includes(':sub:');
if (isSubAgent) return 'text-violet-400/80';
const ch = session.channel?.toLowerCase();
switch (ch) {
case 'discord': return 'text-[#5865F2]';
case 'telegram': return 'text-[#26A5E4]';
case 'signal': return 'text-[#3A76F0]';
case 'whatsapp': return 'text-[#25D366]';
case 'slack': return 'text-[#E01E5A]';
case 'webchat': return 'text-emerald-400/80';
case 'teamspeak': return 'text-[#2580C3]';
default: return 'text-pc-text-secondary';
}
})();
const colorClass = isCurrentSession ? 'text-pc-accent-light' : brandColor;
// Detect cron sessions from key pattern
const isCron = session.key.includes(':cron:');
if (isCron) {
return <Clock size={size} className={baseClass} />;
return <Clock size={size} className={colorClass} />;
}
// Detect sub-agent / spawned sessions
const isSubAgent = session.key.includes(':spawn:') || session.key.includes(':sub:');
if (isSubAgent) {
return <Bot size={size} className={baseClass} />;
return <Bot size={size} className={colorClass} />;
}
const channel = session.channel?.toLowerCase();
switch (channel) {
case 'discord':
return <DiscordIcon size={size} className={baseClass} />;
return <DiscordIcon size={size} className={colorClass} />;
case 'telegram':
return <TelegramIcon size={size} className={baseClass} />;
return <TelegramIcon size={size} className={colorClass} />;
case 'signal':
return <SignalIcon size={size} className={baseClass} />;
return <SignalIcon size={size} className={colorClass} />;
case 'whatsapp':
return <WhatsAppIcon size={size} className={baseClass} />;
return <WhatsAppIcon size={size} className={colorClass} />;
case 'slack':
return <SlackIcon size={size} className={baseClass} />;
return <SlackIcon size={size} className={colorClass} />;
case 'webchat':
return <Globe size={size} className={baseClass} />;
return <Globe size={size} className={colorClass} />;
case 'teamspeak':
return <MessageSquare size={size} className={baseClass} />;
return <MessageSquare size={size} className={colorClass} />;
default:
return <MessageSquare size={size} className={baseClass} />;
return <MessageSquare size={size} className={colorClass} />;
}
}

View File

@@ -1,11 +1,12 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { X, Sparkles, Search, Pin, Trash2, Columns2, Clock, Bot, MessageSquare, Globe, Zap, ArrowUpCircle } from 'lucide-react';
import { X, Search, Pin, Trash2, Columns2, Clock, Bot, MessageSquare, Globe, Zap, ArrowUpCircle, Download } from 'lucide-react';
import type { Session } from '../types';
import { useT } from '../hooks/useLocale';
import { SessionIcon } from './SessionIcon';
import { sessionDisplayName } from '../lib/sessionName';
import { relativeTime } from '../lib/relativeTime';
import { useUpdateCheck } from '../hooks/useUpdateCheck';
import { usePwaInstall } from '../hooks/usePwaInstall';
function VersionBadge() {
const update = useUpdateCheck(__APP_VERSION__);
@@ -31,6 +32,34 @@ function VersionBadge() {
);
}
function SidebarFooter() {
const pwa = usePwaInstall();
return (
<div className="px-4 py-3 border-t border-pc-border flex items-center justify-center gap-3">
{pwa.canInstall && (
<button
onClick={pwa.install}
className="inline-flex items-center gap-1 text-[10px] text-pc-accent-light hover:text-[var(--pc-accent)] transition-colors"
title="Install app"
>
<Download size={11} />
<span>Install</span>
</button>
)}
<a
href="https://github.com/MarlBurroW/pinchchat"
target="_blank"
rel="noopener noreferrer"
className="text-pc-text-faint hover:text-pc-text-secondary transition-colors"
title="GitHub"
>
<Globe size={11} />
</a>
<VersionBadge />
</div>
);
}
const PINNED_KEY = 'pinchchat-pinned-sessions';
const WIDTH_KEY = 'pinchchat-sidebar-width';
const ORDER_KEY = 'pinchchat-session-order';
@@ -258,8 +287,8 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
<div className="flex items-center gap-2">
<div className="relative">
<div className="absolute -inset-1.5 rounded-xl bg-gradient-to-r from-cyan-400/15 to-violet-500/15 blur-lg" />
<div className="relative flex h-8 w-8 items-center justify-center rounded-xl border border-pc-border bg-pc-elevated/50">
<Sparkles className="h-4 w-4 text-pc-accent-light" />
<div className="relative flex h-8 w-8 items-center justify-center rounded-xl overflow-hidden">
<img src="/logo.png" alt="PinchChat" className="h-8 w-8 object-contain" />
</div>
</div>
<span className="font-semibold text-sm text-pc-text tracking-wide">{t('sidebar.title')}</span>
@@ -511,12 +540,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit,
})}
</div>
{/* Footer with version */}
<div className="px-4 py-3 border-t border-pc-border flex items-center justify-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-violet-300/60 shadow-[0_0_10px_rgba(168,85,247,0.5)]" />
<span className="h-1.5 w-1.5 rounded-full bg-[var(--pc-accent-dim)] shadow-[0_0_10px_rgba(34,211,238,0.5)]" />
<span className="h-1.5 w-1.5 rounded-full bg-indigo-300/50 shadow-[0_0_10px_rgba(99,102,241,0.4)]" />
<VersionBadge />
</div>
<SidebarFooter />
{/* Resize drag handle */}
<div
onMouseDown={startDrag}

View File

@@ -0,0 +1,52 @@
import { useState, useEffect, useCallback, useRef } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
// Check standalone mode outside of React to avoid setState-in-effect
const isStandalone = typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches;
export function usePwaInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(isStandalone);
const installedRef = useRef(isStandalone);
useEffect(() => {
if (installedRef.current) return;
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
const installedHandler = () => {
installedRef.current = true;
setIsInstalled(true);
setDeferredPrompt(null);
};
window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('appinstalled', installedHandler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
window.removeEventListener('appinstalled', installedHandler);
};
}, []);
const install = useCallback(async () => {
if (!deferredPrompt) return false;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
setDeferredPrompt(null);
return outcome === 'accepted';
}, [deferredPrompt]);
return {
canInstall: !!deferredPrompt && !isInstalled,
isInstalled,
install,
};
}