mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 11:55:38 +00:00
admin page & components
This commit is contained in:
119
src/components/admin/AdminStats.vue
Normal file
119
src/components/admin/AdminStats.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-stats">
|
||||||
|
<h2 class="admin-stats__title">Statistics</h2>
|
||||||
|
<div class="admin-stats__grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__value">{{ stats.totalUsers }}</span>
|
||||||
|
<span class="stat-card__label">Total Users</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__value">{{ stats.activeTorrents }}</span>
|
||||||
|
<span class="stat-card__label">Active Torrents</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__value">{{ stats.totalRequests }}</span>
|
||||||
|
<span class="stat-card__label">Total Requests</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__value">{{ stats.pendingRequests }}</span>
|
||||||
|
<span class="stat-card__label">Pending Requests</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__value">{{ stats.approvedRequests }}</span>
|
||||||
|
<span class="stat-card__label">Approved</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__value">{{ stats.totalStorage }}</span>
|
||||||
|
<span class="stat-card__label">Storage Used</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalUsers: number;
|
||||||
|
activeTorrents: number;
|
||||||
|
totalRequests: number;
|
||||||
|
pendingRequests: number;
|
||||||
|
approvedRequests: number;
|
||||||
|
totalStorage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = ref<Stats>({
|
||||||
|
totalUsers: 0,
|
||||||
|
activeTorrents: 0,
|
||||||
|
totalRequests: 0,
|
||||||
|
pendingRequests: 0,
|
||||||
|
approvedRequests: 0,
|
||||||
|
totalStorage: "0 GB"
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
stats.value = {
|
||||||
|
totalUsers: 142,
|
||||||
|
activeTorrents: 23,
|
||||||
|
totalRequests: 856,
|
||||||
|
pendingRequests: 12,
|
||||||
|
approvedRequests: 712,
|
||||||
|
totalStorage: "2.4 TB"
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</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: 1.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $text-color-70;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
193
src/components/admin/RecentActivityFeed.vue
Normal file
193
src/components/admin/RecentActivityFeed.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div class="activity-feed">
|
||||||
|
<h2 class="activity-feed__title">Recent Activity</h2>
|
||||||
|
<div class="activity-feed__list">
|
||||||
|
<div
|
||||||
|
class="activity-item"
|
||||||
|
v-for="activity in activities"
|
||||||
|
:key="activity.id"
|
||||||
|
>
|
||||||
|
<div class="activity-item__icon">
|
||||||
|
<component :is="getIcon(activity.type)" />
|
||||||
|
</div>
|
||||||
|
<div class="activity-item__content">
|
||||||
|
<span class="activity-item__message">{{ activity.message }}</span>
|
||||||
|
<span class="activity-item__time">{{
|
||||||
|
formatTime(activity.timestamp)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } 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";
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
id: number;
|
||||||
|
type: "request" | "download" | "user" | "movie";
|
||||||
|
message: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities = ref<Activity[]>([]);
|
||||||
|
|
||||||
|
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`;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
activities.value = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: "request",
|
||||||
|
message: "New request: Interstellar (2014)",
|
||||||
|
timestamp: new Date(Date.now() - 5 * 60000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: "download",
|
||||||
|
message: "Torrent completed: Dune Part Two",
|
||||||
|
timestamp: new Date(Date.now() - 23 * 60000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: "user",
|
||||||
|
message: "New user registered: john_doe",
|
||||||
|
timestamp: new Date(Date.now() - 45 * 60000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
type: "movie",
|
||||||
|
message: "Movie added to library: The Batman",
|
||||||
|
timestamp: new Date(Date.now() - 2 * 3600000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
type: "request",
|
||||||
|
message: "Request approved: Oppenheimer",
|
||||||
|
timestamp: new Date(Date.now() - 3 * 3600000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
type: "download",
|
||||||
|
message: "Torrent started: Poor Things",
|
||||||
|
timestamp: new Date(Date.now() - 5 * 3600000)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</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: 1.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
fill: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__message {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: $text-color;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: $text-color-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
159
src/components/admin/SystemStatusPanel.vue
Normal file
159
src/components/admin/SystemStatusPanel.vue
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div class="system-status">
|
||||||
|
<h2 class="system-status__title">System Status</h2>
|
||||||
|
<div class="system-status__items">
|
||||||
|
<div class="status-item" v-for="item in systemItems" :key="item.name">
|
||||||
|
<div class="status-item__header">
|
||||||
|
<span class="status-item__name">{{ item.name }}</span>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'status-item__indicator',
|
||||||
|
`status-item__indicator--${item.status}`
|
||||||
|
]"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item__details">
|
||||||
|
<span class="status-item__value">{{ item.value }}</span>
|
||||||
|
<span class="status-item__description">{{ item.description }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
|
||||||
|
interface SystemItem {
|
||||||
|
name: string;
|
||||||
|
status: "online" | "warning" | "offline";
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemItems = ref<SystemItem[]>([]);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
systemItems.value = [
|
||||||
|
{
|
||||||
|
name: "API Server",
|
||||||
|
status: "online",
|
||||||
|
value: "Running",
|
||||||
|
description: "All endpoints responding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.system-status {
|
||||||
|
background-color: var(--background-color-secondary);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--background-ui);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-color;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $text-color-70;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: $text-color-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
607
src/components/admin/TorrentManagementGrid.vue
Normal file
607
src/components/admin/TorrentManagementGrid.vue
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
<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 @click="sortBy('size')" class="sortable">
|
||||||
|
Size
|
||||||
|
<span v-if="sortColumn === 'size'">{{
|
||||||
|
sortDirection === "asc" ? "↑" : "↓"
|
||||||
|
}}</span>
|
||||||
|
</th>
|
||||||
|
<th @click="sortBy('seeders')" class="sortable">
|
||||||
|
Seeders
|
||||||
|
<span v-if="sortColumn === 'seeders'">{{
|
||||||
|
sortDirection === "asc" ? "↑" : "↓"
|
||||||
|
}}</span>
|
||||||
|
</th>
|
||||||
|
<th>Leechers</th>
|
||||||
|
<th>Uploaded</th>
|
||||||
|
<th>Downloaded</th>
|
||||||
|
<th>Ratio</th>
|
||||||
|
<th>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">{{ torrent.name }}</td>
|
||||||
|
<td>{{ torrent.size }}</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>
|
||||||
|
<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 } 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 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();
|
||||||
|
});
|
||||||
|
</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: 1.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $text-color;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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;
|
||||||
|
|
||||||
|
&: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;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--highlight-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading,
|
||||||
|
&__error {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: $text-color-70;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error {
|
||||||
|
color: var(--color-error-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--background-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__table {
|
||||||
|
width: 100%;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--background-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
&.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: $text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ratio {
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&--good {
|
||||||
|
color: var(--color-success-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
color: var(--color-error-highlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&--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;
|
||||||
|
|
||||||
|
&: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;
|
||||||
|
|
||||||
|
&.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>
|
||||||
76
src/pages/AdminPage.vue
Normal file
76
src/pages/AdminPage.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<section class="admin">
|
||||||
|
<h1 class="admin__title">Admin Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="admin__grid">
|
||||||
|
<AdminStats class="admin__stats" />
|
||||||
|
<SystemStatusPanel class="admin__system-status" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin__torrents">
|
||||||
|
<TorrentManagementGrid />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin__activity">
|
||||||
|
<RecentActivityFeed />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AdminStats from "@/components/admin/AdminStats.vue";
|
||||||
|
import TorrentManagementGrid from "@/components/admin/TorrentManagementGrid.vue";
|
||||||
|
import SystemStatusPanel from "@/components/admin/SystemStatusPanel.vue";
|
||||||
|
import RecentActivityFeed from "@/components/admin/RecentActivityFeed.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "scss/variables";
|
||||||
|
@import "scss/media-queries";
|
||||||
|
|
||||||
|
.admin {
|
||||||
|
padding: 3rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: $text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__system-status {
|
||||||
|
grid-column: 2;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__torrents {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__activity {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -74,10 +74,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
// }
|
// }
|
||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
name: "404",
|
name: "admin",
|
||||||
path: "/404",
|
path: "/admin",
|
||||||
component: () => import("./pages/404Page.vue")
|
meta: { requiresAuth: true },
|
||||||
}
|
component: () => import("./pages/AdminPage.vue")
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// path: "*",
|
// path: "*",
|
||||||
// redirect: "/"
|
// redirect: "/"
|
||||||
|
|||||||
Reference in New Issue
Block a user