feat: explore page space banners, icon-derived gradients, and space descriptions

- Redesign explore cards with banner images, overlapping icons, and frosted fade
- Extract dominant colors from space icons for dynamic banner gradients
- Add space description/banner fields to schema with migration
- Move origin label from banner overlay to content metadata row
- Support space descriptions in settings overview panel
This commit is contained in:
Jannis Braun
2026-03-09 13:49:50 +01:00
parent fccfea8b2e
commit 22e7616c70
13 changed files with 485 additions and 46 deletions
@@ -4,6 +4,7 @@ import { useExploreStore, type TaggedExploreSpace } from '../../stores/exploreSt
import { useSpaceStore } from '../../stores/spaceStore';
import { LoadingSpinner } from '../ui/LoadingSpinner';
import { getSpaceGradient } from '../../utils/gradients';
import { extractDominantColors, colorsToGradient } from '../../utils/colorExtractor';
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
export function ExplorePage() {
@@ -219,14 +220,34 @@ function SpaceCard({
const [requestMessage, setRequestMessage] = useState('');
const [requestSent, setRequestSent] = useState(isPending);
const [joinError, setJoinError] = useState('');
const [iconGradient, setIconGradient] = useState<string | null>(null);
const gradient = getSpaceGradient(space.id, space.name);
const fallbackGradient = getSpaceGradient(space.id, space.name).gradient;
const isPublic = space.visibility === 'public';
const isJoined = space.joined === true;
const originLabel = space._instanceOrigin
? (() => { try { return new URL(space._instanceOrigin).host; } catch { return space._instanceOrigin; } })()
: null;
const iconUrl = space.icon
? (space.icon.startsWith('http') ? space.icon : `/api/uploads/${space.icon}`)
: null;
const bannerUrl = space.banner
? (space.banner.startsWith('http') ? space.banner : `/api/uploads/${space.banner}`)
: null;
// Extract dominant colors from icon when no banner is set
useEffect(() => {
if (bannerUrl || !iconUrl) return;
let cancelled = false;
extractDominantColors(iconUrl)
.then(colors => {
if (!cancelled && colors.length > 0) setIconGradient(colorsToGradient(colors));
})
.catch(() => {});
return () => { cancelled = true; };
}, [iconUrl, bannerUrl]);
const handlePublicJoin = async () => {
setJoining(true);
setJoinError('');
@@ -263,23 +284,24 @@ function SpaceCard({
? 'border-accent-mint/20 hover:border-accent-mint/40'
: 'border-border-soft hover:border-border-hard'
}`}>
{/* Banner / Icon area */}
<div className="h-32 relative flex items-center justify-center" style={{ background: gradient.gradient }}>
{space.icon ? (
<img
src={space.icon.startsWith('http') ? space.icon : `/api/uploads/${space.icon}`}
alt={space.name}
className="w-16 h-16 rounded-2xl object-cover shadow-lg"
/>
{/* Banner area */}
<div className="h-32 relative overflow-hidden">
{/* Background layer */}
{bannerUrl ? (
<img src={bannerUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
) : (
<span className="text-3xl font-bold text-white/90 drop-shadow-md">
{space.name.charAt(0).toUpperCase()}
</span>
<div className="absolute inset-0" style={{ background: iconGradient ?? fallbackGradient }} />
)}
{/* Frosted bottom fade — Aether Drift glass */}
<div
className="absolute bottom-0 inset-x-0 h-16"
style={{ background: 'linear-gradient(to top, rgba(20,20,26,0.9), transparent)' }}
/>
{/* Joined badge (top-left) */}
{isJoined && (
<div className="absolute top-2 left-2">
<div className="absolute top-2 left-2 z-[2]">
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-accent-mint/25 text-accent-mint backdrop-blur-sm">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
@@ -290,8 +312,8 @@ function SpaceCard({
)}
{/* Visibility badge */}
<div className="absolute top-2 right-2">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider ${
<div className="absolute top-2 right-2 z-[2]">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider backdrop-blur-sm ${
isPublic
? 'bg-accent-mint/20 text-accent-mint'
: 'bg-accent-amber/20 text-accent-amber'
@@ -300,18 +322,28 @@ function SpaceCard({
</span>
</div>
{/* Instance origin */}
{originLabel && (
<div className="absolute bottom-2 left-2">
<span className="px-1.5 py-0.5 rounded bg-black/40 text-[10px] text-white/80 font-medium backdrop-blur-sm">
{originLabel}
</span>
</div>
{/* Overlapping icon */}
<div className="relative px-4 -mt-8 z-10">
{iconUrl ? (
<img
src={iconUrl}
alt={space.name}
className="w-14 h-14 rounded-xl object-cover ring-[3px] ring-surface-channel shadow-lg"
/>
) : (
<div
className="w-14 h-14 rounded-xl ring-[3px] ring-surface-channel shadow-lg flex items-center justify-center text-xl font-bold text-white/90"
style={{ background: fallbackGradient }}
>
{space.name.charAt(0).toUpperCase()}
</div>
)}
</div>
{/* Content */}
<div className="p-4 flex flex-col flex-1">
<div className="px-4 pt-2 pb-4 flex flex-col flex-1">
<h3 className="text-[15px] font-bold text-txt-primary truncate mb-1">{space.name}</h3>
{space.description ? (
@@ -329,6 +361,14 @@ function SpaceCard({
</svg>
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}
</span>
{originLabel && (
<span className="flex items-center gap-1 text-txt-tertiary/70">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-50">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
{originLabel}
</span>
)}
</div>
{/* Action area */}