v2 - lift all w/ icons, reactive layout, sort & filter

This commit is contained in:
2026-02-27 17:01:38 +01:00
parent 0f774e8f2e
commit fd842b218b
5 changed files with 1814 additions and 193 deletions

View File

@@ -1,65 +1,224 @@
<template> <template>
<div class="admin-stats"> <div class="admin-stats">
<h2 class="admin-stats__title">Statistics</h2> <div class="admin-stats__header">
<div class="admin-stats__grid"> <h2 class="admin-stats__title">Statistics</h2>
<div class="stat-card"> <div class="admin-stats__controls">
<span class="stat-card__value">{{ stats.totalUsers }}</span> <select
<span class="stat-card__label">Total Users</span> v-model="timeRange"
class="time-range-select"
@change="fetchStats"
>
<option value="today">Today</option>
<option value="week">This Week</option>
<option value="month">This Month</option>
<option value="all">All Time</option>
</select>
<button class="refresh-btn" @click="fetchStats" :disabled="loading">
<IconActivity :class="{ spin: loading }" />
</button>
</div> </div>
<div class="stat-card"> </div>
<span class="stat-card__value">{{ stats.activeTorrents }}</span>
<span class="stat-card__label">Active Torrents</span> <div v-if="loading" class="admin-stats__loading">Loading statistics...</div>
</div>
<div class="stat-card"> <div v-else class="admin-stats__grid">
<span class="stat-card__value">{{ stats.totalRequests }}</span> <div
<span class="stat-card__label">Total Requests</span> class="stat-card"
</div> v-for="stat in statCards"
<div class="stat-card"> :key="stat.key"
<span class="stat-card__value">{{ stats.pendingRequests }}</span> @click="handleCardClick(stat.key)"
<span class="stat-card__label">Pending Requests</span> :class="{ 'stat-card--clickable': stat.clickable }"
</div> >
<div class="stat-card"> <div class="stat-card__header">
<span class="stat-card__value">{{ stats.approvedRequests }}</span> <component :is="stat.icon" class="stat-card__icon" />
<span class="stat-card__label">Approved</span> <span
</div> v-if="stat.trend !== 0"
<div class="stat-card"> :class="[
<span class="stat-card__value">{{ stats.totalStorage }}</span> 'stat-card__trend',
<span class="stat-card__label">Storage Used</span> stat.trend > 0 ? 'stat-card__trend--up' : 'stat-card__trend--down'
]"
>
{{ stat.trend > 0 ? "↑" : "↓" }} {{ Math.abs(stat.trend) }}%
</span>
</div>
<span class="stat-card__value">{{ stat.value }}</span>
<span class="stat-card__label">{{ stat.label }}</span>
<div v-if="stat.sparkline" class="stat-card__sparkline">
<div
v-for="(point, index) in stat.sparkline"
:key="index"
class="sparkline-bar"
:style="{
height: `${(point / Math.max(...stat.sparkline)) * 100}%`
}"
></div>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, computed, onMounted, onUnmounted } from "vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconPlay from "@/icons/IconPlay.vue";
import IconRequest from "@/icons/IconRequest.vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Stats { interface Stat {
totalUsers: number; key: string;
activeTorrents: number; value: string | number;
totalRequests: number; label: string;
pendingRequests: number; trend: number;
approvedRequests: number; icon: any;
totalStorage: string; clickable: boolean;
sparkline?: number[];
} }
const stats = ref<Stats>({ const stats = ref({
totalUsers: 0, totalUsers: 0,
activeTorrents: 0, activeTorrents: 0,
totalRequests: 0, totalRequests: 0,
pendingRequests: 0, pendingRequests: 0,
approvedRequests: 0, approvedRequests: 0,
totalStorage: "0 GB" totalStorage: "0 GB",
usersTrend: 0,
torrentsTrend: 0,
requestsTrend: 0,
pendingTrend: 0,
approvedTrend: 0,
storageTrend: 0,
usersSparkline: [] as number[],
torrentsSparkline: [] as number[],
requestsSparkline: [] as number[]
}); });
const loading = ref(false);
const timeRange = ref("week");
let refreshInterval: number | null = null;
const statCards = computed<Stat[]>(() => [
{
key: "totalUsers",
value: stats.value.totalUsers,
label: "Total Users",
trend: stats.value.usersTrend,
icon: IconProfile,
clickable: true,
sparkline: stats.value.usersSparkline
},
{
key: "activeTorrents",
value: stats.value.activeTorrents,
label: "Active Torrents",
trend: stats.value.torrentsTrend,
icon: IconPlay,
clickable: true,
sparkline: stats.value.torrentsSparkline
},
{
key: "totalRequests",
value: stats.value.totalRequests,
label: "Total Requests",
trend: stats.value.requestsTrend,
icon: IconRequest,
clickable: true,
sparkline: stats.value.requestsSparkline
},
{
key: "pendingRequests",
value: stats.value.pendingRequests,
label: "Pending Requests",
trend: stats.value.pendingTrend,
icon: IconRequest,
clickable: true
},
{
key: "approvedRequests",
value: stats.value.approvedRequests,
label: "Approved",
trend: stats.value.approvedTrend,
icon: IconRequest,
clickable: true
},
{
key: "totalStorage",
value: stats.value.totalStorage,
label: "Storage Used",
trend: stats.value.storageTrend,
icon: IconActivity,
clickable: false
}
]);
const generateSparkline = (
baseValue: number,
points: number = 7
): number[] => {
return Array.from({ length: points }, (_, i) => {
const variance = Math.random() * 0.3 - 0.15;
return Math.max(0, Math.floor(baseValue * (1 + variance)));
});
};
async function fetchStats() {
loading.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const baseUsers = 142;
const baseTorrents = 23;
const baseRequests = 856;
stats.value = {
totalUsers: baseUsers,
activeTorrents: baseTorrents,
totalRequests: baseRequests,
pendingRequests: 12,
approvedRequests: 712,
totalStorage: "2.4 TB",
usersTrend: 8.5,
torrentsTrend: -3.2,
requestsTrend: 12.7,
pendingTrend: -15.4,
approvedTrend: 18.2,
storageTrend: 5.8,
usersSparkline: generateSparkline(baseUsers / 7),
torrentsSparkline: generateSparkline(baseTorrents),
requestsSparkline: generateSparkline(baseRequests / 30)
};
} finally {
loading.value = false;
}
}
function handleCardClick(key: string) {
console.log(`Stat card clicked: ${key}`);
}
function startAutoRefresh() {
refreshInterval = window.setInterval(() => {
if (!loading.value) {
fetchStats();
}
}, 60000);
}
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
onMounted(() => { onMounted(() => {
stats.value = { fetchStats();
totalUsers: 142, startAutoRefresh();
activeTorrents: 23, });
totalRequests: 856,
pendingRequests: 12, onUnmounted(() => {
approvedRequests: 712, stopAutoRefresh();
totalStorage: "2.4 TB"
};
}); });
</script> </script>
@@ -72,14 +231,64 @@
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
overflow: hidden;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.75rem;
@include mobile-only {
margin-bottom: 0.75rem;
width: 100%;
}
}
&__title { &__title {
margin: 0 0 1rem 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 400; font-weight: 400;
color: $text-color; color: $text-color;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.8px; letter-spacing: 0.8px;
@include mobile-only {
font-size: 1rem;
}
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
@include mobile-only {
width: 100%;
justify-content: space-between;
}
}
&__loading {
padding: 2rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
} }
&__grid { &__grid {
@@ -89,11 +298,78 @@
@include mobile-only { @include mobile-only {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
width: 100%;
}
}
}
.time-range-select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
cursor: pointer;
@include mobile-only {
flex: 1;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
} }
} }
} }
.stat-card { .stat-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -101,19 +377,150 @@
background-color: var(--background-ui); background-color: var(--background-ui);
border-radius: 0.5rem; border-radius: 0.5rem;
text-align: center; text-align: center;
transition: all 0.2s;
overflow: hidden;
min-width: 0;
@include mobile-only {
padding: 0.65rem 0.4rem;
width: 100%;
box-sizing: border-box;
}
&--clickable {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background-color: var(--background-40);
}
@include mobile-only {
&:hover {
transform: none;
}
&:active {
transform: scale(0.98);
}
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 0.5rem;
@include mobile-only {
margin-bottom: 0.35rem;
}
}
&__icon {
width: 24px;
height: 24px;
fill: var(--highlight-color);
opacity: 0.8;
@include mobile-only {
width: 18px;
height: 18px;
}
}
&__trend {
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
@include mobile-only {
font-size: 0.65rem;
padding: 0.15rem 0.3rem;
}
&--up {
color: $white;
background-color: var(--color-success-highlight);
}
&--down {
color: $white;
background-color: var(--color-error-highlight);
}
}
&__value { &__value {
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 600; font-weight: 600;
color: var(--highlight-color); color: var(--highlight-color);
margin-bottom: 0.25rem;
@include mobile-only {
font-size: 1.4rem;
margin-bottom: 0.15rem;
}
} }
&__label { &__label {
font-size: 0.85rem; font-size: 0.85rem;
color: $text-color-70; color: $text-color-70;
margin-top: 0.25rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 0.5rem;
word-break: break-word;
max-width: 100%;
@include mobile-only {
font-size: 0.68rem;
margin-bottom: 0.35rem;
letter-spacing: 0.2px;
line-height: 1.2;
}
}
&__sparkline {
display: flex;
align-items: flex-end;
justify-content: space-between;
width: 100%;
height: 30px;
margin-top: 0.5rem;
gap: 2px;
@include mobile-only {
height: 20px;
margin-top: 0.35rem;
gap: 1px;
}
}
}
.sparkline-bar {
flex: 1;
background: linear-gradient(
180deg,
var(--highlight-color) 0%,
var(--color-green-70) 100%
);
border-radius: 2px 2px 0 0;
min-height: 3px;
transition: all 0.3s ease;
.stat-card:hover & {
opacity: 0.9;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
} }
} }
</style> </style>

