Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions app/components/admin/UserStats.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<template>
<div class="border-t border-border p-4 space-y-4">
<div v-if="pending && !data" class="text-center text-text-muted py-4">
<Icon name="ph:circle-notch" class="animate-spin" />
</div>
<template v-else-if="data">
<!-- Current and resulting totals -->
<div class="grid grid-cols-3 gap-3">
<div v-for="stat in summary" :key="stat.id" class="rounded border border-border bg-bg-secondary p-3">
<p class="text-xs text-text-muted">{{ stat.label }}</p>
<p class="num text-lg font-medium" :class="stat.class">{{ stat.current }}</p>
<p v-if="stat.next !== stat.current" class="num text-xs text-text-muted">→ {{ stat.next }}</p>
</div>
</div>

<form class="grid gap-3 md:grid-cols-[repeat(2,minmax(0,1fr))_auto]" @submit.prevent="submit">
<div v-for="field in fields" :key="field" class="space-y-1">
<span class="text-xs font-bold text-text-muted">{{ t(`admin.users.stats.${field}`) }}</span>
<div class="flex gap-1">
<select v-model="form[field].remove" class="input !py-2 text-xs w-auto" :aria-label="t('admin.users.stats.operation')">
<option :value="false">+ {{ t('admin.users.stats.add') }}</option>
<option :value="true">− {{ t('admin.users.stats.remove') }}</option>
</select>
<input
v-model.number="form[field].amount"
type="number"
min="0"
step="any"
class="input !py-2 text-xs font-mono flex-1 min-w-0"
:aria-label="t(`admin.users.stats.${field}`)"
/>
<select v-model="form[field].unit" class="input !py-2 text-xs w-auto" :aria-label="t('admin.users.stats.unit')">
<option v-for="unit in units" :key="unit" :value="unit">{{ unit }}</option>
</select>
</div>
</div>
<div class="space-y-1 md:col-span-3">
<span class="text-xs font-bold text-text-muted">{{ t('admin.users.stats.reason') }}</span>
<div class="flex gap-2">
<input
v-model="form.reason"
type="text"
maxlength="200"
class="input !py-2 text-xs flex-1"
:placeholder="t('admin.users.stats.reasonPlaceholder')"
/>
<button type="submit" class="btn btn-primary !py-2 text-xs" :disabled="!canSubmit || saving">
<Icon v-if="saving" name="ph:circle-notch" class="animate-spin" />
{{ t('admin.users.stats.apply') }}
</button>
</div>
</div>
</form>

<div v-if="data.adjustments.length">
<p class="text-xs font-bold text-text-muted mb-2">{{ t('admin.users.stats.history') }}</p>
<ul class="divide-y divide-border border border-border rounded text-xs">
<li v-for="a in data.adjustments" :key="a.id" class="px-3 py-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span class="num text-text-muted">{{ formatDate(a.createdAt) }}</span>
<span v-if="a.uploadedDelta" class="num text-seed">↑ {{ signed(a.uploadedDelta) }}</span>
<span v-if="a.downloadedDelta" class="num text-leech">↓ {{ signed(a.downloadedDelta) }}</span>
<span class="text-text-secondary flex-1 min-w-0 break-words">{{ a.reason }}</span>
<span class="text-text-muted">{{ a.admin ?? t('admin.users.stats.deletedAdmin') }}</span>
</li>
</ul>
</div>
</template>
</div>
</template>

<script setup lang="ts">
import { useNotificationStore } from '~/stores/notifications';
import { STAT_UNITS, applyStatDelta, ratioOf, toBytes, type StatUnit } from '~~/shared/utils/statAdjust';

interface StatsResponse {
uploaded: number;
downloaded: number;
adjustments: {
id: string;
uploadedDelta: number;
downloadedDelta: number;
reason: string;
createdAt: string;
admin: string | null;
}[];
}

const props = defineProps<{ userId: string; username: string }>();
const { t } = useI18n();
const notifications = useNotificationStore();

const units = Object.keys(STAT_UNITS) as StatUnit[];
const fields = ['uploaded', 'downloaded'] as const;

const { data, pending, refresh } = await useFetch<StatsResponse>(() => `/api/admin/users/${props.userId}/stats`, {
lazy: true,
});

const emptyField = () => ({ remove: false, amount: 0, unit: 'GB' as StatUnit });
const form = reactive({ uploaded: emptyField(), downloaded: emptyField(), reason: '' });

const deltas = computed(() => ({
uploaded: toBytes(form.uploaded.amount, form.uploaded.unit, form.uploaded.remove),
downloaded: toBytes(form.downloaded.amount, form.downloaded.unit, form.downloaded.remove),
}));
const canSubmit = computed(
() => (deltas.value.uploaded !== 0 || deltas.value.downloaded !== 0) && form.reason.trim().length > 0
);

