Add verified administrator controls and upload abuse limits
This commit is contained in:
393
apps/web/src/AdminPanel.vue
Normal file
393
apps/web/src/AdminPanel.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import {
|
||||
ShieldCheck,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
Pause,
|
||||
Play,
|
||||
Folder,
|
||||
Globe,
|
||||
HardDrive,
|
||||
X,
|
||||
} from '@lucide/vue';
|
||||
import { api, showError } from './client';
|
||||
const emit = defineEmits(['devices']);
|
||||
const overview = ref(null),
|
||||
files = ref([]),
|
||||
cursor = ref(null),
|
||||
events = ref([]),
|
||||
view = ref('files'),
|
||||
busy = ref(false),
|
||||
loading = ref(true),
|
||||
notice = ref(''),
|
||||
confirmation = ref(null),
|
||||
dialog = ref(null);
|
||||
const maxConcurrent = ref(4),
|
||||
capacity = ref(20),
|
||||
countries = ref(''),
|
||||
ips = ref('');
|
||||
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
|
||||
const size = (bytes) =>
|
||||
bytes >= 1073741824
|
||||
? `${(bytes / 1073741824).toFixed(1)} GiB`
|
||||
: bytes >= 1048576
|
||||
? `${(bytes / 1048576).toFixed(1)} MiB`
|
||||
: `${Math.max(0, Math.round(bytes / 1024))} KiB`;
|
||||
const date = (value) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value))
|
||||
: '확인 불가';
|
||||
function region(location) {
|
||||
if (!location) return '확인 불가';
|
||||
return (
|
||||
[
|
||||
location.countryCode ? countryNames.of(location.countryCode) : null,
|
||||
location.city || location.region,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '확인 불가'
|
||||
);
|
||||
}
|
||||
function form(settings) {
|
||||
maxConcurrent.value = settings.maxConcurrentUploads;
|
||||
capacity.value = settings.totalStorageLimitMb / 1024;
|
||||
countries.value = settings.blockedCountries.join(', ');
|
||||
ips.value = settings.blockedIps.join('\n');
|
||||
}
|
||||
async function refresh(resetForm = true) {
|
||||
const [summary, list, audit] = await Promise.all([
|
||||
api('/admin/summary'),
|
||||
api('/admin/files'),
|
||||
api('/admin/audit'),
|
||||
]);
|
||||
overview.value = summary;
|
||||
files.value = list.items;
|
||||
cursor.value = list.nextCursor;
|
||||
events.value = audit.events;
|
||||
if (resetForm) form(summary.settings);
|
||||
}
|
||||
async function act(fn) {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
notice.value = '';
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function reload() {
|
||||
await act(async () => {
|
||||
await refresh();
|
||||
notice.value = '최신 관리 정보를 불러왔어요.';
|
||||
});
|
||||
}
|
||||
async function pauseUploads() {
|
||||
await act(async () => {
|
||||
const settings = await api('/admin/settings', 'PATCH', {
|
||||
revision: overview.value.settings.revision,
|
||||
uploadsPaused: !overview.value.settings.uploadsPaused,
|
||||
});
|
||||
overview.value.settings = settings;
|
||||
notice.value = settings.uploadsPaused
|
||||
? '신규 업로드를 중지했어요. 기존 전송은 계속됩니다.'
|
||||
: '신규 업로드를 다시 허용했어요.';
|
||||
await refresh(false);
|
||||
});
|
||||
}
|
||||
const split = (value) =>
|
||||
value
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
function addBlockedIp(ip) {
|
||||
ips.value = [...new Set([...split(ips.value), ip])].join('\n');
|
||||
view.value = 'rules';
|
||||
notice.value = 'IP를 입력했어요. 설정 저장을 누르면 차단됩니다.';
|
||||
}
|
||||
async function save() {
|
||||
await act(async () => {
|
||||
const settings = await api('/admin/settings', 'PATCH', {
|
||||
revision: overview.value.settings.revision,
|
||||
maxConcurrentUploads: Number(maxConcurrent.value),
|
||||
totalStorageLimitMb: Math.round(Number(capacity.value) * 1024),
|
||||
blockedCountries: split(countries.value),
|
||||
blockedIps: split(ips.value),
|
||||
});
|
||||
overview.value.settings = settings;
|
||||
form(settings);
|
||||
notice.value = '관리 설정을 저장했어요.';
|
||||
await refresh(false);
|
||||
});
|
||||
}
|
||||
async function more() {
|
||||
await act(async () => {
|
||||
const result = await api('/admin/files?cursor=' + encodeURIComponent(cursor.value));
|
||||
files.value.push(...result.items);
|
||||
cursor.value = result.nextCursor;
|
||||
});
|
||||
}
|
||||
let trigger;
|
||||
async function confirm(file) {
|
||||
trigger = document.activeElement;
|
||||
confirmation.value = file;
|
||||
await nextTick();
|
||||
dialog.value?.querySelector('button')?.focus();
|
||||
}
|
||||
function closeConfirmation() {
|
||||
confirmation.value = null;
|
||||
trigger?.focus();
|
||||
}
|
||||
function dialogKey(event) {
|
||||
if (event.key === 'Escape' && !busy.value) {
|
||||
event.preventDefault();
|
||||
closeConfirmation();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = [...dialog.value.querySelectorAll('button:not(:disabled)')],
|
||||
first = controls[0],
|
||||
last = controls.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
const file = confirmation.value;
|
||||
await act(async () => {
|
||||
await api('/admin/files/' + file.id, 'DELETE');
|
||||
closeConfirmation();
|
||||
notice.value = '원본과 연결된 항목을 삭제했어요.';
|
||||
await refresh(false);
|
||||
});
|
||||
}
|
||||
const actionName = (action) =>
|
||||
({ 'settings-change': '관리 설정 변경', 'file-delete': '원본 파일 삭제' })[action] || action;
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<header class="admin-heading">
|
||||
<div>
|
||||
<p class="admin-eyebrow"><ShieldCheck :size="17" />관리자</p>
|
||||
<h1>서비스 관리</h1>
|
||||
</div>
|
||||
<div class="admin-toolbar">
|
||||
<button class="button small" :disabled="busy || loading" @click="reload">
|
||||
<RefreshCw :size="16" />새로고침</button
|
||||
><button class="text-button" @click="emit('devices')">
|
||||
<ArrowLeft :size="16" />메인으로
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="loading" role="status" class="admin-muted">관리 정보를 불러오고 있어요.</p>
|
||||
<template v-if="overview">
|
||||
<div class="admin-stats">
|
||||
<article>
|
||||
<Folder :size="21" /><span>저장 파일</span><strong>{{ overview.storedFiles }}개</strong
|
||||
><small
|
||||
>{{ size(overview.storedBytes) }} /
|
||||
{{ size(overview.settings.totalStorageLimitMb * 1048576) }}</small
|
||||
>
|
||||
</article>
|
||||
<article>
|
||||
<HardDrive :size="21" /><span>디스크 여유</span
|
||||
><strong>{{ size(overview.diskFreeBytes) }}</strong
|
||||
><small>업로드 예약 {{ size(overview.reservedBytes) }}</small>
|
||||
</article>
|
||||
<article>
|
||||
<RefreshCw :size="21" /><span>업로드 처리</span
|
||||
><strong
|
||||
>{{ overview.activeRequests }} / {{ overview.settings.maxConcurrentUploads }}</strong
|
||||
><small>파일 저장 작업 {{ overview.activeFileWrites }}개</small>
|
||||
</article>
|
||||
<article>
|
||||
<ShieldCheck :size="21" /><span>활성 계정</span
|
||||
><strong>{{ overview.activeAccounts }}명</strong
|
||||
><small
|
||||
>차단 국가 {{ overview.settings.blockedCountries.length }} · IP 규칙
|
||||
{{ overview.settings.blockedIps.length }}</small
|
||||
>
|
||||
</article>
|
||||
</div>
|
||||
<div class="admin-upload-control">
|
||||
<div>
|
||||
<strong>{{
|
||||
overview.settings.uploadsPaused ? '신규 업로드 중지됨' : '신규 업로드 허용 중'
|
||||
}}</strong>
|
||||
<p>이미 시작한 전송과 기존 파일 다운로드는 유지됩니다.</p>
|
||||
</div>
|
||||
<button class="button" :disabled="busy" @click="pauseUploads">
|
||||
<Play v-if="overview.settings.uploadsPaused" :size="16" /><Pause v-else :size="16" />{{
|
||||
overview.settings.uploadsPaused ? '업로드 재개' : '신규 업로드 중지'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<section v-if="overview.requests.length" class="admin-activity">
|
||||
<strong>진행 중인 업로드 요청</strong>
|
||||
<div v-for="request in overview.requests" :key="request.ip + ':' + request.startedAt">
|
||||
<code>{{ request.ip || 'IP 확인 불가' }}</code
|
||||
><span
|
||||
>{{ request.countryCode ? countryNames.of(request.countryCode) : '지역 확인 불가' }} ·
|
||||
{{ date(request.startedAt) }}</span
|
||||
><button
|
||||
class="text-button"
|
||||
:disabled="busy || !request.ip"
|
||||
@click="addBlockedIp(request.ip)"
|
||||
>
|
||||
차단 규칙에 추가
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="notice" class="admin-notice" role="status">{{ notice }}</p>
|
||||
<nav class="admin-tabs" aria-label="관리 항목">
|
||||
<button :class="{ selected: view === 'files' }" @click="view = 'files'">저장 파일</button
|
||||
><button :class="{ selected: view === 'rules' }" @click="view = 'rules'">
|
||||
접속 · 용량 설정</button
|
||||
><button :class="{ selected: view === 'audit' }" @click="view = 'audit'">관리 기록</button>
|
||||
</nav>
|
||||
<section v-if="view === 'files'" class="admin-files">
|
||||
<p v-if="!files.length" class="admin-muted">저장된 파일이 없습니다.</p>
|
||||
<article v-for="file in files" :key="file.id" class="admin-file">
|
||||
<div class="admin-file-heading">
|
||||
<strong>{{ file.name }}</strong
|
||||
><span>{{ size(file.size) }}</span>
|
||||
</div>
|
||||
<div class="admin-file-info">
|
||||
<span>{{
|
||||
file.uploader?.name ||
|
||||
(file.uploader?.kind === 'user' ? '로그인 계정' : '비로그인 기기')
|
||||
}}</span
|
||||
><code>{{ file.path.split('/')[0] }}</code>
|
||||
</div>
|
||||
<div class="admin-file-info">
|
||||
<Globe :size="15" /><span>최근 접속 지역 · {{ region(file.lastLocation) }}</span
|
||||
><code>{{ file.lastLocation?.ip || 'IP 확인 불가' }}</code>
|
||||
</div>
|
||||
<footer>
|
||||
<small>보관 만료 · {{ date(file.expiresAt) }}까지</small
|
||||
><button class="text-button admin-delete" :disabled="busy" @click="confirm(file)">
|
||||
<Trash2 :size="16" />원본 삭제
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
<button v-if="cursor" class="button small" :disabled="busy" @click="more">더 보기</button>
|
||||
</section>
|
||||
<form v-else-if="view === 'rules'" class="admin-settings" @submit.prevent="save">
|
||||
<div class="admin-settings-grid">
|
||||
<label
|
||||
>동시에 처리할 업로드 수<input
|
||||
v-model="maxConcurrent"
|
||||
type="number"
|
||||
min="1"
|
||||
max="16"
|
||||
required
|
||||
:disabled="busy" /></label
|
||||
><label
|
||||
>전체 파일 저장 한도 (GiB)<input
|
||||
v-model="capacity"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1024"
|
||||
step="any"
|
||||
required
|
||||
:disabled="busy"
|
||||
/></label>
|
||||
</div>
|
||||
<p class="admin-muted">
|
||||
기기·개인별 한도와 디스크 최소 여유 공간도 함께 적용합니다. 한도를 줄여도 기존 파일은
|
||||
삭제하지 않습니다.
|
||||
</p>
|
||||
<label
|
||||
>차단할 국가 코드<input
|
||||
v-model="countries"
|
||||
placeholder="예: CN, RU"
|
||||
:disabled="busy"
|
||||
/><small>CN 중국 · RU 러시아 · US 미국 · JP 일본 · KR 대한민국</small></label
|
||||
>
|
||||
<label
|
||||
>차단할 IP 또는 IP 대역<textarea
|
||||
v-model="ips"
|
||||
rows="4"
|
||||
placeholder="203.0.113.10 198.51.100.0/24"
|
||||
:disabled="busy"
|
||||
></textarea
|
||||
><small>쉼표나 줄바꿈으로 구분합니다. IPv4·IPv6·CIDR을 지원합니다.</small></label
|
||||
>
|
||||
<p class="admin-muted">
|
||||
기본 접속 정보 기준으로 차단합니다. 지역 정보가 없는 접속은 국가 규칙으로 차단되지
|
||||
않습니다. 현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.
|
||||
</p>
|
||||
<button class="button primary" :disabled="busy">설정 저장</button>
|
||||
</form>
|
||||
<section v-else class="admin-audit">
|
||||
<p v-if="!events.length" class="admin-muted">관리 기록이 없습니다.</p>
|
||||
<article v-for="event in events" :key="event.id">
|
||||
<strong>{{ actionName(event.action) }}</strong
|
||||
><span>{{ date(event.createdAt) }}</span
|
||||
><small v-if="event.action === 'file-delete'">{{ event.details.name }}</small
|
||||
><small v-else
|
||||
>업로드 {{ event.details.uploadsPaused ? '중지' : '허용' }} · 동시
|
||||
{{ event.details.maxConcurrentUploads }}개 · 국가
|
||||
{{ event.details.blockedCountries?.join(', ') || '없음' }} · IP
|
||||
{{ event.details.blockedIps?.length || 0 }}개</small
|
||||
>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
<div v-if="confirmation" class="modal-backdrop" @click.self="!busy && closeConfirmation()">
|
||||
<section
|
||||
ref="dialog"
|
||||
class="modal admin-confirm"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-confirm-title"
|
||||
aria-describedby="admin-confirm-description"
|
||||
@keydown="dialogKey"
|
||||
>
|
||||
<header class="modal-title">
|
||||
<h2 id="admin-confirm-title">원본 파일을 삭제할까요?</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
:disabled="busy"
|
||||
aria-label="삭제 확인 닫기"
|
||||
@click="closeConfirmation"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<strong class="admin-confirm-file">{{ confirmation.name }}</strong>
|
||||
<p id="admin-confirm-description">
|
||||
원본과 모든 사용자의 연결된 항목이 삭제됩니다. 복원할 수 없습니다.
|
||||
</p>
|
||||
<div class="admin-confirm-actions">
|
||||
<button class="button" :disabled="busy" @click="closeConfirmation">취소</button
|
||||
><button class="button primary" :disabled="busy" @click="remove">원본 삭제 확인</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -40,6 +40,7 @@ import QRCode from 'qrcode';
|
||||
import { version as appVersion } from '../package.json';
|
||||
import { DEVICE_NAME_MAX_LENGTH } from '@hop/shared';
|
||||
import AccountPanel from './AccountPanel.vue';
|
||||
import AdminPanel from './AdminPanel.vue';
|
||||
import SendComposer from './SendComposer.vue';
|
||||
import InstallButton from './InstallButton.vue';
|
||||
import LogoMark from './LogoMark.vue';
|
||||
@@ -550,6 +551,12 @@ watch(
|
||||
},
|
||||
);
|
||||
let timer;
|
||||
watch(
|
||||
() => account.value?.administrator,
|
||||
(allowed) => {
|
||||
if (!allowed && tab.value === 'admin') tab.value = 'devices';
|
||||
},
|
||||
);
|
||||
onMounted(async () => {
|
||||
applyTheme();
|
||||
media.addEventListener('change', applyTheme);
|
||||
@@ -816,6 +823,16 @@ async function spaceAction(space, action, memberId) {
|
||||
<button role="menuitem" @click="openAbout">
|
||||
<Info :size="18" /><span>서비스 정보</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="account?.administrator"
|
||||
role="menuitem"
|
||||
@click="
|
||||
tab = 'admin';
|
||||
closeHeaderMore();
|
||||
"
|
||||
>
|
||||
<ShieldCheck :size="18" /><span>관리자</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1166,6 +1183,10 @@ async function spaceAction(space, action, memberId) {
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<AdminPanel
|
||||
v-else-if="tab === 'admin' && account?.administrator"
|
||||
@devices="tab = 'devices'"
|
||||
/>
|
||||
<template v-else
|
||||
><AccountPanel initial-view="inbox" @devices="tab = 'devices'" @send-item="prepareVault"
|
||||
/></template>
|
||||
|
||||
@@ -166,8 +166,13 @@ function connect() {
|
||||
account.value = null;
|
||||
window.dispatchEvent(new Event('hop-account-revoked'));
|
||||
});
|
||||
socket.on('service:blocked', () => showError(Error('이 접속은 서비스 이용이 제한되었습니다.')));
|
||||
socket.on('connect_error', (err) => {
|
||||
connected.value = false;
|
||||
if (err.message === 'ACCESS_BLOCKED') {
|
||||
showError(Error('이 접속은 서비스 이용이 제한되었습니다.'));
|
||||
return;
|
||||
}
|
||||
if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return;
|
||||
reauthorizing = (async () => {
|
||||
const session = await api('/guest/session', 'POST', {});
|
||||
|
||||
@@ -3908,3 +3908,303 @@ a.button {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-top: 32px;
|
||||
}
|
||||
.admin-heading,
|
||||
.admin-toolbar,
|
||||
.admin-upload-control,
|
||||
.admin-file-heading,
|
||||
.admin-file footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.admin-heading {
|
||||
margin-bottom: 28px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-heading h1 {
|
||||
margin: 6px 0 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.admin-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-toolbar {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.admin-stats article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-stats svg,
|
||||
.admin-stats span,
|
||||
.admin-stats small,
|
||||
.admin-muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.admin-stats strong {
|
||||
font-size: 25px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.admin-stats small {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.admin-upload-control {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.admin-upload-control p {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin: 7px 0 0;
|
||||
}
|
||||
.admin-upload-control button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-notice {
|
||||
font-size: 14px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin: 24px 0;
|
||||
}
|
||||
.admin-tabs button {
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-tabs button.selected {
|
||||
color: var(--ink);
|
||||
border-bottom-color: var(--ink);
|
||||
}
|
||||
.admin-files {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.admin-file {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
padding: 22px;
|
||||
}
|
||||
.admin-file-heading {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.admin-file-heading strong {
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-file-heading > span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-file-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.admin-file-info code {
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 100%;
|
||||
}
|
||||
.admin-file footer {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.admin-file footer small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.admin-delete {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-settings {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
max-width: 840px;
|
||||
}
|
||||
.admin-settings label {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.admin-settings input,
|
||||
.admin-settings textarea {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-settings small {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.admin-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.admin-muted {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
.admin-settings > .button {
|
||||
justify-self: start;
|
||||
}
|
||||
.admin-audit {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-audit article {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.admin-audit article span,
|
||||
.admin-audit small {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-audit small {
|
||||
grid-column: 1/-1;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.admin-confirm .modal-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.admin-confirm-file {
|
||||
display: block;
|
||||
margin: 22px 0 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.admin-confirm p {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.admin-confirm-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.admin-panel {
|
||||
padding-top: 16px;
|
||||
}
|
||||
.admin-heading {
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.admin-heading h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.admin-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-stats article {
|
||||
padding: 15px;
|
||||
}
|
||||
.admin-stats strong {
|
||||
font-size: 22px;
|
||||
}
|
||||
.admin-upload-control {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
.admin-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.admin-tabs {
|
||||
gap: 18px;
|
||||
}
|
||||
.admin-tabs button {
|
||||
font-size: 14px;
|
||||
}
|
||||
.admin-file {
|
||||
padding: 17px;
|
||||
}
|
||||
.admin-file footer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.admin-settings > .button {
|
||||
justify-self: stretch;
|
||||
}
|
||||
}
|
||||
.admin-activity {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.admin-activity > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-activity code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.admin-activity span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user