View File

@@ -1,41 +1,137 @@
<template> <template>
<div class="activity-feed"> <div class="activity-feed">
<h2 class="activity-feed__title">Recent Activity</h2> <div class="activity-feed__header">
<div class="activity-feed__list"> <h2 class="activity-feed__title">Recent Activity</h2>
<div class="activity-feed__controls">
<select v-model="typeFilter" class="activity-feed__filter">
<option value="">All Types</option>
<option value="request">Requests</option>
<option value="download">Downloads</option>
<option value="user">Users</option>
<option value="movie">Library</option>
</select>
<select
v-model="timeFilter"
class="activity-feed__filter"
@change="fetchActivities"
>
<option value="1h">Last Hour</option>
<option value="24h">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
</select>
<button
class="refresh-btn"
@click="fetchActivities"
:disabled="loading"
>
<IconActivity :class="{ spin: loading }" />
</button>
</div>
</div>
<div v-if="loading" class="activity-feed__loading">
Loading activities...
</div>
<div v-else-if="error" class="activity-feed__error">{{ error }}</div>
<div v-else class="activity-feed__list">
<div <div
class="activity-item" class="activity-item"
v-for="activity in activities" v-for="activity in filteredActivities"
:key="activity.id" :key="activity.id"
@click="handleActivityClick(activity)"
> >
<div class="activity-item__icon"> <div
:class="[
'activity-item__icon',
`activity-item__icon--${activity.type}`
]"
>
<component :is="getIcon(activity.type)" /> <component :is="getIcon(activity.type)" />
</div> </div>
<div class="activity-item__content"> <div class="activity-item__content">
<span class="activity-item__message">{{ activity.message }}</span> <div class="activity-item__header">
<span class="activity-item__time">{{ <span class="activity-item__message">{{ activity.message }}</span>
formatTime(activity.timestamp) <span v-if="activity.metadata" class="activity-item__badge">
}}</span> {{ activity.metadata }}
</span>
</div>
<div class="activity-item__footer">
<span class="activity-item__user" v-if="activity.user">{{
activity.user
}}</span>
<span class="activity-item__time">{{
formatTime(activity.timestamp)
}}</span>
</div>
</div> </div>
</div> </div>
<div v-if="filteredActivities.length === 0" class="activity-feed__empty">
No activities found
</div>
</div>
<div
v-if="!loading && filteredActivities.length > 0"
class="activity-feed__footer"
>
<span class="activity-count"
>{{ filteredActivities.length }} of
{{ activities.length }} activities</span
>
<button
v-if="hasMore"
class="load-more-btn"
@click="loadMore"
:disabled="loadingMore"
>
{{ loadingMore ? "Loading..." : "Load More" }}
</button>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, computed, onMounted, onUnmounted } from "vue";
import IconMovie from "@/icons/IconMovie.vue"; import IconMovie from "@/icons/IconMovie.vue";
import IconPlay from "@/icons/IconPlay.vue"; import IconPlay from "@/icons/IconPlay.vue";
import IconRequest from "@/icons/IconRequest.vue"; import IconRequest from "@/icons/IconRequest.vue";
import IconProfile from "@/icons/IconProfile.vue"; import IconProfile from "@/icons/IconProfile.vue";
import IconActivity from "@/icons/IconActivity.vue";
type ActivityType = "request" | "download" | "user" | "movie";
interface Activity { interface Activity {
id: number; id: number;
type: "request" | "download" | "user" | "movie"; type: ActivityType;
message: string; message: string;
timestamp: Date; timestamp: Date;
user?: string;
metadata?: string;
details?: any;
} }
const activities = ref<Activity[]>([]); const activities = ref<Activity[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const error = ref("");
const typeFilter = ref<ActivityType | "">("");
const timeFilter = ref("24h");
const hasMore = ref(true);
const page = ref(1);
let refreshInterval: number | null = null;
const filteredActivities = computed(() => {
let result = [...activities.value];
if (typeFilter.value) {
result = result.filter(a => a.type === typeFilter.value);
}
return result;
});
const getIcon = (type: string) => { const getIcon = (type: string) => {
const icons: Record<string, any> = { const icons: Record<string, any> = {
@@ -60,57 +156,137 @@
return `${days}d ago`; return `${days}d ago`;
}; };
onMounted(() => { const generateMockActivities = (
activities.value = [ count: number,
{ startId: number
id: 1, ): Activity[] => {
type: "request", const types: ActivityType[] = ["request", "download", "user", "movie"];
message: "New request: Interstellar (2014)", const messages = {
timestamp: new Date(Date.now() - 5 * 60000) request: [
}, "New request: Interstellar (2014)",
{ "Request approved: Oppenheimer",
id: 2, "Request denied: The Matrix",
type: "download", "Request fulfilled: Dune Part Two"
message: "Torrent completed: Dune Part Two", ],
timestamp: new Date(Date.now() - 23 * 60000) download: [
}, "Torrent completed: Dune Part Two",
{ "Torrent started: Poor Things",
id: 3, "Download failed: Network Error",
type: "user", "Torrent paused by admin"
message: "New user registered: john_doe", ],
timestamp: new Date(Date.now() - 45 * 60000) user: [
}, "New user registered: john_doe",
{ "User upgraded to VIP: sarah_s",
id: 4, "User login from new device: alex_p",
type: "movie", "Password changed: mike_r"
message: "Movie added to library: The Batman", ],
timestamp: new Date(Date.now() - 2 * 3600000) movie: [
}, "Movie added to library: The Batman",
{ "Library scan completed: 12 new items",
id: 5, "Show updated: Breaking Bad S5",
type: "request", "Media deleted: Old Movie (1999)"
message: "Request approved: Oppenheimer", ]
timestamp: new Date(Date.now() - 3 * 3600000) };
},
{ const users = [
id: 6, "admin",
type: "download", "kevin_m",
message: "Torrent started: Poor Things", "sarah_s",
timestamp: new Date(Date.now() - 5 * 3600000) "john_doe",
}, "alex_p",
{ "mike_r"
id: 7,
type: "user",
message: "User upgraded to VIP: sarah_s",
timestamp: new Date(Date.now() - 8 * 3600000)
},
{
id: 8,
type: "movie",
message: "Library scan completed: 12 new items",
timestamp: new Date(Date.now() - 12 * 3600000)
}
]; ];
return Array.from({ length: count }, (_, i) => {
const type = types[Math.floor(Math.random() * types.length)];
const typeMessages = messages[type];
const message =
typeMessages[Math.floor(Math.random() * typeMessages.length)];
const timeOffset = Math.random() * 24 * 60 * 60 * 1000; // Random time in last 24h
return {
id: startId + i,
type,
message,
timestamp: new Date(Date.now() - timeOffset),
user: users[Math.floor(Math.random() * users.length)],
metadata: type === "request" ? "Pending" : undefined
};
});
};
async function fetchActivities() {
loading.value = true;
error.value = "";
page.value = 1;
try {
await new Promise(resolve => setTimeout(resolve, 500));
activities.value = generateMockActivities(15, 1).sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
hasMore.value = true;
} catch (e) {
error.value = "Failed to load activities";
} finally {
loading.value = false;
}
}
async function loadMore() {
if (!hasMore.value || loadingMore.value) return;
loadingMore.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const newActivities = generateMockActivities(
10,
activities.value.length + 1
);
activities.value = [...activities.value, ...newActivities].sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
page.value += 1;
if (page.value >= 5) {
hasMore.value = false;
}
} finally {
loadingMore.value = false;
}
}
function handleActivityClick(activity: Activity) {
console.log("Activity clicked:", activity);
}
function startAutoRefresh() {
refreshInterval = window.setInterval(() => {
if (!loading.value && !loadingMore.value) {
fetchActivities();
}
}, 30000);
}
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
onMounted(() => {
fetchActivities();
startAutoRefresh();
});
onUnmounted(() => {
stopAutoRefresh();
}); });
</script> </script>
@@ -123,22 +299,154 @@
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
overflow: hidden;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 1rem;
@include mobile-only {
gap: 0.75rem;
margin-bottom: 0.75rem;
}
}
&__title { &__title {
margin: 0 0 1rem 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 400; font-weight: 400;
color: $text-color; color: $text-color;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.8px; letter-spacing: 0.8px;
@include mobile-only {
font-size: 1rem;
width: 100%;
}
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
@include mobile-only {
width: 100%;
gap: 0.4rem;
}
}
&__filter {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
cursor: pointer;
@include mobile-only {
flex: 1;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
min-width: 0;
max-width: calc(50% - 0.2rem - 20px);
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
&__loading,
&__error {
padding: 2rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__error {
color: var(--color-error-highlight);
} }
&__list { &__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: 400px; max-height: 500px;
overflow-y: auto; overflow-y: auto;
padding-right: 0.25rem;
@include mobile-only {
max-height: 400px;
gap: 0.4rem;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--background-40);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-color-50);
border-radius: 3px;
&:hover {
background: var(--text-color-70);
}
}
}
&__empty {
padding: 2rem;
text-align: center;
color: $text-color-50;
font-style: italic;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__footer {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--background-40);
display: flex;
justify-content: space-between;
align-items: center;
@include mobile-only {
margin-top: 0.75rem;
padding-top: 0.5rem;
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
}
} }
} }
@@ -150,11 +458,30 @@
background-color: var(--background-ui); background-color: var(--background-ui);
border-radius: 0.5rem; border-radius: 0.5rem;
transition: background-color 0.2s; transition: background-color 0.2s;
cursor: pointer;
min-width: 0;
@include mobile-only {
gap: 0.65rem;
padding: 0.65rem;
width: 100%;
box-sizing: border-box;
}
&:hover { &:hover {
background-color: var(--background-40); background-color: var(--background-40);
} }
@include mobile-only {
&:hover {
background-color: var(--background-ui);
}
&:active {
background-color: var(--background-40);
}
}
&__icon { &__icon {
flex-shrink: 0; flex-shrink: 0;
width: 32px; width: 32px;
@@ -162,13 +489,38 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: var(--highlight-color);
border-radius: 50%; border-radius: 50%;
@include mobile-only {
width: 28px;
height: 28px;
}
&--request {
background-color: #3b82f6;
}
&--download {
background-color: var(--highlight-color);
}
&--user {
background-color: #8b5cf6;
}
&--movie {
background-color: #f59e0b;
}
svg { svg {
width: 16px; width: 16px;
height: 16px; height: 16px;
fill: $white; fill: $white;
@include mobile-only {
width: 14px;
height: 14px;
}
} }
} }
@@ -176,18 +528,181 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.35rem;
min-width: 0;
@include mobile-only {
gap: 0.25rem;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
} }
&__message { &__message {
font-size: 0.9rem; font-size: 0.9rem;
color: $text-color; color: $text-color;
line-height: 1.3; line-height: 1.3;
flex: 1;
word-break: break-word;
overflow-wrap: break-word;
@include mobile-only {
font-size: 0.8rem;
line-height: 1.25;
}
}
&__badge {
flex-shrink: 0;
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
background-color: var(--color-warning);
color: $black;
font-weight: 500;
text-transform: uppercase;
@include mobile-only {
font-size: 0.6rem;
padding: 0.1rem 0.3rem;
}
}
&__footer {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.75rem;
@include mobile-only {
font-size: 0.7rem;
gap: 0.35rem;
}
}
&__user {
color: $text-color-70;
font-weight: 500;
@include mobile-only {
font-size: 0.7rem;
}
&::before {
content: "@";
opacity: 0.7;
}
} }
&__time { &__time {
font-size: 0.75rem;
color: $text-color-50; color: $text-color-50;
@include mobile-only {
font-size: 0.7rem;
}
&::before {
content: "•";
margin-right: 0.5rem;
@include mobile-only {
margin-right: 0.3rem;
}
}
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
}
}
.load-more-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--background-40);
background-color: var(--background-ui);
color: $text-color;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
@include mobile-only {
width: 100%;
padding: 0.65rem 1rem;
font-size: 0.9rem;
}
&:hover:not(:disabled) {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.activity-count {
font-size: 0.8rem;
color: $text-color-50;
@include mobile-only {
font-size: 0.75rem;
text-align: center;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
} }
} }
</style> </style>

