mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Improve management page loading states
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminInvite, AdminUser } from '@/lib/types';
|
import type { AdminInvite, AdminUser } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ interface AdminPageProps {
|
|||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
users: AdminUser[];
|
users: AdminUser[];
|
||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCreateInvite: (hours: number) => Promise<void>;
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
onDeleteAllInvites: () => Promise<void>;
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
@@ -48,8 +51,22 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
|
{!!props.error && (
|
||||||
|
<div className="local-error">
|
||||||
|
<span>{props.error}</span>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3>{t('txt_users')}</h3>
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_users')}</h3>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -94,6 +111,20 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{props.loading && !props.users.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<LoadingState lines={4} compact />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!props.loading && !props.users.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<div className="empty empty-comfortable">{t('txt_no_users_found')}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -101,7 +132,7 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h3>{t('txt_invites')}</h3>
|
<h3>{t('txt_invites')}</h3>
|
||||||
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
|
<button type="button" className="btn btn-secondary" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,6 +191,20 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{props.loading && !props.invites.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<LoadingState lines={4} compact />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!props.loading && !props.invites.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}>
|
||||||
|
<div className="empty empty-comfortable">{t('txt_no_invites_found')}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
import { Clock3, Pencil, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AuthorizedDevice } from '@/lib/types';
|
import type { AuthorizedDevice } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface SecurityDevicesPageProps {
|
interface SecurityDevicesPageProps {
|
||||||
devices: AuthorizedDevice[];
|
devices: AuthorizedDevice[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
@@ -72,7 +74,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
|
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
<RefreshCw size={14} className="btn-icon" />
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
{t('txt_refresh')}
|
{t('txt_refresh')}
|
||||||
</button>
|
</button>
|
||||||
@@ -90,6 +92,15 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
||||||
|
{!!props.error && (
|
||||||
|
<div className="local-error">
|
||||||
|
<span>{props.error}</span>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -166,6 +177,13 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{props.loading && props.devices.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7}>
|
||||||
|
<LoadingState lines={5} compact />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
{!props.loading && props.devices.length === 0 && (
|
{!props.loading && props.devices.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>
|
<td colSpan={7}>
|
||||||
|
|||||||
@@ -20,26 +20,39 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
refreshAdmin() {
|
refreshAdmin() {
|
||||||
void refetchUsers();
|
void Promise.all([refetchUsers(), refetchInvites()]).catch((error) => {
|
||||||
void refetchInvites();
|
onNotify('error', error instanceof Error ? error.message : t('txt_load_admin_data_failed'));
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async createInvite(hours: number) {
|
async createInvite(hours: number) {
|
||||||
await createInvite(authedFetch, hours);
|
try {
|
||||||
await refetchInvites();
|
await createInvite(authedFetch, hours);
|
||||||
onNotify('success', t('txt_invite_created'));
|
await refetchInvites();
|
||||||
|
onNotify('success', t('txt_invite_created'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
||||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
try {
|
||||||
await refetchUsers();
|
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||||
onNotify('success', t('txt_user_status_updated'));
|
await refetchUsers();
|
||||||
|
onNotify('success', t('txt_user_status_updated'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async revokeInvite(code: string) {
|
async revokeInvite(code: string) {
|
||||||
await revokeInvite(authedFetch, code);
|
try {
|
||||||
await refetchInvites();
|
await revokeInvite(authedFetch, code);
|
||||||
onNotify('success', t('txt_invite_revoked'));
|
await refetchInvites();
|
||||||
|
onNotify('success', t('txt_invite_revoked'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteAllInvites() {
|
async deleteAllInvites() {
|
||||||
@@ -50,9 +63,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await deleteAllInvites(authedFetch);
|
try {
|
||||||
await refetchInvites();
|
await deleteAllInvites(authedFetch);
|
||||||
onNotify('success', t('txt_all_invites_deleted'));
|
await refetchInvites();
|
||||||
|
onNotify('success', t('txt_all_invites_deleted'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_invites_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -66,9 +83,13 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await deleteUser(authedFetch, userId);
|
try {
|
||||||
await refetchUsers();
|
await deleteUser(authedFetch, userId);
|
||||||
onNotify('success', t('txt_user_deleted'));
|
await refetchUsers();
|
||||||
|
onNotify('success', t('txt_user_deleted'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_user_failed'));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -698,7 +698,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.local-error {
|
.local-error {
|
||||||
@apply mt-2.5 font-semibold;
|
@apply mt-2.5 flex flex-wrap items-center gap-2 font-semibold;
|
||||||
color: #b42318;
|
color: #b42318;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user