mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
feat(devices): inline rename UI for connected HWID devices
Pairs with the bot commit that adds the user_device_aliases table. Surfaces the new local_name field on the existing devices list in both the user-facing subscription page and the admin user-detail page, with the same inline-edit pattern in both places. User-side (src/pages/Subscription.tsx): - pencil button next to each device row toggles edit mode - input is focused automatically, capped at 64 chars (matches the backend ALIAS_MAX_LENGTH and DB column width) - Enter saves, Escape cancels, empty input clears the alias - display priority: local_name → device_model → platform - works identically in classic / single-tariff / multi-tariff — subscriptionId is forwarded as a query param like every other device-management endpoint already does Admin-side (src/pages/AdminUserDetail.tsx): - same pencil + inline input pattern, admin acts on behalf of the user. notify.success on save, loadDevices() refresh. API (src/api/subscription.ts + src/api/adminUsers.ts): - new renameDevice(hwid, name, subscriptionId?) on subscriptionApi - new renameUserDevice(userId, hwid, name) on adminUsersApi - existing getDevices/getUserDevices contracts widened with local_name?: string | null on the returned device shape Locales (src/locales/ru.json): - subscription.renameDevice / .renameDeviceSave / .renameDeviceCancel / .renameDevicePlaceholder / .deviceRenamed - admin.users.detail.devices.rename / .renameSave / .renamed
This commit is contained in:
@@ -308,6 +308,23 @@ export default function Subscription() {
|
||||
},
|
||||
});
|
||||
|
||||
// Local device alias (rename) state. Only one device can be in edit-mode
|
||||
// at a time — `editingDeviceHwid` doubles as both the toggle and the
|
||||
// identifier of the row being edited.
|
||||
const [editingDeviceHwid, setEditingDeviceHwid] = useState<string | null>(null);
|
||||
const [editingDeviceName, setEditingDeviceName] = useState('');
|
||||
const DEVICE_ALIAS_MAX_LENGTH = 64;
|
||||
|
||||
const renameDeviceMutation = useMutation({
|
||||
mutationFn: ({ hwid, name }: { hwid: string; name: string | null }) =>
|
||||
subscriptionApi.renameDevice(hwid, name, subscriptionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||
setEditingDeviceHwid(null);
|
||||
setEditingDeviceName('');
|
||||
},
|
||||
});
|
||||
|
||||
// Pause subscription mutation
|
||||
const pauseMutation = useMutation({
|
||||
mutationFn: () => subscriptionApi.togglePause(subscriptionId),
|
||||
@@ -2403,73 +2420,203 @@ export default function Subscription() {
|
||||
? `${devicesData.total} · ∞`
|
||||
: `${devicesData.total} / ${t('subscription.devices', { count: devicesData.device_limit })}`}
|
||||
</div>
|
||||
{devicesData.devices.map((device) => (
|
||||
<div
|
||||
key={device.hwid}
|
||||
className="flex items-center justify-between rounded-[12px] p-3.5"
|
||||
style={{
|
||||
background: g.innerBg,
|
||||
border: `1px solid ${g.innerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center rounded-[10px]"
|
||||
style={{ background: g.trackBg }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={g.textSecondary}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{devicesData.devices.map((device) => {
|
||||
const isEditing = editingDeviceHwid === device.hwid;
|
||||
// Display priority: user alias → device model → platform.
|
||||
const displayName =
|
||||
(device.local_name && device.local_name.trim()) ||
|
||||
device.device_model ||
|
||||
device.platform;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={device.hwid}
|
||||
className="flex items-center justify-between rounded-[12px] p-3.5"
|
||||
style={{
|
||||
background: g.innerBg,
|
||||
border: `1px solid ${g.innerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
|
||||
style={{ background: g.trackBg }}
|
||||
>
|
||||
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={g.textSecondary}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={editingDeviceName}
|
||||
maxLength={DEVICE_ALIAS_MAX_LENGTH}
|
||||
placeholder={device.device_model || device.platform}
|
||||
onChange={(e) => setEditingDeviceName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const trimmed = editingDeviceName.trim();
|
||||
renameDeviceMutation.mutate({
|
||||
hwid: device.hwid,
|
||||
name: trimmed || null,
|
||||
});
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setEditingDeviceHwid(null);
|
||||
setEditingDeviceName('');
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-md border-none bg-transparent px-2 py-1 text-sm font-semibold text-dark-50 outline-none focus:ring-1"
|
||||
style={{
|
||||
background: g.trackBg,
|
||||
boxShadow: `inset 0 0 0 1px ${g.innerBorder}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="truncate text-sm font-semibold text-dark-50">
|
||||
{displayName}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-dark-50/30">
|
||||
<span className="truncate">{device.platform}</span>
|
||||
<span className="font-mono text-dark-50/20">
|
||||
{device.hwid.slice(0, 8).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-dark-50">
|
||||
{device.device_model || device.platform}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-dark-50/30">
|
||||
<span>{device.platform}</span>
|
||||
<span className="font-mono text-dark-50/20">
|
||||
{device.hwid.slice(0, 8).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const trimmed = editingDeviceName.trim();
|
||||
renameDeviceMutation.mutate({
|
||||
hwid: device.hwid,
|
||||
name: trimmed || null,
|
||||
});
|
||||
}}
|
||||
disabled={renameDeviceMutation.isPending}
|
||||
className="p-2 transition-colors"
|
||||
style={{ color: g.textSecondary }}
|
||||
title={t('subscription.renameDeviceSave', 'Сохранить')}
|
||||
aria-label={t('subscription.renameDeviceSave', 'Сохранить')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingDeviceHwid(null);
|
||||
setEditingDeviceName('');
|
||||
}}
|
||||
disabled={renameDeviceMutation.isPending}
|
||||
className="p-2 transition-colors"
|
||||
style={{ color: g.textFaint }}
|
||||
title={t('subscription.renameDeviceCancel', 'Отмена')}
|
||||
aria-label={t('subscription.renameDeviceCancel', 'Отмена')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingDeviceHwid(device.hwid);
|
||||
setEditingDeviceName(device.local_name || '');
|
||||
}}
|
||||
className="p-2 transition-colors"
|
||||
style={{ color: g.textFaint }}
|
||||
title={t('subscription.renameDevice', 'Переименовать')}
|
||||
aria-label={t('subscription.renameDevice', 'Переименовать')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm(t('subscription.confirmDeleteDevice'))) {
|
||||
deleteDeviceMutation.mutate(device.hwid);
|
||||
}
|
||||
}}
|
||||
disabled={deleteDeviceMutation.isPending}
|
||||
className="p-2 transition-colors"
|
||||
style={{ color: g.textFaint }}
|
||||
title={t('subscription.deleteDevice')}
|
||||
aria-label={t('subscription.deleteDevice')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path 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>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(t('subscription.confirmDeleteDevice'))) {
|
||||
deleteDeviceMutation.mutate(device.hwid);
|
||||
}
|
||||
}}
|
||||
disabled={deleteDeviceMutation.isPending}
|
||||
className="p-2 transition-colors"
|
||||
style={{ color: g.textFaint }}
|
||||
title={t('subscription.deleteDevice')}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path 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 className="py-8 text-center text-[12px] text-dark-50/25">
|
||||
|
||||
Reference in New Issue
Block a user