mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 03:49:07 +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",
|
||||
path: "/404",
|
||||
component: () => import("./pages/404Page.vue")
|
||||
}
|
||||
name: "admin",
|
||||
path: "/admin",
|
||||
meta: { requiresAuth: true },
|
||||
component: () => import("./pages/AdminPage.vue")
|
||||
},
|
||||
// {
|
||||
// path: "*",
|
||||
// redirect: "/"
|
||||
|
||||
Reference in New Issue
Block a user