diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index e3452a39..b536613f 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -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' } + ] } ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 19bdc7f3..32eec279 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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'), diff --git a/packages/server/src/routes/explore.ts b/packages/server/src/routes/explore.ts index 4d77038b..628b9e1b 100644 --- a/packages/server/src/routes/explore.ts +++ b/packages/server/src/routes/explore.ts @@ -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 { 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 { 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 { 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, diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 1da4f92d..41d9101a 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -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 { 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 { 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 { 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 { updates.icon = icon || null; } + if (banner !== undefined) { + updates.banner = banner || null; + } + if (visibility !== undefined) { const validVisibilities = ['public', 'request', 'private']; if (!validVisibilities.includes(visibility)) { diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index da3f7750..30ddc733 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -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'], diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 24ac0a3b..f8b3cfc2 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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; } diff --git a/packages/web/src/components/chat/ExplorePage.tsx b/packages/web/src/components/chat/ExplorePage.tsx index deea39f4..7620f486 100644 --- a/packages/web/src/components/chat/ExplorePage.tsx +++ b/packages/web/src/components/chat/ExplorePage.tsx @@ -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(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 */} -
- {space.icon ? ( - {space.name} + {/* Banner area */} +
+ {/* Background layer */} + {bannerUrl ? ( + ) : ( - - {space.name.charAt(0).toUpperCase()} - +
)} + {/* Frosted bottom fade — Aether Drift glass */} +
+ {/* Joined badge (top-left) */} {isJoined && ( -
+
@@ -290,8 +312,8 @@ function SpaceCard({ )} {/* Visibility badge */} -
- +
- {/* Instance origin */} - {originLabel && ( -
- - {originLabel} - +
+ + {/* Overlapping icon */} +
+ {iconUrl ? ( + {space.name} + ) : ( +
+ {space.name.charAt(0).toUpperCase()}
)}
{/* Content */} -
+

{space.name}

{space.description ? ( @@ -329,6 +361,14 @@ function SpaceCard({ {space.memberCount} {space.memberCount === 1 ? 'member' : 'members'} + {originLabel && ( + + + + + {originLabel} + + )}
{/* Action area */} diff --git a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx index 5205b7bf..07524c7c 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx @@ -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(null); const [iconPreview, setIconPreview] = useState(null); const [uploadingIcon, setUploadingIcon] = useState(false); const [cropSrc, setCropSrc] = useState(null); + + // Banner state (same pattern as icon) + const [bannerFilename, setBannerFilename] = useState(null); + const [bannerPreview, setBannerPreview] = useState(null); + const [uploadingBanner, setUploadingBanner] = useState(false); + const [bannerCropSrc, setBannerCropSrc] = useState(null); + const bannerFileInputRef = useRef(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) => { 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) => { + 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) {
+ {/* Space Banner */} +
+ +
+ + + {canManageSpace && (displayBannerSrc || space.banner) && bannerFilename !== '' && ( + + )} +
+
+ {/* Space Name */}