mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-03 13:23:08 +00:00
feat: fallback image for unavailable icon links render
This commit is contained in:
parent
072b5968bb
commit
70e61f2b7a
@ -1,4 +1,5 @@
|
||||
## TODOs
|
||||
- [ ] replace mock wallet connection/disconnection
|
||||
- [ ] replace mock Ordinal verification (API)
|
||||
- [ ]
|
||||
- [ ] figure out using actual icons for cells
|
||||
- [ ] store message cache in indexedDB -- make app local-first (update from/to Waku when available)
|
||||
@ -5,6 +5,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Layout, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { CreateCellDialog } from './CreateCellDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CypherImage } from './ui/CypherImage';
|
||||
|
||||
const CellList = () => {
|
||||
const { cells, isInitialLoading, posts, refreshData, isRefreshing } = useForum();
|
||||
@ -67,10 +68,11 @@ const CellList = () => {
|
||||
cells.map((cell) => (
|
||||
<Link to={`/cell/${cell.id}`} key={cell.id} className="board-card group">
|
||||
<div className="flex gap-4 items-start">
|
||||
<img
|
||||
<CypherImage
|
||||
src={cell.icon}
|
||||
alt={cell.name}
|
||||
className="w-16 h-16 object-cover rounded-sm border border-cyber-muted group-hover:border-cyber-accent transition-colors"
|
||||
generateUniqueFallback={true}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold mb-1 group-hover:text-cyber-accent transition-colors">{cell.name}</h2>
|
||||
|
||||
@ -8,6 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ArrowLeft, ArrowUp, ArrowDown, Clock, MessageCircle, Send, RefreshCw } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Comment } from '@/types';
|
||||
import { CypherImage } from './ui/CypherImage';
|
||||
|
||||
const PostDetail = () => {
|
||||
const { postId } = useParams<{ postId: string }>();
|
||||
|
||||
@ -8,6 +8,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ArrowLeft, MessageSquare, MessageCircle, ArrowUp, ArrowDown, Clock, RefreshCw } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { CypherImage } from './ui/CypherImage';
|
||||
|
||||
const PostList = () => {
|
||||
const { cellId } = useParams<{ cellId: string }>();
|
||||
@ -99,10 +100,11 @@ const PostList = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-start mb-6">
|
||||
<img
|
||||
<CypherImage
|
||||
src={cell.icon}
|
||||
alt={cell.name}
|
||||
className="w-12 h-12 object-cover rounded-sm border border-cyber-muted"
|
||||
generateUniqueFallback={true}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
161
src/components/ui/CypherImage.tsx
Normal file
161
src/components/ui/CypherImage.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CypherImageProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
fallbackClassName?: string;
|
||||
generateUniqueFallback?: boolean;
|
||||
} & Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'className'>;
|
||||
|
||||
/**
|
||||
* CypherImage component that renders a cypherpunk-style fallback image
|
||||
* when the actual image cannot be loaded.
|
||||
*/
|
||||
export function CypherImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
fallbackClassName,
|
||||
generateUniqueFallback = false,
|
||||
...props
|
||||
}: CypherImageProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
// Generate a seed based on the alt text or src to create consistent fallbacks for the same resource
|
||||
const seed = generateUniqueFallback ?
|
||||
(alt || src).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) : 0;
|
||||
|
||||
// Handle image load error
|
||||
const handleError = () => {
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
if (imageError) {
|
||||
// Generate some values based on the seed for variety
|
||||
const hue = (seed % 60) + 140; // Cyan-ish colors (140-200)
|
||||
const gridSize = (seed % 8) + 8; // 8-16px
|
||||
const noiseIntensity = (seed % 30) + 5; // 5-35%
|
||||
const scanlineOpacity = ((seed % 4) + 1) / 10; // 0.1-0.5
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center overflow-hidden relative',
|
||||
className,
|
||||
fallbackClassName
|
||||
)}
|
||||
title={alt}
|
||||
aria-label={alt}
|
||||
style={{
|
||||
backgroundColor: '#0a1119',
|
||||
backgroundImage: `
|
||||
linear-gradient(135deg,
|
||||
rgba(0, 0, 0, 0.9) 25%,
|
||||
rgba(${hue / 3}, ${hue}, ${hue}, 0.15) 25%,
|
||||
rgba(${hue / 3}, ${hue}, ${hue}, 0.15) 50%,
|
||||
rgba(0, 0, 0, 0.9) 50%,
|
||||
rgba(0, 0, 0, 0.9) 75%,
|
||||
rgba(${hue / 3}, ${hue}, ${hue}, 0.15) 75%)
|
||||
`,
|
||||
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||
boxShadow: 'inset 0 0 30px rgba(0, 255, 170, 0.2)'
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Noise overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='${noiseIntensity/100}'/%3E%3C/svg%3E")`,
|
||||
mixBlendMode: 'overlay'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scanlines */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none mix-blend-overlay"
|
||||
style={{
|
||||
background: `repeating-linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
transparent 1px,
|
||||
rgba(0, 255, 170, ${scanlineOpacity}) 1px,
|
||||
rgba(0, 255, 170, ${scanlineOpacity}) 2px
|
||||
)`
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* CRT glow effect */}
|
||||
<div className="absolute inset-0 opacity-10 bg-cyan-500 blur-xl"></div>
|
||||
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
{/* Glitch effect lines */}
|
||||
<div
|
||||
className="absolute w-full h-1 bg-gradient-to-r from-transparent via-cyan-400 to-transparent"
|
||||
style={{
|
||||
top: `${(seed % 70) + 15}%`,
|
||||
opacity: 0.4,
|
||||
boxShadow: '0 0 8px rgba(0, 255, 255, 0.8)',
|
||||
transform: `skewY(${(seed % 5) - 2.5}deg)`
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main content container with glitch effect */}
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{
|
||||
textShadow: '0 0 5px rgba(0, 255, 255, 0.8), 0 0 10px rgba(0, 255, 255, 0.5)'
|
||||
}}
|
||||
>
|
||||
{/* Glitched text behind the main letter */}
|
||||
<div
|
||||
className="absolute font-mono opacity-70"
|
||||
style={{
|
||||
color: `hsl(${hue}, 100%, 70%)`,
|
||||
transform: `translate(${(seed % 5) - 2.5}px, ${(seed % 5) - 2.5}px)`
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 3 }, (_, i) => {
|
||||
const chars = '01[]{}><#$%&@!?;:+=*/\\|~^';
|
||||
return chars[(seed + i) % chars.length];
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
{/* First letter of alt text in center */}
|
||||
<div
|
||||
className="relative font-bold text-2xl md:text-3xl cyberpunk-glow z-10"
|
||||
style={{ color: `hsl(${hue}, 100%, 80%)` }}
|
||||
>
|
||||
{alt.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
{/* Random characters that occasionally "glitch" in */}
|
||||
<div
|
||||
className="absolute font-mono text-xs text-cyan-400 opacity-80 z-0"
|
||||
style={{
|
||||
bottom: '20%',
|
||||
right: '20%',
|
||||
transform: `rotate(${(seed % 20) - 10}deg)`,
|
||||
mixBlendMode: 'screen'
|
||||
}}
|
||||
>
|
||||
{seed.toString(16).substring(0, 4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={handleError}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@ -110,3 +109,40 @@
|
||||
@apply border-l-2 border-cyber-muted pl-4 py-2 my-3 hover:border-cyber-accent transition-colors;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cyberpunk glow animation for CypherImage */
|
||||
@keyframes cyber-flicker {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.8));
|
||||
}
|
||||
8%, 10% {
|
||||
opacity: 0.8;
|
||||
filter: drop-shadow(0 0 5px rgba(0, 255, 255, 0.8));
|
||||
}
|
||||
20%, 25% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 1px rgba(0, 255, 255, 0.5));
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 255, 255, 1));
|
||||
}
|
||||
40%, 45% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6));
|
||||
}
|
||||
50%, 55% {
|
||||
opacity: 0.9;
|
||||
filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.8));
|
||||
}
|
||||
60%, 100% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
.cyberpunk-glow {
|
||||
animation: cyber-flicker 8s infinite;
|
||||
will-change: filter, opacity;
|
||||
}
|
||||
|
||||
@ -156,6 +156,7 @@ class MessageManager {
|
||||
const messages = await this.storeManager.queryStore();
|
||||
|
||||
for (const message of messages) {
|
||||
console.log("message", message);
|
||||
this.updateCache(message);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user