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
+32 -6
View File
@@ -52,7 +52,7 @@ interface ChatState {
clearTyping: (channelId: string, userId: string) => void;
getMessages: (channelId: string) => MessageWithUser[];
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;
ackChannel: (channelId: 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);
},
setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>) => {
const rsMap = new Map<string, string>();
setReadStates: (readStates: ReadState[], channelLastMessageIds: Map<string, string>, originChannelIds?: Set<string>) => {
// 1. Merge server read states into existing local state (preserves optimistic acks)
const rsMap = new Map(get().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>();
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);
if (!lastRead) {
unread.add(channelId);
@@ -490,10 +516,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
unread.add(channelId);
}
} catch {
// Corrupted read state (e.g. temp_ ID) — treat as unread
unread.add(channelId);
}
}
set({ readStates: rsMap, unreadChannels: unread });
},
+3
View File
@@ -111,6 +111,9 @@ export const useExploreStore = create<ExploreState>((set, get) => ({
if (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 });
}
}
+6 -1
View File
@@ -203,10 +203,13 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
const origin = space?._instanceOrigin ?? '';
const client = getApiForOrigin(origin);
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) {
updated.icon = resolveAssetUrl(updated.icon, origin) ?? updated.icon;
}
if (origin && updated.banner) {
updated.banner = resolveAssetUrl(updated.banner, origin) ?? updated.banner;
}
set((state) => ({
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,
name: s.name,
icon: s.icon,
banner: s.banner ?? null,
ownerId: s.ownerId,
inviteCode: s.inviteCode,
visibility: s.visibility ?? 'private' as const,
@@ -435,6 +439,7 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
id: space.id,
name: space.name,
icon: space.icon,
banner: space.banner ?? null,
ownerId: space.ownerId,
inviteCode: space.inviteCode,
visibility: space.visibility,