656 lines
20 KiB
Vue
656 lines
20 KiB
Vue
<template>
|
|
<main class="w-full min-w-0 max-w-full space-y-4">
|
|
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
|
<div class="min-w-0 space-y-1">
|
|
<div class="flex items-center gap-2 text-lg font-semibold text-highlighted">
|
|
<UIcon name="i-lucide-workflow" class="size-5 text-toned" />
|
|
<span>Task Definitions</span>
|
|
</div>
|
|
|
|
<p class="text-sm text-toned">
|
|
Create definitions to turn any website into a downloadable feed of links.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-end gap-2">
|
|
<div v-if="showFilter && definitions.length > 0" class="relative w-full sm:w-80">
|
|
<span
|
|
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-toned"
|
|
>
|
|
<UIcon name="i-lucide-filter" class="size-4" />
|
|
</span>
|
|
<input
|
|
id="filter"
|
|
ref="filterInput"
|
|
v-model="query"
|
|
type="search"
|
|
placeholder="Filter displayed content"
|
|
class="w-full rounded-md border border-default bg-elevated py-2 pr-3 pl-9 text-sm text-default outline-none transition focus:border-primary"
|
|
/>
|
|
</div>
|
|
|
|
<UButton
|
|
v-if="definitions.length > 0"
|
|
color="neutral"
|
|
:variant="showFilter ? 'soft' : 'outline'"
|
|
size="sm"
|
|
icon="i-lucide-filter"
|
|
@click="toggleFilterPanel"
|
|
>
|
|
<span v-if="!isMobile">Filter</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-search"
|
|
@click="inspect = true"
|
|
>
|
|
<span v-if="!isMobile">Inspect</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-plus"
|
|
@click="openCreate"
|
|
>
|
|
<span v-if="!isMobile">New Definition</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
:icon="display_style === 'list' ? 'i-lucide-list' : 'i-lucide-grid-2x2'"
|
|
@click="toggleDisplayStyle"
|
|
>
|
|
<span v-if="!isMobile">{{ display_style === 'list' ? 'List' : 'Grid' }}</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
size="sm"
|
|
icon="i-lucide-refresh-cw"
|
|
:loading="isLoading"
|
|
:disabled="isLoading"
|
|
@click="() => void reloadContent()"
|
|
>
|
|
<span v-if="!isMobile">Reload</span>
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="lastError"
|
|
color="error"
|
|
variant="soft"
|
|
icon="i-lucide-circle-alert"
|
|
title="Error"
|
|
:description="lastError"
|
|
/>
|
|
|
|
<div
|
|
v-if="display_style === 'list' && filteredDefinitions.length > 0"
|
|
class="w-full min-w-0 max-w-full overflow-hidden rounded-lg border border-default bg-default"
|
|
>
|
|
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
|
|
<table class="min-w-245 w-full text-sm">
|
|
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
|
|
<tr class="text-center [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold">
|
|
<th class="w-full text-left">Definition</th>
|
|
<th class="w-28 whitespace-nowrap">Priority</th>
|
|
<th class="w-36 whitespace-nowrap">Updated</th>
|
|
<th class="w-44 whitespace-nowrap">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody class="divide-y divide-default">
|
|
<tr
|
|
v-for="definition in filteredDefinitions"
|
|
:key="definition.id"
|
|
class="hover:bg-muted/20"
|
|
>
|
|
<td class="px-3 py-3 align-middle">
|
|
<div class="space-y-1">
|
|
<div class="font-semibold text-highlighted">
|
|
{{ definition.name || '(Unnamed definition)' }}
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center gap-3 text-xs text-toned">
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 transition hover:bg-muted"
|
|
@click="() => void toggle(definition)"
|
|
>
|
|
<UIcon
|
|
name="i-lucide-power"
|
|
class="size-3.5"
|
|
:class="definition.enabled ? 'text-success' : 'text-error'"
|
|
/>
|
|
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
|
</button>
|
|
|
|
<span class="inline-flex items-center gap-1">
|
|
<UIcon name="i-lucide-link" class="size-3.5" />
|
|
<span
|
|
>{{ definition.match_url.length }} match pattern{{
|
|
definition.match_url.length === 1 ? '' : 's'
|
|
}}</span
|
|
>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td class="px-3 py-3 text-center align-middle">{{ definition.priority }}</td>
|
|
|
|
<td class="px-3 py-3 text-center align-middle whitespace-nowrap">
|
|
<UTooltip :text="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')">
|
|
<span
|
|
class="inline-flex"
|
|
:date-datetime="moment(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
|
v-rtime="definition.updated_at"
|
|
/>
|
|
</UTooltip>
|
|
</td>
|
|
|
|
<td class="w-44 px-3 py-3 align-middle whitespace-nowrap">
|
|
<div class="flex items-center justify-end gap-2">
|
|
<UButton
|
|
color="info"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-file-up"
|
|
@click="() => void exportDefinition(definition)"
|
|
>
|
|
<span v-if="!isMobile">Export</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="warning"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-pencil"
|
|
@click="() => void openEdit(definition)"
|
|
>
|
|
<span v-if="!isMobile">Edit</span>
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="error"
|
|
variant="outline"
|
|
size="xs"
|
|
icon="i-lucide-trash"
|
|
@click="() => void remove(definition)"
|
|
>
|
|
<span v-if="!isMobile">Delete</span>
|
|
</UButton>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="filteredDefinitions.length > 0"
|
|
class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
|
>
|
|
<UCard
|
|
v-for="definition in filteredDefinitions"
|
|
:key="definition.id"
|
|
class="flex h-full flex-col border bg-default"
|
|
:ui="{ header: 'p-4 pb-3', body: 'flex flex-1 flex-col gap-4 p-4 pt-0' }"
|
|
>
|
|
<template #header>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0 flex-1 space-y-2">
|
|
<div class="text-sm font-semibold text-highlighted wrap-break-word">
|
|
{{ definition.name || '(Unnamed definition)' }}
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2 text-xs text-toned *:min-w-32 *:flex-1">
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 transition hover:border-primary hover:text-default"
|
|
@click="() => void toggle(definition)"
|
|
>
|
|
<UIcon
|
|
name="i-lucide-power"
|
|
class="size-3.5"
|
|
:class="definition.enabled ? 'text-success' : 'text-error'"
|
|
/>
|
|
<span>{{ definition.enabled ? 'Enabled' : 'Disabled' }}</span>
|
|
</button>
|
|
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
|
>
|
|
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
|
|
<span>Priority {{ definition.priority }}</span>
|
|
</span>
|
|
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1"
|
|
>
|
|
<UIcon name="i-lucide-link" class="size-3.5" />
|
|
<span
|
|
>{{ definition.match_url.length }} match pattern{{
|
|
definition.match_url.length === 1 ? '' : 's'
|
|
}}</span
|
|
>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<UButton
|
|
color="info"
|
|
variant="ghost"
|
|
size="xs"
|
|
icon="i-lucide-file-up"
|
|
square
|
|
@click="() => void exportDefinition(definition)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-3 text-sm text-default">
|
|
<div class="rounded-md border border-default bg-muted/20 px-3 py-2">
|
|
<div
|
|
class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-toned"
|
|
>
|
|
<UIcon name="i-lucide-link" class="size-3.5" />
|
|
<span>Match patterns</span>
|
|
</div>
|
|
|
|
<div class="space-y-1 text-sm">
|
|
<div
|
|
v-for="pattern in definition.match_url.slice(0, 3)"
|
|
:key="pattern"
|
|
class="truncate text-default"
|
|
>
|
|
{{ pattern }}
|
|
</div>
|
|
<div v-if="definition.match_url.length > 3" class="text-xs text-toned">
|
|
+{{ definition.match_url.length - 3 }} more
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-auto flex flex-wrap gap-2 pt-2 *:min-w-32 *:flex-1">
|
|
<UButton
|
|
color="warning"
|
|
variant="outline"
|
|
icon="i-lucide-pencil"
|
|
class="w-full justify-center"
|
|
@click="() => void openEdit(definition)"
|
|
>
|
|
Edit
|
|
</UButton>
|
|
|
|
<UButton
|
|
color="error"
|
|
variant="outline"
|
|
icon="i-lucide-trash"
|
|
class="w-full justify-center"
|
|
@click="() => void remove(definition)"
|
|
>
|
|
Delete
|
|
</UButton>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="isLoading"
|
|
color="info"
|
|
variant="soft"
|
|
icon="i-lucide-loader-circle"
|
|
title="Loading"
|
|
description="Loading data. Please wait..."
|
|
/>
|
|
|
|
<div v-else-if="query && filteredDefinitions.length < 1" class="space-y-3">
|
|
<UAlert
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-search"
|
|
title="No Results"
|
|
:description="`No results found for the query: ${query}. Please try a different search term.`"
|
|
/>
|
|
|
|
<UButton color="neutral" variant="outline" size="sm" @click="query = ''"
|
|
>Clear filter</UButton
|
|
>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-else-if="!definitions.length"
|
|
color="warning"
|
|
variant="soft"
|
|
icon="i-lucide-circle-alert"
|
|
title="No definitions"
|
|
description="There are no task definitions. Click the New Definition button to create your first task definition."
|
|
/>
|
|
|
|
<UModal
|
|
v-if="isEditorOpen"
|
|
:open="isEditorOpen"
|
|
:title="editorTitle"
|
|
:description="editorDescription"
|
|
:dismissible="!editorLoading && !editorSubmitting"
|
|
:ui="{ content: 'w-full sm:max-w-7xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
|
@update:open="(open) => !open && closeEditor()"
|
|
>
|
|
<template #body>
|
|
<TaskDefinitionEditor
|
|
:document="workingDefinition"
|
|
:initial-show-import="showImportByDefault"
|
|
:available-definitions="definitions"
|
|
:loading="editorLoading"
|
|
:submitting="editorSubmitting"
|
|
@submit="submitDefinition"
|
|
@cancel="closeEditor"
|
|
@import-existing="importExistingDefinition"
|
|
/>
|
|
</template>
|
|
</UModal>
|
|
|
|
<UModal
|
|
v-if="inspect"
|
|
:open="inspect"
|
|
title="Inspect Task Handler"
|
|
description="Enter the URL of the resource you want to inspect."
|
|
:ui="{ content: 'w-full sm:max-w-4xl', body: 'max-h-[85vh] overflow-y-auto p-4 sm:p-6' }"
|
|
@update:open="(open) => !open && (inspect = false)"
|
|
>
|
|
<template #body>
|
|
<TaskInspect />
|
|
</template>
|
|
</UModal>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import moment from 'moment';
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import { useStorage } from '@vueuse/core';
|
|
|
|
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions';
|
|
import { useDialog } from '~/composables/useDialog';
|
|
import { useMediaQuery } from '~/composables/useMediaQuery';
|
|
import { copyText, encode } from '~/utils';
|
|
|
|
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 lastError = taskDefs.lastError;
|
|
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<TaskDefinitionSummary[]>(() => [...definitionsRef.value]);
|
|
|
|
const { confirmDialog } = useDialog();
|
|
|
|
const isEditorOpen = ref(false);
|
|
const editorMode = ref<'create' | 'edit'>('create');
|
|
const editorLoading = ref(false);
|
|
const editorSubmitting = ref(false);
|
|
const workingDefinition = ref<TaskDefinitionDocument | null>(null);
|
|
const workingId = ref<number | null>(null);
|
|
const inspect = ref(false);
|
|
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid');
|
|
|
|
const query = ref('');
|
|
const showFilter = ref(false);
|
|
const filterInput = ref<HTMLInputElement | null>(null);
|
|
const hideImportByDefault = ref(false);
|
|
|
|
const filteredDefinitions = computed<TaskDefinitionSummary[]>(() => {
|
|
const normalizedQuery = query.value.trim().toLowerCase();
|
|
if (!normalizedQuery) {
|
|
return definitions.value;
|
|
}
|
|
|
|
return definitions.value.filter((definition) => {
|
|
const haystack = [
|
|
definition.name,
|
|
definition.priority,
|
|
definition.enabled ? 'enabled' : 'disabled',
|
|
...definition.match_url,
|
|
]
|
|
.join(' ')
|
|
.toLowerCase();
|
|
|
|
return haystack.includes(normalizedQuery);
|
|
});
|
|
});
|
|
|
|
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
|
|
if (editorMode.value !== 'edit' || !workingId.value) {
|
|
return undefined;
|
|
}
|
|
|
|
return definitions.value.find((item) => item.id === workingId.value);
|
|
});
|
|
|
|
const editorTitle = computed(() => {
|
|
return editorMode.value === 'create'
|
|
? 'Create Task Definition'
|
|
: `Edit - ${currentSummary.value?.name || 'Task Definition'}`;
|
|
});
|
|
|
|
const editorDescription = computed(() => {
|
|
if (editorLoading.value) {
|
|
return 'Loading full definition before editing.';
|
|
}
|
|
|
|
return 'Use the GUI editor when it fits, or switch to advanced JSON for full control.';
|
|
});
|
|
|
|
const showImportByDefault = computed(
|
|
() => editorMode.value === 'create' && !hideImportByDefault.value,
|
|
);
|
|
|
|
watch(showFilter, (value) => {
|
|
if (!value) {
|
|
query.value = '';
|
|
}
|
|
});
|
|
|
|
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
|
|
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument;
|
|
};
|
|
|
|
const toggleFilterPanel = async (): Promise<void> => {
|
|
showFilter.value = !showFilter.value;
|
|
if (!showFilter.value) {
|
|
query.value = '';
|
|
return;
|
|
}
|
|
|
|
await nextTick();
|
|
filterInput.value?.focus();
|
|
};
|
|
|
|
const reloadContent = async (): Promise<void> => {
|
|
await loadDefinitions(1, 1000);
|
|
};
|
|
|
|
const toggleDisplayStyle = (): void => {
|
|
display_style.value = display_style.value === 'list' ? 'grid' : 'list';
|
|
};
|
|
|
|
const openCreate = (): void => {
|
|
editorMode.value = 'create';
|
|
workingId.value = null;
|
|
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION);
|
|
editorLoading.value = false;
|
|
editorSubmitting.value = false;
|
|
hideImportByDefault.value = false;
|
|
isEditorOpen.value = true;
|
|
};
|
|
|
|
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
|
|
editorMode.value = 'edit';
|
|
workingId.value = summary.id;
|
|
workingDefinition.value = null;
|
|
editorLoading.value = true;
|
|
editorSubmitting.value = false;
|
|
hideImportByDefault.value = true;
|
|
isEditorOpen.value = true;
|
|
|
|
const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id);
|
|
if (!detailed) {
|
|
closeEditor();
|
|
return;
|
|
}
|
|
|
|
workingDefinition.value = {
|
|
name: detailed.name,
|
|
priority: detailed.priority,
|
|
enabled: detailed.enabled,
|
|
match_url: [...detailed.match_url],
|
|
definition: JSON.parse(JSON.stringify(detailed.definition)),
|
|
};
|
|
editorLoading.value = false;
|
|
};
|
|
|
|
const importExistingDefinition = async (id: number): Promise<void> => {
|
|
const detailed = await getDefinition(id);
|
|
if (!detailed) {
|
|
return;
|
|
}
|
|
|
|
editorMode.value = 'create';
|
|
workingId.value = null;
|
|
workingDefinition.value = {
|
|
name: detailed.name,
|
|
priority: detailed.priority,
|
|
enabled: detailed.enabled,
|
|
match_url: [...detailed.match_url],
|
|
definition: JSON.parse(JSON.stringify(detailed.definition)),
|
|
};
|
|
hideImportByDefault.value = true;
|
|
editorLoading.value = false;
|
|
isEditorOpen.value = true;
|
|
};
|
|
|
|
const closeEditor = (): void => {
|
|
if (editorSubmitting.value) {
|
|
return;
|
|
}
|
|
|
|
isEditorOpen.value = false;
|
|
workingDefinition.value = null;
|
|
workingId.value = null;
|
|
editorLoading.value = false;
|
|
editorSubmitting.value = false;
|
|
hideImportByDefault.value = false;
|
|
};
|
|
|
|
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
|
|
let shouldClose = false;
|
|
editorSubmitting.value = true;
|
|
|
|
try {
|
|
if (editorMode.value === 'create') {
|
|
const created = await createDefinition(definition);
|
|
if (created) {
|
|
shouldClose = true;
|
|
}
|
|
} else if (workingId.value) {
|
|
const updated = await updateDefinition(workingId.value, definition);
|
|
if (updated) {
|
|
shouldClose = true;
|
|
}
|
|
}
|
|
} finally {
|
|
editorSubmitting.value = false;
|
|
}
|
|
|
|
if (shouldClose) {
|
|
closeEditor();
|
|
}
|
|
};
|
|
|
|
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: 'error',
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
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 reloadContent();
|
|
}
|
|
});
|
|
</script>
|