fix: federated space icon display/upload in settings, restyle settings panels

Fix three federation bugs in Space Settings: icon URL was double-wrapped
through home API, upload targeted home instance instead of remote, and
updateSpace used home API client. Also restyle settings panels (User,
Space, Members, Roles, Connected Instances) with consistent section
headers, grouped cards, and improved layout.
This commit is contained in:
Jannis Braun
2026-03-09 03:30:15 +01:00
parent d75788febd
commit 096251e5b0
8 changed files with 599 additions and 485 deletions
@@ -84,107 +84,121 @@ function StreamingLimitsPanel() {
const pillOff = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
return (
<div className="space-y-4">
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
These limits apply to all users on this instance. Users can pick values within these bounds.
</div>
{/* Bitrate Range */}
{/* Bandwidth */}
<div>
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-2">
Bitrate Range
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
<input
type="range"
min={100}
max={draft.maxBitrateKbps - 500}
step={100}
value={draft.minBitrateKbps}
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Bandwidth</div>
<p className="text-xs text-txt-tertiary mb-2">Minimum and maximum bitrate bounds, and the step size for the quality slider.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
{/* Bitrate Range */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Bitrate Range
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
<input
type="range"
min={100}
max={draft.maxBitrateKbps - 500}
step={100}
value={draft.minBitrateKbps}
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div>
</div>
<div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Max</label>
<input
type="range"
min={draft.minBitrateKbps + 500}
max={50000}
step={500}
value={draft.maxBitrateKbps}
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div>
</div>
</div>
</div>
<div className="flex-1">
<label className="text-[11px] text-txt-tertiary mb-1 block">Max</label>
<input
type="range"
min={draft.minBitrateKbps + 500}
max={50000}
step={500}
value={draft.maxBitrateKbps}
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
/>
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div>
{/* Bitrate Step */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Slider Step
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={50}
max={5000}
step={50}
value={draft.bitrateStepKbps}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
}}
className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary"
/>
<span className="text-[12px] text-txt-tertiary">kbps</span>
</div>
</div>
</div>
</div>
{/* Bitrate Step */}
{/* Quality */}
<div>
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
Slider Step
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={50}
max={5000}
step={50}
value={draft.bitrateStepKbps}
onChange={(e) => {
const v = Number(e.target.value);
if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
}}
className="w-24 px-2 py-1 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary"
/>
<span className="text-[12px] text-txt-tertiary">kbps</span>
</div>
</div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Quality</div>
<p className="text-xs text-txt-tertiary mb-2">Available resolution and frame rate options for screen sharing.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
{/* Allowed Resolutions */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Allowed Resolutions
</div>
<div className="flex gap-1.5">
{VALID_RESOLUTIONS.map((res) => (
<button
key={res}
onClick={() => toggleResolution(res)}
className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
>
{res}p
</button>
))}
</div>
</div>
{/* Allowed Resolutions */}
<div>
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
Allowed Resolutions
</div>
<div className="flex gap-1.5">
{VALID_RESOLUTIONS.map((res) => (
<button
key={res}
onClick={() => toggleResolution(res)}
className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
>
{res}p
</button>
))}
</div>
</div>
{/* Allowed Frame Rates */}
<div>
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
Allowed Frame Rates
</div>
<div className="flex gap-1.5">
{VALID_FRAMERATES.map((fps) => (
<button
key={fps}
onClick={() => toggleFramerate(fps)}
className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
>
{fps} fps
</button>
))}
{/* Allowed Frame Rates */}
<div>
<div className="text-xs text-txt-secondary mb-1.5">
Allowed Frame Rates
</div>
<div className="flex gap-1.5">
{VALID_FRAMERATES.map((fps) => (
<button
key={fps}
onClick={() => toggleFramerate(fps)}
className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
>
{fps} fps
</button>
))}
</div>
</div>
</div>
</div>
@@ -276,7 +290,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
];
return (
<div className="space-y-4">
<div className="space-y-5">
{!discoveryEnabled && (
<div className="p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Space discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
@@ -284,48 +298,50 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
)}
<div>
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-2">
Visibility
</div>
<div className="space-y-1.5">
{visibilityOptions.map((opt) => (
<label
key={opt.value}
className={`flex items-start gap-3 p-2.5 rounded cursor-pointer transition-colors ${
visibility === opt.value
? 'bg-interactive-selected'
: 'hover:bg-interactive-hover'
}`}
>
<input
type="radio"
name="visibility"
value={opt.value}
checked={visibility === opt.value}
onChange={() => setVisibility(opt.value)}
className="mt-0.5 accent-accent-primary"
/>
<div>
<div className="text-sm font-medium text-txt-primary">{opt.label}</div>
<div className="text-xs text-txt-tertiary">{opt.desc}</div>
</div>
</label>
))}
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Visibility</div>
<p className="text-xs text-txt-tertiary mb-2">Control who can discover and join this space.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="space-y-1.5">
{visibilityOptions.map((opt) => (
<label
key={opt.value}
className={`flex items-start gap-3 p-2.5 rounded cursor-pointer transition-colors ${
visibility === opt.value
? 'bg-interactive-selected'
: 'hover:bg-interactive-hover'
}`}
>
<input
type="radio"
name="visibility"
value={opt.value}
checked={visibility === opt.value}
onChange={() => setVisibility(opt.value)}
className="mt-0.5 accent-accent-primary"
/>
<div>
<div className="text-sm font-medium text-txt-primary">{opt.label}</div>
<div className="text-xs text-txt-tertiary">{opt.desc}</div>
</div>
</label>
))}
</div>
</div>
</div>
<div>
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
Description
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Description</div>
<p className="text-xs text-txt-tertiary mb-2">A short summary shown on the Explore page.</p>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
placeholder="A short description for the Explore page..."
rows={3}
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary resize-none placeholder:text-txt-tertiary"
/>
<div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div>
</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
placeholder="A short description for the Explore page..."
rows={3}
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary resize-none placeholder:text-txt-tertiary"
/>
<div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div>
</div>
{saveError && (
@@ -334,6 +350,12 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
{saveSuccess && (
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
)}
{/* Pending Join Requests — only shown when visibility is 'request' */}
{(visibility === 'request' || (space.visibility as SpaceVisibility) === 'request') && (
<JoinRequestsSection spaceId={spaceId} />
)}
{hasChanges && (
<div className="sticky bottom-0 z-10 pointer-events-none">
<div className="flex justify-center pt-3 pb-1">
@@ -355,11 +377,6 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
</div>
</div>
)}
{/* Pending Join Requests — only shown when visibility is 'request' */}
{(visibility === 'request' || (space.visibility as SpaceVisibility) === 'request') && (
<JoinRequestsSection spaceId={spaceId} />
)}
</div>
);
}
@@ -493,9 +510,9 @@ export function SpaceSettingsModal() {
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
<div className="flex gap-4">
<div className="flex gap-4 h-[min(460px,65vh)]">
{/* Tabs */}
<div className="w-32 flex-shrink-0 sticky top-0 self-start z-10">
<div className="w-32 flex-shrink-0 self-start z-10">
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
Overview
@@ -522,7 +539,7 @@ export function SpaceSettingsModal() {
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}