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: 'description', type: 'TEXT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'spaces',
|
||||
columns: [
|
||||
{ name: 'banner', type: 'TEXT' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const spaces = sqliteTable('spaces', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
icon: text('icon'),
|
||||
banner: text('banner'),
|
||||
ownerId: text('owner_id').notNull().references(() => users.id),
|
||||
inviteCode: text('invite_code').unique(),
|
||||
visibility: text('visibility').default('private'),
|
||||
|
||||
@@ -145,6 +145,7 @@ function buildFullSpace(spaceId: string, forUserId: string): SpaceWithChannelsAn
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
icon: space.icon,
|
||||
banner: space.banner ?? null,
|
||||
ownerId: space.ownerId,
|
||||
inviteCode: space.inviteCode,
|
||||
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 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
|
||||
FROM spaces s
|
||||
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;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
banner: string | null;
|
||||
description: string | null;
|
||||
visibility: string;
|
||||
created_at: number;
|
||||
@@ -235,6 +237,7 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
icon: r.icon,
|
||||
banner: r.banner,
|
||||
description: r.description,
|
||||
visibility: r.visibility as ExploreSpace['visibility'],
|
||||
memberCount: r.member_count,
|
||||
|
||||
@@ -25,6 +25,7 @@ function rowToSpace(row: typeof schema.spaces.$inferSelect): Space {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
icon: row.icon,
|
||||
banner: row.banner ?? null,
|
||||
ownerId: row.ownerId,
|
||||
inviteCode: row.inviteCode,
|
||||
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', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { name, icon, visibility, description } = request.body;
|
||||
const { name, icon, banner, visibility, description } = request.body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
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,
|
||||
name: trimmedName,
|
||||
icon: icon ?? null,
|
||||
banner: banner ?? null,
|
||||
ownerId: request.userId,
|
||||
inviteCode,
|
||||
visibility: safeVisibility,
|
||||
@@ -269,7 +271,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { name, icon, visibility, description } = request.body;
|
||||
const { name, icon, banner, visibility, description } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (banner !== undefined) {
|
||||
updates.banner = banner || null;
|
||||
}
|
||||
|
||||
if (visibility !== undefined) {
|
||||
const validVisibilities = ['public', 'request', 'private'];
|
||||
if (!validVisibilities.includes(visibility)) {
|
||||
|
||||
@@ -675,6 +675,7 @@ function buildReadyPayload(userId: string): {
|
||||
id: spaceRow.id,
|
||||
name: spaceRow.name,
|
||||
icon: spaceRow.icon,
|
||||
banner: spaceRow.banner ?? null,
|
||||
ownerId: spaceRow.ownerId,
|
||||
inviteCode: spaceRow.inviteCode,
|
||||
visibility: (spaceRow.visibility ?? 'private') as SpaceWithChannelsAndMembers['visibility'],
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface Space {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
banner: string | null;
|
||||
ownerId: string;
|
||||
inviteCode: string | null;
|
||||
visibility: SpaceVisibility;
|
||||
@@ -45,6 +46,7 @@ export interface ExploreSpace {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
banner: string | null;
|
||||
description: string | null;
|
||||
visibility: SpaceVisibility;
|
||||
memberCount: number;
|
||||
@@ -300,6 +302,7 @@ export interface AuthResponse {
|
||||
export interface CreateSpaceRequest {
|
||||
name: string;
|
||||
icon?: string;
|
||||
banner?: string;
|
||||
visibility?: SpaceVisibility;
|
||||
description?: string;
|
||||
}
|
||||
@@ -319,6 +322,7 @@ export interface UpdateChannelRequest {
|
||||
export interface UpdateSpaceRequest {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
banner?: string;
|
||||
visibility?: SpaceVisibility;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useExploreStore, type TaggedExploreSpace } from '../../stores/exploreSt
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { getSpaceGradient } from '../../utils/gradients';
|
||||
import { extractDominantColors, colorsToGradient } from '../../utils/colorExtractor';
|
||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||
|
||||
export function ExplorePage() {
|
||||
@@ -219,14 +220,34 @@ function SpaceCard({
|
||||
const [requestMessage, setRequestMessage] = useState('');
|
||||
const [requestSent, setRequestSent] = useState(isPending);
|
||||
const [joinError, setJoinError] = useState('');
|
||||
const [iconGradient, setIconGradient] = useState<string | null>(null);
|
||||
|
||||
const gradient = getSpaceGradient(space.id, space.name);
|
||||
const fallbackGradient = getSpaceGradient(space.id, space.name).gradient;
|
||||
const isPublic = space.visibility === 'public';
|
||||
const isJoined = space.joined === true;
|
||||
const originLabel = space._instanceOrigin
|
||||
? (() => { try { return new URL(space._instanceOrigin).host; } catch { return space._instanceOrigin; } })()
|
||||
: null;
|
||||
|
||||
const iconUrl = space.icon
|
||||
? (space.icon.startsWith('http') ? space.icon : `/api/uploads/${space.icon}`)
|
||||
: null;
|
||||
const bannerUrl = space.banner
|
||||
? (space.banner.startsWith('http') ? space.banner : `/api/uploads/${space.banner}`)
|
||||
: null;
|
||||
|
||||
// Extract dominant colors from icon when no banner is set
|
||||
useEffect(() => {
|
||||
if (bannerUrl || !iconUrl) return;
|
||||
let cancelled = false;
|
||||
extractDominantColors(iconUrl)
|
||||
.then(colors => {
|
||||
if (!cancelled && colors.length > 0) setIconGradient(colorsToGradient(colors));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [iconUrl, bannerUrl]);
|
||||
|
||||
const handlePublicJoin = async () => {
|
||||
setJoining(true);
|
||||
setJoinError('');
|
||||
@@ -263,23 +284,24 @@ function SpaceCard({
|
||||
? 'border-accent-mint/20 hover:border-accent-mint/40'
|
||||
: 'border-border-soft hover:border-border-hard'
|
||||
}`}>
|
||||
{/* Banner / Icon area */}
|
||||
<div className="h-32 relative flex items-center justify-center" style={{ background: gradient.gradient }}>
|
||||
{space.icon ? (
|
||||
<img
|
||||
src={space.icon.startsWith('http') ? space.icon : `/api/uploads/${space.icon}`}
|
||||
alt={space.name}
|
||||
className="w-16 h-16 rounded-2xl object-cover shadow-lg"
|
||||
/>
|
||||
{/* Banner area */}
|
||||
<div className="h-32 relative overflow-hidden">
|
||||
{/* Background layer */}
|
||||
{bannerUrl ? (
|
||||
<img src={bannerUrl} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-white/90 drop-shadow-md">
|
||||
{space.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="absolute inset-0" style={{ background: iconGradient ?? fallbackGradient }} />
|
||||
)}
|
||||
|
||||
{/* Frosted bottom fade — Aether Drift glass */}
|
||||
<div
|
||||
className="absolute bottom-0 inset-x-0 h-16"
|
||||
style={{ background: 'linear-gradient(to top, rgba(20,20,26,0.9), transparent)' }}
|
||||
/>
|
||||
|
||||
{/* Joined badge (top-left) */}
|
||||
{isJoined && (
|
||||
<div className="absolute top-2 left-2">
|
||||
<div className="absolute top-2 left-2 z-[2]">
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-accent-mint/25 text-accent-mint backdrop-blur-sm">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
@@ -290,8 +312,8 @@ function SpaceCard({
|
||||
)}
|
||||
|
||||
{/* Visibility badge */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider ${
|
||||
<div className="absolute top-2 right-2 z-[2]">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider backdrop-blur-sm ${
|
||||
isPublic
|
||||
? 'bg-accent-mint/20 text-accent-mint'
|
||||
: 'bg-accent-amber/20 text-accent-amber'
|
||||
@@ -300,18 +322,28 @@ function SpaceCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Instance origin */}
|
||||
{originLabel && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<span className="px-1.5 py-0.5 rounded bg-black/40 text-[10px] text-white/80 font-medium backdrop-blur-sm">
|
||||
{originLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Overlapping icon */}
|
||||
<div className="relative px-4 -mt-8 z-10">
|
||||
{iconUrl ? (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt={space.name}
|
||||
className="w-14 h-14 rounded-xl object-cover ring-[3px] ring-surface-channel shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-14 h-14 rounded-xl ring-[3px] ring-surface-channel shadow-lg flex items-center justify-center text-xl font-bold text-white/90"
|
||||
style={{ background: fallbackGradient }}
|
||||
>
|
||||
{space.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 flex flex-col flex-1">
|
||||
<div className="px-4 pt-2 pb-4 flex flex-col flex-1">
|
||||
<h3 className="text-[15px] font-bold text-txt-primary truncate mb-1">{space.name}</h3>
|
||||
|
||||
{space.description ? (
|
||||
@@ -329,6 +361,14 @@ function SpaceCard({
|
||||
</svg>
|
||||
{space.memberCount} {space.memberCount === 1 ? 'member' : 'members'}
|
||||
</span>
|
||||
{originLabel && (
|
||||
<span className="flex items-center gap-1 text-txt-tertiary/70">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" className="opacity-50">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||
</svg>
|
||||
{originLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action area */}
|
||||
|
||||
@@ -28,11 +28,19 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE);
|
||||
|
||||
const [spaceName, setSpaceName] = useState(space?.name ?? '');
|
||||
// null = no change, '' = remove icon, 'filename.png' = new icon uploaded
|
||||
// null = no change, '' = remove, 'filename.png' = new uploaded
|
||||
const [iconFilename, setIconFilename] = useState<string | null>(null);
|
||||
const [iconPreview, setIconPreview] = useState<string | null>(null);
|
||||
const [uploadingIcon, setUploadingIcon] = useState(false);
|
||||
const [cropSrc, setCropSrc] = useState<string | null>(null);
|
||||
|
||||
// Banner state (same pattern as icon)
|
||||
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
||||
const bannerFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
@@ -42,25 +50,36 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
useEffect(() => {
|
||||
if (space) {
|
||||
setSpaceName(space.name);
|
||||
// Reset icon state when space data changes externally
|
||||
setIconFilename(null);
|
||||
if (iconPreview) {
|
||||
URL.revokeObjectURL(iconPreview);
|
||||
setIconPreview(null);
|
||||
}
|
||||
setBannerFilename(null);
|
||||
if (bannerPreview) {
|
||||
URL.revokeObjectURL(bannerPreview);
|
||||
setBannerPreview(null);
|
||||
}
|
||||
}
|
||||
}, [space?.name, space?.icon]);
|
||||
}, [space?.name, space?.icon, space?.banner]);
|
||||
|
||||
if (!space) return null;
|
||||
|
||||
const hasNameChange = spaceName.trim() !== space.name;
|
||||
const hasIconChange = iconFilename !== null;
|
||||
const hasChanges = hasNameChange || hasIconChange;
|
||||
const hasBannerChange = bannerFilename !== null;
|
||||
const hasChanges = hasNameChange || hasIconChange || hasBannerChange;
|
||||
|
||||
const currentIconUrl = space.icon
|
||||
? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon))
|
||||
: null;
|
||||
|
||||
const currentBannerUrl = space.banner
|
||||
? (space.banner.startsWith('http') ? space.banner : api.uploads.url(space.banner))
|
||||
: null;
|
||||
|
||||
// ─── Icon handlers ────────────────────────────────────────────────────────
|
||||
|
||||
const handleIconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -94,26 +113,73 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
const handleRemoveIcon = () => {
|
||||
if (iconPreview) URL.revokeObjectURL(iconPreview);
|
||||
setIconPreview(null);
|
||||
setIconFilename(''); // '' signals removal
|
||||
setIconFilename('');
|
||||
};
|
||||
|
||||
// ─── Banner handlers ──────────────────────────────────────────────────────
|
||||
|
||||
const handleBannerSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setBannerCropSrc(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
if (bannerFileInputRef.current) bannerFileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleBannerCropComplete = async (blob: Blob) => {
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
setBannerPreview(previewUrl);
|
||||
setBannerCropSrc(null);
|
||||
|
||||
const file = new File([blob], 'banner.png', { type: 'image/png' });
|
||||
setUploadingBanner(true);
|
||||
try {
|
||||
const spaceApi = getApiForOrigin(space._instanceOrigin);
|
||||
const attachment = await spaceApi.uploads.upload(file);
|
||||
setBannerFilename(attachment.filename);
|
||||
} catch {
|
||||
setSaveError('Failed to upload banner');
|
||||
setBannerPreview(null);
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
} finally {
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveBanner = () => {
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
setBannerPreview(null);
|
||||
setBannerFilename('');
|
||||
};
|
||||
|
||||
// ─── Save / Discard / Delete ──────────────────────────────────────────────
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setSaveSuccess(false);
|
||||
try {
|
||||
const updates: { name?: string; icon?: string } = {};
|
||||
const updates: { name?: string; icon?: string; banner?: string } = {};
|
||||
if (hasNameChange) updates.name = spaceName.trim();
|
||||
if (hasIconChange) {
|
||||
// Empty string signals icon removal to the backend (sets to null)
|
||||
updates.icon = iconFilename === '' ? '' : iconFilename!;
|
||||
}
|
||||
if (hasBannerChange) {
|
||||
updates.banner = bannerFilename === '' ? '' : bannerFilename!;
|
||||
}
|
||||
await updateSpace(spaceId, updates);
|
||||
setIconFilename(null);
|
||||
if (iconPreview) {
|
||||
URL.revokeObjectURL(iconPreview);
|
||||
setIconPreview(null);
|
||||
}
|
||||
setBannerFilename(null);
|
||||
if (bannerPreview) {
|
||||
URL.revokeObjectURL(bannerPreview);
|
||||
setBannerPreview(null);
|
||||
}
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
} catch (err) {
|
||||
@@ -130,6 +196,11 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
URL.revokeObjectURL(iconPreview);
|
||||
setIconPreview(null);
|
||||
}
|
||||
setBannerFilename(null);
|
||||
if (bannerPreview) {
|
||||
URL.revokeObjectURL(bannerPreview);
|
||||
setBannerPreview(null);
|
||||
}
|
||||
setSaveError('');
|
||||
};
|
||||
|
||||
@@ -147,9 +218,9 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Determine what icon to show: preview of pending upload, or current space icon
|
||||
const displayIconSrc = iconPreview ?? (iconFilename === '' ? null : currentIconUrl);
|
||||
const displayIconName = space.name;
|
||||
const displayBannerSrc = bannerPreview ?? (bannerFilename === '' ? null : currentBannerUrl);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -215,6 +286,68 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Space Banner */}
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
Space Banner
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canManageSpace && bannerFileInputRef.current?.click()}
|
||||
disabled={!canManageSpace || uploadingBanner}
|
||||
className={`relative w-full h-24 rounded-lg bg-surface-input border-2 border-dashed border-border-subtle flex items-center justify-center overflow-hidden group ${
|
||||
canManageSpace ? 'hover:border-accent-primary cursor-pointer' : 'cursor-default'
|
||||
} transition-colors`}
|
||||
>
|
||||
{displayBannerSrc ? (
|
||||
<>
|
||||
<img src={displayBannerSrc} alt="Space banner" className="w-full h-full object-cover" />
|
||||
{canManageSpace && (
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-txt-tertiary">
|
||||
<svg className="w-6 h-6 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||
</svg>
|
||||
<span className="text-[11px]">Upload banner (16:9)</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingBanner && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={bannerFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleBannerSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
{canManageSpace && (displayBannerSrc || space.banner) && bannerFilename !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveBanner}
|
||||
className="text-xs text-txt-tertiary hover:text-txt-danger transition-colors self-start"
|
||||
>
|
||||
Remove banner
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Space Name */}
|
||||
<div>
|
||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||
@@ -266,7 +399,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || uploadingIcon || !spaceName.trim()}
|
||||
disabled={saving || uploadingIcon || uploadingBanner || !spaceName.trim()}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
@@ -286,6 +419,16 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
||||
cropShape="round"
|
||||
aspectRatio={1}
|
||||
/>
|
||||
|
||||
<ImageCropModal
|
||||
isOpen={bannerCropSrc !== null}
|
||||
onClose={() => setBannerCropSrc(null)}
|
||||
imageSrc={bannerCropSrc ?? ''}
|
||||
onCropComplete={handleBannerCropComplete}
|
||||
title="Crop Space Banner"
|
||||
cropShape="rect"
|
||||
aspectRatio={16 / 9}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
if (!isHome) {
|
||||
for (const space of event.spaces) {
|
||||
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) {
|
||||
for (const member of (space as any).members) {
|
||||
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
|
||||
if (isHome) {
|
||||
const { loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState();
|
||||
const { loadMessages: reloadMessages, currentChannelId } = useChatStore.getState();
|
||||
if (currentChannelId) {
|
||||
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
|
||||
@@ -474,6 +480,19 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
chPermsMap3.delete(event.channelId);
|
||||
ctsMap3.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();
|
||||
if (currentChannelId === event.channelId) {
|
||||
@@ -493,6 +512,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
if (!isHome && 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();
|
||||
setSpaces(currentSpaces.map(s => s.id === event.space.id ? { ...s, ...event.space } : s));
|
||||
break;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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