rapid interations of server side app and firefox extension

This commit is contained in:
Bryan Van Deusen
2026-01-24 22:52:51 -05:00
commit b9b8048a2d
81 changed files with 9675 additions and 0 deletions
+88
View File
@@ -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>
+136
View File
@@ -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()
}
+13
View File
@@ -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')
+45
View File
@@ -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',
},
},
})
+41
View File
@@ -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
+66
View File
@@ -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
+50
View File
@@ -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,
}
})
+41
View File
@@ -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,
}
})
+48
View File
@@ -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,
}
})
+69
View File
@@ -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,
}
})
+112
View File
@@ -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,
}
})
+52
View File
@@ -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,
}
})
+339
View File
@@ -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>
+283
View File
@@ -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>
+329
View File
@@ -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>
+474
View File
@@ -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>
+316
View File
@@ -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>
+246
View File
@@ -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>
+502
View File
@@ -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>