mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 11:55:38 +00:00
Add admin components: stats, activity feed, system status, torrent management
This commit is contained in:
503
src/components/admin/AdminStats.vue
Normal file
503
src/components/admin/AdminStats.vue
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-stats">
|
||||||
|
<div class="admin-stats__header">
|
||||||
|
<h2 class="admin-stats__title">Statistics</h2>
|
||||||
|
<div class="admin-stats__controls">
|
||||||
|
<select
|
||||||
|
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 v-if="loading" class="admin-stats__loading">Loading statistics...</div>
|
||||||
|
|
||||||
|
<div v-else class="admin-stats__grid">
|
||||||
|
<div
|
||||||
|
class="stat-card"
|
||||||
|
v-for="stat in statCards"
|
||||||
|
:key="stat.key"
|
||||||
|
@click="handleCardClick(stat.key)"
|
||||||
|
:class="{ 'stat-card--clickable': stat.clickable }"
|
||||||
|
>
|
||||||
|
<div class="stat-card__header">
|
||||||
|
<component :is="stat.icon" class="stat-card__icon" />
|
||||||
|
<span
|
||||||
|
v-if="stat.trend !== 0"
|
||||||
|
:class="[
|
||||||
|
'stat-card__trend',
|
||||||
|
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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } 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 Stat {
|
||||||
|
key: string;
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
trend: number;
|
||||||
|
icon: any;
|
||||||
|
clickable: boolean;
|
||||||
|
sparkline?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = ref({
|
||||||
|
totalUsers: 0,
|
||||||
|
activeTorrents: 0,
|
||||||
|
totalRequests: 0,
|
||||||
|
pendingRequests: 0,
|
||||||
|
approvedRequests: 0,
|
||||||
|
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");
|
||||||
|
|
||||||
|
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 }, () => {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => fetchStats());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.admin-stats {
|
||||||
|
background-color: var(--background-color-secondary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
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: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.6rem;
|
||||||
|
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 {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.6rem 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.4rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: var(--highlight-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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 {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 1rem 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $text-color-70;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sparkline {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
height: 18px;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
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>
|
||||||
685
src/components/admin/RecentActivityFeed.vue
Normal file
685
src/components/admin/RecentActivityFeed.vue
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
<template>
|
||||||
|
<div class="activity-feed">
|
||||||
|
<div class="activity-feed__header">
|
||||||
|
<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
|
||||||
|
class="activity-item"
|
||||||
|
v-for="activity in filteredActivities"
|
||||||
|
:key="activity.id"
|
||||||
|
@click="handleActivityClick(activity)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'activity-item__icon',
|
||||||
|
`activity-item__icon--${activity.type}`
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<component :is="getIcon(activity.type)" />
|
||||||
|
</div>
|
||||||
|
<div class="activity-item__content">
|
||||||
|
<div class="activity-item__header">
|
||||||
|
<span class="activity-item__message">{{ activity.message }}</span>
|
||||||
|
<span v-if="activity.metadata" class="activity-item__badge">
|
||||||
|
{{ 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 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
|
import IconMovie from "@/icons/IconMovie.vue";
|
||||||
|
import IconPlay from "@/icons/IconPlay.vue";
|
||||||
|
import IconRequest from "@/icons/IconRequest.vue";
|
||||||
|
import IconProfile from "@/icons/IconProfile.vue";
|
||||||
|
import IconActivity from "@/icons/IconActivity.vue";
|
||||||
|
|
||||||
|
type ActivityType = "request" | "download" | "user" | "movie";
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: number;
|
||||||
|
type: ActivityType;
|
||||||
|
message: string;
|
||||||
|
timestamp: Date;
|
||||||
|
user?: string;
|
||||||
|
metadata?: string;
|
||||||
|
details?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 icons: Record<string, any> = {
|
||||||
|
request: IconRequest,
|
||||||
|
download: IconPlay,
|
||||||
|
user: IconProfile,
|
||||||
|
movie: IconMovie
|
||||||
|
};
|
||||||
|
return icons[type] || IconMovie;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return "Just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${days}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMockActivities = (
|
||||||
|
count: number,
|
||||||
|
startId: number
|
||||||
|
): Activity[] => {
|
||||||
|
const types: ActivityType[] = ["request", "download", "user", "movie"];
|
||||||
|
const messages = {
|
||||||
|
request: [
|
||||||
|
"New request: Interstellar (2014)",
|
||||||
|
"Request approved: Oppenheimer",
|
||||||
|
"Request denied: The Matrix",
|
||||||
|
"Request fulfilled: Dune Part Two"
|
||||||
|
],
|
||||||
|
download: [
|
||||||
|
"Torrent completed: Dune Part Two",
|
||||||
|
"Torrent started: Poor Things",
|
||||||
|
"Download failed: Network Error",
|
||||||
|
"Torrent paused by admin"
|
||||||
|
],
|
||||||
|
user: [
|
||||||
|
"New user registered: john_doe",
|
||||||
|
"User upgraded to VIP: sarah_s",
|
||||||
|
"User login from new device: alex_p",
|
||||||
|
"Password changed: mike_r"
|
||||||
|
],
|
||||||
|
movie: [
|
||||||
|
"Movie added to library: The Batman",
|
||||||
|
"Library scan completed: 12 new items",
|
||||||
|
"Show updated: Breaking Bad S5",
|
||||||
|
"Media deleted: Old Movie (1999)"
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
"admin",
|
||||||
|
"kevin_m",
|
||||||
|
"sarah_s",
|
||||||
|
"john_doe",
|
||||||
|
"alex_p",
|
||||||
|
"mike_r"
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchActivities);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.activity-feed {
|
||||||
|
background-color: var(--background-color-secondary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
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: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
max-height: 450px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
max-height: 350px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: var(--background-40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--request {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--download {
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--user {
|
||||||
|
background-color: #8b5cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--movie {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: $white;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__message {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $text-color;
|
||||||
|
line-height: 1.3;
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
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 {
|
||||||
|
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>
|
||||||
750
src/components/admin/SystemStatusPanel.vue
Normal file
750
src/components/admin/SystemStatusPanel.vue
Normal file
@@ -0,0 +1,750 @@
|
|||||||
|
<template>
|
||||||
|
<div class="system-status">
|
||||||
|
<div class="system-status__header">
|
||||||
|
<h2 class="system-status__title">System Status</h2>
|
||||||
|
<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">
|
||||||
|
<span class="status-item__name">{{ item.name }}</span>
|
||||||
|
<div class="status-item__indicator-wrapper">
|
||||||
|
<span class="status-item__uptime" v-if="item.uptime">{{
|
||||||
|
item.uptime
|
||||||
|
}}</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'status-item__indicator',
|
||||||
|
`status-item__indicator--${item.status}`
|
||||||
|
]"
|
||||||
|
:title="`${item.status}`"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-item__details">
|
||||||
|
<span class="status-item__value">{{ item.value }}</span>
|
||||||
|
<span class="status-item__description">{{ item.description }}</span>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } 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 {
|
||||||
|
name: string;
|
||||||
|
status: "online" | "warning" | "offline";
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
uptime?: string;
|
||||||
|
lastCheck?: string;
|
||||||
|
metrics?: Metric[];
|
||||||
|
logs?: LogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemItems = ref<SystemItem[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const selectedItem = ref<SystemItem | 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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchSystemStatus);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.system-status {
|
||||||
|
background-color: var(--background-color-secondary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
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: 0.75rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: $text-color-70;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
padding: 0.65rem;
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.6rem;
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-color;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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 {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--online {
|
||||||
|
background-color: var(--color-success-highlight);
|
||||||
|
box-shadow: 0 0 6px var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background-color: var(--color-warning-highlight);
|
||||||
|
box-shadow: 0 0 6px var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--offline {
|
||||||
|
background-color: var(--color-error-highlight);
|
||||||
|
box-shadow: 0 0 6px var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.15rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $text-color-70;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: $text-color-50;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__metrics {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
min-width: 65px;
|
||||||
|
color: $text-color-70;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 5px;
|
||||||
|
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>
|
||||||
723
src/components/admin/TorrentManagementGrid.vue
Normal file
723
src/components/admin/TorrentManagementGrid.vue
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
<template>
|
||||||
|
<div class="torrent-management">
|
||||||
|
<div class="torrent-management__header">
|
||||||
|
<h2 class="torrent-management__title">Torrent Management</h2>
|
||||||
|
<div class="torrent-management__controls">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search torrents..."
|
||||||
|
class="torrent-management__search"
|
||||||
|
/>
|
||||||
|
<select v-model="statusFilter" class="torrent-management__filter">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="seeding">Seeding</option>
|
||||||
|
<option value="downloading">Downloading</option>
|
||||||
|
<option value="paused">Paused</option>
|
||||||
|
<option value="stopped">Stopped</option>
|
||||||
|
</select>
|
||||||
|
<button class="refresh-btn" @click="fetchTorrents" :disabled="loading">
|
||||||
|
<IconActivity :class="{ spin: loading }" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="torrent-management__loading">
|
||||||
|
Loading torrents...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" class="torrent-management__error">{{ error }}</div>
|
||||||
|
|
||||||
|
<table v-else class="torrent-management__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th @click="sortBy('name')" class="sortable">
|
||||||
|
Name
|
||||||
|
<span v-if="sortColumn === 'name'">{{
|
||||||
|
sortDirection === "asc" ? "↑" : "↓"
|
||||||
|
}}</span>
|
||||||
|
</th>
|
||||||
|
<th v-if="!isMobile" @click="sortBy('size')" class="sortable">
|
||||||
|
Size
|
||||||
|
<span v-if="sortColumn === 'size'">{{
|
||||||
|
sortDirection === "asc" ? "↑" : "↓"
|
||||||
|
}}</span>
|
||||||
|
</th>
|
||||||
|
<th v-if="!isMobile" @click="sortBy('seeders')" class="sortable">
|
||||||
|
Seeders
|
||||||
|
<span v-if="sortColumn === 'seeders'">{{
|
||||||
|
sortDirection === "asc" ? "↑" : "↓"
|
||||||
|
}}</span>
|
||||||
|
</th>
|
||||||
|
<th v-if="!isMobile">Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="torrent in filteredTorrents"
|
||||||
|
:key="torrent.id"
|
||||||
|
:class="{ processing: torrent.processing }"
|
||||||
|
>
|
||||||
|
<td class="torrent-name" :title="torrent.name">
|
||||||
|
<div class="torrent-name__title">{{ torrent.name }}</div>
|
||||||
|
<div v-if="isMobile" class="torrent-name__meta">
|
||||||
|
<span class="meta-item">{{ torrent.size }}</span>
|
||||||
|
<span class="meta-separator">•</span>
|
||||||
|
<span class="meta-item">{{ torrent.seeders }} seeders</span>
|
||||||
|
<span class="meta-separator">•</span>
|
||||||
|
<span
|
||||||
|
:class="['status-badge', `status-badge--${torrent.status}`]"
|
||||||
|
>
|
||||||
|
{{ torrent.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="!isMobile">{{ torrent.size }}</td>
|
||||||
|
<td v-if="!isMobile">{{ torrent.seeders }}</td>
|
||||||
|
<td v-if="!isMobile">
|
||||||
|
<span :class="['status-badge', `status-badge--${torrent.status}`]">
|
||||||
|
{{ torrent.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button
|
||||||
|
v-if="
|
||||||
|
torrent.status === 'seeding' || torrent.status === 'downloading'
|
||||||
|
"
|
||||||
|
class="action-btn"
|
||||||
|
title="Pause"
|
||||||
|
@click="pauseTorrent(torrent)"
|
||||||
|
:disabled="torrent.processing"
|
||||||
|
>
|
||||||
|
<IconStop />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="torrent.status === 'paused' || torrent.status === 'stopped'"
|
||||||
|
class="action-btn"
|
||||||
|
title="Resume"
|
||||||
|
@click="resumeTorrent(torrent)"
|
||||||
|
:disabled="torrent.processing"
|
||||||
|
>
|
||||||
|
<IconPlay />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--danger"
|
||||||
|
title="Delete"
|
||||||
|
@click="deleteTorrent(torrent)"
|
||||||
|
:disabled="torrent.processing"
|
||||||
|
>
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn"
|
||||||
|
title="Details"
|
||||||
|
@click="showDetails(torrent)"
|
||||||
|
>
|
||||||
|
<IconInfo />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="torrent-management__footer">
|
||||||
|
<span class="torrent-count"
|
||||||
|
>{{ filteredTorrents.length }} of {{ torrents.length }} torrents</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
|
import IconStop from "@/icons/IconStop.vue";
|
||||||
|
import IconPlay from "@/icons/IconPlay.vue";
|
||||||
|
import IconClose from "@/icons/IconClose.vue";
|
||||||
|
import IconInfo from "@/icons/IconInfo.vue";
|
||||||
|
import IconActivity from "@/icons/IconActivity.vue";
|
||||||
|
|
||||||
|
interface Torrent {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
seeders: number;
|
||||||
|
leechers: number;
|
||||||
|
uploaded: string;
|
||||||
|
downloaded: string;
|
||||||
|
ratio: number;
|
||||||
|
status: "seeding" | "downloading" | "paused" | "stopped";
|
||||||
|
processing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const torrents = ref<Torrent[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const statusFilter = ref("");
|
||||||
|
const sortColumn = ref<keyof Torrent>("name");
|
||||||
|
const sortDirection = ref<"asc" | "desc">("asc");
|
||||||
|
|
||||||
|
const windowWidth = ref(window.innerWidth);
|
||||||
|
const isMobile = computed(() => windowWidth.value <= 768);
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
windowWidth.value = window.innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredTorrents = computed(() => {
|
||||||
|
let result = [...torrents.value];
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
result = result.filter(t => t.name.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter.value) {
|
||||||
|
result = result.filter(t => t.status === statusFilter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const aVal = a[sortColumn.value];
|
||||||
|
const bVal = b[sortColumn.value];
|
||||||
|
|
||||||
|
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||||
|
return sortDirection.value === "asc"
|
||||||
|
? aVal.localeCompare(bVal)
|
||||||
|
: bVal.localeCompare(aVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||||
|
return sortDirection.value === "asc" ? aVal - bVal : bVal - aVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
function sortBy(column: keyof Torrent) {
|
||||||
|
if (sortColumn.value === column) {
|
||||||
|
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
sortColumn.value = column;
|
||||||
|
sortDirection.value = "asc";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTorrents() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
torrents.value = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Movie.Name.2024.1080p.BluRay.x264",
|
||||||
|
size: "2.4 GB",
|
||||||
|
seeders: 156,
|
||||||
|
leechers: 23,
|
||||||
|
uploaded: "45.2 GB",
|
||||||
|
downloaded: "2.4 GB",
|
||||||
|
ratio: 18.83,
|
||||||
|
status: "seeding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "TV.Show.S01E01.720p.WEB-DL",
|
||||||
|
size: "1.2 GB",
|
||||||
|
seeders: 89,
|
||||||
|
leechers: 12,
|
||||||
|
uploaded: "12.8 GB",
|
||||||
|
downloaded: "1.2 GB",
|
||||||
|
ratio: 10.67,
|
||||||
|
status: "seeding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Documentary.2024.HDRip",
|
||||||
|
size: "890 MB",
|
||||||
|
seeders: 45,
|
||||||
|
leechers: 8,
|
||||||
|
uploaded: "2.1 GB",
|
||||||
|
downloaded: "650 MB",
|
||||||
|
ratio: 3.31,
|
||||||
|
status: "downloading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Anime.Series.S02E10.1080p",
|
||||||
|
size: "1.8 GB",
|
||||||
|
seeders: 234,
|
||||||
|
leechers: 56,
|
||||||
|
uploaded: "89.4 GB",
|
||||||
|
downloaded: "1.8 GB",
|
||||||
|
ratio: 49.67,
|
||||||
|
status: "seeding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Concert.2024.4K.UHD",
|
||||||
|
size: "12.5 GB",
|
||||||
|
seeders: 67,
|
||||||
|
leechers: 5,
|
||||||
|
uploaded: "0 B",
|
||||||
|
downloaded: "0 B",
|
||||||
|
ratio: 0,
|
||||||
|
status: "paused"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: "Drama.Series.2024.S01E05.1080p",
|
||||||
|
size: "2.1 GB",
|
||||||
|
seeders: 112,
|
||||||
|
leechers: 34,
|
||||||
|
uploaded: "8.9 GB",
|
||||||
|
downloaded: "2.1 GB",
|
||||||
|
ratio: 4.24,
|
||||||
|
status: "seeding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: "Action.Movie.2024.BRRip",
|
||||||
|
size: "1.5 GB",
|
||||||
|
seeders: 0,
|
||||||
|
leechers: 0,
|
||||||
|
uploaded: "0 B",
|
||||||
|
downloaded: "0 B",
|
||||||
|
ratio: 0,
|
||||||
|
status: "stopped"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = "Failed to load torrents";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pauseTorrent(torrent: Torrent) {
|
||||||
|
torrent.processing = true;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
torrent.status = "paused";
|
||||||
|
torrent.processing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeTorrent(torrent: Torrent) {
|
||||||
|
torrent.processing = true;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
torrent.status = "seeding";
|
||||||
|
torrent.processing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTorrent(torrent: Torrent) {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${torrent.name}"?`)) return;
|
||||||
|
|
||||||
|
torrent.processing = true;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
torrents.value = torrents.value.filter(t => t.id !== torrent.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDetails(torrent: Torrent) {
|
||||||
|
alert(
|
||||||
|
`Torrent Details:\n\nName: ${torrent.name}\nSize: ${torrent.size}\nSeeders: ${torrent.seeders}\nLeechers: ${torrent.leechers}\nUploaded: ${torrent.uploaded}\nDownloaded: ${torrent.downloaded}\nRatio: ${torrent.ratio.toFixed(2)}\nStatus: ${torrent.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTorrents();
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.torrent-management {
|
||||||
|
background-color: var(--background-color-secondary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
width: 100%;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
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;
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--highlight-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--background-40);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__table {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
table-layout: fixed;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--background-40);
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--table-background-color);
|
||||||
|
color: var(--table-header-text-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $text-color;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:nth-child(even) {
|
||||||
|
background-color: var(--background-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.processing {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-name {
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
max-width: none;
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-separator {
|
||||||
|
color: var(--text-color-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--seeding {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: var(--color-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--downloading {
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: $black;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--paused {
|
||||||
|
background-color: var(--background-40);
|
||||||
|
color: $text-color-70;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--stopped {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--background-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: $text-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $text-color-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user