Improve management page loading states

This commit is contained in:
shuaiplus
2026-05-04 04:19:59 +08:00
parent 0ab7c44981
commit 97a3aa691d
4 changed files with 105 additions and 21 deletions
+46 -1
View File
@@ -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">
<div className="section-head">
<h3>{t('txt_users')}</h3> <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">
+19 -1
View File
@@ -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}>
+23 -2
View File
@@ -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) {
try {
await createInvite(authedFetch, hours); await createInvite(authedFetch, hours);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_created')); 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') {
try {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_status_updated')); 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) {
try {
await revokeInvite(authedFetch, code); await revokeInvite(authedFetch, code);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_revoked')); 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 () => {
try {
await deleteAllInvites(authedFetch); await deleteAllInvites(authedFetch);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_all_invites_deleted')); 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 () => {
try {
await deleteUser(authedFetch, userId); await deleteUser(authedFetch, userId);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_deleted')); onNotify('success', t('txt_user_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_user_failed'));
}
})(); })();
}, },
}); });
+1 -1
View File
@@ -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;
} }