View File

@@ -1,77 +1,265 @@
<template> <template>
<div class="system-status"> <div class="system-status">
<h2 class="system-status__title">System Status</h2> <div class="system-status__header">
<div class="system-status__items"> <h2 class="system-status__title">System Status</h2>
<div class="status-item" v-for="item in systemItems" :key="item.name"> <button
class="refresh-btn"
@click="fetchSystemStatus"
:disabled="loading"
>
<IconActivity :class="{ spin: loading }" />
</button>
</div>
<div v-if="loading" class="system-status__loading">
Loading system status...
</div>
<div v-else class="system-status__items">
<div
class="status-item"
v-for="item in systemItems"
:key="item.name"
@click="showDetails(item)"
>
<div class="status-item__header"> <div class="status-item__header">
<span class="status-item__name">{{ item.name }}</span> <span class="status-item__name">{{ item.name }}</span>
<span <div class="status-item__indicator-wrapper">
:class="[ <span class="status-item__uptime" v-if="item.uptime">{{
'status-item__indicator', item.uptime
`status-item__indicator--${item.status}` }}</span>
]" <span
></span> :class="[
'status-item__indicator',
`status-item__indicator--${item.status}`
]"
:title="`${item.status}`"
></span>
</div>
</div> </div>
<div class="status-item__details"> <div class="status-item__details">
<span class="status-item__value">{{ item.value }}</span> <span class="status-item__value">{{ item.value }}</span>
<span class="status-item__description">{{ item.description }}</span> <span class="status-item__description">{{ item.description }}</span>
</div> </div>
<div v-if="item.metrics" class="status-item__metrics">
<div
v-for="metric in item.metrics"
:key="metric.label"
class="metric"
>
<span class="metric__label">{{ metric.label }}</span>
<div class="metric__bar">
<div
class="metric__fill"
:style="{ width: `${metric.value}%` }"
:class="getMetricClass(metric.value)"
></div>
</div>
<span class="metric__value">{{ metric.value }}%</span>
</div>
</div>
</div>
</div>
<!-- Details Modal -->
<div v-if="selectedItem" class="modal-overlay" @click="closeDetails">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>{{ selectedItem.name }} Details</h3>
<button class="close-btn" @click="closeDetails">
<IconClose />
</button>
</div>
<div class="modal-body">
<div class="detail-row">
<span class="detail-label">Status:</span>
<span
:class="['detail-value', `detail-value--${selectedItem.status}`]"
>
{{ selectedItem.status.toUpperCase() }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Current Value:</span>
<span class="detail-value">{{ selectedItem.value }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Description:</span>
<span class="detail-value">{{ selectedItem.description }}</span>
</div>
<div v-if="selectedItem.uptime" class="detail-row">
<span class="detail-label">Uptime:</span>
<span class="detail-value">{{ selectedItem.uptime }}</span>
</div>
<div v-if="selectedItem.lastCheck" class="detail-row">
<span class="detail-label">Last Check:</span>
<span class="detail-value">{{ selectedItem.lastCheck }}</span>
</div>
<div v-if="selectedItem.logs" class="detail-logs">
<h4>Recent Logs</h4>
<div
class="log-entry"
v-for="(log, index) in selectedItem.logs"
:key="index"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="action-btn" @click="restartService(selectedItem)">
Restart Service
</button>
<button
class="action-btn action-btn--secondary"
@click="viewFullLogs(selectedItem)"
>
View Full Logs
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from "vue"; import { ref, onMounted, onUnmounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
import IconClose from "@/icons/IconClose.vue";
interface Metric {
label: string;
value: number;
}
interface LogEntry {
time: string;
message: string;
}
interface SystemItem { interface SystemItem {
name: string; name: string;
status: "online" | "warning" | "offline"; status: "online" | "warning" | "offline";
value: string; value: string;
description: string; description: string;
uptime?: string;
lastCheck?: string;
metrics?: Metric[];
logs?: LogEntry[];
} }
const systemItems = ref<SystemItem[]>([]); const systemItems = ref<SystemItem[]>([]);
const loading = ref(false);
const selectedItem = ref<SystemItem | null>(null);
let refreshInterval: number | null = null;
const getMetricClass = (value: number) => {
if (value >= 90) return "metric__fill--critical";
if (value >= 70) return "metric__fill--warning";
return "metric__fill--good";
};
async function fetchSystemStatus() {
loading.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 500));
systemItems.value = [
{
name: "API Server",
status: "online",
value: "Running",
description: "All endpoints responding",
uptime: "15d 7h 23m",
lastCheck: "Just now",
metrics: [
{ label: "CPU", value: 23 },
{ label: "Memory", value: 45 }
],
logs: [
{ time: "2m ago", message: "Health check passed" },
{ time: "5m ago", message: "Request handled: /api/v2/movie" },
{ time: "7m ago", message: "Cache hit: user_settings" }
]
},
{
name: "Disk Space",
status: "warning",
value: "45% Used",
description: "1.2 TB / 2.7 TB",
uptime: "15d 7h 23m",
lastCheck: "Just now",
metrics: [
{ label: "System", value: 45 },
{ label: "Media", value: 78 }
],
logs: [
{ time: "5m ago", message: "Disk usage check completed" },
{ time: "10m ago", message: "Media folder: 78% full" }
]
},
{
name: "Plex Connection",
status: "online",
value: "Connected",
description: "Server: Home",
uptime: "15d 7h 23m",
lastCheck: "Just now",
metrics: [{ label: "Response Time", value: 15 }],
logs: [
{ time: "2m ago", message: "Plex API request successful" },
{ time: "8m ago", message: "Library sync completed" }
]
}
];
} finally {
loading.value = false;
}
}
function showDetails(item: SystemItem) {
selectedItem.value = item;
}
function closeDetails() {
selectedItem.value = null;
}
function restartService(item: SystemItem) {
console.log(`Restarting service: ${item.name}`);
alert(`Restart initiated for ${item.name}`);
closeDetails();
}
function viewFullLogs(item: SystemItem) {
console.log(`Viewing full logs for: ${item.name}`);
alert(`Full logs for ${item.name} would open here`);
}
function startAutoRefresh() {
refreshInterval = window.setInterval(() => {
if (!loading.value && !selectedItem.value) {
fetchSystemStatus();
}
}, 30000);
}
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
onMounted(() => { onMounted(() => {
systemItems.value = [ fetchSystemStatus();
{ startAutoRefresh();
name: "API Server", });
status: "online",
value: "Running", onUnmounted(() => {
description: "All endpoints responding" stopAutoRefresh();
},
{
name: "Database",
status: "online",
value: "Connected",
description: "Latency: 12ms"
},
{
name: "Cache Server",
status: "online",
value: "Active",
description: "Hit rate: 87%"
},
{
name: "Disk Space",
status: "warning",
value: "45% Used",
description: "1.2 TB / 2.7 TB"
},
{
name: "Background Jobs",
status: "online",
value: "3 Running",
description: "Queue size: 12"
},
{
name: "Plex Connection",
status: "online",
value: "Connected",
description: "Server: Home"
}
];
}); });
</script> </script>
@@ -84,20 +272,103 @@
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
overflow: hidden;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
@include mobile-only {
margin-bottom: 0.75rem;
}
}
&__title { &__title {
margin: 0 0 1rem 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 400; font-weight: 400;
color: $text-color; color: $text-color;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.8px; letter-spacing: 0.8px;
@include mobile-only {
font-size: 1rem;
}
}
&__loading {
padding: 2rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
} }
&__items { &__items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
@include mobile-only {
gap: 0.5rem;
}
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
} }
} }
@@ -105,6 +376,31 @@
padding: 0.75rem; padding: 0.75rem;
background-color: var(--background-ui); background-color: var(--background-ui);
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
min-width: 0;
@include mobile-only {
padding: 0.65rem;
width: 100%;
box-sizing: border-box;
}
&:hover {
background-color: var(--background-40);
transform: translateX(2px);
}
@include mobile-only {
&:hover {
transform: none;
}
&:active {
background-color: var(--background-40);
transform: scale(0.98);
}
}
&__header { &__header {
display: flex; display: flex;
@@ -117,12 +413,41 @@
font-weight: 500; font-weight: 500;
color: $text-color; color: $text-color;
font-size: 0.95rem; font-size: 0.95rem;
@include mobile-only {
font-size: 0.85rem;
}
}
&__indicator-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
@include mobile-only {
gap: 0.35rem;
}
}
&__uptime {
font-size: 0.75rem;
color: $text-color-50;
@include mobile-only {
font-size: 0.7rem;
}
} }
&__indicator { &__indicator {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
animation: pulse 2s infinite;
@include mobile-only {
width: 8px;
height: 8px;
}
&--online { &--online {
background-color: var(--color-success-highlight); background-color: var(--color-success-highlight);
@@ -144,16 +469,301 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 0.5rem;
@include mobile-only {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
margin-bottom: 0.35rem;
}
} }
&__value { &__value {
font-size: 0.85rem; font-size: 0.85rem;
color: $text-color-70; color: $text-color-70;
@include mobile-only {
font-size: 0.8rem;
}
} }
&__description { &__description {
font-size: 0.8rem; font-size: 0.8rem;
color: $text-color-50; color: $text-color-50;
@include mobile-only {
font-size: 0.75rem;
}
}
&__metrics {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
@include mobile-only {
margin-top: 0.35rem;
gap: 0.35rem;
}
}
}
.metric {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
&__label {
min-width: 70px;
color: $text-color-70;
}
&__bar {
flex: 1;
height: 6px;
background-color: var(--background-40);
border-radius: 3px;
overflow: hidden;
}
&__fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
&--good {
background-color: var(--color-success-highlight);
}
&--warning {
background-color: var(--color-warning-highlight);
}
&--critical {
background-color: var(--color-error-highlight);
}
}
&__value {
min-width: 35px;
text-align: right;
color: $text-color-50;
}
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
@include mobile-only {
padding: 0.5rem;
align-items: flex-end;
}
}
.modal-content {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
max-width: 600px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
@include mobile-only {
max-height: 90vh;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--background-40);
@include mobile-only {
padding: 1rem;
}
h3 {
margin: 0;
color: $text-color;
font-weight: 400;
@include mobile-only {
font-size: 1rem;
}
}
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-40);
}
svg {
width: 20px;
height: 20px;
fill: $text-color;
}
}
.modal-body {
padding: 1.5rem;
@include mobile-only {
padding: 1rem;
}
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--background-40);
&:last-child {
border-bottom: none;
}
}
.detail-label {
font-weight: 500;
color: $text-color-70;
}
.detail-value {
color: $text-color;
&--online {
color: var(--color-success-highlight);
}
&--warning {
color: var(--color-warning-highlight);
}
&--offline {
color: var(--color-error-highlight);
}
}
.detail-logs {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--background-40);
h4 {
margin: 0 0 0.75rem 0;
color: $text-color;
font-weight: 400;
font-size: 0.95rem;
}
}
.log-entry {
display: flex;
gap: 1rem;
padding: 0.5rem;
font-size: 0.8rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.log-time {
min-width: 60px;
color: $text-color-50;
}
.log-message {
color: $text-color-70;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--background-40);
display: flex;
gap: 0.5rem;
justify-content: flex-end;
@include mobile-only {
padding: 1rem;
flex-direction: column-reverse;
}
}
.action-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--highlight-color);
background-color: var(--highlight-color);
color: $white;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
&:hover {
background-color: var(--color-green-90);
border-color: var(--color-green-90);
}
&--secondary {
background-color: transparent;
color: $text-color;
border-color: var(--background-40);
&:hover {
background-color: var(--background-ui);
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
} }
} }
</style> </style>

