rapid interations of server side app and firefox extension
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<v-navigation-drawer v-model="drawer" app>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-image-multiple"
|
||||
title="Gallery Subscriber"
|
||||
subtitle="Download Manager"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list nav density="compact">
|
||||
<v-list-item
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
/>
|
||||
</v-list>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar app elevation="1">
|
||||
<v-app-bar-nav-icon @click="drawer = !drawer" />
|
||||
<v-toolbar-title>{{ currentPageTitle }}</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn icon @click="toggleTheme">
|
||||
<v-icon>{{ isDark ? 'mdi-weather-sunny' : 'mdi-weather-night' }}</v-icon>
|
||||
</v-btn>
|
||||
</v-app-bar>
|
||||
|
||||
<v-main>
|
||||
<v-container fluid>
|
||||
<router-view />
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
<v-snackbar
|
||||
v-model="notification.show"
|
||||
:color="notification.color"
|
||||
:timeout="3000"
|
||||
>
|
||||
{{ notification.message }}
|
||||
<template v-slot:actions>
|
||||
<v-btn variant="text" @click="notification.show = false">Close</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useNotificationStore } from './stores/notifications'
|
||||
import { useWebSocketStore } from './stores/websocket'
|
||||
|
||||
const theme = useTheme()
|
||||
const route = useRoute()
|
||||
const notificationStore = useNotificationStore()
|
||||
const wsStore = useWebSocketStore()
|
||||
|
||||
// Initialize WebSocket listeners
|
||||
onMounted(() => {
|
||||
wsStore.init()
|
||||
})
|
||||
|
||||
const drawer = ref(true)
|
||||
|
||||
const navItems = [
|
||||
{ title: 'Dashboard', icon: 'mdi-view-dashboard', path: '/' },
|
||||
{ title: 'Subscriptions', icon: 'mdi-account-multiple', path: '/subscriptions' },
|
||||
{ title: 'Downloads', icon: 'mdi-download', path: '/downloads' },
|
||||
{ title: 'Credentials', icon: 'mdi-key', path: '/credentials' },
|
||||
{ title: 'Settings', icon: 'mdi-cog', path: '/settings' },
|
||||
]
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
const item = navItems.find(item => item.path === route.path)
|
||||
return item?.title || 'Gallery Subscriber'
|
||||
})
|
||||
|
||||
const isDark = computed(() => theme.global.current.value.dark)
|
||||
|
||||
const toggleTheme = () => {
|
||||
theme.global.name.value = isDark.value ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
const notification = computed(() => notificationStore)
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws/events`
|
||||
|
||||
// Singleton WebSocket connection
|
||||
let ws = null
|
||||
let reconnectTimeout = null
|
||||
const listeners = new Map()
|
||||
const isConnected = ref(false)
|
||||
const lastEvent = ref(null)
|
||||
|
||||
function connect() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ws = new WebSocket(WS_URL)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
isConnected.value = true
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected')
|
||||
isConnected.value = false
|
||||
ws = null
|
||||
|
||||
// Reconnect after 5 seconds
|
||||
if (!reconnectTimeout) {
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectTimeout = null
|
||||
connect()
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
lastEvent.value = data
|
||||
|
||||
// Handle ping
|
||||
if (data.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong' }))
|
||||
return
|
||||
}
|
||||
|
||||
// Notify all listeners for this event type
|
||||
const typeListeners = listeners.get(data.type) || []
|
||||
typeListeners.forEach(callback => {
|
||||
try {
|
||||
callback(data.data)
|
||||
} catch (e) {
|
||||
console.error('Error in WebSocket listener:', e)
|
||||
}
|
||||
})
|
||||
|
||||
// Also notify wildcard listeners
|
||||
const wildcardListeners = listeners.get('*') || []
|
||||
wildcardListeners.forEach(callback => {
|
||||
try {
|
||||
callback(data.type, data.data)
|
||||
} catch (e) {
|
||||
console.error('Error in WebSocket listener:', e)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create WebSocket:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
isConnected.value = false
|
||||
}
|
||||
|
||||
function subscribe(eventType, callback) {
|
||||
if (!listeners.has(eventType)) {
|
||||
listeners.set(eventType, [])
|
||||
}
|
||||
listeners.get(eventType).push(callback)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const typeListeners = listeners.get(eventType)
|
||||
if (typeListeners) {
|
||||
const index = typeListeners.indexOf(callback)
|
||||
if (index > -1) {
|
||||
typeListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function send(type, data = {}) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type, data }))
|
||||
}
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
onMounted(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
lastEvent,
|
||||
subscribe,
|
||||
send,
|
||||
connect,
|
||||
disconnect,
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-connect on import
|
||||
if (typeof window !== 'undefined') {
|
||||
connect()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import vuetify from './plugins/vuetify'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'vuetify/styles'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
|
||||
export default createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
themes: {
|
||||
dark: {
|
||||
colors: {
|
||||
primary: '#1976D2',
|
||||
secondary: '#424242',
|
||||
accent: '#82B1FF',
|
||||
error: '#FF5252',
|
||||
info: '#2196F3',
|
||||
success: '#4CAF50',
|
||||
warning: '#FFC107',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
colors: {
|
||||
primary: '#1976D2',
|
||||
secondary: '#424242',
|
||||
accent: '#82B1FF',
|
||||
error: '#FF5252',
|
||||
info: '#2196F3',
|
||||
success: '#4CAF50',
|
||||
warning: '#FFC107',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
VCard: {
|
||||
elevation: 2,
|
||||
},
|
||||
VBtn: {
|
||||
variant: 'flat',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
},
|
||||
{
|
||||
path: '/subscriptions',
|
||||
name: 'Subscriptions',
|
||||
component: () => import('../views/Subscriptions.vue'),
|
||||
},
|
||||
// Legacy route redirects
|
||||
{
|
||||
path: '/sources',
|
||||
redirect: '/subscriptions',
|
||||
},
|
||||
{
|
||||
path: '/downloads',
|
||||
name: 'Downloads',
|
||||
component: () => import('../views/Downloads.vue'),
|
||||
},
|
||||
{
|
||||
path: '/credentials',
|
||||
name: 'Credentials',
|
||||
component: () => import('../views/Credentials.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,66 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Subscriptions API
|
||||
export const subscriptionsApi = {
|
||||
list: (params = {}) => api.get('/subscriptions', { params }),
|
||||
get: (id) => api.get(`/subscriptions/${id}`),
|
||||
create: (data) => api.post('/subscriptions', data),
|
||||
update: (id, data) => api.patch(`/subscriptions/${id}`, data),
|
||||
delete: (id) => api.delete(`/subscriptions/${id}`),
|
||||
triggerCheck: (id) => api.post(`/subscriptions/${id}/check`),
|
||||
listSources: (id) => api.get(`/subscriptions/${id}/sources`),
|
||||
addSource: (id, data) => api.post(`/subscriptions/${id}/sources`, data),
|
||||
}
|
||||
|
||||
// Sources API
|
||||
export const sourcesApi = {
|
||||
list: (params = {}) => api.get('/sources', { params }),
|
||||
get: (id) => api.get(`/sources/${id}`),
|
||||
create: (data) => api.post('/sources', data),
|
||||
update: (id, data) => api.patch(`/sources/${id}`, data),
|
||||
delete: (id) => api.delete(`/sources/${id}`),
|
||||
triggerCheck: (id) => api.post(`/sources/${id}/check`),
|
||||
}
|
||||
|
||||
// Downloads API
|
||||
export const downloadsApi = {
|
||||
list: (params = {}) => api.get('/downloads', { params }),
|
||||
get: (id) => api.get(`/downloads/${id}`),
|
||||
retry: (id) => api.post(`/downloads/${id}/retry`),
|
||||
stats: (params = {}) => api.get('/downloads/stats', { params }),
|
||||
}
|
||||
|
||||
// Credentials API
|
||||
export const credentialsApi = {
|
||||
list: () => api.get('/credentials'),
|
||||
upload: (data) => api.post('/credentials', data),
|
||||
delete: (platform) => api.delete(`/credentials/${platform}`),
|
||||
verify: (platform) => api.post('/credentials/verify', { platform }),
|
||||
}
|
||||
|
||||
// Settings API
|
||||
export const settingsApi = {
|
||||
get: () => api.get('/settings'),
|
||||
update: (data) => api.patch('/settings', data),
|
||||
getGalleryDL: () => api.get('/settings/gallery-dl'),
|
||||
updateGalleryDL: (config) => api.put('/settings/gallery-dl', { config }),
|
||||
getApiKey: () => api.get('/settings/api-key'),
|
||||
regenerateApiKey: () => api.post('/settings/api-key/regenerate'),
|
||||
}
|
||||
|
||||
// Platforms API
|
||||
export const platformsApi = {
|
||||
list: () => api.get('/platforms'),
|
||||
get: (platform) => api.get(`/platforms/${platform}`),
|
||||
getConfigSchema: (platform) => api.get(`/platforms/${platform}/config-schema`),
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { downloadsApi } from '../services/api'
|
||||
|
||||
export const useDownloadsStore = defineStore('downloads', () => {
|
||||
const downloads = ref([])
|
||||
const stats = ref(null)
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
async function fetchDownloads(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await downloadsApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
...params,
|
||||
})
|
||||
downloads.value = response.data.items
|
||||
total.value = response.data.total
|
||||
return response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats(params = {}) {
|
||||
const response = await downloadsApi.stats(params)
|
||||
stats.value = response.data
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function retryDownload(id) {
|
||||
return await downloadsApi.retry(id)
|
||||
}
|
||||
|
||||
return {
|
||||
downloads,
|
||||
stats,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
perPage,
|
||||
fetchDownloads,
|
||||
fetchStats,
|
||||
retryDownload,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', () => {
|
||||
const show = ref(false)
|
||||
const message = ref('')
|
||||
const color = ref('info')
|
||||
|
||||
function notify(msg, type = 'info') {
|
||||
message.value = msg
|
||||
color.value = type
|
||||
show.value = true
|
||||
}
|
||||
|
||||
function success(msg) {
|
||||
notify(msg, 'success')
|
||||
}
|
||||
|
||||
function error(msg) {
|
||||
notify(msg, 'error')
|
||||
}
|
||||
|
||||
function warning(msg) {
|
||||
notify(msg, 'warning')
|
||||
}
|
||||
|
||||
function info(msg) {
|
||||
notify(msg, 'info')
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
message,
|
||||
color,
|
||||
notify,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { settingsApi } from '../services/api'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const settings = ref({})
|
||||
const galleryDLConfig = ref({})
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await settingsApi.get()
|
||||
settings.value = response.data.settings
|
||||
return response.data.settings
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSettings(data) {
|
||||
const response = await settingsApi.update(data)
|
||||
settings.value = response.data.settings
|
||||
return response.data.settings
|
||||
}
|
||||
|
||||
async function fetchGalleryDLConfig() {
|
||||
const response = await settingsApi.getGalleryDL()
|
||||
galleryDLConfig.value = response.data.config
|
||||
return response.data.config
|
||||
}
|
||||
|
||||
async function updateGalleryDLConfig(config) {
|
||||
const response = await settingsApi.updateGalleryDL(config)
|
||||
galleryDLConfig.value = response.data.config
|
||||
return response.data.config
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
galleryDLConfig,
|
||||
loading,
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
fetchGalleryDLConfig,
|
||||
updateGalleryDLConfig,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { sourcesApi } from '../services/api'
|
||||
|
||||
export const useSourcesStore = defineStore('sources', () => {
|
||||
const sources = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const enabledSources = computed(() => sources.value.filter(s => s.enabled))
|
||||
const disabledSources = computed(() => sources.value.filter(s => !s.enabled))
|
||||
|
||||
async function fetchSources(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await sourcesApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
...params,
|
||||
})
|
||||
sources.value = response.data.items
|
||||
total.value = response.data.total
|
||||
return response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createSource(data) {
|
||||
const response = await sourcesApi.create(data)
|
||||
sources.value.push(response.data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function updateSource(id, data) {
|
||||
const response = await sourcesApi.update(id, data)
|
||||
const index = sources.value.findIndex(s => s.id === id)
|
||||
if (index !== -1) {
|
||||
sources.value[index] = response.data
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteSource(id) {
|
||||
await sourcesApi.delete(id)
|
||||
sources.value = sources.value.filter(s => s.id !== id)
|
||||
}
|
||||
|
||||
async function triggerCheck(id) {
|
||||
return await sourcesApi.triggerCheck(id)
|
||||
}
|
||||
|
||||
return {
|
||||
sources,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
perPage,
|
||||
enabledSources,
|
||||
disabledSources,
|
||||
fetchSources,
|
||||
createSource,
|
||||
updateSource,
|
||||
deleteSource,
|
||||
triggerCheck,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { subscriptionsApi, sourcesApi } from '../services/api'
|
||||
|
||||
export const useSubscriptionsStore = defineStore('subscriptions', () => {
|
||||
const subscriptions = ref([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
const enabledSubscriptions = computed(() => subscriptions.value.filter(s => s.enabled))
|
||||
const disabledSubscriptions = computed(() => subscriptions.value.filter(s => !s.enabled))
|
||||
|
||||
async function fetchSubscriptions(params = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await subscriptionsApi.list({
|
||||
page: currentPage.value,
|
||||
per_page: perPage.value,
|
||||
...params,
|
||||
})
|
||||
subscriptions.value = response.data.items
|
||||
total.value = response.data.total
|
||||
return response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getSubscription(id) {
|
||||
const response = await subscriptionsApi.get(id)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function createSubscription(data) {
|
||||
const response = await subscriptionsApi.create(data)
|
||||
subscriptions.value.push(response.data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function updateSubscription(id, data) {
|
||||
const response = await subscriptionsApi.update(id, data)
|
||||
const index = subscriptions.value.findIndex(s => s.id === id)
|
||||
if (index !== -1) {
|
||||
subscriptions.value[index] = response.data
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteSubscription(id) {
|
||||
await subscriptionsApi.delete(id)
|
||||
subscriptions.value = subscriptions.value.filter(s => s.id !== id)
|
||||
}
|
||||
|
||||
async function triggerCheck(id) {
|
||||
return await subscriptionsApi.triggerCheck(id)
|
||||
}
|
||||
|
||||
async function addSource(subscriptionId, sourceData) {
|
||||
const response = await subscriptionsApi.addSource(subscriptionId, sourceData)
|
||||
// Update the subscription in the store
|
||||
const index = subscriptions.value.findIndex(s => s.id === subscriptionId)
|
||||
if (index !== -1 && subscriptions.value[index].sources) {
|
||||
subscriptions.value[index].sources.push(response.data)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function deleteSource(sourceId) {
|
||||
await sourcesApi.delete(sourceId)
|
||||
// Update subscriptions that contain this source
|
||||
for (const sub of subscriptions.value) {
|
||||
if (sub.sources) {
|
||||
sub.sources = sub.sources.filter(s => s.id !== sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSource(sourceId, data) {
|
||||
const response = await sourcesApi.update(sourceId, data)
|
||||
// Update in subscriptions
|
||||
for (const sub of subscriptions.value) {
|
||||
if (sub.sources) {
|
||||
const idx = sub.sources.findIndex(s => s.id === sourceId)
|
||||
if (idx !== -1) {
|
||||
sub.sources[idx] = response.data
|
||||
}
|
||||
}
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
perPage,
|
||||
enabledSubscriptions,
|
||||
disabledSubscriptions,
|
||||
fetchSubscriptions,
|
||||
getSubscription,
|
||||
createSubscription,
|
||||
updateSubscription,
|
||||
deleteSubscription,
|
||||
triggerCheck,
|
||||
addSource,
|
||||
deleteSource,
|
||||
updateSource,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useWebSocket } from '../composables/useWebSocket'
|
||||
import { useNotificationStore } from './notifications'
|
||||
import { useDownloadsStore } from './downloads'
|
||||
import { useSourcesStore } from './sources'
|
||||
|
||||
export const useWebSocketStore = defineStore('websocket', () => {
|
||||
const { isConnected, subscribe } = useWebSocket()
|
||||
const initialized = ref(false)
|
||||
|
||||
function init() {
|
||||
if (initialized.value) return
|
||||
initialized.value = true
|
||||
|
||||
const notifications = useNotificationStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const sourcesStore = useSourcesStore()
|
||||
|
||||
// Subscribe to download events
|
||||
subscribe('download.started', (data) => {
|
||||
notifications.info(`Download started for source ${data.source_id}`)
|
||||
})
|
||||
|
||||
subscribe('download.completed', (data) => {
|
||||
notifications.success(`Download completed: ${data.file_count} files`)
|
||||
// Refresh downloads list
|
||||
downloadsStore.fetchDownloads()
|
||||
downloadsStore.fetchStats()
|
||||
})
|
||||
|
||||
subscribe('download.failed', (data) => {
|
||||
notifications.error(`Download failed: ${data.error_message}`)
|
||||
downloadsStore.fetchDownloads()
|
||||
})
|
||||
|
||||
// Subscribe to source events
|
||||
subscribe('source.updated', (data) => {
|
||||
sourcesStore.fetchSources()
|
||||
})
|
||||
|
||||
// Subscribe to credential events
|
||||
subscribe('credential.expiring', (data) => {
|
||||
notifications.warning(`${data.platform} credentials expiring in ${data.expires_in_hours} hours`)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
init,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<!-- Platform Cards -->
|
||||
<v-col
|
||||
v-for="(info, platform) in platforms"
|
||||
:key="platform"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon :color="getPlatformColor(platform)" class="mr-2">
|
||||
{{ getPlatformIcon(platform) }}
|
||||
</v-icon>
|
||||
{{ info.name }}
|
||||
<v-spacer />
|
||||
<v-chip
|
||||
:color="getCredentialStatus(platform).color"
|
||||
size="small"
|
||||
>
|
||||
{{ getCredentialStatus(platform).text }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-4">{{ info.description }}</p>
|
||||
|
||||
<div v-if="getCredential(platform)" class="mb-4">
|
||||
<v-list-item density="compact" class="px-0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon color="success">mdi-check-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Credentials stored</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
Last verified: {{ formatDate(getCredential(platform).last_verified) }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-if="getCredential(platform).expires_at"
|
||||
density="compact"
|
||||
class="px-0"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="isExpiringSoon(platform) ? 'warning' : 'grey'">
|
||||
mdi-clock-outline
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
Expires: {{ formatDate(getCredential(platform).expires_at) }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-else
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
No credentials stored for {{ info.name }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
v-if="getCredential(platform)"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="verifyCredential(platform)"
|
||||
:loading="verifyingPlatform === platform"
|
||||
>
|
||||
Verify
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="openUploadDialog(platform, info)"
|
||||
>
|
||||
{{ getCredential(platform) ? 'Update' : 'Add' }} Credentials
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="getCredential(platform)"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="confirmDelete(platform)"
|
||||
>
|
||||
Remove
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Upload Dialog -->
|
||||
<v-dialog v-model="uploadDialog" max-width="600">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
{{ uploadPlatform?.name }} Credentials
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-alert type="info" variant="tonal" class="mb-4">
|
||||
<template v-if="uploadPlatform?.auth_type === 'token'">
|
||||
Paste your Discord user token below.
|
||||
<strong>Never share your token with anyone!</strong>
|
||||
</template>
|
||||
<template v-else>
|
||||
Paste your cookies in Netscape format, or use the Firefox extension
|
||||
for automatic export.
|
||||
</template>
|
||||
</v-alert>
|
||||
|
||||
<v-textarea
|
||||
v-model="credentialData"
|
||||
:label="uploadPlatform?.auth_type === 'token' ? 'Token' : 'Cookies (Netscape format)'"
|
||||
rows="8"
|
||||
variant="outlined"
|
||||
:placeholder="getPlaceholder()"
|
||||
/>
|
||||
|
||||
<v-expansion-panels class="mt-4">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
How to get {{ uploadPlatform?.auth_type === 'token' ? 'your token' : 'cookies' }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<template v-if="uploadPlatform?.auth_type === 'token'">
|
||||
<ol>
|
||||
<li>Open Discord in your browser</li>
|
||||
<li>Press F12 to open Developer Tools</li>
|
||||
<li>Go to the Network tab</li>
|
||||
<li>Refresh the page</li>
|
||||
<li>Look for a request to discord.com/api</li>
|
||||
<li>Find the "Authorization" header in the request headers</li>
|
||||
<li>Copy the token value (without "Bearer " prefix)</li>
|
||||
</ol>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p><strong>Option 1: Firefox Extension (Recommended)</strong></p>
|
||||
<p>Install our Firefox extension to automatically export cookies.</p>
|
||||
<p class="mt-4"><strong>Option 2: Browser Export</strong></p>
|
||||
<ol>
|
||||
<li>Install a "cookies.txt" browser extension</li>
|
||||
<li>Visit {{ uploadPlatform?.name }} and log in</li>
|
||||
<li>Use the extension to export cookies</li>
|
||||
<li>Paste the exported text here</li>
|
||||
</ol>
|
||||
</template>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="uploadDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="uploading"
|
||||
:disabled="!credentialData"
|
||||
@click="uploadCredentials"
|
||||
>
|
||||
Save
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Remove Credentials</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to remove credentials for
|
||||
<strong>{{ platforms[deletePlatform]?.name }}</strong>?
|
||||
Downloads from this platform may fail without valid credentials.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="deleteCredential">Remove</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { credentialsApi, platformsApi } from '../services/api'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const platforms = ref({})
|
||||
const credentials = ref([])
|
||||
const loading = ref(false)
|
||||
const uploadDialog = ref(false)
|
||||
const uploadPlatform = ref(null)
|
||||
const uploadPlatformKey = ref('')
|
||||
const credentialData = ref('')
|
||||
const uploading = ref(false)
|
||||
const deleteDialog = ref(false)
|
||||
const deletePlatform = ref('')
|
||||
const verifyingPlatform = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [platformsRes, credentialsRes] = await Promise.all([
|
||||
platformsApi.list(),
|
||||
credentialsApi.list(),
|
||||
])
|
||||
platforms.value = platformsRes.data.platforms
|
||||
credentials.value = credentialsRes.data.items
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to load: ${error.message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function getCredential(platform) {
|
||||
return credentials.value.find(c => c.platform === platform)
|
||||
}
|
||||
|
||||
function getCredentialStatus(platform) {
|
||||
const cred = getCredential(platform)
|
||||
if (!cred) return { color: 'grey', text: 'Not configured' }
|
||||
if (isExpiringSoon(platform)) return { color: 'warning', text: 'Expiring soon' }
|
||||
return { color: 'success', text: 'Active' }
|
||||
}
|
||||
|
||||
function isExpiringSoon(platform) {
|
||||
const cred = getCredential(platform)
|
||||
if (!cred?.expires_at) return false
|
||||
const expiresAt = new Date(cred.expires_at)
|
||||
const daysUntilExpiry = (expiresAt - new Date()) / (1000 * 60 * 60 * 24)
|
||||
return daysUntilExpiry < 7
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function openUploadDialog(platform, info) {
|
||||
uploadPlatformKey.value = platform
|
||||
uploadPlatform.value = info
|
||||
credentialData.value = ''
|
||||
uploadDialog.value = true
|
||||
}
|
||||
|
||||
function getPlaceholder() {
|
||||
if (uploadPlatform.value?.auth_type === 'token') {
|
||||
return 'mfa.AbCdEf123456...'
|
||||
}
|
||||
return '# Netscape HTTP Cookie File\n.patreon.com\tTRUE\t/\tTRUE\t0\tsession_id\tabc123...'
|
||||
}
|
||||
|
||||
async function uploadCredentials() {
|
||||
uploading.value = true
|
||||
try {
|
||||
await credentialsApi.upload({
|
||||
platform: uploadPlatformKey.value,
|
||||
credential_type: uploadPlatform.value?.auth_type === 'token' ? 'token' : 'cookies',
|
||||
data: credentialData.value,
|
||||
})
|
||||
notifications.success('Credentials saved')
|
||||
uploadDialog.value = false
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.response?.data?.error || error.message}`)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCredential(platform) {
|
||||
verifyingPlatform.value = platform
|
||||
try {
|
||||
const response = await credentialsApi.verify(platform)
|
||||
if (response.data.is_valid) {
|
||||
notifications.success(`${platforms.value[platform].name} credentials verified`)
|
||||
} else {
|
||||
notifications.warning(`Verification failed: ${response.data.error || 'Unknown error'}`)
|
||||
}
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
notifications.error(`Verification failed: ${error.message}`)
|
||||
} finally {
|
||||
verifyingPlatform.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(platform) {
|
||||
deletePlatform.value = platform
|
||||
deleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteCredential() {
|
||||
try {
|
||||
await credentialsApi.delete(deletePlatform.value)
|
||||
notifications.success('Credentials removed')
|
||||
deleteDialog.value = false
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to remove: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row>
|
||||
<!-- Stats Cards -->
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text class="d-flex align-center">
|
||||
<v-avatar color="primary" size="48" class="mr-4">
|
||||
<v-icon>mdi-account-multiple</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h4">{{ sourcesStore.sources.length }}</div>
|
||||
<div class="text-caption">Total Sources</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text class="d-flex align-center">
|
||||
<v-avatar color="success" size="48" class="mr-4">
|
||||
<v-icon>mdi-check-circle</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h4">{{ stats?.completed || 0 }}</div>
|
||||
<div class="text-caption">Completed Downloads</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text class="d-flex align-center">
|
||||
<v-avatar color="error" size="48" class="mr-4">
|
||||
<v-icon>mdi-alert-circle</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h4">{{ stats?.failed || 0 }}</div>
|
||||
<div class="text-caption">Failed Downloads</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-card>
|
||||
<v-card-text class="d-flex align-center">
|
||||
<v-avatar color="warning" size="48" class="mr-4">
|
||||
<v-icon>mdi-clock-outline</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h4">{{ stats?.pending || 0 }}</div>
|
||||
<div class="text-caption">Pending Downloads</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<!-- Downloads by Platform -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title>Downloads by Platform</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-item-title>
|
||||
<template v-slot:append>
|
||||
<v-chip size="small">{{ count }}</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
No download data yet
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title>Recent Downloads</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list v-if="recentDownloads.length">
|
||||
<v-list-item
|
||||
v-for="download in recentDownloads"
|
||||
:key="download.id"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon :color="getStatusColor(download.status)">
|
||||
{{ getStatusIcon(download.status) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ truncate(download.url, 40) }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ formatDate(download.created_at) }}
|
||||
</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<v-chip
|
||||
:color="getStatusColor(download.status)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ download.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
No recent downloads
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn variant="text" to="/downloads">View All Downloads</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-4">
|
||||
<!-- Sources needing attention -->
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
Sources Needing Attention
|
||||
<v-chip class="ml-2" color="error" size="small" v-if="sourcesWithErrors.length">
|
||||
{{ sourcesWithErrors.length }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-table v-if="sourcesWithErrors.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Platform</th>
|
||||
<th>Error Count</th>
|
||||
<th>Last Check</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="source in sourcesWithErrors" :key="source.id">
|
||||
<td>{{ source.subscription_name || source.subscription?.name || 'Unknown' }}</td>
|
||||
<td class="text-capitalize">{{ source.platform }}</td>
|
||||
<td>
|
||||
<v-chip color="error" size="small">
|
||||
{{ source.error_count }} errors
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>{{ formatDate(source.last_check) }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="retrySource(source)"
|
||||
>
|
||||
Retry
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
:to="`/sources/${source.id}/edit`"
|
||||
>
|
||||
Edit
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<div v-else class="text-center text-grey py-4">
|
||||
<v-icon size="48" class="mb-2">mdi-check-circle-outline</v-icon>
|
||||
<div>All sources are healthy!</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const sourcesStore = useSourcesStore()
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const stats = computed(() => downloadsStore.stats)
|
||||
const recentDownloads = computed(() => downloadsStore.downloads.slice(0, 5))
|
||||
const sourcesWithErrors = computed(() =>
|
||||
sourcesStore.sources.filter(s => s.error_count > 0).slice(0, 5)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
sourcesStore.fetchSources(),
|
||||
downloadsStore.fetchStats(),
|
||||
downloadsStore.fetchDownloads({ per_page: 5 }),
|
||||
])
|
||||
})
|
||||
|
||||
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',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
completed: 'mdi-check-circle',
|
||||
failed: 'mdi-alert-circle',
|
||||
running: 'mdi-loading mdi-spin',
|
||||
pending: 'mdi-clock-outline',
|
||||
skipped: 'mdi-skip-next',
|
||||
}
|
||||
return icons[status] || 'mdi-help-circle'
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colors = {
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
running: 'info',
|
||||
pending: 'warning',
|
||||
skipped: 'grey',
|
||||
}
|
||||
return colors[status] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function truncate(str, length) {
|
||||
if (!str) return ''
|
||||
if (str.length <= length) return str
|
||||
return str.substring(0, length) + '...'
|
||||
}
|
||||
|
||||
async function retrySource(source) {
|
||||
try {
|
||||
await sourcesStore.triggerCheck(source.id)
|
||||
notifications.success(`Queued check for ${source.subscription_name || source.platform}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to queue check: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Filters -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filterSourceId"
|
||||
:items="sourceOptions"
|
||||
label="Source"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-model="filterFromDate"
|
||||
label="From Date"
|
||||
type="date"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-model="filterToDate"
|
||||
label="To Date"
|
||||
type="date"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Downloads Table -->
|
||||
<v-card>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="downloadsStore.downloads"
|
||||
:loading="downloadsStore.loading"
|
||||
:items-per-page="20"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.status="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusColor(item.status)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="small">{{ getStatusIcon(item.status) }}</v-icon>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.url="{ item }">
|
||||
<span :title="item.url">{{ truncateUrl(item.url) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.error_type="{ item }">
|
||||
<v-chip
|
||||
v-if="item.error_type"
|
||||
color="error"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ formatErrorType(item.error_type) }}
|
||||
</v-chip>
|
||||
<span v-else class="text-grey">-</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.file_count="{ item }">
|
||||
<v-chip v-if="item.file_count > 0" color="success" size="small">
|
||||
{{ item.file_count }} files
|
||||
</v-chip>
|
||||
<span v-else class="text-grey">0</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.created_at="{ item }">
|
||||
{{ formatDate(item.created_at) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.duration="{ item }">
|
||||
{{ formatDuration(item) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<v-btn
|
||||
v-if="item.status === 'failed'"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="retryDownload(item)"
|
||||
:loading="retryingIds.includes(item.id)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent">Retry</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="showDetails(item)"
|
||||
>
|
||||
<v-icon>mdi-information</v-icon>
|
||||
<v-tooltip activator="parent">Details</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Details Dialog -->
|
||||
<v-dialog v-model="detailsDialog" max-width="700">
|
||||
<v-card v-if="selectedDownload">
|
||||
<v-card-title>
|
||||
Download Details
|
||||
<v-chip
|
||||
:color="getStatusColor(selectedDownload.status)"
|
||||
size="small"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ selectedDownload.status }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-table density="compact">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="font-weight-bold" width="150">URL</td>
|
||||
<td style="word-break: break-all">{{ selectedDownload.url }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Source ID</td>
|
||||
<td>{{ selectedDownload.source_id || 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Files Downloaded</td>
|
||||
<td>{{ selectedDownload.file_count }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Started</td>
|
||||
<td>{{ formatDate(selectedDownload.started_at) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="font-weight-bold">Completed</td>
|
||||
<td>{{ formatDate(selectedDownload.completed_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="selectedDownload.error_type">
|
||||
<td class="font-weight-bold">Error Type</td>
|
||||
<td class="text-error">{{ formatErrorType(selectedDownload.error_type) }}</td>
|
||||
</tr>
|
||||
<tr v-if="selectedDownload.error_message">
|
||||
<td class="font-weight-bold">Error Message</td>
|
||||
<td class="text-error">{{ selectedDownload.error_message }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<v-expansion-panels class="mt-4" v-if="selectedDownload.metadata">
|
||||
<v-expansion-panel v-if="selectedDownload.metadata.stdout">
|
||||
<v-expansion-panel-title>Output Log</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<pre class="text-caption" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stdout }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel v-if="selectedDownload.metadata.stderr">
|
||||
<v-expansion-panel-title>Error Log</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<pre class="text-caption text-error" style="white-space: pre-wrap; max-height: 300px; overflow: auto">{{ selectedDownload.metadata.stderr }}</pre>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="selectedDownload.status === 'failed'"
|
||||
color="primary"
|
||||
@click="retryDownload(selectedDownload); detailsDialog = false"
|
||||
>
|
||||
Retry Download
|
||||
</v-btn>
|
||||
<v-btn variant="text" @click="detailsDialog = false">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useDownloadsStore } from '../stores/downloads'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const downloadsStore = useDownloadsStore()
|
||||
const sourcesStore = useSourcesStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const filterStatus = ref(null)
|
||||
const filterSourceId = ref(null)
|
||||
const filterFromDate = ref(null)
|
||||
const filterToDate = ref(null)
|
||||
const detailsDialog = ref(false)
|
||||
const selectedDownload = ref(null)
|
||||
const retryingIds = ref([])
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'Pending', value: 'pending' },
|
||||
{ title: 'Running', value: 'running' },
|
||||
{ title: 'Completed', value: 'completed' },
|
||||
{ title: 'Failed', value: 'failed' },
|
||||
{ title: 'Skipped', value: 'skipped' },
|
||||
]
|
||||
|
||||
const sourceOptions = computed(() =>
|
||||
sourcesStore.sources.map(s => ({ title: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const headers = [
|
||||
{ title: 'Status', key: 'status', width: 120 },
|
||||
{ title: 'URL', key: 'url' },
|
||||
{ title: 'Error', key: 'error_type', width: 150 },
|
||||
{ title: 'Files', key: 'file_count', width: 100 },
|
||||
{ title: 'Date', key: 'created_at', width: 180 },
|
||||
{ title: 'Duration', key: 'duration', width: 100 },
|
||||
{ title: 'Actions', key: 'actions', width: 100, sortable: false },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadDownloads(),
|
||||
sourcesStore.fetchSources(),
|
||||
])
|
||||
})
|
||||
|
||||
watch([filterStatus, filterSourceId, filterFromDate, filterToDate], () => {
|
||||
loadDownloads()
|
||||
})
|
||||
|
||||
async function loadDownloads() {
|
||||
const params = {}
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterSourceId.value) params.source_id = filterSourceId.value
|
||||
if (filterFromDate.value) params.from_date = filterFromDate.value
|
||||
if (filterToDate.value) params.to_date = filterToDate.value
|
||||
await downloadsStore.fetchDownloads(params)
|
||||
}
|
||||
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
completed: 'mdi-check-circle',
|
||||
failed: 'mdi-alert-circle',
|
||||
running: 'mdi-loading',
|
||||
pending: 'mdi-clock-outline',
|
||||
skipped: 'mdi-skip-next',
|
||||
}
|
||||
return icons[status] || 'mdi-help-circle'
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colors = {
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
running: 'info',
|
||||
pending: 'warning',
|
||||
skipped: 'grey',
|
||||
}
|
||||
return colors[status] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDuration(download) {
|
||||
if (!download.started_at || !download.completed_at) return '-'
|
||||
const start = new Date(download.started_at)
|
||||
const end = new Date(download.completed_at)
|
||||
const seconds = Math.round((end - start) / 1000)
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
}
|
||||
|
||||
function formatErrorType(errorType) {
|
||||
if (!errorType) return ''
|
||||
return errorType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
|
||||
function truncateUrl(url) {
|
||||
if (!url) return ''
|
||||
if (url.length <= 50) return url
|
||||
return url.substring(0, 47) + '...'
|
||||
}
|
||||
|
||||
function showDetails(download) {
|
||||
selectedDownload.value = download
|
||||
detailsDialog.value = true
|
||||
}
|
||||
|
||||
async function retryDownload(download) {
|
||||
retryingIds.value.push(download.id)
|
||||
try {
|
||||
await downloadsStore.retryDownload(download.id)
|
||||
notifications.success('Download queued for retry')
|
||||
await loadDownloads()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to retry: ${error.message}`)
|
||||
} finally {
|
||||
retryingIds.value = retryingIds.value.filter(id => id !== download.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card :loading="loading" class="mb-6">
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-cog</v-icon>
|
||||
Application Settings
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-form ref="settingsForm">
|
||||
<!-- Download Settings -->
|
||||
<h3 class="text-h6 mb-4">Download Settings</h3>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="settings['download.parallel_limit']"
|
||||
label="Parallel Download Limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
hint="Maximum number of concurrent downloads (1-10)"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="settings['download.rate_limit']"
|
||||
label="Rate Limit (seconds)"
|
||||
type="number"
|
||||
min="0.5"
|
||||
max="30"
|
||||
step="0.5"
|
||||
hint="Delay between downloads to avoid rate limiting"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="settings['download.retry_count']"
|
||||
label="Retry Count"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
hint="Number of times to retry failed downloads"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="settings['download.schedule_interval']"
|
||||
label="Schedule Interval (seconds)"
|
||||
type="number"
|
||||
min="300"
|
||||
hint="How often to check for new content (minimum 5 minutes)"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-6" />
|
||||
|
||||
<!-- Notification Settings -->
|
||||
<h3 class="text-h6 mb-4">Notifications</h3>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-switch
|
||||
v-model="settings['notification.enabled']"
|
||||
label="Enable Notifications"
|
||||
color="primary"
|
||||
hint="Receive notifications when new content is downloaded"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="settings['notification.webhook_url']"
|
||||
label="Webhook URL (optional)"
|
||||
:disabled="!settings['notification.enabled']"
|
||||
hint="Discord/Slack webhook for push notifications"
|
||||
persistent-hint
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-6" />
|
||||
|
||||
<!-- Extension API Key -->
|
||||
<h3 class="text-h6 mb-4">Extension API Key</h3>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-alert type="info" variant="tonal" class="mb-4">
|
||||
Use this API key to configure the browser extension. Keep it secret - anyone with this key can add subscriptions to your account.
|
||||
</v-alert>
|
||||
<v-text-field
|
||||
v-model="settings['security.extension_api_key']"
|
||||
label="API Key"
|
||||
readonly
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
:append-inner-icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="showApiKey = !showApiKey"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="copyApiKey"
|
||||
>
|
||||
<v-icon>mdi-content-copy</v-icon>
|
||||
<v-tooltip activator="parent">Copy to clipboard</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
color="warning"
|
||||
@click="confirmRegenerateKey"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent">Regenerate key</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="loadSettings"
|
||||
:disabled="saving"
|
||||
>
|
||||
Reset
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="saveSettings"
|
||||
:loading="saving"
|
||||
>
|
||||
Save Settings
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Gallery-dl Configuration -->
|
||||
<v-card class="mb-6">
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-code-json</v-icon>
|
||||
Gallery-dl Configuration
|
||||
<v-chip class="ml-2" size="small" color="warning" variant="tonal">
|
||||
Advanced
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
||||
This is the raw gallery-dl configuration file. Only modify if you know what you're doing.
|
||||
Invalid configuration may cause downloads to fail.
|
||||
</v-alert>
|
||||
|
||||
<v-textarea
|
||||
v-model="galleryDLConfigText"
|
||||
label="gallery-dl.conf (JSON)"
|
||||
rows="20"
|
||||
variant="outlined"
|
||||
:error-messages="configError"
|
||||
@input="validateConfig"
|
||||
class="font-monospace"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
href="https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon start>mdi-open-in-new</v-icon>
|
||||
Documentation
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="loadGalleryDLConfig"
|
||||
:disabled="savingConfig"
|
||||
>
|
||||
Reset
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="saveGalleryDLConfig"
|
||||
:loading="savingConfig"
|
||||
:disabled="!!configError"
|
||||
>
|
||||
Save Configuration
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Platform Default Settings -->
|
||||
<v-card class="mb-6">
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-web</v-icon>
|
||||
Platform Defaults
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<p class="text-body-2 mb-4">
|
||||
Default settings for each platform. These can be overridden per-source.
|
||||
</p>
|
||||
|
||||
<v-expansion-panels>
|
||||
<v-expansion-panel
|
||||
v-for="(info, platform) in platforms"
|
||||
:key="platform"
|
||||
>
|
||||
<v-expansion-panel-title>
|
||||
<v-icon :color="getPlatformColor(platform)" class="mr-2">
|
||||
{{ getPlatformIcon(platform) }}
|
||||
</v-icon>
|
||||
{{ info.name }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-table density="compact">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="200"><strong>Auth Required</strong></td>
|
||||
<td>
|
||||
<v-chip :color="info.requires_auth ? 'warning' : 'success'" size="small">
|
||||
{{ info.requires_auth ? 'Yes' : 'No' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Auth Type</strong></td>
|
||||
<td class="text-capitalize">{{ info.auth_type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Content Types</strong></td>
|
||||
<td>
|
||||
<v-chip
|
||||
v-for="type in info.content_types"
|
||||
:key="type"
|
||||
size="small"
|
||||
class="mr-1 mb-1"
|
||||
>
|
||||
{{ type }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Default Sleep</strong></td>
|
||||
<td>{{ info.default_config?.sleep || 3.0 }} seconds</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>URL Examples</strong></td>
|
||||
<td>
|
||||
<div v-for="url in info.url_examples" :key="url" class="text-caption">
|
||||
{{ url }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- System Information -->
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-information</v-icon>
|
||||
System Information
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-table density="compact">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="200"><strong>Version</strong></td>
|
||||
<td>1.0.0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>API Status</strong></td>
|
||||
<td>
|
||||
<v-chip :color="apiHealthy ? 'success' : 'error'" size="small">
|
||||
{{ apiHealthy ? 'Connected' : 'Disconnected' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Download Path</strong></td>
|
||||
<td>/data/downloads</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { platformsApi } from '../services/api'
|
||||
import api from '../services/api'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
const settings = ref({})
|
||||
const galleryDLConfigText = ref('')
|
||||
const configError = ref('')
|
||||
const platforms = ref({})
|
||||
const apiHealthy = ref(true)
|
||||
const showApiKey = ref(false)
|
||||
const regenerateDialog = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
})
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
loadSettings(),
|
||||
loadGalleryDLConfig(),
|
||||
loadPlatforms(),
|
||||
checkApiHealth(),
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await settingsStore.fetchSettings()
|
||||
settings.value = { ...data }
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to load settings: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsStore.updateSettings(settings.value)
|
||||
notifications.success('Settings saved')
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGalleryDLConfig() {
|
||||
try {
|
||||
const config = await settingsStore.fetchGalleryDLConfig()
|
||||
galleryDLConfigText.value = JSON.stringify(config, null, 2)
|
||||
configError.value = ''
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to load config: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateConfig() {
|
||||
try {
|
||||
JSON.parse(galleryDLConfigText.value)
|
||||
configError.value = ''
|
||||
} catch (e) {
|
||||
configError.value = `Invalid JSON: ${e.message}`
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGalleryDLConfig() {
|
||||
if (configError.value) return
|
||||
|
||||
savingConfig.value = true
|
||||
try {
|
||||
const config = JSON.parse(galleryDLConfigText.value)
|
||||
await settingsStore.updateGalleryDLConfig(config)
|
||||
notifications.success('Gallery-dl configuration saved')
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.message}`)
|
||||
} finally {
|
||||
savingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlatforms() {
|
||||
try {
|
||||
const response = await platformsApi.list()
|
||||
platforms.value = response.data.platforms
|
||||
} catch (error) {
|
||||
console.error('Failed to load platforms:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkApiHealth() {
|
||||
try {
|
||||
await api.get('/health')
|
||||
apiHealthy.value = true
|
||||
} catch {
|
||||
apiHealthy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
async function copyApiKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(settings.value['security.extension_api_key'])
|
||||
notifications.success('API key copied to clipboard')
|
||||
} catch (error) {
|
||||
notifications.error('Failed to copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRegenerateKey() {
|
||||
if (confirm('Are you sure you want to regenerate the API key? The old key will stop working immediately.')) {
|
||||
await regenerateApiKey()
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateApiKey() {
|
||||
try {
|
||||
const response = await api.post('/settings/api-key/regenerate')
|
||||
settings.value['security.extension_api_key'] = response.data.api_key
|
||||
notifications.success('API key regenerated')
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to regenerate: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.font-monospace {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-btn
|
||||
variant="text"
|
||||
to="/subscriptions"
|
||||
prepend-icon="mdi-arrow-left"
|
||||
class="mb-4"
|
||||
>
|
||||
Back to Subscriptions
|
||||
</v-btn>
|
||||
|
||||
<v-card :loading="loading">
|
||||
<v-card-title>
|
||||
{{ isEdit ? 'Edit Source' : 'Add New Source' }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-form ref="form" v-model="valid">
|
||||
<v-row>
|
||||
<!-- Subscription (required when creating) -->
|
||||
<v-col cols="12" md="6" v-if="!isEdit">
|
||||
<v-select
|
||||
v-model="formData.subscription_id"
|
||||
:items="subscriptionOptions"
|
||||
label="Subscription"
|
||||
:rules="[rules.required]"
|
||||
hint="Select the subscription to add this source to"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="formData.platform"
|
||||
:items="platformOptions"
|
||||
label="Platform"
|
||||
:rules="[rules.required]"
|
||||
:disabled="isEdit"
|
||||
@update:model-value="onPlatformChange"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="formData.url"
|
||||
label="URL"
|
||||
:rules="[rules.required, rules.url]"
|
||||
:hint="urlHint"
|
||||
persistent-hint
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-switch
|
||||
v-model="formData.enabled"
|
||||
label="Enabled"
|
||||
color="success"
|
||||
hint="Disabled sources won't be checked automatically"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model.number="formData.check_interval"
|
||||
label="Check Interval (seconds)"
|
||||
type="number"
|
||||
:rules="[rules.minValue(60)]"
|
||||
hint="Minimum time between automatic checks"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Platform-Specific Config -->
|
||||
<v-divider class="my-6" />
|
||||
<h3 class="text-h6 mb-4">Download Options</h3>
|
||||
|
||||
<v-row v-if="configSchema">
|
||||
<v-col
|
||||
v-for="field in configSchema.fields"
|
||||
:key="field.name"
|
||||
cols="12"
|
||||
:md="field.type === 'text' ? 12 : 6"
|
||||
>
|
||||
<!-- Multiselect (content types) -->
|
||||
<v-select
|
||||
v-if="field.type === 'multiselect'"
|
||||
v-model="formData.metadata[field.name]"
|
||||
:items="field.options"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
persistent-hint
|
||||
multiple
|
||||
chips
|
||||
/>
|
||||
|
||||
<!-- Number input -->
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model.number="formData.metadata[field.name]"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
persistent-hint
|
||||
type="number"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
/>
|
||||
|
||||
<!-- Boolean switch -->
|
||||
<v-switch
|
||||
v-else-if="field.type === 'boolean'"
|
||||
v-model="formData.metadata[field.name]"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
|
||||
<!-- Text input -->
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'text'"
|
||||
v-model="formData.metadata[field.name]"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
:placeholder="field.placeholder"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-alert
|
||||
v-if="!formData.platform"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
>
|
||||
Select a platform to see available download options
|
||||
</v-alert>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" to="/subscriptions">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
:disabled="!valid"
|
||||
@click="save"
|
||||
>
|
||||
{{ isEdit ? 'Update' : 'Create' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useSubscriptionsStore } from '../stores/subscriptions'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { platformsApi, sourcesApi } from '../services/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const sourcesStore = useSourcesStore()
|
||||
const subscriptionsStore = useSubscriptionsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const form = ref(null)
|
||||
const valid = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const configSchema = ref(null)
|
||||
const platforms = ref({})
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const subscriptionOptions = computed(() =>
|
||||
subscriptionsStore.subscriptions.map(s => ({ title: s.name, value: s.id }))
|
||||
)
|
||||
|
||||
const formData = ref({
|
||||
subscription_id: null,
|
||||
platform: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
check_interval: 3600,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
const platformOptions = [
|
||||
{ title: 'Patreon', value: 'patreon' },
|
||||
{ title: 'SubscribeStar', value: 'subscribestar' },
|
||||
{ title: 'Hentai Foundry', value: 'hentaifoundry' },
|
||||
{ title: 'Discord', value: 'discord' },
|
||||
]
|
||||
|
||||
const urlHint = computed(() => {
|
||||
if (!formData.value.platform || !platforms.value[formData.value.platform]) {
|
||||
return 'Enter the URL for this source'
|
||||
}
|
||||
const examples = platforms.value[formData.value.platform].url_examples
|
||||
return `Example: ${examples?.[0] || ''}`
|
||||
})
|
||||
|
||||
const rules = {
|
||||
required: v => !!v || 'Required',
|
||||
url: v => {
|
||||
try {
|
||||
new URL(v)
|
||||
return true
|
||||
} catch {
|
||||
return 'Must be a valid URL'
|
||||
}
|
||||
},
|
||||
minValue: min => v => v >= min || `Minimum value is ${min}`,
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
// Load platform info and subscriptions in parallel
|
||||
const [platformsResponse] = await Promise.all([
|
||||
platformsApi.list(),
|
||||
subscriptionsStore.fetchSubscriptions(),
|
||||
])
|
||||
platforms.value = platformsResponse.data.platforms
|
||||
|
||||
// If editing, load source data
|
||||
if (isEdit.value) {
|
||||
const response = await sourcesApi.get(route.params.id)
|
||||
const source = response.data
|
||||
formData.value = {
|
||||
subscription_id: source.subscription_id,
|
||||
platform: source.platform,
|
||||
url: source.url,
|
||||
enabled: source.enabled,
|
||||
check_interval: source.check_interval,
|
||||
metadata: source.metadata || {},
|
||||
}
|
||||
await loadConfigSchema(source.platform)
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to load: ${error.message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => formData.value.platform, (newPlatform) => {
|
||||
if (newPlatform && !isEdit.value) {
|
||||
loadConfigSchema(newPlatform)
|
||||
}
|
||||
})
|
||||
|
||||
async function onPlatformChange(platform) {
|
||||
if (!platform) {
|
||||
configSchema.value = null
|
||||
return
|
||||
}
|
||||
await loadConfigSchema(platform)
|
||||
}
|
||||
|
||||
async function loadConfigSchema(platform) {
|
||||
try {
|
||||
const response = await platformsApi.getConfigSchema(platform)
|
||||
configSchema.value = response.data
|
||||
|
||||
// Set defaults for metadata if not already set
|
||||
for (const field of response.data.fields) {
|
||||
if (formData.value.metadata[field.name] === undefined && field.default !== undefined) {
|
||||
formData.value.metadata[field.name] = field.default
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load config schema:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.value.validate()) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await sourcesStore.updateSource(route.params.id, {
|
||||
enabled: formData.value.enabled,
|
||||
check_interval: formData.value.check_interval,
|
||||
metadata: formData.value.metadata,
|
||||
})
|
||||
notifications.success('Source updated')
|
||||
} else {
|
||||
await sourcesStore.createSource({
|
||||
subscription_id: formData.value.subscription_id,
|
||||
platform: formData.value.platform,
|
||||
url: formData.value.url,
|
||||
enabled: formData.value.enabled,
|
||||
check_interval: formData.value.check_interval,
|
||||
metadata: formData.value.metadata,
|
||||
})
|
||||
notifications.success('Source created')
|
||||
}
|
||||
router.push('/subscriptions')
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.response?.data?.error || error.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row class="mb-4">
|
||||
<v-col>
|
||||
<v-btn color="primary" to="/sources/new" prepend-icon="mdi-plus">
|
||||
Add Source
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-select
|
||||
v-model="filterPlatform"
|
||||
:items="platformOptions"
|
||||
label="Filter by Platform"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
style="min-width: 180px"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-select
|
||||
v-model="filterEnabled"
|
||||
:items="enabledOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
style="min-width: 140px"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="sourcesStore.sources"
|
||||
:loading="sourcesStore.loading"
|
||||
:items-per-page="20"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.platform="{ item }">
|
||||
<v-chip
|
||||
:color="getPlatformColor(item.platform)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="small">{{ getPlatformIcon(item.platform) }}</v-icon>
|
||||
{{ item.platform }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.enabled="{ item }">
|
||||
<v-switch
|
||||
:model-value="item.enabled"
|
||||
color="success"
|
||||
hide-details
|
||||
density="compact"
|
||||
@update:model-value="toggleEnabled(item)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.error_count="{ item }">
|
||||
<v-chip
|
||||
v-if="item.error_count > 0"
|
||||
color="error"
|
||||
size="small"
|
||||
>
|
||||
{{ item.error_count }}
|
||||
</v-chip>
|
||||
<span v-else class="text-grey">0</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.last_check="{ item }">
|
||||
{{ formatDate(item.last_check) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.last_success="{ item }">
|
||||
{{ formatDate(item.last_success) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="triggerCheck(item)"
|
||||
:loading="checkingIds.includes(item.id)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent">Check Now</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
:to="`/sources/${item.id}/edit`"
|
||||
>
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent">Edit</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="confirmDelete(item)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
<v-tooltip activator="parent">Delete</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete Source</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete <strong>{{ sourceToDelete?.name }}</strong>?
|
||||
This will also remove all associated download history.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="deleteSource">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useSourcesStore } from '../stores/sources'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const sourcesStore = useSourcesStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const filterPlatform = ref(null)
|
||||
const filterEnabled = ref(null)
|
||||
const deleteDialog = ref(false)
|
||||
const sourceToDelete = ref(null)
|
||||
const checkingIds = ref([])
|
||||
|
||||
const platformOptions = [
|
||||
{ title: 'Patreon', value: 'patreon' },
|
||||
{ title: 'SubscribeStar', value: 'subscribestar' },
|
||||
{ title: 'Hentai Foundry', value: 'hentaifoundry' },
|
||||
{ title: 'Discord', value: 'discord' },
|
||||
]
|
||||
|
||||
const enabledOptions = [
|
||||
{ title: 'Enabled', value: 'true' },
|
||||
{ title: 'Disabled', value: 'false' },
|
||||
]
|
||||
|
||||
const headers = [
|
||||
{ title: 'Name', key: 'name', sortable: true },
|
||||
{ title: 'Platform', key: 'platform', sortable: true },
|
||||
{ title: 'Enabled', key: 'enabled', sortable: true },
|
||||
{ title: 'Priority', key: 'priority', sortable: true },
|
||||
{ title: 'Errors', key: 'error_count', sortable: true },
|
||||
{ title: 'Last Check', key: 'last_check', sortable: true },
|
||||
{ title: 'Last Success', key: 'last_success', sortable: true },
|
||||
{ title: 'Actions', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
loadSources()
|
||||
})
|
||||
|
||||
watch([filterPlatform, filterEnabled], () => {
|
||||
loadSources()
|
||||
})
|
||||
|
||||
async function loadSources() {
|
||||
const params = {}
|
||||
if (filterPlatform.value) params.platform = filterPlatform.value
|
||||
if (filterEnabled.value) params.enabled = filterEnabled.value
|
||||
await sourcesStore.fetchSources(params)
|
||||
}
|
||||
|
||||
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: 'red',
|
||||
subscribestar: 'amber',
|
||||
hentaifoundry: 'purple',
|
||||
discord: 'indigo',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
async function toggleEnabled(source) {
|
||||
try {
|
||||
await sourcesStore.updateSource(source.id, { enabled: !source.enabled })
|
||||
notifications.success(`${source.name} ${!source.enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to update: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerCheck(source) {
|
||||
checkingIds.value.push(source.id)
|
||||
try {
|
||||
await sourcesStore.triggerCheck(source.id)
|
||||
notifications.success(`Queued check for ${source.name}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to queue check: ${error.message}`)
|
||||
} finally {
|
||||
checkingIds.value = checkingIds.value.filter(id => id !== source.id)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(source) {
|
||||
sourceToDelete.value = source
|
||||
deleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteSource() {
|
||||
try {
|
||||
await sourcesStore.deleteSource(sourceToDelete.value.id)
|
||||
notifications.success(`Deleted ${sourceToDelete.value.name}`)
|
||||
deleteDialog.value = false
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,502 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row class="mb-4">
|
||||
<v-col>
|
||||
<v-btn color="primary" @click="showAddDialog = true" prepend-icon="mdi-plus">
|
||||
Add Subscription
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="Search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
style="min-width: 200px"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-select
|
||||
v-model="filterEnabled"
|
||||
:items="enabledOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
clearable
|
||||
style="min-width: 140px"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="subscriptionsStore.subscriptions"
|
||||
:loading="subscriptionsStore.loading"
|
||||
:items-per-page="20"
|
||||
v-model:expanded="expandedIds"
|
||||
item-value="id"
|
||||
class="elevation-1"
|
||||
>
|
||||
<template v-slot:item.name="{ item }">
|
||||
<div class="d-flex align-center cursor-pointer" @click="toggleExpand(item.id)">
|
||||
<v-icon class="mr-2" size="small">
|
||||
{{ expandedIds.includes(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
|
||||
</v-icon>
|
||||
<strong>{{ item.name }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.platform_count="{ item }">
|
||||
<v-chip size="small" variant="tonal">
|
||||
{{ item.platform_count }} platform{{ item.platform_count !== 1 ? 's' : '' }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.enabled="{ item }">
|
||||
<v-switch
|
||||
:model-value="item.enabled"
|
||||
color="success"
|
||||
hide-details
|
||||
density="compact"
|
||||
@click.stop
|
||||
@update:model-value="toggleEnabled(item)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click.stop="triggerCheck(item)"
|
||||
:loading="checkingIds.includes(item.id)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
<v-tooltip activator="parent">Check All Sources</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="success"
|
||||
@click.stop="openAddSourceDialog(item)"
|
||||
>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
<v-tooltip activator="parent">Add Source</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="editSubscription(item)"
|
||||
>
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent">Edit</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click.stop="confirmDelete(item)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
<v-tooltip activator="parent">Delete</v-tooltip>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- Expanded row showing sources -->
|
||||
<template v-slot:expanded-row="{ columns, item }">
|
||||
<tr>
|
||||
<td :colspan="columns.length" class="pa-0">
|
||||
<v-table density="compact" class="ml-8 bg-grey-lighten-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<th>URL</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last Check</th>
|
||||
<th>Errors</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="source in item.sources" :key="source.id">
|
||||
<td>
|
||||
<v-chip
|
||||
:color="getPlatformColor(source.platform)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="small">{{ getPlatformIcon(source.platform) }}</v-icon>
|
||||
{{ source.platform }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-truncate" style="max-width: 300px">
|
||||
<a :href="source.url" target="_blank" @click.stop>{{ source.url }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<v-switch
|
||||
:model-value="source.enabled"
|
||||
color="success"
|
||||
hide-details
|
||||
density="compact"
|
||||
@update:model-value="toggleSourceEnabled(source)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ formatDate(source.last_check) }}</td>
|
||||
<td>
|
||||
<v-chip v-if="source.error_count > 0" color="error" size="x-small">
|
||||
{{ source.error_count }}
|
||||
</v-chip>
|
||||
<span v-else class="text-grey">0</span>
|
||||
</td>
|
||||
<td>
|
||||
<v-btn icon size="x-small" variant="text" color="error" @click.stop="confirmDeleteSource(source, item)">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!item.sources || item.sources.length === 0">
|
||||
<td :colspan="6" class="text-center text-grey py-4">
|
||||
No sources yet. Click "+" to add a platform.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Add Subscription Dialog -->
|
||||
<v-dialog v-model="showAddDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>{{ editingSubscription ? 'Edit Subscription' : 'Add Subscription' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="subscriptionForm.name"
|
||||
label="Name (artist/creator name)"
|
||||
hint="This will be used as the folder name"
|
||||
persistent-hint
|
||||
required
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="subscriptionForm.description"
|
||||
label="Description (optional)"
|
||||
rows="2"
|
||||
class="mt-4"
|
||||
/>
|
||||
<v-slider
|
||||
v-model="subscriptionForm.priority"
|
||||
label="Priority"
|
||||
min="0"
|
||||
max="10"
|
||||
step="1"
|
||||
thumb-label
|
||||
class="mt-4"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="closeAddDialog">Cancel</v-btn>
|
||||
<v-btn color="primary" @click="saveSubscription" :loading="saving">Save</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Add Source Dialog -->
|
||||
<v-dialog v-model="showSourceDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>Add Source to {{ sourceDialogSubscription?.name }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-select
|
||||
v-model="sourceForm.platform"
|
||||
:items="platformOptions"
|
||||
label="Platform"
|
||||
required
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="sourceForm.url"
|
||||
label="URL"
|
||||
placeholder="https://..."
|
||||
required
|
||||
class="mt-4"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showSourceDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" @click="addSource" :loading="savingSource">Add</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete Subscription</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete <strong>{{ subscriptionToDelete?.name }}</strong>?
|
||||
This will also remove all sources and download history.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="deleteSubscription">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Source Dialog -->
|
||||
<v-dialog v-model="deleteSourceDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete Source</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to remove the {{ sourceToDelete?.platform }} source?
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="deleteSourceDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="deleteSource">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useSubscriptionsStore } from '../stores/subscriptions'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const subscriptionsStore = useSubscriptionsStore()
|
||||
const notifications = useNotificationStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterEnabled = ref(null)
|
||||
const expandedIds = ref([])
|
||||
const checkingIds = ref([])
|
||||
|
||||
// Subscription dialog
|
||||
const showAddDialog = ref(false)
|
||||
const editingSubscription = ref(null)
|
||||
const saving = ref(false)
|
||||
const subscriptionForm = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
priority: 0,
|
||||
})
|
||||
|
||||
// Source dialog
|
||||
const showSourceDialog = ref(false)
|
||||
const sourceDialogSubscription = ref(null)
|
||||
const savingSource = ref(false)
|
||||
const sourceForm = ref({
|
||||
platform: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
// Delete dialogs
|
||||
const deleteDialog = ref(false)
|
||||
const subscriptionToDelete = ref(null)
|
||||
const deleteSourceDialog = ref(false)
|
||||
const sourceToDelete = ref(null)
|
||||
|
||||
const enabledOptions = [
|
||||
{ title: 'Enabled', value: 'true' },
|
||||
{ title: 'Disabled', value: 'false' },
|
||||
]
|
||||
|
||||
const platformOptions = [
|
||||
{ title: 'Patreon', value: 'patreon' },
|
||||
{ title: 'SubscribeStar', value: 'subscribestar' },
|
||||
{ title: 'Hentai Foundry', value: 'hentaifoundry' },
|
||||
{ title: 'Discord', value: 'discord' },
|
||||
]
|
||||
|
||||
const headers = [
|
||||
{ title: 'Name', key: 'name', sortable: true },
|
||||
{ title: 'Platforms', key: 'platform_count', sortable: true },
|
||||
{ title: 'Enabled', key: 'enabled', sortable: true },
|
||||
{ title: 'Priority', key: 'priority', sortable: true },
|
||||
{ title: 'Actions', key: 'actions', sortable: false },
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
loadSubscriptions()
|
||||
})
|
||||
|
||||
watch([searchQuery, filterEnabled], () => {
|
||||
loadSubscriptions()
|
||||
})
|
||||
|
||||
async function loadSubscriptions() {
|
||||
const params = {}
|
||||
if (searchQuery.value) params.search = searchQuery.value
|
||||
if (filterEnabled.value) params.enabled = filterEnabled.value
|
||||
await subscriptionsStore.fetchSubscriptions(params)
|
||||
}
|
||||
|
||||
function toggleExpand(id) {
|
||||
const idx = expandedIds.value.indexOf(id)
|
||||
if (idx === -1) {
|
||||
expandedIds.value.push(id)
|
||||
} else {
|
||||
expandedIds.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
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: 'red',
|
||||
subscribestar: 'amber',
|
||||
hentaifoundry: 'purple',
|
||||
discord: 'indigo',
|
||||
}
|
||||
return colors[platform] || 'grey'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'Never'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
async function toggleEnabled(subscription) {
|
||||
try {
|
||||
await subscriptionsStore.updateSubscription(subscription.id, { enabled: !subscription.enabled })
|
||||
notifications.success(`${subscription.name} ${!subscription.enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to update: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSourceEnabled(source) {
|
||||
try {
|
||||
await subscriptionsStore.updateSource(source.id, { enabled: !source.enabled })
|
||||
notifications.success(`Source ${!source.enabled ? 'enabled' : 'disabled'}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to update: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerCheck(subscription) {
|
||||
checkingIds.value.push(subscription.id)
|
||||
try {
|
||||
await subscriptionsStore.triggerCheck(subscription.id)
|
||||
notifications.success(`Queued checks for ${subscription.name}`)
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to queue checks: ${error.message}`)
|
||||
} finally {
|
||||
checkingIds.value = checkingIds.value.filter(id => id !== subscription.id)
|
||||
}
|
||||
}
|
||||
|
||||
function editSubscription(subscription) {
|
||||
editingSubscription.value = subscription
|
||||
subscriptionForm.value = {
|
||||
name: subscription.name,
|
||||
description: subscription.description || '',
|
||||
priority: subscription.priority,
|
||||
}
|
||||
showAddDialog.value = true
|
||||
}
|
||||
|
||||
function closeAddDialog() {
|
||||
showAddDialog.value = false
|
||||
editingSubscription.value = null
|
||||
subscriptionForm.value = { name: '', description: '', priority: 0 }
|
||||
}
|
||||
|
||||
async function saveSubscription() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingSubscription.value) {
|
||||
await subscriptionsStore.updateSubscription(editingSubscription.value.id, subscriptionForm.value)
|
||||
notifications.success('Subscription updated')
|
||||
} else {
|
||||
await subscriptionsStore.createSubscription(subscriptionForm.value)
|
||||
notifications.success('Subscription created')
|
||||
}
|
||||
closeAddDialog()
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to save: ${error.message}`)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(subscription) {
|
||||
subscriptionToDelete.value = subscription
|
||||
deleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteSubscription() {
|
||||
try {
|
||||
await subscriptionsStore.deleteSubscription(subscriptionToDelete.value.id)
|
||||
notifications.success(`Deleted ${subscriptionToDelete.value.name}`)
|
||||
deleteDialog.value = false
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function openAddSourceDialog(subscription) {
|
||||
sourceDialogSubscription.value = subscription
|
||||
sourceForm.value = { platform: '', url: '' }
|
||||
showSourceDialog.value = true
|
||||
}
|
||||
|
||||
async function addSource() {
|
||||
savingSource.value = true
|
||||
try {
|
||||
await subscriptionsStore.addSource(sourceDialogSubscription.value.id, sourceForm.value)
|
||||
notifications.success('Source added')
|
||||
showSourceDialog.value = false
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to add source: ${error.message}`)
|
||||
} finally {
|
||||
savingSource.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteSource(source, subscription) {
|
||||
sourceToDelete.value = source
|
||||
sourceDialogSubscription.value = subscription
|
||||
deleteSourceDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteSource() {
|
||||
try {
|
||||
await subscriptionsStore.deleteSource(sourceToDelete.value.id)
|
||||
notifications.success('Source removed')
|
||||
deleteSourceDialog.value = false
|
||||
loadSubscriptions()
|
||||
} catch (error) {
|
||||
notifications.error(`Failed to delete: ${error.message}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user