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
+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>