ytptube/ui/app/pages/task_definitions.vue
2026-01-23 23:38:59 +03:00

435 lines
16 KiB
Vue

<template>
<main>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<template v-if="isEditorOpen">
<span class="icon"><i class="fa-solid fa-pen-to-square" /></span>
<span>{{ editorTitle }}</span>
</template>
<template v-else>
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
<span>Task Definitions</span>
</template>
</span>
</span>
<div class="is-pulled-right" v-if="!isEditorOpen">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="isEditorOpen ? closeEditor() : openCreate()">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span v-if="!isMobile">New Definition</span>
</button>
</p>
<p class="control">
<button @click="() => inspect = true" class="button is-warning">
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
<span v-if="!isMobile">Inspect</span>
</button>
</p>
<p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
<span class="icon">
<i class="fa-solid"
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
<span v-if="!isMobile">
{{ display_style === 'list' ? 'List' : 'Grid' }}
</span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="async () => await loadDefinitions()"
:class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile" v-if="!isEditorOpen">
<span class="subtitle">
Create definitions to turn any website into a downloadable feed of links.
</span>
</div>
</div>
</div>
<div class="columns" v-if="'list' === display_style && definitions.length > 0 && !isEditorOpen">
<div class="column is-12">
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed;">
<thead>
<tr class="has-text-centered is-unselectable">
<th width="40%">Name</th>
<th width="20%">Priority</th>
<th width="20%">Updated</th>
<th width="20%">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="definition in definitions" :key="definition.id">
<td class="is-vcentered">
<div class="is-text-overflow">{{ definition.name || '(Unnamed definition)' }}</div>
<div class="is-size-7">
<span class="icon-text is-clickable" @click="toggle(definition)"
v-tooltip="'Click to ' + (definition.enabled ? 'disable' : 'enable') + ' definition'">
<span class="icon">
<i class="fa-solid fa-power-off"
:class="{ 'has-text-success': definition.enabled, 'has-text-danger': !definition.enabled }" />
</span>
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
</span>
</div>
</td>
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
<td class="is-vcentered has-text-centered">
<span class="has-tooltip" :date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-rtime="definition.updated_at" />
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-small is-info" type="button" @click="exportDefinition(definition)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span>
</button>
</div>
<div class="control">
<button class="button is-small is-warning" type="button" @click="openEdit(definition)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span v-if="!isMobile">Edit</span>
</button>
</div>
<div class="control">
<button class="button is-small is-danger" type="button" @click="remove(definition)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="columns is-multiline" v-if="'grid' === display_style && definitions.length > 0 && !isEditorOpen">
<div class="column is-6" v-for="definition in definitions" :key="definition.id">
<div class="card">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
{{ definition.name || '(Unnamed definition)' }}
</div>
<div class="card-header-icon">
<div class="field has-addons">
<div class="control" @click="toggle(definition)">
<span class="icon" :class="definition.enabled ? 'has-text-success' : 'has-text-danger'"
v-tooltip="`Definition is ${definition.enabled ? 'enabled' : 'disabled'}. Click to toggle.`">
<i class="fa-solid fa-power-off" />
</span>
</div>
<div class="control">
<button class="has-text-info" v-tooltip="'Export'" @click="exportDefinition(definition)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</button>
</div>
</div>
</div>
</header>
<div class="card-content">
<div class="content">
<p>
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
<span>Priority: {{ definition.priority }}</span>
</span>
</p>
<p>
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-clock" /></span>
<span>Updated: <span class="has-tooltip"
:date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-tooltip="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-rtime="definition.updated_at" />
</span>
</span>
</p>
</div>
</div>
<footer class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="openEdit(definition)">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-pen-to-square" /></span>
<span>Edit</span>
</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="remove(definition)">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</span>
</button>
</div>
</footer>
</div>
</div>
</div>
<div class="columns is-multiline" v-if="!definitions.length">
<div class="column is-12">
<Message v-if="isLoading" class="is-info" title="Loading" icon="fas fa-spinner fa-spin">
Loading data. Please wait...
</Message>
<Message v-if="!isLoading && !isEditorOpen" title="No definitions" class="is-warning"
icon="fas fa-exclamation-circle">
There are no task definitions. Click the <span class="icon"><i class="fas fa-add" /></span> <strong>New
Definition</strong> button to create your first task definition.
</Message>
</div>
</div>
<div class="columns" v-if="isEditorOpen">
<div class="column is-12">
<TaskDefinitionEditor :title="editorTitle" :document="workingDefinition"
:initial-show-import="'create' === editorMode" :available-definitions="definitions" :loading="editorLoading"
:submitting="editorSubmitting" @submit="submitDefinition" @cancel="closeEditor"
@import-existing="importExistingDefinition" />
</div>
</div>
<Modal v-if="inspect" @close="() => inspect = false" :contentClass="`modal-content-max`">
<TaskInspect />
</Modal>
</main>
</template>
<script setup lang="ts">
import moment from 'moment'
import { computed, onMounted, ref } from 'vue'
import { useStorage } from '@vueuse/core'
import TaskDefinitionEditor from '~/components/TaskDefinitionEditor.vue'
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions'
import { useDialog } from '~/composables/useDialog'
import { useNotification } from '~/composables/useNotification'
import { copyText, encode } from '~/utils'
import { useMediaQuery } from '~/composables/useMediaQuery'
import type {
TaskDefinitionDetailed,
TaskDefinitionDocument,
TaskDefinitionSummary,
} from '~/types/task_definitions'
const DEFAULT_DEFINITION: TaskDefinitionDocument = {
name: 'New Definition',
priority: 0,
enabled: true,
match_url: ['https://example.com/*'],
definition: {
parse: {
items: {
type: 'css',
selector: 'body',
fields: {
link: { type: 'css', expression: 'a', attribute: 'href' },
title: { type: 'css', expression: 'a', attribute: 'text' },
},
},
},
},
}
const isMobile = useMediaQuery({ maxWidth: 1024 })
const taskDefs = useTaskDefinitionsComposable()
const definitionsRef = taskDefs.definitions
const isLoading = taskDefs.isLoading
const loadDefinitions = taskDefs.loadDefinitions
const getDefinition = taskDefs.getDefinition
const createDefinition = taskDefs.createDefinition
const updateDefinition = taskDefs.updateDefinition
const deleteDefinition = taskDefs.deleteDefinition
const toggleEnabled = taskDefs.toggleEnabled
const definitions = computed(() => definitionsRef.value)
const { confirmDialog } = useDialog()
const toast = useNotification()
const isEditorOpen = ref<boolean>(false)
const editorMode = ref<'create' | 'edit'>('create')
const editorLoading = ref<boolean>(false)
const editorSubmitting = ref<boolean>(false)
const workingDefinition = ref<TaskDefinitionDocument | null>(null)
const workingId = ref<number | null>(null)
const inspect = ref<boolean>(false)
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid')
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
if ('edit' !== editorMode.value || !workingId.value) {
return undefined
}
return definitions.value.find(item => item.id === workingId.value)
})
const editorTitle = computed<string>(() => {
return 'create' === editorMode.value
? 'Create Task Definition'
: `Edit - ${currentSummary.value?.name || 'Task Definition'}`
})
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument
}
const openCreate = (): void => {
editorMode.value = 'create'
workingId.value = null
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION)
isEditorOpen.value = true
editorLoading.value = false
editorSubmitting.value = false
}
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
editorMode.value = 'edit'
workingId.value = summary.id
workingDefinition.value = null
editorLoading.value = true
editorSubmitting.value = false
isEditorOpen.value = true
const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id)
if (!detailed) {
isEditorOpen.value = false
editorLoading.value = false
return
}
const document: TaskDefinitionDocument = {
name: detailed.name,
priority: detailed.priority,
enabled: detailed.enabled,
match_url: [...detailed.match_url],
definition: JSON.parse(JSON.stringify(detailed.definition)),
}
workingDefinition.value = document
editorLoading.value = false
}
const importExistingDefinition = async (id: number): Promise<void> => {
const detailed = await getDefinition(id)
if (!detailed) {
toast.error('Failed to load task definition for import.')
return
}
const document: TaskDefinitionDocument = {
name: detailed.name,
priority: detailed.priority,
enabled: detailed.enabled,
match_url: [...detailed.match_url],
definition: JSON.parse(JSON.stringify(detailed.definition)),
}
editorMode.value = 'create'
workingId.value = null
workingDefinition.value = document
isEditorOpen.value = true
editorLoading.value = false
}
const closeEditor = (): void => {
if (editorSubmitting.value) {
return
}
isEditorOpen.value = false
workingDefinition.value = null
workingId.value = null
editorLoading.value = false
}
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
editorSubmitting.value = true
try {
if ('create' === editorMode.value) {
const created = await createDefinition(definition)
if (created) {
isEditorOpen.value = false
workingDefinition.value = null
workingId.value = null
}
}
else if (workingId.value) {
const updated = await updateDefinition(workingId.value, definition)
if (updated) {
isEditorOpen.value = false
workingDefinition.value = null
workingId.value = null
}
}
}
finally {
editorSubmitting.value = false
}
}
const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
const result = await confirmDialog({
title: 'Delete Task Definition',
message: `Are you sure you want to delete "${summary.name || summary.id}"?`,
confirmColor: 'is-danger',
})
if (!result.status) {
return
}
await deleteDefinition(summary.id)
}
const toggle = async (summary: TaskDefinitionSummary): Promise<void> => {
await toggleEnabled(summary.id, !summary.enabled)
}
const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> => {
const detailed = await getDefinition(summary.id)
if (!detailed) {
return
}
return copyText(encode({
_type: 'task_definition',
_version: '2.0',
name: detailed.name,
priority: detailed.priority,
enabled: detailed.enabled,
match_url: detailed.match_url,
definition: detailed.definition,
}))
}
onMounted(async () => {
if (!definitions.value.length) {
await loadDefinitions()
}
})
</script>