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:
@@ -381,12 +381,13 @@ export function ConnectedInstances() {
|
|||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-white/[0.06] pt-4">
|
<div>
|
||||||
<h3 className="text-xs font-bold text-txt-secondary uppercase mb-3">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
Connected Instances
|
Connected Instances
|
||||||
</h3>
|
</div>
|
||||||
|
<p className="text-xs text-txt-tertiary mb-2">Link accounts across federated Backspace instances.</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="rounded-lg bg-white/[0.02] p-3 space-y-2">
|
||||||
{/* Home instance (always shown, non-removable) */}
|
{/* Home instance (always shown, non-removable) */}
|
||||||
<div className="flex items-center justify-between p-3 bg-surface-channel rounded-lg">
|
<div className="flex items-center justify-between p-3 bg-surface-channel rounded-lg">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
|||||||
@@ -84,107 +84,121 @@ function StreamingLimitsPanel() {
|
|||||||
const pillOff = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
|
const pillOff = 'bg-surface-elevated text-txt-secondary hover:bg-interactive-hover';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<div className="text-xs text-txt-tertiary">
|
<div className="text-xs text-txt-tertiary">
|
||||||
These limits apply to all users on this instance. Users can pick values within these bounds.
|
These limits apply to all users on this instance. Users can pick values within these bounds.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bitrate Range */}
|
{/* Bandwidth */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-2">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Bandwidth</div>
|
||||||
Bitrate Range
|
<p className="text-xs text-txt-tertiary mb-2">Minimum and maximum bitrate bounds, and the step size for the quality slider.</p>
|
||||||
</div>
|
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
{/* Bitrate Range */}
|
||||||
<div className="flex-1">
|
<div>
|
||||||
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
|
<div className="text-xs text-txt-secondary mb-1.5">
|
||||||
<input
|
Bitrate Range
|
||||||
type="range"
|
</div>
|
||||||
min={100}
|
<div className="flex items-center gap-3">
|
||||||
max={draft.maxBitrateKbps - 500}
|
<div className="flex-1">
|
||||||
step={100}
|
<label className="text-[11px] text-txt-tertiary mb-1 block">Min</label>
|
||||||
value={draft.minBitrateKbps}
|
<input
|
||||||
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
|
type="range"
|
||||||
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
|
min={100}
|
||||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
max={draft.maxBitrateKbps - 500}
|
||||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
|
step={100}
|
||||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
|
value={draft.minBitrateKbps}
|
||||||
/>
|
onChange={(e) => setDraft({ ...draft, minBitrateKbps: Number(e.target.value) })}
|
||||||
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.minBitrateKbps)}</div>
|
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>
|
||||||
<div className="flex-1">
|
|
||||||
<label className="text-[11px] text-txt-tertiary mb-1 block">Max</label>
|
{/* Bitrate Step */}
|
||||||
<input
|
<div>
|
||||||
type="range"
|
<div className="text-xs text-txt-secondary mb-1.5">
|
||||||
min={draft.minBitrateKbps + 500}
|
Slider Step
|
||||||
max={50000}
|
</div>
|
||||||
step={500}
|
<div className="flex items-center gap-2">
|
||||||
value={draft.maxBitrateKbps}
|
<input
|
||||||
onChange={(e) => setDraft({ ...draft, maxBitrateKbps: Number(e.target.value) })}
|
type="number"
|
||||||
className="w-full h-1.5 accent-accent-primary cursor-pointer appearance-none bg-interactive-muted rounded-full
|
min={50}
|
||||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
max={5000}
|
||||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md
|
step={50}
|
||||||
[&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:border-0"
|
value={draft.bitrateStepKbps}
|
||||||
/>
|
onChange={(e) => {
|
||||||
<div className="text-[11px] text-txt-secondary mt-0.5">{formatKbps(draft.maxBitrateKbps)}</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bitrate Step */}
|
{/* Quality */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Quality</div>
|
||||||
Slider Step
|
<p className="text-xs text-txt-tertiary mb-2">Available resolution and frame rate options for screen sharing.</p>
|
||||||
</div>
|
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
{/* Allowed Resolutions */}
|
||||||
<input
|
<div>
|
||||||
type="number"
|
<div className="text-xs text-txt-secondary mb-1.5">
|
||||||
min={50}
|
Allowed Resolutions
|
||||||
max={5000}
|
</div>
|
||||||
step={50}
|
<div className="flex gap-1.5">
|
||||||
value={draft.bitrateStepKbps}
|
{VALID_RESOLUTIONS.map((res) => (
|
||||||
onChange={(e) => {
|
<button
|
||||||
const v = Number(e.target.value);
|
key={res}
|
||||||
if (v >= 50 && v <= 5000) setDraft({ ...draft, bitrateStepKbps: v });
|
onClick={() => toggleResolution(res)}
|
||||||
}}
|
className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
|
||||||
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"
|
>
|
||||||
/>
|
{res}p
|
||||||
<span className="text-[12px] text-txt-tertiary">kbps</span>
|
</button>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Allowed Resolutions */}
|
{/* Allowed Frame Rates */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
|
<div className="text-xs text-txt-secondary mb-1.5">
|
||||||
Allowed Resolutions
|
Allowed Frame Rates
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{VALID_RESOLUTIONS.map((res) => (
|
{VALID_FRAMERATES.map((fps) => (
|
||||||
<button
|
<button
|
||||||
key={res}
|
key={fps}
|
||||||
onClick={() => toggleResolution(res)}
|
onClick={() => toggleFramerate(fps)}
|
||||||
className={`${pillBase} ${draft.allowedResolutions.includes(res) ? pillOn : pillOff}`}
|
className={`${pillBase} ${draft.allowedFramerates.includes(fps) ? pillOn : pillOff}`}
|
||||||
>
|
>
|
||||||
{res}p
|
{fps} fps
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,7 +290,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{!discoveryEnabled && (
|
{!discoveryEnabled && (
|
||||||
<div className="p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
|
<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.
|
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>
|
||||||
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-2">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Visibility</div>
|
||||||
Visibility
|
<p className="text-xs text-txt-tertiary mb-2">Control who can discover and join this space.</p>
|
||||||
</div>
|
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{visibilityOptions.map((opt) => (
|
{visibilityOptions.map((opt) => (
|
||||||
<label
|
<label
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
className={`flex items-start gap-3 p-2.5 rounded cursor-pointer transition-colors ${
|
className={`flex items-start gap-3 p-2.5 rounded cursor-pointer transition-colors ${
|
||||||
visibility === opt.value
|
visibility === opt.value
|
||||||
? 'bg-interactive-selected'
|
? 'bg-interactive-selected'
|
||||||
: 'hover:bg-interactive-hover'
|
: 'hover:bg-interactive-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="visibility"
|
name="visibility"
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
checked={visibility === opt.value}
|
checked={visibility === opt.value}
|
||||||
onChange={() => setVisibility(opt.value)}
|
onChange={() => setVisibility(opt.value)}
|
||||||
className="mt-0.5 accent-accent-primary"
|
className="mt-0.5 accent-accent-primary"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-txt-primary">{opt.label}</div>
|
<div className="text-sm font-medium text-txt-primary">{opt.label}</div>
|
||||||
<div className="text-xs text-txt-tertiary">{opt.desc}</div>
|
<div className="text-xs text-txt-tertiary">{opt.desc}</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-txt-tertiary font-semibold uppercase tracking-wider mb-1.5">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Description</div>
|
||||||
Description
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
@@ -334,6 +350,12 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
{saveSuccess && (
|
{saveSuccess && (
|
||||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
<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 && (
|
{hasChanges && (
|
||||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
@@ -355,11 +377,6 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pending Join Requests — only shown when visibility is 'request' */}
|
|
||||||
{(visibility === 'request' || (space.visibility as SpaceVisibility) === 'request') && (
|
|
||||||
<JoinRequestsSection spaceId={spaceId} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -493,9 +510,9 @@ export function SpaceSettingsModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={closeModal} title="Space Settings" maxWidth="max-w-xl">
|
<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 */}
|
{/* 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">
|
<div className="glass-bubble rounded-lg p-1.5 space-y-0.5">
|
||||||
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
<button onClick={() => setTab('overview')} className={tabClass('overview')}>
|
||||||
Overview
|
Overview
|
||||||
@@ -522,7 +539,7 @@ export function SpaceSettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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 === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
|
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function UserSettingsModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={closeModal} title="User Settings" maxWidth="max-w-lg">
|
<Modal isOpen={isOpen} onClose={closeModal} title="User Settings" maxWidth="max-w-lg">
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
{/* Profile preview */}
|
{/* Profile preview */}
|
||||||
<div className="flex items-center gap-4 p-4 bg-surface-channel rounded-lg">
|
<div className="flex items-center gap-4 p-4 bg-surface-channel rounded-lg">
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -83,89 +83,96 @@ export function UserSettingsModal() {
|
|||||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">{success}</div>
|
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">{success}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Profile section card */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Profile</div>
|
||||||
Status
|
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||||
</label>
|
<div>
|
||||||
<select
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
value={status}
|
Status
|
||||||
onChange={(e) => setStatus(e.target.value as any)}
|
</label>
|
||||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary appearance-none"
|
<select
|
||||||
>
|
value={status}
|
||||||
<option value="online">Online</option>
|
onChange={(e) => setStatus(e.target.value as any)}
|
||||||
<option value="idle">Idle</option>
|
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary appearance-none"
|
||||||
<option value="dnd">Do Not Disturb</option>
|
>
|
||||||
</select>
|
<option value="online">Online</option>
|
||||||
|
<option value="idle">Idle</option>
|
||||||
|
<option value="dnd">Do Not Disturb</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
|
Custom Status
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customStatus}
|
||||||
|
onChange={(e) => setCustomStatus(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
|
||||||
|
placeholder="What are you up to?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Processing section card */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
Display Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
|
||||||
Custom Status
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customStatus}
|
|
||||||
onChange={(e) => setCustomStatus(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
|
|
||||||
placeholder="What are you up to?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Voice Processing */}
|
|
||||||
<div className="border-t border-white/[0.06] pt-4">
|
|
||||||
<h3 className="text-xs font-bold text-txt-secondary uppercase mb-3">
|
|
||||||
Voice Processing
|
Voice Processing
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-txt-primary">AI Noise Suppression</div>
|
|
||||||
<div className="text-xs text-txt-tertiary">ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setRnnoiseEnabled(!rnnoiseEnabled)}
|
|
||||||
className={`relative w-10 h-5 rounded-full transition-colors ${rnnoiseEnabled ? 'bg-status-online' : 'bg-surface-input'}`}
|
|
||||||
>
|
|
||||||
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${rnnoiseEnabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-txt-primary">Echo Cancellation</div>
|
<div className="text-sm text-txt-primary">AI Noise Suppression</div>
|
||||||
<div className="text-xs text-txt-tertiary">Removes echo when using speakers</div>
|
<div className="text-xs text-txt-tertiary">ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setRnnoiseEnabled(!rnnoiseEnabled)}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors ${rnnoiseEnabled ? 'bg-status-online' : 'bg-surface-input'}`}
|
||||||
|
>
|
||||||
|
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${rnnoiseEnabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setEchoCancellation(!echoCancellation)}
|
|
||||||
className={`relative w-10 h-5 rounded-full transition-colors ${echoCancellation ? 'bg-status-online' : 'bg-surface-input'}`}
|
|
||||||
>
|
|
||||||
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${echoCancellation ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2">
|
<div className="flex items-center justify-between py-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-txt-primary">Auto Gain Control</div>
|
<div className="text-sm text-txt-primary">Echo Cancellation</div>
|
||||||
<div className="text-xs text-txt-tertiary">Auto-adjusts mic volume — can cause voice ducking during streams</div>
|
<div className="text-xs text-txt-tertiary">Removes echo when using speakers</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setEchoCancellation(!echoCancellation)}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors ${echoCancellation ? 'bg-status-online' : 'bg-surface-input'}`}
|
||||||
|
>
|
||||||
|
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${echoCancellation ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-txt-primary">Auto Gain Control</div>
|
||||||
|
<div className="text-xs text-txt-tertiary">Auto-adjusts mic volume — can cause voice ducking during streams</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAutoGainControl(!autoGainControl)}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors ${autoGainControl ? 'bg-status-online' : 'bg-surface-input'}`}
|
||||||
|
>
|
||||||
|
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${autoGainControl ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => setAutoGainControl(!autoGainControl)}
|
|
||||||
className={`relative w-10 h-5 rounded-full transition-colors ${autoGainControl ? 'bg-status-online' : 'bg-surface-input'}`}
|
|
||||||
>
|
|
||||||
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${autoGainControl ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
|||||||
const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS);
|
const canKick = hasPermissionBit(myPerms, PermissionBits.KICK_MEMBERS);
|
||||||
|
|
||||||
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
|
const [pendingRoleChanges, setPendingRoleChanges] = useState<Map<string, Set<string>>>(new Map());
|
||||||
|
const [expandedMemberId, setExpandedMemberId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
// Assignable roles: exclude @everyone (where role.id === spaceId)
|
// Assignable roles: exclude @everyone (where role.id === spaceId)
|
||||||
@@ -57,6 +58,7 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
|||||||
next.delete(userId);
|
next.delete(userId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setExpandedMemberId(null);
|
||||||
await loadSpaceDetail(spaceId);
|
await loadSpaceDetail(spaceId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update roles');
|
setError(err instanceof Error ? err.message : 'Failed to update roles');
|
||||||
@@ -80,105 +82,139 @@ export function MembersPanel({ spaceId }: MembersPanelProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const canExpandMember = (member: MemberWithUser) =>
|
||||||
<div>
|
canManageRoles && member.userId !== currentUser?.id && member.userId !== space.ownerId && assignableRoles.length > 0;
|
||||||
{error && (
|
|
||||||
<div className="mb-3 p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2 max-h-[400px] overflow-y-auto scrollbar-thin">
|
|
||||||
{members.map((member) => {
|
|
||||||
const displayName = member.user.displayName ?? member.user.username;
|
|
||||||
const isOwner = member.userId === space.ownerId;
|
|
||||||
const memberRoleIds = getMemberRoleIds(member);
|
|
||||||
const hasPendingChanges = pendingRoleChanges.has(member.userId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={member.userId} className="p-2 rounded hover:bg-interactive-hover">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
{error && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||||
<Avatar
|
)}
|
||||||
src={member.user.avatar}
|
<p className="text-xs text-txt-tertiary">Manage members of this space. Click a member to edit their roles.</p>
|
||||||
name={displayName}
|
|
||||||
size={32}
|
<div>
|
||||||
status={member.user.status}
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
user={member.user}
|
Members ({members.length})
|
||||||
/>
|
</div>
|
||||||
<div>
|
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||||
<div className="text-sm font-medium">{displayName}</div>
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
{members.map((member) => {
|
||||||
{isOwner && (
|
const displayName = member.user.displayName ?? member.user.username;
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium">
|
const isOwner = member.userId === space.ownerId;
|
||||||
Owner
|
const memberRoleIds = getMemberRoleIds(member);
|
||||||
</span>
|
const hasPendingChanges = pendingRoleChanges.has(member.userId);
|
||||||
)}
|
const isExpanded = expandedMemberId === member.userId;
|
||||||
{member.roles?.filter((r) => r.id !== spaceId).map((r) => (
|
const expandable = canExpandMember(member);
|
||||||
<span
|
|
||||||
key={r.id}
|
return (
|
||||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
<div key={member.userId}>
|
||||||
style={{ backgroundColor: `${r.color}20`, color: r.color }}
|
<div
|
||||||
|
className={`flex items-center justify-between p-2 rounded transition-colors ${
|
||||||
|
expandable ? 'cursor-pointer hover:bg-interactive-hover' : ''
|
||||||
|
} ${isExpanded ? 'bg-interactive-hover' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (expandable) {
|
||||||
|
setExpandedMemberId(isExpanded ? null : member.userId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Avatar
|
||||||
|
src={member.user.avatar}
|
||||||
|
name={displayName}
|
||||||
|
size={32}
|
||||||
|
status={member.user.status}
|
||||||
|
user={member.user}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium truncate">{displayName}</div>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{isOwner && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent-rose/20 text-txt-danger font-medium">
|
||||||
|
Owner
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{member.roles?.filter((r) => r.id !== spaceId).map((r) => (
|
||||||
|
<span
|
||||||
|
key={r.id}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||||
|
style={{ backgroundColor: `${r.color}20`, color: r.color }}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && (
|
||||||
|
<span className="text-[10px] text-txt-tertiary">No roles</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{canKick && member.userId !== currentUser?.id && !isOwner && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleKick(member.userId); }}
|
||||||
|
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
||||||
>
|
>
|
||||||
{r.name}
|
Kick
|
||||||
</span>
|
</button>
|
||||||
))}
|
)}
|
||||||
{!isOwner && (!member.roles || member.roles.filter((r) => r.id !== spaceId).length === 0) && (
|
{expandable && (
|
||||||
<span className="text-[10px] text-txt-tertiary">No roles</span>
|
<svg
|
||||||
|
className={`w-4 h-4 text-txt-tertiary transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{canKick && member.userId !== currentUser?.id && !isOwner && (
|
{/* Role checkboxes — only shown when expanded */}
|
||||||
<div className="flex items-center gap-2">
|
{isExpanded && expandable && (
|
||||||
<button
|
<div className="mt-1 mb-1 ml-10 space-y-1">
|
||||||
onClick={() => handleKick(member.userId)}
|
{assignableRoles.map((role) => (
|
||||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors"
|
<label key={role.id} className="flex items-center gap-2 cursor-pointer group/role">
|
||||||
>
|
<input
|
||||||
Kick
|
type="checkbox"
|
||||||
</button>
|
checked={memberRoleIds.has(role.id)}
|
||||||
</div>
|
onChange={() => handleRoleToggle(member.userId, role.id, memberRoleIds)}
|
||||||
)}
|
className="w-3.5 h-3.5 rounded border-txt-tertiary accent-accent-primary"
|
||||||
</div>
|
/>
|
||||||
|
<span
|
||||||
{/* Role checkboxes — shown for non-self, non-owner members when user can manage roles */}
|
className="text-xs font-medium"
|
||||||
{canManageRoles && member.userId !== currentUser?.id && !isOwner && assignableRoles.length > 0 && (
|
style={{ color: role.color !== '#9ca3af' ? role.color : undefined }}
|
||||||
<div className="mt-2 ml-10 space-y-1">
|
>
|
||||||
{assignableRoles.map((role) => (
|
{role.name}
|
||||||
<label key={role.id} className="flex items-center gap-2 cursor-pointer group/role">
|
</span>
|
||||||
<input
|
</label>
|
||||||
type="checkbox"
|
))}
|
||||||
checked={memberRoleIds.has(role.id)}
|
{hasPendingChanges && (
|
||||||
onChange={() => handleRoleToggle(member.userId, role.id, memberRoleIds)}
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
className="w-3.5 h-3.5 rounded border-txt-tertiary accent-accent-primary"
|
<button
|
||||||
/>
|
onClick={() => handleSaveRoles(member.userId)}
|
||||||
<span
|
className="px-2 py-0.5 text-xs bg-accent-primary hover:bg-accent-primary/80 text-white rounded transition-colors"
|
||||||
className="text-xs font-medium"
|
>
|
||||||
style={{ color: role.color !== '#9ca3af' ? role.color : undefined }}
|
Save
|
||||||
>
|
</button>
|
||||||
{role.name}
|
<button
|
||||||
</span>
|
onClick={() => handleCancelRoleChange(member.userId)}
|
||||||
</label>
|
className="px-2 py-0.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
))}
|
>
|
||||||
{hasPendingChanges && (
|
Cancel
|
||||||
<div className="flex items-center gap-2 mt-1.5">
|
</button>
|
||||||
<button
|
</div>
|
||||||
onClick={() => handleSaveRoles(member.userId)}
|
)}
|
||||||
className="px-2 py-0.5 text-xs bg-accent-primary hover:bg-accent-primary/80 text-white rounded transition-colors"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCancelRoleChange(member.userId)}
|
|
||||||
className="px-2 py-0.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../../stores/authStore';
|
|||||||
import { useUIStore } from '../../../stores/uiStore';
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../../../api/client';
|
import { api } from '../../../api/client';
|
||||||
|
import { getApiForOrigin } from '../../../stores/spaceStore';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
|
||||||
|
|
||||||
interface OverviewPanelProps {
|
interface OverviewPanelProps {
|
||||||
@@ -56,7 +57,9 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
const hasIconChange = iconFilename !== null;
|
const hasIconChange = iconFilename !== null;
|
||||||
const hasChanges = hasNameChange || hasIconChange;
|
const hasChanges = hasNameChange || hasIconChange;
|
||||||
|
|
||||||
const currentIconUrl = space.icon ? api.uploads.url(space.icon) : null;
|
const currentIconUrl = space.icon
|
||||||
|
? (space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon))
|
||||||
|
: null;
|
||||||
|
|
||||||
const handleIconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleIconSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -76,7 +79,8 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
const file = new File([blob], 'icon.png', { type: 'image/png' });
|
const file = new File([blob], 'icon.png', { type: 'image/png' });
|
||||||
setUploadingIcon(true);
|
setUploadingIcon(true);
|
||||||
try {
|
try {
|
||||||
const attachment = await api.uploads.upload(file);
|
const spaceApi = getApiForOrigin(space._instanceOrigin);
|
||||||
|
const attachment = await spaceApi.uploads.upload(file);
|
||||||
setIconFilename(attachment.filename);
|
setIconFilename(attachment.filename);
|
||||||
} catch {
|
} catch {
|
||||||
setSaveError('Failed to upload icon');
|
setSaveError('Failed to upload icon');
|
||||||
@@ -149,10 +153,14 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
|
{/* Space Identity */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Space Identity</div>
|
||||||
|
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||||
{/* Space Icon */}
|
{/* Space Icon */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
Space Icon
|
Space Icon
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -209,7 +217,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
|
|
||||||
{/* Space Name */}
|
{/* Space Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
Space Name
|
Space Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -220,6 +228,8 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
disabled={!canManageSpace}
|
disabled={!canManageSpace}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Save / Discard */}
|
{/* Save / Discard */}
|
||||||
{saveError && (
|
{saveError && (
|
||||||
@@ -228,6 +238,22 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
{saveSuccess && (
|
{saveSuccess && (
|
||||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Settings saved</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Danger Zone */}
|
||||||
|
{isOwner && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold text-txt-danger uppercase tracking-wider mb-1.5">Danger Zone</div>
|
||||||
|
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||||
|
<p className="text-xs text-txt-tertiary mb-3">Permanently delete this space and all its data. This cannot be undone.</p>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-4 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded transition-colors"
|
||||||
|
>
|
||||||
|
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Space'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{canManageSpace && hasChanges && (
|
{canManageSpace && hasChanges && (
|
||||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
@@ -249,19 +275,6 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Danger Zone */}
|
|
||||||
{isOwner && (
|
|
||||||
<div className="pt-4 border-t border-border-soft">
|
|
||||||
<h3 className="text-sm font-bold text-txt-danger mb-2">Danger Zone</h3>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="px-4 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded transition-colors"
|
|
||||||
>
|
|
||||||
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Space'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ImageCropModal
|
<ImageCropModal
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function RolesPanel({ spaceId }: RolesPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-5">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||||
)}
|
)}
|
||||||
@@ -127,36 +127,42 @@ export function RolesPanel({ spaceId }: RolesPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div>
|
||||||
{sortedRoles.map((role) => {
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Roles</div>
|
||||||
const isEveryone = role.id === spaceId;
|
<p className="text-xs text-txt-tertiary mb-2">Roles define what permissions members have. Higher roles take priority.</p>
|
||||||
return (
|
<div className="rounded-lg bg-white/[0.02] p-2">
|
||||||
<button
|
<div className="space-y-0.5">
|
||||||
key={role.id}
|
{sortedRoles.map((role) => {
|
||||||
onClick={() => setEditingRoleId(role.id)}
|
const isEveryone = role.id === spaceId;
|
||||||
className="w-full flex items-center justify-between px-3 py-2 rounded hover:bg-interactive-hover transition-colors text-left group"
|
return (
|
||||||
>
|
<button
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
key={role.id}
|
||||||
<div
|
onClick={() => setEditingRoleId(role.id)}
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
className="w-full flex items-center justify-between px-3 py-2 rounded hover:bg-interactive-hover transition-colors text-left group"
|
||||||
style={{ backgroundColor: role.color }}
|
>
|
||||||
/>
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<span className="text-sm text-txt-primary truncate">
|
<div
|
||||||
{isEveryone ? '@everyone' : role.name}
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
</span>
|
style={{ backgroundColor: role.color }}
|
||||||
</div>
|
/>
|
||||||
<svg
|
<span className="text-sm text-txt-primary truncate">
|
||||||
className="w-4 h-4 text-txt-tertiary group-hover:text-txt-secondary transition-colors flex-shrink-0"
|
{isEveryone ? '@everyone' : role.name}
|
||||||
fill="none"
|
</span>
|
||||||
viewBox="0 0 24 24"
|
</div>
|
||||||
stroke="currentColor"
|
<svg
|
||||||
strokeWidth={2}
|
className="w-4 h-4 text-txt-tertiary group-hover:text-txt-secondary transition-colors flex-shrink-0"
|
||||||
>
|
fill="none"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
stroke="currentColor"
|
||||||
</button>
|
strokeWidth={2}
|
||||||
);
|
>
|
||||||
})}
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -190,12 +196,14 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
const hasColorChange = !isEveryone && draftColor !== role.color;
|
const hasColorChange = !isEveryone && draftColor !== role.color;
|
||||||
const hasPermChange = permissionsToString(draftPermissions) !== (role.permissions ?? '0');
|
const hasPermChange = permissionsToString(draftPermissions) !== (role.permissions ?? '0');
|
||||||
const hasChanges = hasNameChange || hasColorChange || hasPermChange;
|
const hasChanges = hasNameChange || hasColorChange || hasPermChange;
|
||||||
|
const showPill = hasChanges || !isEveryone;
|
||||||
|
|
||||||
const togglePermission = (bit: bigint) => {
|
const togglePermission = (bit: bigint) => {
|
||||||
setDraftPermissions((prev) => (prev & bit) !== 0n ? prev & ~bit : prev | bit);
|
setDraftPermissions((prev) => (prev & bit) !== 0n ? prev & ~bit : prev | bit);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
setConfirmDelete(false);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError('');
|
setSaveError('');
|
||||||
setSaveSuccess(false);
|
setSaveSuccess(false);
|
||||||
@@ -219,6 +227,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
setDraftName(role.name);
|
setDraftName(role.name);
|
||||||
setDraftColor(role.color);
|
setDraftColor(role.color);
|
||||||
setDraftPermissions(stringToPermissions(role.permissions));
|
setDraftPermissions(stringToPermissions(role.permissions));
|
||||||
|
setConfirmDelete(false);
|
||||||
setSaveError('');
|
setSaveError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -242,7 +251,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<div className="sticky top-0 z-10 pointer-events-none pb-3">
|
<div className="sticky top-0 z-10 pointer-events-none pb-3">
|
||||||
<button
|
<button
|
||||||
@@ -256,149 +265,153 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Role Name (not editable for @everyone) */}
|
{/* Identity card (Name + Color — not shown for @everyone) */}
|
||||||
{!isEveryone && (
|
{!isEveryone && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Identity</div>
|
||||||
Role Name
|
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4">
|
||||||
</label>
|
<div>
|
||||||
<input
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
type="text"
|
Role Name
|
||||||
value={draftName}
|
</label>
|
||||||
onChange={(e) => setDraftName(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Role Color (not editable for @everyone) */}
|
|
||||||
{!isEveryone && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
|
||||||
Role Color
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
{PRESET_COLORS.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
onClick={() => setDraftColor(c)}
|
|
||||||
className={`w-7 h-7 rounded-full border-2 transition-all ${
|
|
||||||
draftColor === c ? 'border-white scale-110' : 'border-transparent hover:scale-105'
|
|
||||||
}`}
|
|
||||||
style={{ backgroundColor: c }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<label className="relative w-7 h-7 rounded-full border-2 border-border-subtle hover:border-accent-primary transition-colors cursor-pointer overflow-hidden">
|
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="text"
|
||||||
value={draftColor}
|
value={draftName}
|
||||||
onChange={(e) => setDraftColor(e.target.value)}
|
onChange={(e) => setDraftName(e.target.value)}
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="w-full h-full rounded-full bg-gradient-to-br from-red-400 via-green-400 to-blue-400" />
|
</div>
|
||||||
</label>
|
<div>
|
||||||
<input
|
<label className="block text-xs text-txt-secondary mb-1.5">
|
||||||
type="text"
|
Role Color
|
||||||
value={draftColor}
|
</label>
|
||||||
onChange={(e) => {
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
const v = e.target.value;
|
{PRESET_COLORS.map((c) => (
|
||||||
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setDraftColor(v);
|
<button
|
||||||
}}
|
key={c}
|
||||||
className="w-20 px-2 py-1 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary font-mono"
|
onClick={() => setDraftColor(c)}
|
||||||
maxLength={7}
|
className={`w-7 h-7 rounded-full border-2 transition-all ${
|
||||||
/>
|
draftColor === c ? 'border-white scale-110' : 'border-transparent hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<label className="relative w-7 h-7 rounded-full border-2 border-border-subtle hover:border-accent-primary transition-colors cursor-pointer overflow-hidden">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={draftColor}
|
||||||
|
onChange={(e) => setDraftColor(e.target.value)}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full rounded-full bg-gradient-to-br from-red-400 via-green-400 to-blue-400" />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draftColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setDraftColor(v);
|
||||||
|
}}
|
||||||
|
className="w-20 px-2 py-1 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-1 focus:ring-accent-primary font-mono"
|
||||||
|
maxLength={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Permissions */}
|
{/* Permission groups — each gets its own section card */}
|
||||||
<div>
|
{PERMISSION_GROUPS.map((group) => (
|
||||||
<label className="block text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-2">
|
<div key={group.name}>
|
||||||
Permissions
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
</label>
|
{group.name}
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
{PERMISSION_GROUPS.map((group) => (
|
<div className="rounded-lg bg-white/[0.02] p-3.5">
|
||||||
<div key={group.name}>
|
<div className="space-y-1">
|
||||||
<div className="text-[11px] text-txt-tertiary font-medium mb-1.5 uppercase tracking-wider">
|
{group.perms.map((perm) => {
|
||||||
{group.name}
|
const isOn = (draftPermissions & perm.bit) !== 0n;
|
||||||
</div>
|
const isAdmin = perm.bit === PermissionBits.ADMINISTRATOR;
|
||||||
<div className="space-y-1">
|
return (
|
||||||
{group.perms.map((perm) => {
|
<label
|
||||||
const isOn = (draftPermissions & perm.bit) !== 0n;
|
key={perm.label}
|
||||||
const isAdmin = perm.bit === PermissionBits.ADMINISTRATOR;
|
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-interactive-hover cursor-pointer group/perm"
|
||||||
return (
|
>
|
||||||
<label
|
<span className={`text-sm ${isAdmin ? 'text-txt-danger font-medium' : 'text-txt-primary'}`}>
|
||||||
key={perm.label}
|
{perm.label}
|
||||||
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-interactive-hover cursor-pointer group/perm"
|
</span>
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePermission(perm.bit);
|
||||||
|
}}
|
||||||
|
className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${
|
||||||
|
isOn ? 'bg-accent-primary' : 'bg-interactive-muted'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`text-sm ${isAdmin ? 'text-txt-danger font-medium' : 'text-txt-primary'}`}>
|
|
||||||
{perm.label}
|
|
||||||
</span>
|
|
||||||
<div
|
<div
|
||||||
onClick={(e) => {
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||||
e.preventDefault();
|
isOn ? 'translate-x-4' : 'translate-x-0.5'
|
||||||
togglePermission(perm.bit);
|
|
||||||
}}
|
|
||||||
className={`relative w-9 h-5 rounded-full transition-colors cursor-pointer ${
|
|
||||||
isOn ? 'bg-accent-primary' : 'bg-interactive-muted'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
/>
|
||||||
<div
|
</div>
|
||||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
|
</label>
|
||||||
isOn ? 'translate-x-4' : 'translate-x-0.5'
|
);
|
||||||
}`}
|
})}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
|
||||||
{/* Save / Discard */}
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{saveError}</div>
|
||||||
)}
|
)}
|
||||||
{saveSuccess && (
|
{saveSuccess && (
|
||||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Role saved</div>
|
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">Role saved</div>
|
||||||
)}
|
)}
|
||||||
{hasChanges && (
|
{showPill && (
|
||||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
<div className="glass-bubble rounded-full px-4 py-2 flex items-center gap-2 animate-slide-up pointer-events-auto">
|
<div className={`glass-bubble rounded-full px-4 py-2 flex items-center gap-2 pointer-events-auto${
|
||||||
<button
|
isEveryone ? ' animate-slide-up' : ''
|
||||||
onClick={handleDiscard}
|
}`}>
|
||||||
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
{hasChanges && (
|
||||||
>
|
<>
|
||||||
Discard
|
<button
|
||||||
</button>
|
onClick={handleDiscard}
|
||||||
<button
|
className="px-3 py-1 text-sm text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
onClick={handleSave}
|
>
|
||||||
disabled={saving || (!isEveryone && !draftName.trim())}
|
Discard
|
||||||
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"
|
</button>
|
||||||
>
|
<button
|
||||||
{saving ? 'Saving...' : 'Save'}
|
onClick={handleSave}
|
||||||
</button>
|
disabled={saving || (!isEveryone && !draftName.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'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isEveryone && hasChanges && (
|
||||||
|
<div className="w-px h-5 bg-white/10" />
|
||||||
|
)}
|
||||||
|
{!isEveryone && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-full transition-colors disabled:opacity-50 ${
|
||||||
|
confirmDelete
|
||||||
|
? 'bg-accent-rose/15 text-accent-rose'
|
||||||
|
: 'text-accent-rose hover:bg-accent-rose/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : confirmDelete ? 'Confirm?' : 'Delete Role'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Role (not for @everyone) */}
|
|
||||||
{!isEveryone && (
|
|
||||||
<div className="pt-4 border-t border-border-soft">
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={deleting}
|
|
||||||
className="px-4 py-1.5 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{deleting ? 'Deleting...' : confirmDelete ? 'Click again to confirm' : 'Delete Role'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,21 +24,39 @@ interface CachedInstanceToken {
|
|||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'backspace_instances';
|
const STORAGE_KEY_PREFIX = 'backspace_instances';
|
||||||
|
const LEGACY_STORAGE_KEY = 'backspace_instances';
|
||||||
|
|
||||||
|
function storageKey(userId: string): string {
|
||||||
|
return `${STORAGE_KEY_PREFIX}_${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── localStorage helpers ────────────────────────────────────────────────────
|
// ─── localStorage helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function loadCachedTokens(): Record<string, CachedInstanceToken> {
|
function loadCachedTokens(userId: string): Record<string, CachedInstanceToken> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const scopedKey = storageKey(userId);
|
||||||
if (!raw) return {};
|
const raw = localStorage.getItem(scopedKey);
|
||||||
return JSON.parse(raw) as Record<string, CachedInstanceToken>;
|
if (raw) {
|
||||||
|
return JSON.parse(raw) as Record<string, CachedInstanceToken>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time migration: adopt legacy unscoped key if it exists
|
||||||
|
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||||
|
if (legacy) {
|
||||||
|
const parsed = JSON.parse(legacy) as Record<string, CachedInstanceToken>;
|
||||||
|
localStorage.setItem(scopedKey, legacy);
|
||||||
|
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCachedTokens(instances: ConnectedInstance[]): void {
|
function saveCachedTokens(instances: ConnectedInstance[], userId: string): void {
|
||||||
const cache: Record<string, CachedInstanceToken> = {};
|
const cache: Record<string, CachedInstanceToken> = {};
|
||||||
for (const inst of instances) {
|
for (const inst of instances) {
|
||||||
// Skip tokenless placeholders — writing an empty token would cause
|
// Skip tokenless placeholders — writing an empty token would cause
|
||||||
@@ -50,7 +68,7 @@ function saveCachedTokens(instances: ConnectedInstance[]): void {
|
|||||||
username: inst.username,
|
username: inst.username,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
|
localStorage.setItem(storageKey(userId), JSON.stringify(cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Network error detection ────────────────────────────────────────────────
|
// ─── Network error detection ────────────────────────────────────────────────
|
||||||
@@ -216,7 +234,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const updated = [...state.instances, instance];
|
const updated = [...state.instances, instance];
|
||||||
saveCachedTokens(updated);
|
saveCachedTokens(updated, currentUser.id);
|
||||||
return { instances: updated, isLoading: false };
|
return { instances: updated, isLoading: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,7 +273,8 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const updated = [...state.instances, instance];
|
const updated = [...state.instances, instance];
|
||||||
saveCachedTokens(updated);
|
const userId = useAuthStore.getState().user?.id;
|
||||||
|
if (userId) saveCachedTokens(updated, userId);
|
||||||
return { instances: updated, isLoading: false };
|
return { instances: updated, isLoading: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,7 +303,8 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const updated = state.instances.filter(i => i.origin !== origin);
|
const updated = state.instances.filter(i => i.origin !== origin);
|
||||||
saveCachedTokens(updated);
|
const userId = useAuthStore.getState().user?.id;
|
||||||
|
if (userId) saveCachedTokens(updated, userId);
|
||||||
return { instances: updated };
|
return { instances: updated };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -405,7 +425,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = loadCachedTokens();
|
const cached = loadCachedTokens(currentUser.id);
|
||||||
|
|
||||||
// Split server-known instances into two groups:
|
// Split server-known instances into two groups:
|
||||||
// - withToken: have a cached token → attempt reconnection
|
// - withToken: have a cached token → attempt reconnection
|
||||||
@@ -543,7 +563,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
|
|
||||||
// Save final state to localStorage — persist ALL instances regardless of status
|
// Save final state to localStorage — persist ALL instances regardless of status
|
||||||
// so disconnected instances survive page reload and can auto-reconnect later
|
// so disconnected instances survive page reload and can auto-reconnect later
|
||||||
saveCachedTokens(get().instances);
|
saveCachedTokens(get().instances, currentUser.id);
|
||||||
|
|
||||||
// Mark auto-connect complete so syncInstanceList is now safe to run
|
// Mark auto-connect complete so syncInstanceList is now safe to run
|
||||||
set({ _autoConnectDone: true });
|
set({ _autoConnectDone: true });
|
||||||
@@ -560,7 +580,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
disconnectAllRemote();
|
disconnectAllRemote();
|
||||||
|
|
||||||
set({ instances: [], isLoading: false, error: null, _autoConnectDone: false });
|
set({ instances: [], isLoading: false, error: null, _autoConnectDone: false });
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
// Token cache preserved — scoped per user, survives logout for seamless reconnect
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,14 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
updateSpace: async (spaceId: string, data: UpdateSpaceRequest) => {
|
updateSpace: async (spaceId: string, data: UpdateSpaceRequest) => {
|
||||||
const updated = await api.spaces.update(spaceId, data);
|
const space = get().spaces.find(s => s.id === spaceId);
|
||||||
|
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
|
||||||
|
if (origin && updated.icon) {
|
||||||
|
updated.icon = resolveAssetUrl(updated.icon, origin) ?? updated.icon;
|
||||||
|
}
|
||||||
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),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user