tuning ui and adding adaptive themeing to extension
This commit is contained in:
+10
-2
@@ -58,9 +58,14 @@ const route = useRoute()
|
||||
const notificationStore = useNotificationStore()
|
||||
const wsStore = useWebSocketStore()
|
||||
|
||||
// Initialize WebSocket listeners
|
||||
// Initialize WebSocket listeners and restore theme
|
||||
onMounted(() => {
|
||||
wsStore.init()
|
||||
// Restore theme preference from localStorage
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme) {
|
||||
theme.global.name.value = savedTheme
|
||||
}
|
||||
})
|
||||
|
||||
const drawer = ref(true)
|
||||
@@ -70,6 +75,7 @@ const navItems = [
|
||||
{ title: 'Subscriptions', icon: 'mdi-account-multiple', path: '/subscriptions' },
|
||||
{ title: 'Downloads', icon: 'mdi-download', path: '/downloads' },
|
||||
{ title: 'Credentials', icon: 'mdi-key', path: '/credentials' },
|
||||
{ title: 'Logs', icon: 'mdi-text-box-outline', path: '/logs' },
|
||||
{ title: 'Settings', icon: 'mdi-cog', path: '/settings' },
|
||||
]
|
||||
|
||||
@@ -81,7 +87,9 @@ const currentPageTitle = computed(() => {
|
||||
const isDark = computed(() => theme.global.current.value.dark)
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.global.name.value = isDark.value ? 'light' : 'dark'
|
||||
const newTheme = isDark.value ? 'light' : 'dark'
|
||||
theme.global.name.value = newTheme
|
||||
localStorage.setItem('theme', newTheme)
|
||||
}
|
||||
|
||||
const notification = computed(() => notificationStore)
|
||||
|
||||
@@ -31,6 +31,11 @@ const routes = [
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'Logs',
|
||||
component: () => import('../views/Logs.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -36,6 +36,7 @@ export const downloadsApi = {
|
||||
get: (id) => api.get(`/downloads/${id}`),
|
||||
retry: (id) => api.post(`/downloads/${id}/retry`),
|
||||
stats: (params = {}) => api.get('/downloads/stats', { params }),
|
||||
recentActivity: (params = {}) => api.get('/downloads/recent-activity', { params }),
|
||||
}
|
||||
|
||||
// Credentials API
|
||||
@@ -54,6 +55,7 @@ export const settingsApi = {
|
||||
updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }),
|
||||
getApiKey: () => api.get('/settings/api-key'),
|
||||
regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
|
||||
getLogs: (params = {}) => api.get('/settings/logs', { params }),
|
||||
}
|
||||
|
||||
// Platforms API
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { credentialsApi } from '../services/api'
|
||||
|
||||
export const useCredentialsStore = defineStore('credentials', () => {
|
||||
const credentials = ref([])
|
||||
const platforms = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchCredentials() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await credentialsApi.list()
|
||||
credentials.value = response.data.items
|
||||
platforms.value = response.data.platforms || {}
|
||||
return response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasPlatformCredentials(platform) {
|
||||
return !!platforms.value[platform]
|
||||
}
|
||||
|
||||
return {
|
||||
credentials,
|
||||
platforms,
|
||||
loading,
|
||||
fetchCredentials,
|
||||
hasPlatformCredentials,
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { downloadsApi } from '../services/api'
|
||||
|
||||
export const useDownloadsStore = defineStore('downloads', () => {
|
||||
const downloads = ref([])
|
||||
const recentActivity = ref([])
|
||||
const stats = ref(null)
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
@@ -13,9 +14,9 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
async function fetchDownloads(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
// Use provided per_page or default to 1000 for client-side pagination
|
||||
const response = await downloadsApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
per_page: params.per_page || 1000,
|
||||
...params,
|
||||
})
|
||||
downloads.value = response.data.items
|
||||
@@ -26,6 +27,12 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecentActivity(params = {}) {
|
||||
const response = await downloadsApi.recentActivity(params)
|
||||
recentActivity.value = response.data.items
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function fetchStats(params = {}) {
|
||||
const response = await downloadsApi.stats(params)
|
||||
stats.value = response.data
|
||||
@@ -38,12 +45,14 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
|
||||
return {
|
||||
downloads,
|
||||
recentActivity,
|
||||
stats,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
perPage,
|
||||
fetchDownloads,
|
||||
fetchRecentActivity,
|
||||
fetchStats,
|
||||
retryDownload,
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ export const useSubscriptionsStore = defineStore('subscriptions', () => {
|
||||
async function fetchSubscriptions(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
// Fetch all items for client-side pagination in v-data-table
|
||||
const response = await subscriptionsApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
per_page: 1000,
|
||||
...params,
|
||||
})
|
||||
subscriptions.value = response.data.items
|
||||
|
||||
@@ -59,45 +59,101 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<v-row class="mt-4">
|
||||
<!-- Downloads by Platform -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-title>Downloads by Platform</v-card-title>
|
||||
<v-card-text class="d-flex align-center ga-4 flex-wrap">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="checkingAll"
|
||||
@click="checkAllSources"
|
||||
>
|
||||
Check All Sources
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="warning"
|
||||
prepend-icon="mdi-replay"
|
||||
:loading="retryingAll"
|
||||
:disabled="!failedCount"
|
||||
@click="retryAllFailed"
|
||||
>
|
||||
Retry All Failed ({{ failedCount }})
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<div class="text-body-2 text-grey">
|
||||
<v-icon size="small" class="mr-1">mdi-harddisk</v-icon>
|
||||
Storage: {{ storageStats?.total_size_formatted || 'Calculating...' }}
|
||||
<span v-if="storageStats?.file_count" class="ml-2">
|
||||
({{ storageStats.file_count.toLocaleString() }} files)
|
||||
</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<!-- Running Downloads (auto-refresh) -->
|
||||
<v-col cols="12" md="4">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2" :class="{ 'mdi-spin': runningDownloads.length }">
|
||||
{{ runningDownloads.length ? 'mdi-loading' : 'mdi-download' }}
|
||||
</v-icon>
|
||||
Running
|
||||
<v-chip v-if="runningDownloads.length" size="small" color="info" class="ml-2">
|
||||
{{ runningDownloads.length }}
|
||||
</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="runningDownloads.length"
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="refreshRunning"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="stats?.by_platform">
|
||||
<v-list-item
|
||||
v-for="(count, platform) in stats.by_platform"
|
||||
:key="platform"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="getPlatformColor(platform)">
|
||||
{{ getPlatformIcon(platform) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-capitalize">
|
||||
{{ platform }}
|
||||
<v-list v-if="runningDownloads.length" density="compact">
|
||||
<v-list-item v-for="dl in runningDownloads" :key="dl.id">
|
||||
<v-list-item-title class="text-body-2">
|
||||
{{ truncate(dl.url, 30) }}
|
||||
</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<v-chip size="small">{{ count }}</v-chip>
|
||||
</template>
|
||||
<v-list-item-subtitle>
|
||||
<v-progress-linear
|
||||
indeterminate
|
||||
color="info"
|
||||
height="4"
|
||||
class="mt-1"
|
||||
/>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
No download data yet
|
||||
<v-icon size="32" class="mb-2">mdi-check-circle-outline</v-icon>
|
||||
<div>No active downloads</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-col cols="12" md="8">
|
||||
<v-card>
|
||||
<v-card-title>Recent Downloads</v-card-title>
|
||||
<v-card-title>
|
||||
Recent Activity
|
||||
<v-chip v-if="recentActivity.length" size="small" class="ml-2" variant="tonal">
|
||||
Last 7 days
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="recentDownloads.length">
|
||||
<v-list v-if="recentActivity.length">
|
||||
<v-list-item
|
||||
v-for="download in recentDownloads"
|
||||
v-for="download in recentActivity"
|
||||
:key="download.id"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
@@ -109,7 +165,10 @@
|
||||
{{ truncate(download.url, 40) }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ formatDate(download.created_at) }}
|
||||
{{ formatRelativeTime(download.created_at) }}
|
||||
<span v-if="download.file_count > 0" class="text-success ml-2">
|
||||
{{ download.file_count }} new file{{ download.file_count !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<v-chip
|
||||
@@ -117,13 +176,13 @@
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ download.status }}
|
||||
{{ download.status === 'completed' ? 'new content' : download.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
No recent downloads
|
||||
No recent activity (failures or new downloads)
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
@@ -196,47 +255,135 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { settingsApi } from '../services/api'
|
||||
|
||||
const sourcesStore = useSourcesStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
// State
|
||||
const checkingAll = ref(false)
|
||||
const retryingAll = ref(false)
|
||||
const storageStats = ref(null)
|
||||
const runningDownloads = ref([])
|
||||
let refreshInterval = null
|
||||
|
||||
// Computed
|
||||
const stats = computed(() => downloadsStore.stats)
|
||||
const recentDownloads = computed(() => downloadsStore.downloads.slice(0, 5))
|
||||
const recentActivity = computed(() => downloadsStore.recentActivity)
|
||||
const sourcesWithErrors = computed(() =>
|
||||
sourcesStore.sources.filter(s => s.error_count > 0).slice(0, 5)
|
||||
)
|
||||
const failedCount = computed(() => stats.value?.failed || 0)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboardData()
|
||||
// Start auto-refresh for running downloads
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
async function loadDashboardData() {
|
||||
await Promise.all([
|
||||
sourcesStore.fetchSources(),
|
||||
downloadsStore.fetchStats(),
|
||||
downloadsStore.fetchDownloads({ per_page: 5 }),
|
||||
downloadsStore.fetchRecentActivity({ limit: 10 }),
|
||||
fetchRunningDownloads(),
|
||||
fetchStorageStats(),
|
||||
])
|
||||
})
|
||||
|
||||
function getPlatformIcon(platform) {
|
||||
const icons = {
|
||||
patreon: 'mdi-patreon',
|
||||
subscribestar: 'mdi-star',
|
||||
hentaifoundry: 'mdi-palette',
|
||||
discord: 'mdi-discord',
|
||||
}
|
||||
return icons[platform] || 'mdi-web'
|
||||
}
|
||||
|
||||
function getPlatformColor(platform) {
|
||||
const colors = {
|
||||
patreon: '#FF424D',
|
||||
subscribestar: '#FFD700',
|
||||
hentaifoundry: '#9C27B0',
|
||||
discord: '#5865F2',
|
||||
async function fetchRunningDownloads() {
|
||||
try {
|
||||
const response = await downloadsStore.fetchDownloads({ status: 'running', per_page: 20 })
|
||||
runningDownloads.value = response.items.filter(d => d.status === 'running')
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch running downloads:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStorageStats() {
|
||||
try {
|
||||
const response = await settingsApi.get()
|
||||
storageStats.value = response.data.storage_stats
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch storage stats:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
// Refresh running downloads every 5 seconds when there are active downloads
|
||||
refreshInterval = setInterval(async () => {
|
||||
if (runningDownloads.value.length > 0 || stats.value?.pending > 0) {
|
||||
await fetchRunningDownloads()
|
||||
await downloadsStore.fetchStats()
|
||||
await downloadsStore.fetchRecentActivity({ limit: 10 })
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval)
|
||||
refreshInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRunning() {
|
||||
await fetchRunningDownloads()
|
||||
}
|
||||
|
||||
async function checkAllSources() {
|
||||
checkingAll.value = true
|
||||
try {
|
||||
const enabledSources = sourcesStore.sources.filter(s => s.enabled)
|
||||
let queued = 0
|
||||
for (const source of enabledSources) {
|
||||
try {
|
||||
await sourcesStore.triggerCheck(source.id)
|
||||
queued++
|
||||
} catch (e) {
|
||||
console.error(`Failed to queue ${source.id}:`, e)
|
||||
}
|
||||
}
|
||||
notifications.success(`Queued checks for ${queued} sources`)
|
||||
await fetchRunningDownloads()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to check sources: ${error.message}`)
|
||||
} finally {
|
||||
checkingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryAllFailed() {
|
||||
retryingAll.value = true
|
||||
try {
|
||||
const response = await downloadsStore.fetchDownloads({ status: 'failed', per_page: 100 })
|
||||
const failed = response.items
|
||||
let retried = 0
|
||||
for (const dl of failed) {
|
||||
try {
|
||||
await downloadsStore.retryDownload(dl.id)
|
||||
retried++
|
||||
} catch (e) {
|
||||
console.error(`Failed to retry ${dl.id}:`, e)
|
||||
}
|
||||
}
|
||||
notifications.success(`Queued retry for ${retried} downloads`)
|
||||
await downloadsStore.fetchStats()
|
||||
await fetchRunningDownloads()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to retry downloads: ${error.message}`)
|
||||
} finally {
|
||||
retryingAll.value = false
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function getStatusIcon(status) {
|
||||
@@ -266,6 +413,22 @@ function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr) {
|
||||
if (!dateStr) return 'Unknown'
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins} min${diffMins !== 1 ? 's' : ''} ago`
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function truncate(str, length) {
|
||||
if (!str) return ''
|
||||
if (str.length <= length) return str
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row class="mb-4">
|
||||
<v-col>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="loadLogs"
|
||||
:loading="loading"
|
||||
>
|
||||
Refresh
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-select
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
style="min-width: 140px"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>Recent Download Logs</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate />
|
||||
</div>
|
||||
|
||||
<v-expansion-panels v-else-if="filteredLogs.length">
|
||||
<v-expansion-panel
|
||||
v-for="log in filteredLogs"
|
||||
:key="log.id"
|
||||
>
|
||||
<v-expansion-panel-title>
|
||||
<div class="d-flex align-center ga-2" style="width: 100%">
|
||||
<v-chip
|
||||
:color="getStatusColor(log.status)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ log.status }}
|
||||
</v-chip>
|
||||
<span class="text-truncate" style="max-width: 400px">
|
||||
{{ log.url }}
|
||||
</span>
|
||||
<v-spacer />
|
||||
<span class="text-caption text-grey">
|
||||
{{ formatDate(log.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<div v-if="log.error_message" class="mb-4">
|
||||
<div class="text-subtitle-2 text-error">Error</div>
|
||||
<v-code class="d-block pa-2 bg-error-lighten-5">{{ log.error_message }}</v-code>
|
||||
</div>
|
||||
|
||||
<div v-if="log.stdout" class="mb-4">
|
||||
<div class="text-subtitle-2">Output</div>
|
||||
<pre class="log-output bg-grey-lighten-4 pa-2 rounded">{{ log.stdout }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="log.stderr">
|
||||
<div class="text-subtitle-2 text-warning">Errors/Warnings</div>
|
||||
<pre class="log-output bg-warning-lighten-5 pa-2 rounded">{{ log.stderr }}</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="!log.stdout && !log.stderr && !log.error_message" class="text-grey">
|
||||
No log output available
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<div v-else class="text-center text-grey py-8">
|
||||
<v-icon size="48" class="mb-2">mdi-text-box-outline</v-icon>
|
||||
<div>No logs available</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi } from '../services/api'
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref([])
|
||||
const filterStatus = ref(null)
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'Completed', value: 'completed' },
|
||||
{ title: 'Failed', value: 'failed' },
|
||||
{ title: 'Running', value: 'running' },
|
||||
]
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
if (!filterStatus.value) return logs.value
|
||||
return logs.value.filter(l => l.status === filterStatus.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await settingsApi.getLogs({ limit: 100 })
|
||||
logs.value = response.data.logs
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colors = {
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
running: 'info',
|
||||
pending: 'warning',
|
||||
}
|
||||
return colors[status] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-output {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,29 @@
|
||||
<v-btn color="primary" @click="showAddDialog = true" prepend-icon="mdi-plus">
|
||||
Add Subscription
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-download"
|
||||
@click="exportSubscriptions"
|
||||
>
|
||||
Export
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-upload"
|
||||
@click="triggerImport"
|
||||
>
|
||||
Import
|
||||
</v-btn>
|
||||
<input
|
||||
ref="importInput"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style="display: none"
|
||||
@change="handleImport"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-text-field
|
||||
@@ -30,14 +53,32 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<v-row v-if="selectedIds.length > 0" class="mb-2">
|
||||
<v-col>
|
||||
<v-card color="primary" variant="tonal" class="pa-2">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<span>{{ selectedIds.length }} selected</span>
|
||||
<v-btn size="small" variant="text" @click="bulkEnable(true)">Enable All</v-btn>
|
||||
<v-btn size="small" variant="text" @click="bulkEnable(false)">Disable All</v-btn>
|
||||
<v-btn size="small" variant="text" color="error" @click="bulkDelete">Delete All</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="text" @click="selectedIds = []">Clear Selection</v-btn>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card>
|
||||
<v-data-table
|
||||
v-model="selectedIds"
|
||||
:headers="headers"
|
||||
:items="subscriptionsStore.subscriptions"
|
||||
:loading="subscriptionsStore.loading"
|
||||
:items-per-page="20"
|
||||
v-model:expanded="expandedIds"
|
||||
item-value="id"
|
||||
show-select
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.name="{ item }">
|
||||
@@ -135,6 +176,12 @@
|
||||
<v-icon start size="small">{{ getPlatformIcon(source.platform) }}</v-icon>
|
||||
{{ source.platform }}
|
||||
</v-chip>
|
||||
<v-tooltip v-if="!credentialsStore.hasPlatformCredentials(source.platform)" location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props" color="warning" size="small" class="ml-1">mdi-alert</v-icon>
|
||||
</template>
|
||||
No credentials for {{ source.platform }}. Add them in Settings.
|
||||
</v-tooltip>
|
||||
</td>
|
||||
<td class="text-truncate" style="max-width: 300px">
|
||||
<a :href="source.url" target="_blank" @click.stop>{{ source.url }}</a>
|
||||
@@ -148,7 +195,12 @@
|
||||
@update:model-value="toggleSourceEnabled(source)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ formatDate(source.last_check) }}</td>
|
||||
<td>
|
||||
<div>{{ formatDate(source.last_check) }}</div>
|
||||
<div v-if="source.enabled && source.last_check" class="text-caption text-grey">
|
||||
Next: {{ formatNextCheck(source) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<v-chip v-if="source.error_count > 0" color="error" size="x-small">
|
||||
{{ source.error_count }}
|
||||
@@ -273,15 +325,19 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useSubscriptionsStore } from '../stores/subscriptions'
|
||||
import { useCredentialsStore } from '../stores/credentials'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const subscriptionsStore = useSubscriptionsStore()
|
||||
const credentialsStore = useCredentialsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterEnabled = ref(null)
|
||||
const expandedIds = ref([])
|
||||
const checkingIds = ref([])
|
||||
const selectedIds = ref([])
|
||||
const importInput = ref(null)
|
||||
|
||||
// Subscription dialog
|
||||
const showAddDialog = ref(false)
|
||||
@@ -330,6 +386,7 @@ const headers = [
|
||||
|
||||
onMounted(() => {
|
||||
loadSubscriptions()
|
||||
credentialsStore.fetchCredentials()
|
||||
})
|
||||
|
||||
watch([searchQuery, filterEnabled], () => {
|
||||
@@ -377,6 +434,23 @@ function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function formatNextCheck(source) {
|
||||
if (!source.last_check || !source.check_interval) return 'Unknown'
|
||||
const lastCheck = new Date(source.last_check)
|
||||
const nextCheck = new Date(lastCheck.getTime() + source.check_interval * 1000)
|
||||
const now = new Date()
|
||||
|
||||
if (nextCheck <= now) return 'Due now'
|
||||
|
||||
const diffMs = nextCheck - now
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
|
||||
if (diffMins < 60) return `in ${diffMins}m`
|
||||
if (diffHours < 24) return `in ${diffHours}h`
|
||||
return nextCheck.toLocaleString()
|
||||
}
|
||||
|
||||
async function toggleEnabled(subscription) {
|
||||
try {
|
||||
await subscriptionsStore.updateSubscription(subscription.id, { enabled: !subscription.enabled })
|
||||
@@ -493,6 +567,116 @@ async function deleteSource() {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk actions
|
||||
async function bulkEnable(enabled) {
|
||||
try {
|
||||
for (const id of selectedIds.value) {
|
||||
await subscriptionsStore.updateSubscription(id, { enabled })
|
||||
}
|
||||
notifications.success(`${selectedIds.value.length} subscriptions ${enabled ? 'enabled' : 'disabled'}`)
|
||||
selectedIds.value = []
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to update: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDelete() {
|
||||
if (!confirm(`Delete ${selectedIds.value.length} subscriptions? This cannot be undone.`)) return
|
||||
try {
|
||||
for (const id of selectedIds.value) {
|
||||
await subscriptionsStore.deleteSubscription(id)
|
||||
}
|
||||
notifications.success(`${selectedIds.value.length} subscriptions deleted`)
|
||||
selectedIds.value = []
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Import/Export
|
||||
function exportSubscriptions() {
|
||||
const data = {
|
||||
version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
subscriptions: subscriptionsStore.subscriptions.map(sub => ({
|
||||
name: sub.name,
|
||||
description: sub.description,
|
||||
priority: sub.priority,
|
||||
enabled: sub.enabled,
|
||||
sources: sub.sources?.map(s => ({
|
||||
platform: s.platform,
|
||||
url: s.url,
|
||||
enabled: s.enabled,
|
||||
check_interval: s.check_interval,
|
||||
})) || [],
|
||||
})),
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `subscriptions-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
notifications.success(`Exported ${data.subscriptions.length} subscriptions`)
|
||||
}
|
||||
|
||||
function triggerImport() {
|
||||
importInput.value.click()
|
||||
}
|
||||
|
||||
async function handleImport(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
|
||||
if (!data.subscriptions || !Array.isArray(data.subscriptions)) {
|
||||
throw new Error('Invalid format: missing subscriptions array')
|
||||
}
|
||||
|
||||
let imported = 0
|
||||
for (const sub of data.subscriptions) {
|
||||
try {
|
||||
// Create subscription
|
||||
const created = await subscriptionsStore.createSubscription({
|
||||
name: sub.name,
|
||||
description: sub.description,
|
||||
priority: sub.priority || 0,
|
||||
enabled: sub.enabled !== false,
|
||||
})
|
||||
|
||||
// Add sources
|
||||
if (sub.sources && created.id) {
|
||||
for (const source of sub.sources) {
|
||||
await subscriptionsStore.addSource(created.id, {
|
||||
platform: source.platform,
|
||||
url: source.url,
|
||||
enabled: source.enabled !== false,
|
||||
check_interval: source.check_interval,
|
||||
})
|
||||
}
|
||||
}
|
||||
imported++
|
||||
} catch (e) {
|
||||
console.error(`Failed to import ${sub.name}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
notifications.success(`Imported ${imported} of ${data.subscriptions.length} subscriptions`)
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Import failed: ${error.message}`)
|
||||
}
|
||||
|
||||
// Reset input
|
||||
event.target.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user