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 */}
@@ -28,11 +28,19 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE);
const [spaceName, setSpaceName] = useState(space?.name ?? '');
// null = no change, '' = remove icon, 'filename.png' = new icon uploaded
// null = no change, '' = remove, 'filename.png' = new uploaded
const [iconFilename, setIconFilename] = useState<string | null>(null);
const [iconPreview, setIconPreview] = useState<string | null>(null);
const [uploadingIcon, setUploadingIcon] = useState(false);
const [cropSrc, setCropSrc] = useState<string | null>(null);
// Banner state (same pattern as icon)
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
const [uploadingBanner, setUploadingBanner] = useState(false);
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
const bannerFileInputRef = useRef<HTMLInputElement>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
@@ -42,25 +50,36 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
useEffect(() => {
if (space) {
setSpaceName(space.name);
// Reset icon state when space data changes externally
setIconFilename(null);
if (iconPreview) {
URL.revokeObjectURL(iconPreview);
setIconPreview(null);
}
setBannerFilename(null);
if (bannerPreview) {
URL.revokeObjectURL(bannerPreview);
setBannerPreview(null);
}
}
}, [space?.name, space?.icon]);
}, [space?.name, space?.icon, space?.banner]);
if (!space) return null;
const hasNameChange = spaceName.trim() !== space.name;
const hasIconChange = iconFilename !== null;
const hasChanges = hasNameChange || hasIconChange;
const hasBannerChange = bannerFilename !== null;
const hasChanges = hasNameChange || hasIconChange || hasBannerChange;
const currentIconUrl = space.icon
? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon))
: null;
const currentBannerUrl = space.banner
? (space.banner.startsWith('http') ? space.banner : api.uploads.url(space.banner))
: null;
// ─── Icon handlers ────────────────────────────────────────────────────────
const handleIconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -94,26 +113,73 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
const handleRemoveIcon = () => {
if (iconPreview) URL.revokeObjectURL(iconPreview);
setIconPreview(null);
setIconFilename(''); // '' signals removal
setIconFilename('');
};
// ─── Banner handlers ──────────────────────────────────────────────────────
const handleBannerSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setBannerCropSrc(reader.result as string);
reader.readAsDataURL(file);
if (bannerFileInputRef.current) bannerFileInputRef.current.value = '';
};
const handleBannerCropComplete = async (blob: Blob) => {
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
const previewUrl = URL.createObjectURL(blob);
setBannerPreview(previewUrl);
setBannerCropSrc(null);
const file = new File([blob], 'banner.png', { type: 'image/png' });
setUploadingBanner(true);
try {
const spaceApi = getApiForOrigin(space._instanceOrigin);
const attachment = await spaceApi.uploads.upload(file);
setBannerFilename(attachment.filename);
} catch {
setSaveError('Failed to upload banner');
setBannerPreview(null);
URL.revokeObjectURL(previewUrl);
} finally {
setUploadingBanner(false);
}
};
const handleRemoveBanner = () => {
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
setBannerPreview(null);
setBannerFilename('');
};
// ─── Save / Discard / Delete ──────────────────────────────────────────────
const handleSave = async () => {
setSaving(true);
setSaveError('');
setSaveSuccess(false);
try {
const updates: { name?: string; icon?: string } = {};
const updates: { name?: string; icon?: string; banner?: string } = {};
if (hasNameChange) updates.name = spaceName.trim();
if (hasIconChange) {
// Empty string signals icon removal to the backend (sets to null)
updates.icon = iconFilename === '' ? '' : iconFilename!;
}
if (hasBannerChange) {
updates.banner = bannerFilename === '' ? '' : bannerFilename!;
}
await updateSpace(spaceId, updates);
setIconFilename(null);
if (iconPreview) {
URL.revokeObjectURL(iconPreview);
setIconPreview(null);
}
setBannerFilename(null);
if (bannerPreview) {
URL.revokeObjectURL(bannerPreview);
setBannerPreview(null);
}
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) {
@@ -130,6 +196,11 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
URL.revokeObjectURL(iconPreview);
setIconPreview(null);
}
setBannerFilename(null);
if (bannerPreview) {
URL.revokeObjectURL(bannerPreview);
setBannerPreview(null);
}
setSaveError('');
};
@@ -147,9 +218,9 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
}
};
// Determine what icon to show: preview of pending upload, or current space icon
const displayIconSrc = iconPreview ?? (iconFilename === '' ? null : currentIconUrl);
const displayIconName = space.name;
const displayBannerSrc = bannerPreview ?? (bannerFilename === '' ? null : currentBannerUrl);
return (
<>
@@ -215,6 +286,68 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
</div>
</div>
{/* Space Banner */}
<div>
<label className="block text-xs text-txt-secondary mb-1.5">
Space Banner
</label>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => canManageSpace && bannerFileInputRef.current?.click()}
disabled={!canManageSpace || uploadingBanner}
className={`relative w-full h-24 rounded-lg bg-surface-input border-2 border-dashed border-border-subtle flex items-center justify-center overflow-hidden group ${
canManageSpace ? 'hover:border-accent-primary cursor-pointer' : 'cursor-default'
} transition-colors`}
>
{displayBannerSrc ? (
<>
<img src={displayBannerSrc} alt="Space banner" className="w-full h-full object-cover" />
{canManageSpace && (
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
)}
</>
) : (
<div className="flex flex-col items-center gap-1 text-txt-tertiary">
<svg className="w-6 h-6 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
<span className="text-[11px]">Upload banner (16:9)</span>
</div>
)}
{uploadingBanner && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<svg className="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
)}
</button>
<input
ref={bannerFileInputRef}
type="file"
accept="image/*"
onChange={handleBannerSelect}
className="hidden"
/>
{canManageSpace && (displayBannerSrc || space.banner) && bannerFilename !== '' && (
<button
type="button"
onClick={handleRemoveBanner}
className="text-xs text-txt-tertiary hover:text-txt-danger transition-colors self-start"
>
Remove banner
</button>
)}
</div>
</div>
{/* Space Name */}
<div>
<label className="block text-xs text-txt-secondary mb-1.5">
@@ -266,7 +399,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
</button>
<button
onClick={handleSave}
disabled={saving || uploadingIcon || !spaceName.trim()}
disabled={saving || uploadingIcon || uploadingBanner || !spaceName.trim()}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
@@ -286,6 +419,16 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
cropShape="round"
aspectRatio={1}
/>
<ImageCropModal
isOpen={bannerCropSrc !== null}
onClose={() => setBannerCropSrc(null)}
imageSrc={bannerCropSrc ?? ''}
onCropComplete={handleBannerCropComplete}
title="Crop Space Banner"
cropShape="rect"
aspectRatio={16 / 9}
/>
</>
);
}