const formatRatio = (r: number) => (r === Infinity ? '∞' : r.toFixed(2));
const signed = (bytes: number) => `${bytes < 0 ? '−' : '+'}${formatSize(Math.abs(bytes))}`;

// What the totals and ratio become once applied
const summary = computed(() => {
const up = data.value?.uploaded ?? 0;
const down = data.value?.downloaded ?? 0;
const nextUp = applyStatDelta(up, deltas.value.uploaded).value;
const nextDown = applyStatDelta(down, deltas.value.downloaded).value;
const nextRatio = ratioOf(nextUp, nextDown);
return [
{ id: 'uploaded', label: t('common.uploaded'), class: 'text-seed', current: formatSize(up), next: formatSize(nextUp) },
{ id: 'downloaded', label: t('common.downloaded'), class: 'text-leech', current: formatSize(down), next: formatSize(nextDown) },
{
id: 'ratio',
label: t('common.ratio'),
class: nextRatio < 1 && nextDown > 0 ? 'text-danger' : 'text-text-primary',
current: formatRatio(ratioOf(up, down)),
next: formatRatio(nextRatio),
},
];
});

const saving = ref(false);
async function submit() {
if (!canSubmit.value) return;
saving.value = true;
try {
await $fetch(`/api/admin/users/${props.userId}/stats`, {
method: 'POST',
body: { uploadedDelta: deltas.value.uploaded, downloadedDelta: deltas.value.downloaded, reason: form.reason.trim() },
});
Object.assign(form, { uploaded: emptyField(), downloaded: emptyField(), reason: '' });
notifications.success(t('admin.users.stats.applied', { name: props.username }));
await refresh();
} catch (error) {
const message = (error as { data?: { message?: string } }).data?.message;
notifications.error(t('admin.users.stats.failed'), message);
} finally {
saving.value = false;
}
}
</script>
179 changes: 97 additions & 82 deletions app/components/admin/Users.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,97 +38,110 @@
<div
v-for="u in foundUsers"
:key="u.id"
class="flex items-center justify-between p-3 bg-bg-tertiary/50 rounded border border-border"
class="bg-bg-tertiary/50 rounded border border-border"
>
<div>
<p
class="text-sm font-medium text-text-primary"
>
{{ u.username }}
</p>
<p class="text-xs font-mono text-text-muted">
ID: {{ u.id.substring(0, 8) }}...
</p>
<p
v-if="u.lastIp"
class="text-2xs font-mono text-text-muted/50 mt-0.5"
>
IP: {{ u.lastIp }}
</p>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<label
class="text-xs font-bold text-text-muted"
>{{ t('admin.users.ban') }}</label
>
<button
@click="toggleBan(u)"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none"
:class="u.isBanned ? 'bg-error' : 'bg-bg-primary border border-border'
"
>
<span
class="inline-block h-3 w-3 transform rounded-full bg-white transition-transform"
:class="u.isBanned ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</div>
<div v-if="user?.isAdmin && roles.length > 0" class="flex items-center gap-2">
<label
class="text-xs font-bold text-text-muted"
>{{ t('common.role') }}</label
<div class="flex items-center justify-between p-3">
<div>
<p
class="text-sm font-medium text-text-primary"
>
<select
:value="u.roleId || ''"
@change="assignRole(u, ($event.target as HTMLSelectElement).value)"
class="bg-bg-primary border border-border rounded px-2 py-1 text-sm font-medium text-text-primary focus:outline-none focus:border-accent-primary"
{{ u.username }}
</p>
<p class="text-xs font-mono text-text-muted">
ID: {{ u.id.substring(0, 8) }}...
</p>
<p
v-if="u.lastIp"
class="text-2xs font-mono text-text-muted/50 mt-0.5"
>
<option value="">{{ t('admin.users.noRole') }}</option>
<option v-for="role in roles" :key="role.id" :value="role.id">
{{ role.name }}
</option>
</select>
IP: {{ u.lastIp }}
</p>
</div>
<div v-if="user?.isAdmin" class="flex items-center gap-2">
<label
class="text-xs font-bold text-text-muted"
>{{ t('admin.users.mod') }}</label
>
<button
@click="toggleUserRole(u, 'isModerator')"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none"
:class="u.isModerator
? 'bg-success'
: 'bg-bg-primary border border-border'
"
>
<span
class="inline-block h-3 w-3 transform rounded-full bg-white transition-transform"
:class="u.isModerator ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</div>
<div v-if="user?.isAdmin" class="flex items-center gap-2">
<label
class="text-xs font-bold text-text-muted"
>{{ t('common.admin') }}</label
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<label
class="text-xs font-bold text-text-muted"
>{{ t('admin.users.ban') }}</label
>
<button
@click="toggleBan(u)"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none"
:class="u.isBanned ? 'bg-error' : 'bg-bg-primary border border-border'
"
>
<span
class="inline-block h-3 w-3 transform rounded-full bg-white transition-transform"
:class="u.isBanned ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</div>
<div v-if="user?.isAdmin && roles.length > 0" class="flex items-center gap-2">
<label
class="text-xs font-bold text-text-muted"
>{{ t('common.role') }}</label
>
<select
:value="u.roleId || ''"
@change="assignRole(u, ($event.target as HTMLSelectElement).value)"
class="bg-bg-primary border border-border rounded px-2 py-1 text-sm font-medium text-text-primary focus:outline-none focus:border-accent-primary"
>
<option value="">{{ t('admin.users.noRole') }}</option>
<option v-for="role in roles" :key="role.id" :value="role.id">
{{ role.name }}
</option>
</select>
</div>
<div v-if="user?.isAdmin" class="flex items-center gap-2">
<label
class="text-xs font-bold text-text-muted"
>{{ t('admin.users.mod') }}</label
>
<button
@click="toggleUserRole(u, 'isModerator')"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none"
:class="u.isModerator
? 'bg-success'
: 'bg-bg-primary border border-border'
"
>
<span
class="inline-block h-3 w-3 transform rounded-full bg-white transition-transform"
:class="u.isModerator ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</div>
<div v-if="user?.isAdmin" class="flex items-center gap-2">
<label
class="text-xs font-bold text-text-muted"
>{{ t('common.admin') }}</label
>
<button
@click="toggleUserRole(u, 'isAdmin')"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none"
:class="u.isAdmin
? 'bg-success'
: 'bg-bg-primary border border-border'
"
>
<span
class="inline-block h-3 w-3 transform rounded-full bg-white transition-transform"
:class="u.isAdmin ? 'translate-x-5' : 'translate-x-1'"
/>
</button>
</div>
<button
@click="toggleUserRole(u, 'isAdmin')"
class="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none"
:class="u.isAdmin
? 'bg-success'
: 'bg-bg-primary border border-border'
"
v-if="user?.isAdmin"
type="button"
class="btn btn-ghost !px-2 text-xs"
:aria-expanded="statsOpen === u.id"
@click="statsOpen = statsOpen === u.id ? null : u.id"
>
<span
class="inline-block h-3 w-3 transform rounded-full bg-white transition-transform"
:class="u.isAdmin ? 'translate-x-5' : 'translate-x-1'"
/>
<Icon name="ph:scales" />{{ t('admin.users.stats.open') }}
<Icon :name="statsOpen === u.id ? 'ph:caret-up' : 'ph:caret-down'" class="text-text-muted" />
</button>
</div>
</div>
<AdminUserStats v-if="statsOpen === u.id" :user-id="u.id" :username="u.username" />
</div>
</div>
<div
Expand Down Expand Up @@ -160,6 +173,8 @@ const isSearching = ref(false);
const hasSearched = ref(false);
const foundUsers = ref<any[]>([]);
const roles = ref<Role[]>([]);
// User whose upload/download panel is open
const statsOpen = ref<string | null>(null);

// Load roles on mount
onMounted(async () => {
Expand Down
18 changes: 17 additions & 1 deletion i18n/locales/de/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,23 @@
"confirmUnban": "Möchtest du die Sperre von {name} wirklich aufheben?",
"banFailed": "Benutzer konnte nicht gesperrt werden",
"unbanFailed": "Sperre konnte nicht aufgehoben werden",
"assignRoleFailed": "Rolle konnte nicht zugewiesen werden"
"assignRoleFailed": "Rolle konnte nicht zugewiesen werden",
"stats": {
"open": "Ratio",
"uploaded": "Hochgeladen",
"downloaded": "Heruntergeladen",
"operation": "Hinzufügen oder abziehen",
"add": "Hinzufügen",
"remove": "Abziehen",
"unit": "Einheit",
"reason": "Grund",
"reasonPlaceholder": "Im Verlauf sichtbar, z. B. Ausgleich für den Tracker-Ausfall",
"apply": "Anwenden",
"history": "Frühere Korrekturen",
"deletedAdmin": "Gelöschter Admin",
"applied": "Summen von {name} aktualisiert",
"failed": "Summen konnten nicht aktualisiert werden"
}
},
"footer": {
"title": "Footer-Links",
Expand Down
Loading