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
+6
View File
@@ -79,6 +79,12 @@ export function runMigrations(db: Database.Database): void {
{ name: 'visibility', type: "TEXT DEFAULT 'private'" }, { name: 'visibility', type: "TEXT DEFAULT 'private'" },
{ name: 'description', type: 'TEXT' } { name: 'description', type: 'TEXT' }
] ]
},
{
name: 'spaces',
columns: [
{ name: 'banner', type: 'TEXT' }
]
} }
]; ];
+1
View File
@@ -19,6 +19,7 @@ export const spaces = sqliteTable('spaces', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
name: text('name').notNull(), name: text('name').notNull(),
icon: text('icon'), icon: text('icon'),
banner: text('banner'),
ownerId: text('owner_id').notNull().references(() => users.id), ownerId: text('owner_id').notNull().references(() => users.id),
inviteCode: text('invite_code').unique(), inviteCode: text('invite_code').unique(),
visibility: text('visibility').default('private'), visibility: text('visibility').default('private'),
+4 -1
View File
@@ -145,6 +145,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
id: space.id, id: space.id,
name: space.name, name: space.name,
icon: space.icon, icon: space.icon,
banner: space.banner ?? null,
ownerId: space.ownerId, ownerId: space.ownerId,
inviteCode: space.inviteCode, inviteCode: space.inviteCode,
visibility: (space.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'], visibility: (space.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
@@ -202,7 +203,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
let countSql = `SELECT COUNT(DISTINCT s.id) as total FROM spaces s WHERE s.visibility IN ('public', 'request')`; let countSql = `SELECT COUNT(DISTINCT s.id) as total FROM spaces s WHERE s.visibility IN ('public', 'request')`;
let querySql = ` let querySql = `
SELECT s.id, s.name, s.icon, s.description, s.visibility, s.created_at, SELECT s.id, s.name, s.icon, s.banner, s.description, s.visibility, s.created_at,
COUNT(sm.user_id) as member_count COUNT(sm.user_id) as member_count
FROM spaces s FROM spaces s
LEFT JOIN space_members sm ON sm.space_id = s.id LEFT JOIN space_members sm ON sm.space_id = s.id
@@ -225,6 +226,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
id: string; id: string;
name: string; name: string;
icon: string | null; icon: string | null;
banner: string | null;
description: string | null; description: string | null;
visibility: string; visibility: string;
created_at: number; created_at: number;
@@ -235,6 +237,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
id: r.id, id: r.id,
name: r.name, name: r.name,
icon: r.icon, icon: r.icon,
banner: r.banner,
description: r.description, description: r.description,
visibility: r.visibility as ExploreSpace['visibility'], visibility: r.visibility as ExploreSpace['visibility'],
memberCount: r.member_count, memberCount: r.member_count,
+8 -2
View File
@@ -25,6 +25,7 @@ function rowToSpace(row: typeof schema.spaces.$inferSelect): Space {
id: row.id, id: row.id,
name: row.name, name: row.name,
icon: row.icon, icon: row.icon,
banner: row.banner ?? null,
ownerId: row.ownerId, ownerId: row.ownerId,
inviteCode: row.inviteCode, inviteCode: row.inviteCode,
visibility: (row.visibility ?? 'private') as Space['visibility'], visibility: (row.visibility ?? 'private') as Space['visibility'],
@@ -54,7 +55,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: CreateSpaceRequest }>('/api/spaces', { app.post<{ Body: CreateSpaceRequest }>('/api/spaces', {
preHandler: authenticate, preHandler: authenticate,
}, async (request, reply) => { }, async (request, reply) => {
const { name, icon, visibility, description } = request.body; const { name, icon, banner, visibility, description } = request.body;
if (!name || typeof name !== 'string') { if (!name || typeof name !== 'string') {
return reply.code(400).send({ error: 'Space name is required', statusCode: 400 }); return reply.code(400).send({ error: 'Space name is required', statusCode: 400 });
@@ -84,6 +85,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
id: spaceId, id: spaceId,
name: trimmedName, name: trimmedName,
icon: icon ?? null, icon: icon ?? null,
banner: banner ?? null,
ownerId: request.userId, ownerId: request.userId,
inviteCode, inviteCode,
visibility: safeVisibility, visibility: safeVisibility,
@@ -269,7 +271,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
preHandler: authenticate, preHandler: authenticate,
}, async (request, reply) => { }, async (request, reply) => {
const { id } = request.params; const { id } = request.params;
const { name, icon, visibility, description } = request.body; const { name, icon, banner, visibility, description } = request.body;
const db = getDb(); const db = getDb();
const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get(); const server = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
@@ -295,6 +297,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
updates.icon = icon || null; updates.icon = icon || null;
} }
if (banner !== undefined) {
updates.banner = banner || null;
}
if (visibility !== undefined) { if (visibility !== undefined) {
const validVisibilities = ['public', 'request', 'private']; const validVisibilities = ['public', 'request', 'private'];
if (!validVisibilities.includes(visibility)) { if (!validVisibilities.includes(visibility)) {
+1
View File
@@ -675,6 +675,7 @@ function buildReadyPayload(userId: string): {
id: spaceRow.id, id: spaceRow.id,
name: spaceRow.name, name: spaceRow.name,
icon: spaceRow.icon, icon: spaceRow.icon,
banner: spaceRow.banner ?? null,
ownerId: spaceRow.ownerId, ownerId: spaceRow.ownerId,
inviteCode: spaceRow.inviteCode, inviteCode: spaceRow.inviteCode,
visibility: (spaceRow.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'], visibility: (spaceRow.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
+4
View File
@@ -34,6 +34,7 @@ export interface Space {
id: string; id: string;
name: string; name: string;
icon: string | null; icon: string | null;
banner: string | null;
ownerId: string; ownerId: string;
inviteCode: string | null; inviteCode: string | null;
visibility: SpaceVisibility; visibility: SpaceVisibility;
@@ -45,6 +46,7 @@ export interface ExploreSpace {
id: string; id: string;
name: string; name: string;
icon: string | null; icon: string | null;
banner: string | null;
description: string | null; description: string | null;
visibility: SpaceVisibility; visibility: SpaceVisibility;
memberCount: number; memberCount: number;
@@ -300,6 +302,7 @@ export interface AuthResponse {
export interface CreateSpaceRequest { export interface CreateSpaceRequest {
name: string; name: string;
icon?: string; icon?: string;
banner?: string;
visibility?: SpaceVisibility; visibility?: SpaceVisibility;
description?: string; description?: string;
} }
@@ -319,6 +322,7 @@ export interface UpdateChannelRequest {
export interface UpdateSpaceRequest { export interface UpdateSpaceRequest {
name?: string; name?: string;
icon?: string; icon?: string;
banner?: string;
visibility?: SpaceVisibility; visibility?: SpaceVisibility;
description?: string; description?: string;
} }
@@ -4,6 +4,7 @@ import { useExploreStore, type TaggedExploreSpace } from '../../stores/exploreSt
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { LoadingSpinner } from '../ui/LoadingSpinner'; import { LoadingSpinner } from '../ui/LoadingSpinner';
import { getSpaceGradient } from '../../utils/gradients'; import { getSpaceGradient } from '../../utils/gradients';
import { extractDominantColors, colorsToGradient } from '../../utils/colorExtractor';
import { MemberListToggleButton } from '../layout/MemberListToggleButton'; import { MemberListToggleButton } from '../layout/MemberListToggleButton';
export function ExplorePage() { export function ExplorePage() {
@@ -219,14 +220,34 @@ function SpaceCard({
const [requestMessage, setRequestMessage] = useState(''); const [requestMessage, setRequestMessage] = useState('');
const [requestSent, setRequestSent] = useState(isPending); const [requestSent, setRequestSent] = useState(isPending);
const [joinError, setJoinError] = useState(''); 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 isPublic = space.visibility === 'public';
const isJoined = space.joined === true; const isJoined = space.joined === true;
const originLabel = space._instanceOrigin const originLabel = space._instanceOrigin
? (() => { try { return new URL(space._instanceOrigin).host; } catch { return space._instanceOrigin; } })() ? (() => { try { return new URL(space._instanceOrigin).host; } catch { return space._instanceOrigin; } })()
: null; : 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 () => { const handlePublicJoin = async () => {
setJoining(true); setJoining(true);
setJoinError(''); setJoinError('');
@@ -263,23 +284,24 @@ function SpaceCard({
? 'border-accent-mint/20 hover:border-accent-mint/40' ? 'border-accent-mint/20 hover:border-accent-mint/40'
: 'border-border-soft hover:border-border-hard' : 'border-border-soft hover:border-border-hard'
}`}> }`}>
{/* Banner / Icon area */} {/* Banner area */}
<div className="h-32 relative flex items-center justify-center" style={{ background: gradient.gradient }}> <div className="h-32 relative overflow-hidden">
{space.icon ? ( {/* Background layer */}
<img {bannerUrl ? (
src={space.icon.startsWith('http') ? space.icon : `/api/uploads/${space.icon}`} <img src={bannerUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
alt={space.name}
className="w-16 h-16 rounded-2xl object-cover shadow-lg"
/>
) : ( ) : (
<span className="text-3xl font-bold text-white/90 drop-shadow-md"> <div className="absolute inset-0" style={{ background: iconGradient ?? fallbackGradient }} />
{space.name.charAt(0).toUpperCase()}
</span>
)} )}
{/* 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) */} {/* Joined badge (top-left) */}
{isJoined && ( {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"> <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"> <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" /> <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 */} {/* Visibility badge */}
<div className="absolute top-2 right-2"> <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 ${ <span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider backdrop-blur-sm ${
isPublic isPublic
? 'bg-accent-mint/20 text-accent-mint' ? 'bg-accent-mint/20 text-accent-mint'
: 'bg-accent-amber/20 text-accent-amber' : 'bg-accent-amber/20 text-accent-amber'
@@ -300,18 +322,28 @@ function SpaceCard({
</span> </span>
</div> </div>
{/* Instance origin */} </div>
{originLabel && (
<div className="absolute bottom-2 left-2"> {/* Overlapping icon */}
<span className="px-1.5 py-0.5 rounded bg-black/40 text-[10px] text-white/80 font-medium backdrop-blur-sm"> <div className="relative px-4 -mt-8 z-10">
{originLabel} {iconUrl ? (
</span> <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>
)} )}
</div> </div>
{/* Content */} {/* 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> <h3 className="text-[15px] font-bold text-txt-primary truncate mb-1">{space.name}</h3>
{space.description ? ( {space.description ? (
@@ -329,6 +361,14 @@ function SpaceCard({
</svg> </svg>
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'} {space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}
</span> </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> </div>
{/* Action area */} {/* Action area */}
@@ -28,11 +28,19 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE); const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE);
const [spaceName, setSpaceName] = useState(space?.name ?? ''); 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 [iconFilename, setIconFilename] = useState<string | null>(null);
const [iconPreview, setIconPreview] = useState<string | null>(null); const [iconPreview, setIconPreview] = useState<string | null>(null);
const [uploadingIcon, setUploadingIcon] = useState(false); const [uploadingIcon, setUploadingIcon] = useState(false);
const [cropSrc, setCropSrc] = useState<string | null>(null); 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 [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(''); const [saveError, setSaveError] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
@@ -42,25 +50,36 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
useEffect(() => { useEffect(() => {
if (space) { if (space) {
setSpaceName(space.name); setSpaceName(space.name);
// Reset icon state when space data changes externally
setIconFilename(null); setIconFilename(null);
if (iconPreview) { if (iconPreview) {
URL.revokeObjectURL(iconPreview); URL.revokeObjectURL(iconPreview);
setIconPreview(null); 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; if (!space) return null;
const hasNameChange = spaceName.trim() !== space.name; const hasNameChange = spaceName.trim() !== space.name;
const hasIconChange = iconFilename !== null; const hasIconChange = iconFilename !== null;
const hasChanges = hasNameChange || hasIconChange; const hasBannerChange = bannerFilename !== null;
const hasChanges = hasNameChange || hasIconChange || hasBannerChange;
const currentIconUrl = space.icon const currentIconUrl = space.icon
? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon)) ? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon))
: null; : 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 handleIconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -94,26 +113,73 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
const handleRemoveIcon = () => { const handleRemoveIcon = () => {
if (iconPreview) URL.revokeObjectURL(iconPreview); if (iconPreview) URL.revokeObjectURL(iconPreview);
setIconPreview(null); 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 () => { const handleSave = async () => {
setSaving(true); setSaving(true);
setSaveError(''); setSaveError('');
setSaveSuccess(false); setSaveSuccess(false);
try { try {
const updates: { name?: string; icon?: string } = {}; const updates: { name?: string; icon?: string; banner?: string } = {};
if (hasNameChange) updates.name = spaceName.trim(); if (hasNameChange) updates.name = spaceName.trim();
if (hasIconChange) { if (hasIconChange) {
// Empty string signals icon removal to the backend (sets to null)
updates.icon = iconFilename === '' ? '' : iconFilename!; updates.icon = iconFilename === '' ? '' : iconFilename!;
} }
if (hasBannerChange) {
updates.banner = bannerFilename === '' ? '' : bannerFilename!;
}
await updateSpace(spaceId, updates); await updateSpace(spaceId, updates);
setIconFilename(null); setIconFilename(null);
if (iconPreview) { if (iconPreview) {
URL.revokeObjectURL(iconPreview); URL.revokeObjectURL(iconPreview);
setIconPreview(null); setIconPreview(null);
} }
setBannerFilename(null);
if (bannerPreview) {
URL.revokeObjectURL(bannerPreview);
setBannerPreview(null);
}
setSaveSuccess(true); setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000); setTimeout(() => setSaveSuccess(false), 2000);
} catch (err) { } catch (err) {
@@ -130,6 +196,11 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
URL.revokeObjectURL(iconPreview); URL.revokeObjectURL(iconPreview);
setIconPreview(null); setIconPreview(null);
} }
setBannerFilename(null);
if (bannerPreview) {
URL.revokeObjectURL(bannerPreview);
setBannerPreview(null);
}
setSaveError(''); 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 displayIconSrc = iconPreview ?? (iconFilename === '' ? null : currentIconUrl);
const displayIconName = space.name; const displayIconName = space.name;
const displayBannerSrc = bannerPreview ?? (bannerFilename === '' ? null : currentBannerUrl);
return ( return (
<> <>
@@ -215,6 +286,68 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
</div> </div>
</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 */} {/* Space Name */}
<div> <div>
<label className="block text-xs text-txt-secondary mb-1.5"> <label className="block text-xs text-txt-secondary mb-1.5">
@@ -266,7 +399,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
</button> </button>
<button <button
onClick={handleSave} 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" 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'} {saving ? 'Saving...' : 'Save'}
@@ -286,6 +419,16 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
cropShape="round" cropShape="round"
aspectRatio={1} aspectRatio={1}
/> />
<ImageCropModal
isOpen={bannerCropSrc !== null}
onClose={() => setBannerCropSrc(null)}
imageSrc={bannerCropSrc ?? ''}
onCropComplete={handleBannerCropComplete}
title="Crop Space Banner"
cropShape="rect"
aspectRatio={16 / 9}
/>
</> </>
); );
} }
+27 -5
View File
@@ -97,6 +97,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
if (!isHome) { if (!isHome) {
for (const space of event.spaces) { for (const space of event.spaces) {
if (space.icon) space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon; if (space.icon) space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon;
if (space.banner) space.banner = resolveAssetUrl(space.banner, origin) ?? space.banner;
if ((space as any).members) { if ((space as any).members) {
for (const member of (space as any).members) { for (const member of (space as any).members) {
if (member.user) normalizeUserAssets(member.user, origin); if (member.user) normalizeUserAssets(member.user, origin);
@@ -133,15 +134,20 @@ function handleEvent(origin: string, event: ServerEvent): void {
// Only force-reload the current channel on reconnect; other channels keep their cache // Only force-reload the current channel on reconnect; other channels keep their cache
if (isHome) { if (isHome) {
const { loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState(); const { loadMessages: reloadMessages, currentChannelId } = useChatStore.getState();
if (currentChannelId) { if (currentChannelId) {
reloadMessages(currentChannelId, true); reloadMessages(currentChannelId, true);
} }
// Initialize unread tracking from ready payload }
const { channelLastMessageIds } = useSpaceStore.getState();
if (event.readStates) { // Initialize/update unread tracking for this origin (home or remote)
setReadStates(event.readStates, channelLastMessageIds); if (event.readStates) {
const { channelLastMessageIds, channelOriginMap } = useSpaceStore.getState();
const originChannelIds = new Set<string>();
for (const [channelId, chOrigin] of channelOriginMap) {
if (chOrigin === origin) originChannelIds.add(channelId);
} }
useChatStore.getState().setReadStates(event.readStates, channelLastMessageIds, originChannelIds);
} }
// Clear voice state for the reconnecting origin before repopulating // Clear voice state for the reconnecting origin before repopulating
@@ -474,6 +480,19 @@ function handleEvent(origin: string, event: ServerEvent): void {
chPermsMap3.delete(event.channelId); chPermsMap3.delete(event.channelId);
ctsMap3.delete(event.channelId); ctsMap3.delete(event.channelId);
coMap3.delete(event.channelId); coMap3.delete(event.channelId);
// Clean up unread and read state for the deleted channel
{
const { channelLastMessageIds: clmIds } = useSpaceStore.getState();
clmIds.delete(event.channelId);
const cs = useChatStore.getState();
if (cs.unreadChannels.has(event.channelId) || cs.readStates.has(event.channelId)) {
const newUnread = new Set(cs.unreadChannels);
newUnread.delete(event.channelId);
const newRS = new Map(cs.readStates);
newRS.delete(event.channelId);
useChatStore.setState({ unreadChannels: newUnread, readStates: newRS });
}
}
{ {
const { currentChannelId } = useChatStore.getState(); const { currentChannelId } = useChatStore.getState();
if (currentChannelId === event.channelId) { if (currentChannelId === event.channelId) {
@@ -493,6 +512,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
if (!isHome && event.space.icon) { if (!isHome && event.space.icon) {
event.space.icon = resolveAssetUrl(event.space.icon, origin) ?? event.space.icon; event.space.icon = resolveAssetUrl(event.space.icon, origin) ?? event.space.icon;
} }
if (!isHome && event.space.banner) {
event.space.banner = resolveAssetUrl(event.space.banner, origin) ?? event.space.banner;
}
const { spaces: currentSpaces, setSpaces } = useSpaceStore.getState(); const { spaces: currentSpaces, setSpaces } = useSpaceStore.getState();
setSpaces(currentSpaces.map(s => s.id === event.space.id ? { ...s, ...event.space } : s)); setSpaces(currentSpaces.map(s => s.id === event.space.id ? { ...s, ...event.space } : s));
break; break;
+32 -6
View File
@@ -52,7 +52,7 @@ interface ChatState {
clearTyping: (channelId: string, userId: string) => void; clearTyping: (channelId: string, userId: string) => void;
getMessages: (channelId: string) => MessageWithUser[]; getMessages: (channelId: string) => MessageWithUser[];
getTypingUsers: (channelId: string) => TypingUser[]; getTypingUsers: (channelId: string) => TypingUser[];
setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>) => void; setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>, originChannelIds?: Set<string>) => void;
markChannelUnread: (channelId: string) => void; markChannelUnread: (channelId: string) => void;
ackChannel: (channelId: string) => void; ackChannel: (channelId: string) => void;
onChannelAck: (channelId: string, messageId: string) => void; onChannelAck: (channelId: string, messageId: string) => void;
@@ -473,13 +473,39 @@ export const useChatStore = create<ChatState>((set, get) => ({
return users.filter(t => now - t.timestamp < 5000); return users.filter(t => now - t.timestamp < 5000);
}, },
setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>) => { setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>, originChannelIds?: Set<string>) => {
const rsMap = new Map<string, string>(); // 1. Merge server read states into existing local state (preserves optimistic acks)
const rsMap = new Map(get().readStates);
for (const rs of readStates) { for (const rs of readStates) {
rsMap.set(rs.channelId, rs.lastReadMessageId); const local = rsMap.get(rs.channelId);
if (!local) {
rsMap.set(rs.channelId, rs.lastReadMessageId);
} else {
try {
if (BigInt(rs.lastReadMessageId) > BigInt(local)) {
rsMap.set(rs.channelId, rs.lastReadMessageId);
}
} catch {
rsMap.set(rs.channelId, rs.lastReadMessageId);
}
}
} }
// 2. Rebuild unreadChannels ONLY for channels from this origin
// Keep existing unread entries from other origins untouched
const currentChannelId = get().currentChannelId;
const unread = new Set<string>(); const unread = new Set<string>();
for (const [channelId, lastMsgId] of channelLastMessageIds) { for (const id of get().unreadChannels) {
if (!originChannelIds || !originChannelIds.has(id)) {
unread.add(id); // preserve other-origin unreads
}
}
const channelsToCheck = originChannelIds ?? new Set(channelLastMessageIds.keys());
for (const channelId of channelsToCheck) {
if (channelId === currentChannelId) continue; // skip current channel (acked momentarily)
const lastMsgId = channelLastMessageIds.get(channelId);
if (!lastMsgId) continue; // empty channel
const lastRead = rsMap.get(channelId); const lastRead = rsMap.get(channelId);
if (!lastRead) { if (!lastRead) {
unread.add(channelId); unread.add(channelId);
@@ -490,10 +516,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
unread.add(channelId); unread.add(channelId);
} }
} catch { } catch {
// Corrupted read state (e.g. temp_ ID) — treat as unread
unread.add(channelId); unread.add(channelId);
} }
} }
set({ readStates: rsMap, unreadChannels: unread }); set({ readStates: rsMap, unreadChannels: unread });
}, },
+3
View File
@@ -111,6 +111,9 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
if (origin && space.icon) { if (origin && space.icon) {
space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon; space.icon = resolveAssetUrl(space.icon, origin) ?? space.icon;
} }
if (origin && space.banner) {
space.banner = resolveAssetUrl(space.banner, origin) ?? space.banner;
}
allSpaces.push({ ...space, _instanceOrigin: origin, joined: space.joined ?? false }); allSpaces.push({ ...space, _instanceOrigin: origin, joined: space.joined ?? false });
} }
} }
+6 -1
View File
@@ -203,10 +203,13 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
const origin = space?._instanceOrigin ?? ''; const origin = space?._instanceOrigin ?? '';
const client = getApiForOrigin(origin); const client = getApiForOrigin(origin);
const updated = await client.spaces.update(spaceId, data); const updated = await client.spaces.update(spaceId, data);
// Normalize remote asset URL so the icon displays correctly in-app // Normalize remote asset URLs so the icon/banner display correctly in-app
if (origin && updated.icon) { if (origin && updated.icon) {
updated.icon = resolveAssetUrl(updated.icon, origin) ?? updated.icon; updated.icon = resolveAssetUrl(updated.icon, origin) ?? updated.icon;
} }
if (origin && updated.banner) {
updated.banner = resolveAssetUrl(updated.banner, origin) ?? updated.banner;
}
set((state) => ({ set((state) => ({
spaces: state.spaces.map(s => s.id === spaceId ? { ...s, ...updated } : s), spaces: state.spaces.map(s => s.id === spaceId ? { ...s, ...updated } : s),
})); }));
@@ -321,6 +324,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
id: s.id, id: s.id,
name: s.name, name: s.name,
icon: s.icon, icon: s.icon,
banner: s.banner ?? null,
ownerId: s.ownerId, ownerId: s.ownerId,
inviteCode: s.inviteCode, inviteCode: s.inviteCode,
visibility: s.visibility ?? 'private' as const, visibility: s.visibility ?? 'private' as const,
@@ -435,6 +439,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
id: space.id, id: space.id,
name: space.name, name: space.name,
icon: space.icon, icon: space.icon,
banner: space.banner ?? null,
ownerId: space.ownerId, ownerId: space.ownerId,
inviteCode: space.inviteCode, inviteCode: space.inviteCode,
visibility: space.visibility, visibility: space.visibility,
+179
View File
@@ -0,0 +1,179 @@
/**
* Canvas-based dominant color extraction using median-cut quantization.
* Zero-dependency, client-side only. Used to derive icon-matched gradients
* for space cards on the Explore page.
*/
// Cache extracted colors by URL to avoid re-processing
const colorCache = new Map<string, string[]>();
interface RGB {
r: number;
g: number;
b: number;
}
/**
* Extract 2-3 dominant colors from an image URL.
* Returns hex color strings (e.g. ['#a1b2c3', '#d4e5f6', '#778899']).
* Results are cached by URL.
*
* Returns empty array on failure (CORS, broken image, fully transparent).
*/
export async function extractDominantColors(imageUrl: string): Promise<string[]> {
const cached = colorCache.get(imageUrl);
if (cached) return cached;
try {
const img = new Image();
img.crossOrigin = 'anonymous';
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Failed to load image'));
img.src = imageUrl;
});
// Downsample to 32x32 for speed
const size = 32;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return [];
ctx.drawImage(img, 0, 0, size, size);
const imageData = ctx.getImageData(0, 0, size, size);
const { data } = imageData;
// Collect non-transparent pixels
const pixels: RGB[] = [];
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3]! >= 128) {
pixels.push({ r: data[i]!, g: data[i + 1]!, b: data[i + 2]! });
}
}
if (pixels.length === 0) {
colorCache.set(imageUrl, []);
return [];
}
// Median-cut quantization to 3 buckets
const buckets = medianCut(pixels, 3);
const colors = buckets.map(bucket => {
const avg = averageColor(bucket);
return rgbToHex(avg.r, avg.g, avg.b);
});
// Deduplicate very similar colors (within distance 30)
const unique = deduplicateColors(colors);
colorCache.set(imageUrl, unique);
return unique;
} catch {
colorCache.set(imageUrl, []);
return [];
}
}
/**
* Convert extracted colors to a CSS gradient string (135deg, multi-stop).
*/
export function colorsToGradient(colors: string[]): string {
if (colors.length === 0) return '';
if (colors.length === 1) return colors[0]!;
if (colors.length === 2) return `linear-gradient(135deg, ${colors[0]}, ${colors[1]})`;
return `linear-gradient(135deg, ${colors[0]}, ${colors[1]}, ${colors[2]})`;
}
// ─── Internal helpers ───────────────────────────────────────────────────────
function medianCut(pixels: RGB[], targetBuckets: number): RGB[][] {
if (pixels.length === 0) return [];
let buckets: RGB[][] = [pixels];
while (buckets.length < targetBuckets) {
// Find the bucket with the widest color range
let widestIndex = 0;
let widestRange = -1;
for (let i = 0; i < buckets.length; i++) {
const bucket = buckets[i]!;
if (bucket.length < 2) continue;
const range = getWidestChannelRange(bucket);
if (range.range > widestRange) {
widestRange = range.range;
widestIndex = i;
}
}
if (widestRange <= 0) break;
const bucket = buckets[widestIndex]!;
const { channel } = getWidestChannelRange(bucket);
// Sort by the widest channel and split at median
bucket.sort((a, b) => a[channel] - b[channel]);
const mid = Math.floor(bucket.length / 2);
buckets.splice(widestIndex, 1, bucket.slice(0, mid), bucket.slice(mid));
}
return buckets.filter(b => b.length > 0);
}
function getWidestChannelRange(pixels: RGB[]): { channel: 'r' | 'g' | 'b'; range: number } {
let minR = 255, maxR = 0, minG = 255, maxG = 0, minB = 255, maxB = 0;
for (const p of pixels) {
if (p.r < minR) minR = p.r;
if (p.r > maxR) maxR = p.r;
if (p.g < minG) minG = p.g;
if (p.g > maxG) maxG = p.g;
if (p.b < minB) minB = p.b;
if (p.b > maxB) maxB = p.b;
}
const rRange = maxR - minR;
const gRange = maxG - minG;
const bRange = maxB - minB;
if (rRange >= gRange && rRange >= bRange) return { channel: 'r', range: rRange };
if (gRange >= bRange) return { channel: 'g', range: gRange };
return { channel: 'b', range: bRange };
}
function averageColor(pixels: RGB[]): RGB {
let r = 0, g = 0, b = 0;
for (const p of pixels) {
r += p.r;
g += p.g;
b += p.b;
}
const n = pixels.length;
return { r: Math.round(r / n), g: Math.round(g / n), b: Math.round(b / n) };
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + ((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1);
}
function colorDistance(hex1: string, hex2: string): number {
const r1 = parseInt(hex1.slice(1, 3), 16);
const g1 = parseInt(hex1.slice(3, 5), 16);
const b1 = parseInt(hex1.slice(5, 7), 16);
const r2 = parseInt(hex2.slice(1, 3), 16);
const g2 = parseInt(hex2.slice(3, 5), 16);
const b2 = parseInt(hex2.slice(5, 7), 16);
return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2);
}
function deduplicateColors(colors: string[]): string[] {
const result: string[] = [];
for (const c of colors) {
if (!result.some(existing => colorDistance(existing, c) < 30)) {
result.push(c);
}
}
return result;
}