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:
@@ -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' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
|
||||||
setReadStates(event.readStates, channelLastMessageIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize/update unread tracking for this origin (home or remote)
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
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);
|
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 });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user