feat: standardize input styling with tier system, add depth and admin features

- Define 4 input tier CSS classes (input-standard, input-search, input-embedded, input-danger)
  in globals.css, migrating ~50 inputs across ~28 component files to use them
- Add subtle border and inset shadow to solid input tiers for resting-state visibility
- Fix focus ring clipping in settings panel scroll container
- Fix phantom Tailwind tokens (border-border-primary, placeholder-txt-muted)
- Add admin user management panel, storage management, and account deletion utilities
This commit is contained in:
Jannis Braun
2026-03-14 13:49:32 +01:00
parent afbc4b5e31
commit 836f0acef6
45 changed files with 1478 additions and 235 deletions
+36
View File
@@ -34,6 +34,12 @@ import type {
ChangePasswordRequest,
ChangePasswordResponse,
DeleteAccountRequest,
StorageStats,
OrphanedFile,
CleanupResult,
AdminUserListResponse,
AdminUser,
AdminResetPasswordResponse,
ExploreSpace,
JoinRequest,
Role,
@@ -181,6 +187,16 @@ export class BackspaceApiClient {
myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>;
};
readonly admin: {
storageStats: () => Promise<StorageStats>;
storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>;
storageCleanup: (dryRun?: boolean) => Promise<CleanupResult>;
listUsers: (params?: { q?: string; page?: number; pageSize?: number; showDeleted?: boolean }) => Promise<AdminUserListResponse>;
setUserRole: (userId: string, isAdmin: boolean) => Promise<AdminUser>;
resetUserPassword: (userId: string) => Promise<AdminResetPasswordResponse>;
deleteUser: (userId: string) => Promise<{ success: boolean }>;
};
constructor(baseUrl: string, getToken: () => string | null) {
async function request<T>(
method: string,
@@ -487,6 +503,26 @@ export class BackspaceApiClient {
return request<{ requests: JoinRequest[] }>('GET', `/users/@me/join-requests?${params}`);
},
};
this.admin = {
storageStats: () => request<StorageStats>('GET', '/admin/storage/stats'),
storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'),
storageCleanup: (dryRun = false) => request<CleanupResult>('POST', '/admin/storage/cleanup', { dryRun }),
listUsers: (params) => {
const qs = new URLSearchParams();
if (params?.q) qs.set('q', params.q);
if (params?.page !== undefined) qs.set('page', String(params.page));
if (params?.pageSize !== undefined) qs.set('pageSize', String(params.pageSize));
if (params?.showDeleted) qs.set('showDeleted', 'true');
return request<AdminUserListResponse>('GET', `/admin/users?${qs}`);
},
setUserRole: (userId, isAdmin) =>
request<AdminUser>('PATCH', `/admin/users/${userId}/role`, { isAdmin }),
resetUserPassword: (userId) =>
request<AdminResetPasswordResponse>('POST', `/admin/users/${userId}/reset-password`),
deleteUser: (userId) =>
request<{ success: boolean }>('DELETE', `/admin/users/${userId}`),
};
}
}
+8 -4
View File
@@ -379,7 +379,7 @@ export function JoinPage() {
value={otherDomain}
onChange={(e) => setOtherDomain(e.target.value)}
placeholder="e.g. my-instance.com"
className="flex-1 px-3 py-2.5 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard flex-1 py-2.5"
autoFocus
/>
<button
@@ -406,6 +406,7 @@ export function JoinPage() {
{/* Phase: connect — password prompt for federation */}
{phase === 'connect' && (
<form onSubmit={handleConnect}>
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
{/* Identity card */}
<div className="flex items-center gap-3 bg-surface-input rounded-lg p-3 mb-3">
<Avatar
@@ -431,9 +432,10 @@ export function JoinPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your account password"
className="w-full px-3 py-2.5 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full py-2.5"
disabled={isJoining}
autoFocus
autoComplete="current-password"
/>
<p className="text-xs text-txt-tertiary mt-1">
Your password is verified locally, then used to create or access your account on the remote instance.
@@ -477,8 +479,9 @@ export function JoinPage() {
value={fallbackUsername}
onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance"
className="w-full px-3 py-2.5 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full py-2.5"
disabled={isJoining}
autoComplete="username"
/>
</div>
<div>
@@ -488,9 +491,10 @@ export function JoinPage() {
value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)}
placeholder="Password on the remote instance"
className="w-full px-3 py-2.5 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full py-2.5"
disabled={isJoining}
autoFocus
autoComplete="current-password"
/>
</div>
</div>
@@ -91,7 +91,7 @@ export function LoginPage() {
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
className="input-standard w-full py-2.5"
autoFocus
autoComplete="username"
/>
@@ -105,7 +105,7 @@ export function LoginPage() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
className="input-standard w-full py-2.5"
autoComplete="current-password"
/>
</div>
@@ -257,7 +257,7 @@ export function RegisterPage() {
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
className="input-standard w-full py-2.5"
autoFocus
autoComplete="username"
/>
@@ -296,7 +296,7 @@ export function RegisterPage() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
className="input-standard w-full py-2.5"
autoComplete="new-password"
/>
</div>
@@ -309,7 +309,7 @@ export function RegisterPage() {
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all"
className="input-standard w-full py-2.5"
autoComplete="new-password"
/>
</div>
@@ -396,7 +396,7 @@ export function RegisterPage() {
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={username.trim() || 'Display name'}
className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all placeholder:text-txt-tertiary"
className="input-standard w-full py-2.5"
autoComplete="name"
/>
</div>
@@ -83,7 +83,7 @@ export function ExplorePage() {
placeholder="Search spaces..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full bg-surface-base text-txt-primary text-sm px-3 py-1.5 rounded-[4px] outline-none placeholder:text-txt-tertiary/50 focus:ring-1 focus:ring-accent-primary transition-all"
className="input-search w-full"
/>
{searchQuery && (
<button
@@ -230,10 +230,10 @@ function SpaceCard({
: null;
const iconUrl = space.icon
? (space.icon.startsWith('http') ? space.icon : `/api/uploads/${space.icon}`)
? (space.icon.startsWith('http') || space.icon.startsWith('/') ? space.icon : `/api/uploads/${space.icon}`)
: null;
const bannerUrl = space.banner
? (space.banner.startsWith('http') ? space.banner : `/api/uploads/${space.banner}`)
? (space.banner.startsWith('http') || space.banner.startsWith('/') ? space.banner : `/api/uploads/${space.banner}`)
: null;
// Extract dominant colors from icon when no banner is set
@@ -412,7 +412,7 @@ function SpaceCard({
onChange={(e) => setRequestMessage(e.target.value.slice(0, 200))}
placeholder="Why do you want to join? (optional)"
rows={2}
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"
className="input-standard w-full resize-none"
/>
<div className="flex gap-2">
<button
@@ -268,7 +268,7 @@ function AddFriendTab({
placeholder="You can add a friend with their username"
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
className="w-full bg-surface-base text-txt-primary px-4 py-3 rounded-lg border border-transparent focus:border-txt-link outline-none transition-all placeholder:text-txt-tertiary/50"
className="input-search w-full px-4 py-3 rounded-lg"
/>
<button
type="submit"
@@ -301,7 +301,7 @@ function AddFriendTab({
placeholder="Search people..."
value={discoverQuery}
onChange={(e) => handleDiscoverSearch(e.target.value)}
className="w-full bg-surface-base text-txt-primary text-sm px-3 py-1.5 rounded-[4px] outline-none placeholder:text-txt-tertiary/50 focus:ring-1 focus:ring-accent-primary transition-all"
className="input-search w-full"
/>
{discoverQuery && (
<button
@@ -371,10 +371,10 @@ function UserDiscoverCard({
: null;
const avatarUrl = user.avatar
? (user.avatar.startsWith('http') ? user.avatar : `/api/uploads/${user.avatar}`)
? (user.avatar.startsWith('http') || user.avatar.startsWith('/') ? user.avatar : `/api/uploads/${user.avatar}`)
: null;
const bannerUrl = user.banner
? (user.banner.startsWith('http') ? user.banner : `/api/uploads/${user.banner}`)
? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : `/api/uploads/${user.banner}`)
: null;
const handleSendRequest = async () => {
+3 -3
View File
@@ -229,7 +229,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleEditSubmit}
className="w-full p-3 bg-surface-input rounded-lg text-txt-primary outline-none resize-none text-[15px] leading-[1.5] shadow-inner"
className="input-standard w-full p-3 rounded-lg resize-none text-[15px] leading-[1.5] shadow-inner"
rows={2}
autoFocus
/>
@@ -262,9 +262,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
<div className="mt-1 grid gap-2">
{message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
const attUrl = att.filename.startsWith('http') ? att.filename : `/api/uploads/${att.filename}`;
const attUrl = att.filename.startsWith('http') || att.filename.startsWith('/') ? att.filename : `/api/uploads/${att.filename}`;
const thumbUrl = att.thumbnailFilename
? (att.thumbnailFilename.startsWith('http') ? att.thumbnailFilename : `/api/uploads/${att.thumbnailFilename}`)
? (att.thumbnailFilename.startsWith('http') || att.thumbnailFilename.startsWith('/') ? att.thumbnailFilename : `/api/uploads/${att.thumbnailFilename}`)
: null;
if (isImage) {
return (
@@ -349,7 +349,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
onKeyDown={handleKeyDown}
onPaste={canAttachFiles ? handlePaste : undefined}
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
className="flex-1 py-[10px] px-1 bg-transparent text-txt-primary placeholder-txt-tertiary/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1}
/>
@@ -172,7 +172,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search messages..."
className="flex-1 bg-transparent text-txt-primary text-[14px] placeholder-txt-tertiary outline-none"
className="input-embedded flex-1 text-[14px]"
/>
{query && (
<button
@@ -210,7 +210,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
value={fromFilter}
onChange={(e) => setFromFilter(e.target.value)}
placeholder="username"
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary placeholder-txt-tertiary outline-none"
className="input-search w-full px-2 py-1 text-[13px]"
/>
</div>
<div>
@@ -218,7 +218,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
<select
value={hasFilter}
onChange={(e) => setHasFilter(e.target.value)}
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none appearance-none cursor-pointer"
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
>
<option value="">Any</option>
<option value="file">File</option>
@@ -232,7 +232,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
type="date"
value={beforeFilter}
onChange={(e) => setBeforeFilter(e.target.value)}
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none"
className="input-search w-full px-2 py-1 text-[13px]"
/>
</div>
<div>
@@ -241,7 +241,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
type="date"
value={afterFilter}
onChange={(e) => setAfterFilter(e.target.value)}
className="w-full bg-surface-input rounded px-2 py-1 text-[13px] text-txt-primary outline-none"
className="input-search w-full px-2 py-1 text-[13px]"
/>
</div>
</div>
@@ -370,7 +370,7 @@ export function DmSearchBar() {
onChange={(e) => { setQuery(e.target.value); setSelectedIndex(0); }}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="flex-1 min-w-0 bg-transparent text-txt-primary placeholder-txt-tertiary/60 text-[13px] font-medium outline-none py-[5px]"
className="input-embedded flex-1 min-w-0 text-[13px] font-medium py-[5px]"
/>
</div>
) : (
@@ -126,7 +126,7 @@ function SidebarItem({ id, name, icon, avatarColor, active, onClick, onContextMe
)
) : icon ? (
<img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
src={icon.startsWith('http') || icon.startsWith('/') ? icon : `/api/uploads/${icon}`}
alt={name}
className="w-full h-full object-cover"
/>
@@ -200,7 +200,7 @@ function MiniSpaceIcon({ space }: { space: TaggedSpace }) {
if (icon) {
return (
<img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
src={icon.startsWith('http') || icon.startsWith('/') ? icon : `/api/uploads/${icon}`}
alt=""
className="w-full h-full object-cover rounded-[3px]"
/>
@@ -482,7 +482,7 @@ function FolderFlyout({
{isRenaming ? (
<input
autoFocus
className="w-full bg-surface-input text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-accent-mint/40"
className="input-search w-full px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary"
defaultValue={folder.name || ''}
onBlur={(e) => onRename(e.currentTarget.value)}
onKeyDown={(e) => {
@@ -537,7 +537,7 @@ function FolderFlyout({
<div className="w-8 h-8 rounded-[10px] flex-shrink-0 overflow-hidden flex items-center justify-center" style={grad ? { background: grad.gradient } : undefined}>
{icon ? (
<img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
src={icon.startsWith('http') || icon.startsWith('/') ? icon : `/api/uploads/${icon}`}
alt=""
className="w-full h-full object-cover"
/>
@@ -97,7 +97,7 @@ export function AddDmMemberModal() {
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search for a user..."
className="w-full px-3 py-2 bg-surface-input text-txt-primary placeholder-txt-tertiary/60 rounded-[4px] text-[14px] outline-none focus:ring-1 focus:ring-accent-primary"
className="input-search w-full py-2 text-[14px]"
disabled={memberCount >= 10}
/>
@@ -638,7 +638,7 @@ function PermissionsTab({
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
placeholder="Search members..."
className="w-full px-2.5 py-1.5 text-sm bg-surface-input rounded mb-1 text-txt-primary placeholder-txt-muted outline-none"
className="input-search w-full mb-1"
autoFocus
/>
</div>
@@ -106,7 +106,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isLoading && url.trim() && handleProbe()}
placeholder="https://instance.example.com"
className="flex-1 px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard flex-1"
disabled={isLoading}
/>
<button
@@ -138,7 +138,8 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
</div>
</div>
<div className="space-y-2">
<form onSubmit={(e) => { e.preventDefault(); handleConnect(); }} className="space-y-2">
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
<div>
<label className="block text-xs text-txt-tertiary mb-1">
Enter your password to connect to {new URL(probeResult.origin).host}
@@ -147,24 +148,24 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleConnect()}
placeholder="Your account password"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full"
disabled={isLoading}
autoFocus
autoComplete="current-password"
/>
<div className="text-xs text-txt-tertiary mt-1">
Your password is verified locally, then used to create or access your account on the remote instance.
</div>
</div>
<button
onClick={handleConnect}
type="submit"
disabled={isLoading || !password}
className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Connecting...' : 'Connect'}
</button>
</div>
</form>
<div className="flex gap-2">
<button
@@ -199,7 +200,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
An account already exists on this instance with a different password. Enter the credentials you used on that instance.
</div>
<div className="space-y-2">
<form onSubmit={(e) => { e.preventDefault(); handleFallbackLogin(); }} className="space-y-2">
<div>
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
<input
@@ -207,8 +208,9 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
value={fallbackUsername}
onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full"
disabled={isLoading}
autoComplete="username"
/>
</div>
<div>
@@ -217,21 +219,21 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
type="password"
value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isLoading && fallbackUsername && fallbackPassword && handleFallbackLogin()}
placeholder="Password on the remote instance"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full"
disabled={isLoading}
autoFocus
autoComplete="current-password"
/>
</div>
<button
onClick={handleFallbackLogin}
type="submit"
disabled={isLoading || !fallbackUsername || !fallbackPassword}
className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Logging in...' : 'Login & Connect'}
</button>
</div>
</form>
<div className="flex gap-2">
<button
@@ -345,26 +347,28 @@ function InstanceRow({ inst }: { inst: import('../../stores/instanceStore').Conn
{/* Inline re-authentication prompt */}
{showReauth && (
<div className="space-y-2 pt-1">
<form onSubmit={(e) => { e.preventDefault(); handleReauth(); }} className="space-y-2 pt-1">
<input type="text" autoComplete="username" value={inst.username} readOnly tabIndex={-1} className="sr-only" />
<div className="flex gap-2">
<input
type="password"
value={reauthPassword}
onChange={(e) => setReauthPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !reauthLoading && reauthPassword && handleReauth()}
placeholder="Your account password"
className="flex-1 px-3 py-1.5 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard flex-1 py-1.5"
disabled={reauthLoading}
autoFocus
autoComplete="current-password"
/>
<button
onClick={handleReauth}
type="submit"
disabled={reauthLoading || !reauthPassword}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-xs font-medium rounded transition-colors disabled:opacity-50"
>
{reauthLoading ? 'Connecting...' : 'Connect'}
</button>
<button
type="button"
onClick={() => { setShowReauth(false); setReauthPassword(''); setReauthError(''); }}
className="px-2 py-1.5 text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
@@ -376,7 +380,7 @@ function InstanceRow({ inst }: { inst: import('../../stores/instanceStore').Conn
{reauthError}
</div>
)}
</div>
</form>
)}
</div>
);
@@ -57,7 +57,7 @@ export function CreateCategoryModal() {
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors"
className="input-standard w-full"
placeholder="new-category"
autoFocus
/>
@@ -114,7 +114,7 @@ export function CreateChannelModal() {
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors"
className="input-standard w-full"
placeholder="new-channel"
autoFocus
/>
@@ -129,7 +129,7 @@ export function CreateChannelModal() {
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors"
className="input-standard w-full"
placeholder="What's this channel about?"
/>
</div>
@@ -143,7 +143,7 @@ export function CreateChannelModal() {
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full px-3 py-2 bg-surface-input border border-border-soft rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-colors"
className="input-standard w-full"
>
<option value="">No Category</option>
{[...categories].sort((a, b) => a.position - b.position).map((cat) => (
@@ -215,7 +215,7 @@ export function CreateSpaceModal() {
type="text"
value={name}
onChange={(e) => setName(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"
className="input-standard w-full"
placeholder="My Awesome Space"
autoFocus
/>
@@ -263,7 +263,7 @@ export function CreateSpaceModal() {
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
placeholder="A short description for your space..."
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"
className="input-standard w-full resize-none"
/>
<div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div>
</div>
@@ -247,7 +247,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
<select
value={space.transferTo}
onChange={(e) => handleTransferTo(space.id, e.target.value)}
className="w-full px-2.5 py-1.5 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full px-2.5 py-1.5 text-xs"
>
<option value="">Select new owner...</option>
{space.members.map(m => (
@@ -282,7 +282,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
{/* Step 2: Confirmation */}
{step === 'confirm' && (
<>
<form onSubmit={(e) => { e.preventDefault(); handleConfirmDelete(); }} className="space-y-4">
<div className="bg-accent-rose/10 border border-accent-rose/20 rounded-lg p-3.5">
<p className="text-sm text-txt-danger font-medium">This action is permanent and cannot be undone.</p>
</div>
@@ -295,7 +295,7 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
type="text"
value={confirmUsername}
onChange={(e) => setConfirmUsername(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-rose"
className="input-danger w-full"
placeholder={user.username}
/>
</div>
@@ -306,8 +306,9 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-rose"
className="input-danger w-full"
placeholder="Enter your password"
autoComplete="current-password"
/>
</div>
@@ -323,14 +324,14 @@ export function DeleteAccountModal({ isOpen, onClose }: DeleteAccountModalProps)
Back
</button>
<button
onClick={handleConfirmDelete}
type="submit"
disabled={isLoading || confirmUsername !== user.username || !confirmPassword}
className="flex-1 py-2 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Deleting...' : 'Delete My Account'}
</button>
</div>
</>
</form>
)}
{/* Step 3: Federation Progress */}
@@ -67,7 +67,7 @@ export function InviteModal() {
type="text"
value={isLoading ? 'Generating...' : inviteUrl}
readOnly
className="invite-code-input flex-1 px-3 py-2 bg-surface-input rounded text-txt-primary outline-none font-mono text-xs"
className="input-standard invite-code-input flex-1 font-mono text-xs"
/>
<button
onClick={handleCopy}
@@ -146,7 +146,7 @@ export function JoinSpaceModal() {
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(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"
className="input-standard w-full"
placeholder="e.g. abc123 or https://instance.com/join/abc123"
autoFocus
/>
@@ -177,6 +177,7 @@ export function JoinSpaceModal() {
{/* Phase: connect — password prompt to connect to remote instance */}
{phase === 'connect' && (
<form onSubmit={handleConnect}>
<input type="text" autoComplete="username" value={user?.username || ''} readOnly tabIndex={-1} className="sr-only" />
<p className="text-txt-secondary text-sm mb-4">
Connect to <span className="text-txt-primary font-medium">{hostDisplay}</span> to join this space.
</p>
@@ -190,9 +191,10 @@ export function JoinSpaceModal() {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your account password"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full"
disabled={isLoading}
autoFocus
autoComplete="current-password"
/>
<div className="text-xs text-txt-tertiary mt-1">
Your password is verified locally, then used to create or access your account on the remote instance.
@@ -247,8 +249,9 @@ export function JoinSpaceModal() {
value={fallbackUsername}
onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full"
disabled={isLoading}
autoComplete="username"
/>
</div>
<div>
@@ -258,9 +261,10 @@ export function JoinSpaceModal() {
value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)}
placeholder="Password on the remote instance"
className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full"
disabled={isLoading}
autoFocus
autoComplete="current-password"
/>
</div>
</div>
@@ -87,7 +87,7 @@ export function NewDmModal() {
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search for a user..."
className="w-full px-3 py-2 bg-surface-input text-txt-primary placeholder-txt-tertiary/60 rounded-[4px] text-[14px] outline-none focus:ring-1 focus:ring-accent-primary"
className="input-search w-full py-2 text-[14px]"
/>
{error && (
@@ -116,7 +116,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
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"
className="input-standard w-full resize-none"
/>
<div className="text-[11px] text-txt-tertiary text-right">{description.length}/200</div>
</div>
@@ -120,7 +120,7 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search members..."
className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50"
className="input-search w-full"
autoFocus
/>
</div>
@@ -130,7 +130,7 @@ export function TransferOwnershipModal({ spaceId, onClose }: { spaceId: string;
) : (
filteredMembers.map((member) => {
const avatarUrl = member.user.avatar
? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
? (member.user.avatar.startsWith('http') || member.user.avatar.startsWith('/') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
: null;
return (
<button
@@ -83,7 +83,7 @@ export function UserSettingsModal() {
</div>
{/* Content */}
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin px-1">
{tab === 'account' && <AccountPanel />}
{tab === 'voice' && <VoicePanel />}
{tab === 'privacy' && <PrivacyPanel />}
@@ -56,7 +56,7 @@ export function GeneralPanel() {
value={draft.instanceName}
onChange={(e) => setDraft({ ...draft, instanceName: e.target.value.slice(0, 32) })}
placeholder="Backspace"
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 placeholder:text-txt-tertiary"
className="input-standard w-full"
/>
<div className="text-[11px] text-txt-tertiary text-right mt-1">{draft.instanceName.length}/32</div>
</div>
@@ -0,0 +1,196 @@
import { useState, useEffect, useCallback } from 'react';
import { api } from '../../../api/client';
import type { StorageStats, CleanupResult } from '@backspace/shared';
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, i);
return `${value < 10 ? value.toFixed(2) : value < 100 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
}
export function StoragePanel() {
const [stats, setStats] = useState<StorageStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [cleanupResult, setCleanupResult] = useState<CleanupResult | null>(null);
const [cleaning, setCleaning] = useState(false);
const [previewDone, setPreviewDone] = useState(false);
const fetchStats = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await api.admin.storageStats();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load storage stats');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const handleCleanup = async (dryRun: boolean) => {
setCleaning(true);
setCleanupResult(null);
setError('');
try {
const result = await api.admin.storageCleanup(dryRun);
setCleanupResult(result);
if (dryRun) {
setPreviewDone(true);
} else {
setPreviewDone(false);
await fetchStats();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Cleanup failed');
} finally {
setCleaning(false);
}
};
if (loading) {
return <div className="text-sm text-txt-tertiary">Loading storage stats...</div>;
}
if (error && !stats) {
return (
<div className="space-y-3">
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
<button onClick={fetchStats} className="text-sm text-accent-primary hover:underline">Retry</button>
</div>
);
}
if (!stats) return null;
const hasOrphans = stats.orphanedFiles > 0 || stats.unlinkedAttachments > 0;
return (
<div className="space-y-5">
<div className="text-xs text-txt-tertiary">
Monitor disk usage and clean up orphaned files left behind by deleted content or replaced avatars/banners.
</div>
{/* Storage Overview */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Storage Overview</div>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="text-xs text-txt-tertiary mb-0.5">Total Files</div>
<div className="text-lg font-semibold text-txt-primary">{stats.totalFiles}</div>
<div className="text-xs text-txt-tertiary">{formatBytes(stats.totalSize)}</div>
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="text-xs text-txt-tertiary mb-0.5">Referenced</div>
<div className="text-lg font-semibold text-txt-primary">{stats.referencedFiles}</div>
<div className="text-xs text-txt-tertiary">{formatBytes(stats.referencedSize)}</div>
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="text-xs text-txt-tertiary mb-0.5">Orphaned Files</div>
<div className={`text-lg font-semibold ${stats.orphanedFiles > 0 ? 'text-accent-amber' : 'text-txt-primary'}`}>
{stats.orphanedFiles}
</div>
<div className="text-xs text-txt-tertiary">{formatBytes(stats.orphanedSize)}</div>
</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="text-xs text-txt-tertiary mb-0.5">Unlinked Uploads</div>
<div className={`text-lg font-semibold ${stats.unlinkedAttachments > 0 ? 'text-accent-amber' : 'text-txt-primary'}`}>
{stats.unlinkedAttachments}
</div>
<div className="text-xs text-txt-tertiary">{formatBytes(stats.unlinkedSize)}</div>
</div>
</div>
</div>
{/* File Type Breakdown */}
{stats.breakdown.length > 0 && (
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">File Type Breakdown</div>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="space-y-1.5">
{stats.breakdown.map((b) => (
<div key={b.type} className="flex items-center justify-between text-sm">
<span className="text-txt-secondary capitalize">{b.type}</span>
<span className="text-txt-tertiary">
{b.count} file{b.count !== 1 ? 's' : ''} {formatBytes(b.size)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Cleanup Actions */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Cleanup</div>
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-3">
{!hasOrphans && (
<div className="text-sm text-txt-tertiary">No orphaned files or stale uploads found.</div>
)}
{hasOrphans && (
<div className="flex gap-2">
<button
onClick={() => handleCleanup(true)}
disabled={cleaning}
className="px-3 py-1.5 bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{cleaning ? 'Scanning...' : 'Preview Cleanup'}
</button>
<button
onClick={() => handleCleanup(false)}
disabled={cleaning || !previewDone}
className="px-3 py-1.5 bg-accent-rose hover:bg-accent-rose/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{cleaning ? 'Cleaning...' : 'Clean Up Now'}
</button>
</div>
)}
{cleanupResult && (
<div className={`p-2 rounded text-sm ${
cleanupResult.dryRun
? 'bg-accent-amber/10 border border-accent-amber/30 text-accent-amber'
: 'bg-status-online/10 border border-status-online/30 text-status-online'
}`}>
<div className="font-medium mb-1">
{cleanupResult.dryRun ? 'Preview — no files deleted' : 'Cleanup complete'}
</div>
<div>
{cleanupResult.deletedFiles} orphaned file{cleanupResult.deletedFiles !== 1 ? 's' : ''} ({formatBytes(cleanupResult.freedBytes)})
{cleanupResult.deletedAttachmentRecords > 0 && (
<>, {cleanupResult.deletedAttachmentRecords} stale upload record{cleanupResult.deletedAttachmentRecords !== 1 ? 's' : ''}</>
)}
</div>
{cleanupResult.errors.length > 0 && (
<div className="mt-1 text-txt-danger">
{cleanupResult.errors.length} error{cleanupResult.errors.length !== 1 ? 's' : ''}: {cleanupResult.errors[0]}
</div>
)}
</div>
)}
</div>
</div>
{/* Error / Refresh */}
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
)}
<button
onClick={() => { setCleanupResult(null); setPreviewDone(false); fetchStats(); }}
className="text-sm text-accent-primary hover:underline"
>
Refresh Stats
</button>
</div>
);
}
@@ -142,7 +142,7 @@ export function StreamingPanel() {
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"
className="input-standard w-24 px-2 py-1"
/>
<span className="text-[12px] text-txt-tertiary">kbps</span>
</div>
@@ -0,0 +1,340 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { api } from '../../../api/client';
import { Avatar } from '../../ui/Avatar';
import { ConfirmDialog } from '../../ui/ConfirmDialog';
import { useAuthStore } from '../../../stores/authStore';
import type { AdminUser, AdminUserListResponse } from '@backspace/shared';
export function UsersPanel() {
const currentUser = useAuthStore((s) => s.user);
const [data, setData] = useState<AdminUserListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [showDeleted, setShowDeleted] = useState(false);
const [page, setPage] = useState(1);
const pageSize = 50;
// Confirm dialogs
const [confirmAction, setConfirmAction] = useState<{ type: 'demote' | 'delete'; user: AdminUser } | null>(null);
const [actionLoading, setActionLoading] = useState(false);
// Temp password display
const [tempPassword, setTempPassword] = useState<{ userId: string; password: string } | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const fetchUsers = useCallback(async (q: string, p: number, deleted: boolean) => {
setLoading(true);
setError('');
try {
const result = await api.admin.listUsers({ q: q || undefined, page: p, pageSize, showDeleted: deleted });
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUsers(query, page, showDeleted);
}, [fetchUsers, page, showDeleted]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSearchChange = (value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
fetchUsers(value, 1, showDeleted);
}, 300);
};
const handleToggleAdmin = async (user: AdminUser) => {
if (user.isAdmin) {
// Demoting — confirm first
setConfirmAction({ type: 'demote', user });
return;
}
// Promoting — no confirm needed
setError('');
try {
await api.admin.setUserRole(user.id, true);
fetchUsers(query, page, showDeleted);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const handleResetPassword = async (user: AdminUser) => {
setError('');
setTempPassword(null);
try {
const result = await api.admin.resetUserPassword(user.id);
setTempPassword({ userId: user.id, password: result.temporaryPassword });
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reset password');
}
};
const handleDeleteUser = (user: AdminUser) => {
setConfirmAction({ type: 'delete', user });
};
const handleConfirm = async () => {
if (!confirmAction) return;
setActionLoading(true);
setError('');
try {
if (confirmAction.type === 'demote') {
await api.admin.setUserRole(confirmAction.user.id, false);
} else {
await api.admin.deleteUser(confirmAction.user.id);
}
setConfirmAction(null);
fetchUsers(query, page, showDeleted);
} catch (err) {
setError(err instanceof Error ? err.message : 'Action failed');
setConfirmAction(null);
} finally {
setActionLoading(false);
}
};
const totalPages = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;
const formatDate = (ts: number) => {
const d = new Date(ts);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
};
return (
<div className="space-y-4">
<div className="text-xs text-txt-tertiary">
View and manage user accounts on this instance.
</div>
{/* Search + Show Deleted */}
<div className="flex items-center gap-3">
<input
type="text"
value={query}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search users..."
className="input-search flex-1"
/>
<label className="flex items-center gap-2 text-sm text-txt-secondary cursor-pointer whitespace-nowrap">
<input
type="checkbox"
checked={showDeleted}
onChange={(e) => { setShowDeleted(e.target.checked); setPage(1); }}
className="w-3.5 h-3.5 rounded border-border-soft accent-accent-primary"
/>
Show deleted
</label>
</div>
{/* Error */}
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
)}
{/* Temp password banner */}
{tempPassword && (
<div className="p-3 bg-status-online/10 border border-status-online/30 rounded-lg">
<div className="flex items-center justify-between gap-2">
<div className="text-sm text-txt-secondary">
Temporary password for <span className="font-medium text-txt-primary">{data?.users.find(u => u.id === tempPassword.userId)?.username ?? 'user'}</span>:
</div>
<button
onClick={() => setTempPassword(null)}
className="text-txt-tertiary hover:text-txt-secondary text-xs"
>
Dismiss
</button>
</div>
<div className="mt-1.5 flex items-center gap-2">
<code className="px-2 py-1 bg-black/30 rounded text-sm font-mono text-status-online select-all">
{tempPassword.password}
</code>
<button
onClick={() => navigator.clipboard.writeText(tempPassword.password)}
className="px-2 py-1 bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary text-xs rounded transition-colors"
>
Copy
</button>
</div>
<div className="text-xs text-txt-tertiary mt-1.5">
This password is shown once. The user has been disconnected and must log in again.
</div>
</div>
)}
{/* Loading */}
{loading && !data && (
<div className="text-sm text-txt-tertiary py-4">Loading users...</div>
)}
{/* User list */}
{data && (
<div className="space-y-1.5">
{data.users.length === 0 && (
<div className="text-sm text-txt-tertiary py-4 text-center">No users found</div>
)}
{data.users.map((user) => {
const isSelf = user.id === currentUser?.id;
const isFederated = !!user.homeInstance;
const isDeleted = user.isDeleted;
return (
<div key={user.id} className="flex items-center gap-3 rounded-lg bg-white/[0.02] p-3.5">
{/* Avatar */}
<div className={isDeleted ? 'opacity-50' : ''}>
<Avatar
src={user.avatar ? api.uploads.url(user.avatar) : null}
name={user.displayName || user.username}
size={32}
avatarColor={user.avatarColor as any}
/>
</div>
{/* Info */}
<div className={`flex-1 min-w-0 ${isDeleted ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-2">
<span className={`text-sm font-medium text-txt-primary truncate ${isDeleted ? 'line-through' : ''}`}>
{user.username}
</span>
{user.displayName && !isDeleted && (
<span className="text-xs text-txt-tertiary truncate">{user.displayName}</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
{user.isAdmin && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-amber/20 text-accent-amber">
Admin
</span>
)}
{isFederated && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-sky/20 text-accent-sky truncate max-w-[120px]">
{user.homeInstance}
</span>
)}
{isDeleted && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-accent-rose/20 text-accent-rose">
Deleted
</span>
)}
<span className="text-[10px] text-txt-tertiary">
{formatDate(user.createdAt)}
</span>
</div>
</div>
{/* Actions */}
{!isDeleted && (
<div className="flex items-center gap-1 shrink-0">
{/* Toggle admin */}
<button
onClick={() => handleToggleAdmin(user)}
disabled={isFederated && !user.isAdmin}
title={user.isAdmin ? 'Demote from admin' : isFederated ? 'Federated users cannot be admin' : 'Promote to admin'}
className={`p-1.5 rounded transition-colors ${
user.isAdmin
? 'text-accent-amber hover:bg-accent-amber/10'
: isFederated
? 'text-txt-tertiary/30 cursor-not-allowed'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.06]'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</button>
{/* Reset password */}
<button
onClick={() => handleResetPassword(user)}
disabled={isFederated}
title={isFederated ? 'Federated users authenticate via home instance' : 'Reset password'}
className={`p-1.5 rounded transition-colors ${
isFederated
? 'text-txt-tertiary/30 cursor-not-allowed'
: 'text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.06]'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</button>
{/* Delete user */}
<button
onClick={() => handleDeleteUser(user)}
disabled={isSelf}
title={isSelf ? 'Use account settings to delete your own account' : 'Delete user'}
className={`p-1.5 rounded transition-colors ${
isSelf
? 'text-txt-tertiary/30 cursor-not-allowed'
: 'text-txt-tertiary hover:text-accent-rose hover:bg-accent-rose/10'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
)}
</div>
);
})}
</div>
)}
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between pt-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="text-xs text-txt-tertiary">
Page {page} of {totalPages} ({data.total} user{data.total !== 1 ? 's' : ''})
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1 text-sm text-txt-secondary hover:text-txt-primary bg-white/[0.04] hover:bg-white/[0.08] rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Next
</button>
</div>
)}
{/* Confirm dialogs */}
<ConfirmDialog
isOpen={confirmAction?.type === 'demote'}
onClose={() => setConfirmAction(null)}
onConfirm={handleConfirm}
title="Demote Admin"
description={<>Remove admin privileges from <strong>{confirmAction?.user.username}</strong>? They will lose access to instance settings.</>}
confirmLabel="Demote"
variant="warning"
loading={actionLoading}
/>
<ConfirmDialog
isOpen={confirmAction?.type === 'delete'}
onClose={() => setConfirmAction(null)}
onConfirm={handleConfirm}
title="Delete User"
description={<>Permanently delete <strong>{confirmAction?.user.username}</strong>? This will remove them from all spaces, DMs, and friends lists. This cannot be undone.</>}
confirmLabel="Delete User"
variant="danger"
loading={actionLoading}
/>
</div>
);
}
@@ -498,7 +498,7 @@ export function AccountPanel() {
}
}}
placeholder="#hex"
className="w-24 px-2 py-1.5 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary font-mono"
className="input-standard w-24 px-2 py-1.5 text-xs font-mono"
maxLength={7}
/>
{accentColor && (
@@ -530,7 +530,7 @@ export function AccountPanel() {
}}
rows={3}
placeholder="Tell the world about yourself..."
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary resize-none"
className="input-standard w-full resize-none"
maxLength={190}
/>
<span className="absolute bottom-2 right-2 text-[10px] text-txt-tertiary">
@@ -550,7 +550,7 @@ export function AccountPanel() {
<select
value={status}
onChange={(e) => setStatus(e.target.value as UserStatus)}
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"
className="input-standard w-full appearance-none"
>
<option value="online">Online</option>
<option value="idle">Idle</option>
@@ -564,7 +564,7 @@ export function AccountPanel() {
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"
className="input-standard w-full"
/>
</div>
@@ -574,7 +574,7 @@ export function AccountPanel() {
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"
className="input-standard w-full"
placeholder="What are you up to?"
/>
</div>
@@ -584,7 +584,8 @@ export function AccountPanel() {
{/* ── Password ── */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Password</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-3">
<form onSubmit={(e) => { e.preventDefault(); handleChangePassword(); }} className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-3">
<input type="text" autoComplete="username" value={user.username} readOnly tabIndex={-1} className="sr-only" />
<div>
<label className="block text-xs text-txt-secondary mb-1.5">Current Password</label>
<div className="relative">
@@ -592,8 +593,9 @@ export function AccountPanel() {
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 pr-10 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full pr-10"
placeholder="Enter current password"
autoComplete="current-password"
/>
<button
type="button"
@@ -620,8 +622,9 @@ export function AccountPanel() {
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 pr-10 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full pr-10"
placeholder="Minimum 6 characters"
autoComplete="new-password"
/>
<button
type="button"
@@ -647,8 +650,9 @@ export function AccountPanel() {
type="password"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary"
className="input-standard w-full"
placeholder="Confirm new password"
autoComplete="new-password"
/>
</div>
@@ -674,13 +678,13 @@ export function AccountPanel() {
)}
<button
onClick={handleChangePassword}
type="submit"
disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{passwordLoading ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
</div>
{/* ── Danger Zone ── */}
@@ -2,8 +2,10 @@ import { useState, useEffect } from 'react';
import { useSettingsStore } from '../../../stores/settingsStore';
import { GeneralPanel } from '../instanceSettingsPanels/GeneralPanel';
import { StreamingPanel } from '../instanceSettingsPanels/StreamingPanel';
import { StoragePanel } from '../instanceSettingsPanels/StoragePanel';
import { UsersPanel } from '../instanceSettingsPanels/UsersPanel';
type SubTab = 'general' | 'streaming';
type SubTab = 'general' | 'streaming' | 'storage' | 'users';
export function InstancePanel() {
const fetchInstanceSettings = useSettingsStore((s) => s.fetchInstanceSettings);
@@ -33,11 +35,19 @@ export function InstancePanel() {
<button onClick={() => setSubTab('streaming')} className={pillClass('streaming')}>
Streaming
</button>
<button onClick={() => setSubTab('storage')} className={pillClass('storage')}>
Storage
</button>
<button onClick={() => setSubTab('users')} className={pillClass('users')}>
Users
</button>
</div>
{/* Content */}
{subTab === 'general' && <GeneralPanel />}
{subTab === 'streaming' && <StreamingPanel />}
{subTab === 'storage' && <StoragePanel />}
{subTab === 'users' && <UsersPanel />}
</div>
);
}
@@ -437,7 +437,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
type="text"
value={spaceName}
onChange={(e) => setSpaceName(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"
className="input-standard w-full"
disabled={!canManageSpace}
/>
</div>
@@ -494,7 +494,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
value={transferSearch}
onChange={(e) => setTransferSearch(e.target.value)}
placeholder="Search members..."
className="w-full px-3 py-1.5 bg-surface-input rounded text-sm text-txt-primary placeholder-txt-tertiary outline-none focus:ring-1 focus:ring-accent-primary/50"
className="input-search w-full"
autoFocus
/>
<div className="max-h-[160px] overflow-y-auto space-y-0.5">
@@ -503,7 +503,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
) : (
transferCandidates.map((member) => {
const avatarUrl = member.user.avatar
? (member.user.avatar.startsWith('http') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
? (member.user.avatar.startsWith('http') || member.user.avatar.startsWith('/') ? member.user.avatar : `/api/uploads/${member.user.avatar}`)
: null;
return (
<button
@@ -278,7 +278,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
type="text"
value={draftName}
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"
className="input-standard w-full"
/>
</div>
<div>
@@ -312,7 +312,7 @@ function RoleEditView({ role, spaceId, onBack, onDeleted }: RoleEditViewProps) {
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"
className="input-standard w-20 px-2 py-1 text-xs font-mono"
maxLength={7}
/>
</div>
+1 -1
View File
@@ -102,7 +102,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
>
{src ? (
<img
src={(src.startsWith('http') || src.startsWith('blob:') || src.startsWith('data:'))
src={(src.startsWith('http') || src.startsWith('blob:') || src.startsWith('data:') || src.startsWith('/'))
? src : `/api/uploads/${src}`}
alt={name}
loading="lazy"
@@ -68,7 +68,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
// Banner display
const bannerSrc = user.banner
? (user.banner.startsWith('http') ? user.banner : userApi.uploads.url(user.banner))
? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner))
: null;
const bannerFallback = user.accentColor
? mutedGradient(user.accentColor, adjustColor(user.accentColor, -40))
+27
View File
@@ -203,6 +203,33 @@
.glass-pill-mine:hover {
background: rgba(134, 239, 172, 0.16);
}
/* ── Input Tiers ── */
.input-standard {
@apply bg-surface-input rounded px-3 py-2 text-sm text-txt-primary
placeholder:text-txt-tertiary outline-none
border border-white/[0.06] shadow-input
focus:ring-2 focus:ring-accent-primary focus:border-accent-primary/30
transition-colors;
}
.input-search {
@apply bg-surface-input rounded px-3 py-1.5 text-sm text-txt-primary
placeholder:text-txt-tertiary outline-none
border border-white/[0.06] shadow-input
focus:ring-1 focus:ring-accent-primary focus:border-accent-primary/30
transition-colors;
}
.input-embedded {
@apply bg-transparent text-txt-primary
placeholder:text-txt-tertiary/60 outline-none;
}
.input-danger {
@apply bg-surface-input rounded px-3 py-2 text-sm text-txt-primary
placeholder:text-txt-tertiary outline-none
border border-white/[0.06] shadow-input
focus:ring-2 focus:ring-accent-rose focus:border-accent-rose/30
transition-colors;
}
}
/* Accessibility: fall back to solid surfaces when transparency is reduced */