View File

@@ -48,10 +48,6 @@
sortDirection === "asc" ? "" : "" sortDirection === "asc" ? "" : ""
}}</span> }}</span>
</th> </th>
<th>Leechers</th>
<th>Uploaded</th>
<th>Downloaded</th>
<th>Ratio</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -65,22 +61,6 @@
<td class="torrent-name" :title="torrent.name">{{ torrent.name }}</td> <td class="torrent-name" :title="torrent.name">{{ torrent.name }}</td>
<td>{{ torrent.size }}</td> <td>{{ torrent.size }}</td>
<td>{{ torrent.seeders }}</td> <td>{{ torrent.seeders }}</td>
<td>{{ torrent.leechers }}</td>
<td>{{ torrent.uploaded }}</td>
<td>{{ torrent.downloaded }}</td>
<td>
<span
:class="[
'ratio',
{
'ratio--good': torrent.ratio >= 1,
'ratio--bad': torrent.ratio < 1
}
]"
>
{{ torrent.ratio.toFixed(2) }}
</span>
</td>
<td> <td>
<span :class="['status-badge', `status-badge--${torrent.status}`]"> <span :class="['status-badge', `status-badge--${torrent.status}`]">
{{ torrent.status }} {{ torrent.status }}
@@ -340,6 +320,16 @@
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
overflow-x: auto;
}
&__header { &__header {
display: flex; display: flex;
@@ -348,6 +338,11 @@
margin-bottom: 1rem; margin-bottom: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 1rem;
@include mobile-only {
gap: 0.75rem;
margin-bottom: 0.75rem;
}
} }
&__title { &__title {
@@ -357,12 +352,22 @@
color: $text-color; color: $text-color;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.8px; letter-spacing: 0.8px;
@include mobile-only {
font-size: 1rem;
width: 100%;
}
} }
&__controls { &__controls {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
@include mobile-only {
width: 100%;
gap: 0.4rem;
}
} }
&__search { &__search {
@@ -373,6 +378,14 @@
color: $text-color; color: $text-color;
font-size: 0.85rem; font-size: 0.85rem;
@include mobile-only {
flex: 1;
min-width: 0;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
max-width: calc(50% - 0.2rem - 20px);
}
&:focus { &:focus {
outline: none; outline: none;
border-color: var(--highlight-color); border-color: var(--highlight-color);
@@ -388,6 +401,13 @@
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
@include mobile-only {
flex: 1;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
max-width: calc(50% - 0.2rem - 20px);
}
&:focus { &:focus {
outline: none; outline: none;
border-color: var(--highlight-color); border-color: var(--highlight-color);
@@ -399,6 +419,11 @@
padding: 2rem; padding: 2rem;
text-align: center; text-align: center;
color: $text-color-70; color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
} }
&__error { &__error {
@@ -409,6 +434,11 @@
margin-top: 1rem; margin-top: 1rem;
padding-top: 0.75rem; padding-top: 0.75rem;
border-top: 1px solid var(--background-40); border-top: 1px solid var(--background-40);
@include mobile-only {
margin-top: 0.75rem;
padding-top: 0.5rem;
}
} }
&__table { &__table {
@@ -417,11 +447,23 @@
border-radius: 0.5rem; border-radius: 0.5rem;
overflow: hidden; overflow: hidden;
@include mobile-only {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
max-width: 100%;
}
th, th,
td { td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
border-bottom: 1px solid var(--background-40); border-bottom: 1px solid var(--background-40);
@include mobile-only {
padding: 0.5rem 0.4rem;
font-size: 0.75rem;
}
} }
th { th {
@@ -432,6 +474,12 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
font-weight: 400; font-weight: 400;
@include mobile-only {
font-size: 0.7rem;
letter-spacing: 0.3px;
white-space: nowrap;
}
&.sortable { &.sortable {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -445,6 +493,11 @@
td { td {
font-size: 0.85rem; font-size: 0.85rem;
color: $text-color; color: $text-color;
@include mobile-only {
font-size: 0.75rem;
white-space: nowrap;
}
} }
tbody tr { tbody tr {
@@ -468,25 +521,14 @@
} }
.torrent-name { .torrent-name {
max-width: 200px; max-width: 300px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@include mobile-only { @include mobile-only {
max-width: 120px; max-width: 150px;
} font-size: 0.7rem;
}
.ratio {
font-weight: 500;
&--good {
color: var(--color-success-highlight);
}
&--bad {
color: var(--color-error-highlight);
} }
} }
@@ -498,6 +540,11 @@
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
@include mobile-only {
font-size: 0.6rem;
padding: 0.2rem 0.35rem;
}
&--seeding { &--seeding {
background-color: var(--color-success); background-color: var(--color-success);
color: var(--color-success-text); color: var(--color-success-text);
@@ -569,6 +616,12 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s; transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) { &:hover:not(:disabled) {
background-color: var(--background-ui); background-color: var(--background-ui);
@@ -585,6 +638,11 @@
height: 18px; height: 18px;
fill: $text-color; fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin { &.spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }

View File

@@ -30,9 +30,13 @@
.admin { .admin {
padding: 3rem; padding: 3rem;
max-width: 100%;
overflow-x: hidden;
@include mobile-only { @include mobile-only {
padding: 1rem; padding: 0.75rem;
width: 100%;
box-sizing: border-box;
} }
&__title { &__title {
@@ -40,6 +44,11 @@
font-size: 2rem; font-size: 2rem;
font-weight: 300; font-weight: 300;
color: $text-color; color: $text-color;
@include mobile-only {
font-size: 1.5rem;
margin: 0 0 1rem 0;
}
} }
&__grid { &__grid {
@@ -50,27 +59,49 @@
@include mobile-only { @include mobile-only {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 1rem;
margin-bottom: 1rem;
} }
} }
&__stats { &__stats {
grid-column: 1; grid-column: 1;
min-width: 0;
@include mobile-only {
width: 100%;
}
} }
&__system-status { &__system-status {
grid-column: 2; grid-column: 2;
min-width: 0;
@include mobile-only { @include mobile-only {
grid-column: 1; grid-column: 1;
width: 100%;
} }
} }
&__torrents { &__torrents {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
min-width: 0;
overflow-x: hidden;
@include mobile-only {
margin-bottom: 1rem;
width: 100%;
}
} }
&__activity { &__activity {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
min-width: 0;
@include mobile-only {
margin-bottom: 1rem;
width: 100%;
}
} }
} }
</style> </style>