Merge pull request #535 from arabcoders/dev
Refactor: add enabled field to notifications.
This commit is contained in:
commit
d5ae882df0
22 changed files with 969 additions and 352 deletions
|
|
@ -76,6 +76,7 @@ class Target:
|
||||||
on: list[str] = field(default_factory=list)
|
on: list[str] = field(default_factory=list)
|
||||||
presets: list[str] = field(default_factory=list)
|
presets: list[str] = field(default_factory=list)
|
||||||
request: TargetRequest
|
request: TargetRequest
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
def serialize(self) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|
@ -84,6 +85,7 @@ class Target:
|
||||||
"on": self.on,
|
"on": self.on,
|
||||||
"presets": self.presets,
|
"presets": self.presets,
|
||||||
"request": self.request.serialize(),
|
"request": self.request.serialize(),
|
||||||
|
"enabled": self.enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
def json(self) -> str:
|
def json(self) -> str:
|
||||||
|
|
@ -234,8 +236,18 @@ class Notification(metaclass=Singleton):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Error loading '{self._file}'. '{e!s}'")
|
LOG.error(f"Error loading '{self._file}'. '{e!s}'")
|
||||||
|
|
||||||
|
if not targets or len(targets) < 1:
|
||||||
|
LOG.debug(f"No targets were found in '{self._file}'.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
need_save = False
|
||||||
|
|
||||||
for target in targets:
|
for target in targets:
|
||||||
try:
|
try:
|
||||||
|
if "enabled" not in target:
|
||||||
|
target["enabled"] = True
|
||||||
|
need_save = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Notification.validate(target)
|
Notification.validate(target)
|
||||||
target: Target = self.make_target(target)
|
target: Target = self.make_target(target)
|
||||||
|
|
@ -252,6 +264,10 @@ class Notification(metaclass=Singleton):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Error loading notification target '{target}'. '{e!s}'")
|
LOG.error(f"Error loading notification target '{target}'. '{e!s}'")
|
||||||
|
|
||||||
|
if need_save:
|
||||||
|
LOG.warning("Saving notifications due to schema changes.")
|
||||||
|
self.save(self._targets)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def make_target(self, target: dict) -> Target:
|
def make_target(self, target: dict) -> Target:
|
||||||
|
|
@ -270,6 +286,7 @@ class Notification(metaclass=Singleton):
|
||||||
name=target.get("name"),
|
name=target.get("name"),
|
||||||
on=target.get("on", []),
|
on=target.get("on", []),
|
||||||
presets=target.get("presets", []),
|
presets=target.get("presets", []),
|
||||||
|
enabled=target.get("enabled", True),
|
||||||
request=TargetRequest(
|
request=TargetRequest(
|
||||||
type=target.get("request", {}).get("type", "json"),
|
type=target.get("request", {}).get("type", "json"),
|
||||||
method=target.get("request", {}).get("method", "POST"),
|
method=target.get("request", {}).get("method", "POST"),
|
||||||
|
|
@ -319,6 +336,13 @@ class Notification(metaclass=Singleton):
|
||||||
if "data_key" not in target["request"]:
|
if "data_key" not in target["request"]:
|
||||||
target["request"]["data_key"] = "data"
|
target["request"]["data_key"] = "data"
|
||||||
|
|
||||||
|
if "enabled" not in target:
|
||||||
|
target["enabled"] = True
|
||||||
|
|
||||||
|
if target.get("enabled") is not None and not isinstance(target.get("enabled"), bool):
|
||||||
|
msg = "Enabled must be a boolean."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
if "method" in target["request"] and target["request"]["method"].upper() not in ["POST", "PUT"]:
|
if "method" in target["request"] and target["request"]["method"].upper() not in ["POST", "PUT"]:
|
||||||
msg = "Invalid notification target. Invalid method found."
|
msg = "Invalid notification target. Invalid method found."
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
@ -390,6 +414,9 @@ class Notification(metaclass=Singleton):
|
||||||
apprise_targets: list[Target] = []
|
apprise_targets: list[Target] = []
|
||||||
|
|
||||||
for target in self._targets:
|
for target in self._targets:
|
||||||
|
if not target.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
if len(target.on) > 0 and ev.event not in target.on and "test" != ev.event:
|
if len(target.on) > 0 and ev.event not in target.on and "test" != ev.event:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,11 @@
|
||||||
"description": "Restrict notifications to downloads created by these presets.",
|
"description": "Restrict notifications to downloads created by these presets.",
|
||||||
"default": []
|
"default": []
|
||||||
},
|
},
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the notification target is enabled.",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
"request": {
|
"request": {
|
||||||
"$ref": "#/definitions/request"
|
"$ref": "#/definitions/request"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ class TestTarget:
|
||||||
assert target.name == "Test Webhook"
|
assert target.name == "Test Webhook"
|
||||||
assert target.on == []
|
assert target.on == []
|
||||||
assert target.presets == []
|
assert target.presets == []
|
||||||
|
assert target.enabled is True
|
||||||
assert target.request == request
|
assert target.request == request
|
||||||
|
|
||||||
def test_target_creation_full(self):
|
def test_target_creation_full(self):
|
||||||
|
|
@ -143,6 +144,7 @@ class TestTarget:
|
||||||
name="Full Test Webhook",
|
name="Full Test Webhook",
|
||||||
on=["item_completed", "item_failed"],
|
on=["item_completed", "item_failed"],
|
||||||
presets=["default", "audio_only"],
|
presets=["default", "audio_only"],
|
||||||
|
enabled=False,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -150,6 +152,7 @@ class TestTarget:
|
||||||
assert target.name == "Full Test Webhook"
|
assert target.name == "Full Test Webhook"
|
||||||
assert target.on == ["item_completed", "item_failed"]
|
assert target.on == ["item_completed", "item_failed"]
|
||||||
assert target.presets == ["default", "audio_only"]
|
assert target.presets == ["default", "audio_only"]
|
||||||
|
assert target.enabled is False
|
||||||
|
|
||||||
def test_target_serialize(self):
|
def test_target_serialize(self):
|
||||||
"""Test serializing a Target."""
|
"""Test serializing a Target."""
|
||||||
|
|
@ -379,6 +382,48 @@ class TestNotification:
|
||||||
# Clean up
|
# Clean up
|
||||||
Path(temp_path).unlink()
|
Path(temp_path).unlink()
|
||||||
|
|
||||||
|
@patch("app.library.Notifications.Config")
|
||||||
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
|
@patch("app.library.Notifications.Presets")
|
||||||
|
def test_load_targets_schema_update(self, mock_presets, mock_worker, mock_config):
|
||||||
|
"""Test that load automatically updates file schema when enabled field is missing."""
|
||||||
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
mock_preset = Mock()
|
||||||
|
mock_preset.name = "default"
|
||||||
|
mock_presets.get_instance.return_value.get_all.return_value = [mock_preset]
|
||||||
|
|
||||||
|
# Create target data without enabled field (old schema)
|
||||||
|
target_data = [
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"name": "Old Schema Target",
|
||||||
|
"on": ["item_completed"],
|
||||||
|
"presets": ["default"],
|
||||||
|
"request": {"type": "json", "method": "POST", "url": "https://example.com/webhook"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
|
||||||
|
json.dump(target_data, temp_file)
|
||||||
|
temp_path = temp_file.name
|
||||||
|
|
||||||
|
notification = Notification.get_instance(file=temp_path)
|
||||||
|
|
||||||
|
# Mock the save method to verify it's called
|
||||||
|
with patch.object(notification, "save") as mock_save:
|
||||||
|
notification.load()
|
||||||
|
|
||||||
|
# Verify save was called due to schema update
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
# Verify the target has enabled=True by default
|
||||||
|
assert len(notification._targets) == 1
|
||||||
|
assert notification._targets[0].enabled is True
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
Path(temp_path).unlink()
|
||||||
|
|
||||||
@patch("app.library.Notifications.Config")
|
@patch("app.library.Notifications.Config")
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
@patch("app.library.Notifications.Presets")
|
@patch("app.library.Notifications.Presets")
|
||||||
|
|
@ -543,6 +588,38 @@ class TestNotification:
|
||||||
with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid type found\."):
|
with pytest.raises(ValueError, match=r"Invalid notification target\. Invalid type found\."):
|
||||||
Notification.validate(target_dict)
|
Notification.validate(target_dict)
|
||||||
|
|
||||||
|
def test_validate_target_invalid_enabled(self):
|
||||||
|
"""Test validating a target with invalid enabled field."""
|
||||||
|
target_dict = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"name": "Test Target",
|
||||||
|
"enabled": "yes", # Should be boolean
|
||||||
|
"request": {
|
||||||
|
"url": "https://example.com/webhook",
|
||||||
|
"method": "POST",
|
||||||
|
"type": "json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=r"Enabled must be a boolean\."):
|
||||||
|
Notification.validate(target_dict)
|
||||||
|
|
||||||
|
def test_validate_target_enabled_defaults_to_true(self):
|
||||||
|
"""Test that enabled defaults to True when not provided."""
|
||||||
|
target_dict = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"name": "Test Target",
|
||||||
|
"request": {
|
||||||
|
"url": "https://example.com/webhook",
|
||||||
|
"method": "POST",
|
||||||
|
"type": "json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = Notification.validate(target_dict)
|
||||||
|
assert result is True
|
||||||
|
assert target_dict.get("enabled") is True
|
||||||
|
|
||||||
@patch("app.library.Notifications.Config")
|
@patch("app.library.Notifications.Config")
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
@patch("app.library.Notifications.EventBus")
|
@patch("app.library.Notifications.EventBus")
|
||||||
|
|
@ -633,6 +710,45 @@ class TestNotification:
|
||||||
assert result[0]["text"] == "OK"
|
assert result[0]["text"] == "OK"
|
||||||
mock_client.request.assert_called_once()
|
mock_client.request.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("app.library.Notifications.Config")
|
||||||
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
|
async def test_send_skips_disabled_targets(self, mock_worker, mock_config):
|
||||||
|
"""Test that send method skips disabled targets."""
|
||||||
|
mock_config.get_instance.return_value = Mock(debug=False, config_path="/tmp", app_version="1.0.0")
|
||||||
|
mock_worker.get_instance.return_value = Mock()
|
||||||
|
|
||||||
|
# Mock HTTP client
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.text = "OK"
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.request = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
notification = Notification.get_instance(client=mock_client)
|
||||||
|
|
||||||
|
# Add enabled and disabled targets
|
||||||
|
enabled_request = TargetRequest(type="json", method="POST", url="https://example.com/enabled")
|
||||||
|
enabled_target = Target(id=str(uuid.uuid4()), name="Enabled Target", enabled=True, request=enabled_request)
|
||||||
|
|
||||||
|
disabled_request = TargetRequest(type="json", method="POST", url="https://example.com/disabled")
|
||||||
|
disabled_target = Target(id=str(uuid.uuid4()), name="Disabled Target", enabled=False, request=disabled_request)
|
||||||
|
|
||||||
|
notification._targets = [enabled_target, disabled_target]
|
||||||
|
|
||||||
|
# Create test event
|
||||||
|
item_dto = ItemDTO(
|
||||||
|
id="test_id", url="https://youtube.com/watch?v=test", title="Test Video", folder="/downloads"
|
||||||
|
)
|
||||||
|
event = Event(event="item_completed", data=item_dto)
|
||||||
|
|
||||||
|
result = await notification.send(event)
|
||||||
|
|
||||||
|
# Only enabled target should be called
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["status"] == 200
|
||||||
|
mock_client.request.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@patch("app.library.Notifications.Config")
|
@patch("app.library.Notifications.Config")
|
||||||
@patch("app.library.Notifications.BackgroundWorker")
|
@patch("app.library.Notifications.BackgroundWorker")
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,6 @@ hr {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-hint {
|
|
||||||
user-select: none;
|
|
||||||
cursor: help;
|
|
||||||
border-bottom: 1px dotted;
|
|
||||||
}
|
|
||||||
|
|
||||||
.has-tooltip {
|
.has-tooltip {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
border-bottom: 1px dotted;
|
border-bottom: 1px dotted;
|
||||||
|
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
<template>
|
|
||||||
<vTooltip @show="loadContent" @hide="stopTimer">
|
|
||||||
<slot />
|
|
||||||
<template #popper>
|
|
||||||
<template v-if="error_msg">
|
|
||||||
<Message message_class="is-danger" :newStyle="true">
|
|
||||||
{{ error_msg }}
|
|
||||||
</Message>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
|
|
||||||
<div v-else>
|
|
||||||
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
|
|
||||||
<div class="is-block" style="word-break: all;" v-if="props.title">
|
|
||||||
<span style="font-size: 120%;">{{ props.title }}</span>
|
|
||||||
</div>
|
|
||||||
<figure :class="['image', thumbnail_ratio, 'is-hidden-mobile']">
|
|
||||||
<img @load="e => pImg(e)" :src="url" :alt="props.title" @error="clearCache"
|
|
||||||
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
|
|
||||||
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</vTooltip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useStorage } from '@vueuse/core'
|
|
||||||
import { disableOpacity, enableOpacity } from '~/utils'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
image?: string
|
|
||||||
title?: string
|
|
||||||
loader?: () => Promise<void>
|
|
||||||
privacy?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const cache = useSessionCache()
|
|
||||||
const toast = useNotification()
|
|
||||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
|
||||||
const url = ref<string | null>(null)
|
|
||||||
const error = ref(false)
|
|
||||||
const isPreloading = ref(false)
|
|
||||||
const error_msg = ref<string | null>(null)
|
|
||||||
|
|
||||||
const loadTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const cancelRequest = new AbortController()
|
|
||||||
|
|
||||||
const defaultLoader = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
if (!props.image) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cache.has(props.image)) {
|
|
||||||
url.value = cache.get(props.image)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await request(props.image, { signal: cancelRequest.signal })
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
error_msg.value = `ImageView Request error. ${response.status}: ${response.statusText}`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const objUrl = URL.createObjectURL(await response.blob())
|
|
||||||
cache.set(props.image, objUrl)
|
|
||||||
url.value = objUrl
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e === 'not_needed') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error(e)
|
|
||||||
toast.error(`ImageView Request failure. ${e}`)
|
|
||||||
} finally {
|
|
||||||
isPreloading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopTimer = async (): Promise<void> => {
|
|
||||||
if (error.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.value) {
|
|
||||||
isPreloading.value = false
|
|
||||||
url.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await awaiter(() => isPreloading.value)
|
|
||||||
if (loadTimer !== null) {
|
|
||||||
clearTimeout(loadTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
isPreloading.value = false
|
|
||||||
url.value = null
|
|
||||||
cancelRequest.abort('not_needed')
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadContent = async (): Promise<void> => {
|
|
||||||
if (props.loader) {
|
|
||||||
return props.loader()
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultLoader()
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearCache = async (): Promise<void> => {
|
|
||||||
if (!props.image) return
|
|
||||||
|
|
||||||
cache.remove(props.image)
|
|
||||||
url.value = ''
|
|
||||||
return loadContent()
|
|
||||||
}
|
|
||||||
|
|
||||||
const pImg = (e: Event): void => {
|
|
||||||
const target = e.target as HTMLImageElement
|
|
||||||
if (target.naturalHeight > target.naturalWidth) {
|
|
||||||
target.classList.add('image-portrait')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onMounted(() => disableOpacity())
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
enableOpacity()
|
|
||||||
if (null !== loadTimer) {
|
|
||||||
clearTimeout(loadTimer)
|
|
||||||
}
|
|
||||||
cancelRequest.abort('not_needed')
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
@ -114,17 +114,32 @@
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-text-overflow is-vcentered">
|
<td class="is-text-overflow is-vcentered">
|
||||||
<div class="is-inline is-pulled-right" v-if="item.extras?.duration">
|
<div class="is-inline is-pulled-right" v-if="item.extras?.duration || item?.download_dir">
|
||||||
<span class="tag is-info" v-if="item.extras?.duration">
|
<span class="tag is-info is-unselectable" v-if="item.extras?.duration">
|
||||||
{{ formatTime(item.extras.duration) }}
|
{{ formatTime(item.extras.duration) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="icon is-pointer" v-if="item.download_dir"
|
||||||
|
v-tooltip="`Path: ${getPath(config.app.download_path, item)}`">
|
||||||
|
<i class="fa-solid fa-folder-open" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showThumbnails && getImage(item)">
|
<div v-if="show_popover">
|
||||||
<FloatingImage :image="getImage(item)" :title="`[${item.preset}] - ${item.title}`">
|
<div class="is-text-overflow">
|
||||||
<div class="is-text-overflow">
|
<Popover :showDelay="400" :maxWidth="450">
|
||||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
<template #trigger>
|
||||||
</div>
|
<NuxtLink class="is-text-overflow" target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||||
</FloatingImage>
|
</template>
|
||||||
|
<template #title>
|
||||||
|
<strong>
|
||||||
|
{{ item.title }}
|
||||||
|
<span class="tag is-info is-unselectable">{{ item.preset }}</span>
|
||||||
|
</strong>
|
||||||
|
</template>
|
||||||
|
<img v-if="showThumbnails && getImage(config.app.download_path, item)"
|
||||||
|
:src="getImage(config.app.download_path, item)" class="mt-2 mb-2" />
|
||||||
|
<p v-if="item.description">{{ item.description }}</p>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="is-text-overflow" v-tooltip="`[${item.preset}] - ${item.title}`">
|
<div class="is-text-overflow" v-tooltip="`[${item.preset}] - ${item.title}`">
|
||||||
|
|
@ -143,17 +158,17 @@
|
||||||
<span>{{ setStatus(item) }}</span>
|
<span>{{ setStatus(item) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered has-text-centered is-unselectable">
|
<td class="is-vcentered has-text-centered is-unselectable">
|
||||||
<span class="user-hint" :date-datetime="item.datetime"
|
<span class="has-tooltip" :date-datetime="item.datetime"
|
||||||
v-tooltip="moment(item.datetime).format('YYYY-M-DD H:mm Z')" v-rtime="item.datetime" />
|
v-tooltip="moment(item.datetime).format('YYYY-M-DD H:mm Z')" v-rtime="item.datetime" />
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered has-text-centered is-unselectable"
|
<td class="is-vcentered has-text-centered is-unselectable"
|
||||||
v-if="item.live_in && 'not_live' === item.status">
|
v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)">
|
||||||
<span :date-datetime="item.live_in" class="user-hint"
|
<span :date-datetime="item.live_in || item.extras?.release_in" class="has-tooltip"
|
||||||
v-tooltip="'Will automatically be retried at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')"
|
v-tooltip="`Retry at: ${moment(item.live_in || item.extras?.release_in).format('YYYY-M-DD H:mm Z')}`"
|
||||||
v-rtime="item.live_in" />
|
v-rtime="item.live_in || item.extras?.release_in" />
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered has-text-centered is-unselectable" v-else>
|
<td class="is-vcentered has-text-centered is-unselectable" v-else>
|
||||||
{{ item.file_size ? formatBytes(item.file_size) : '-' }}
|
{{ item.file_size ? formatBytes(item.file_size) : 'N/A' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
|
|
@ -240,17 +255,33 @@
|
||||||
<div class="card is-flex is-full-height is-flex-direction-column"
|
<div class="card is-flex is-full-height is-flex-direction-column"
|
||||||
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
|
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
|
<div class="card-header-title is-text-overflow is-block">
|
||||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
<Popover :showDelay="400" :maxWidth="550" v-if="show_popover">
|
||||||
|
<template #trigger>
|
||||||
|
<NuxtLink class="is-text-overflow" target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template #title><strong>{{ item.title }}</strong></template>
|
||||||
|
<p v-if="item.description">{{ item.description }}</p>
|
||||||
|
</Popover>
|
||||||
|
<template v-else>
|
||||||
|
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<span class="tag is-info" v-if="item.extras?.duration">
|
<span class="tag is-info is-unselectable" v-if="item.extras?.duration">
|
||||||
{{ formatTime(item.extras.duration) }}
|
{{ formatTime(item.extras.duration) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="control" v-if="item.download_dir">
|
||||||
|
<span class="icon" v-tooltip="`Path: ${getPath(config.app.download_path, item)}`">
|
||||||
|
<i class="fa-solid fa-folder-open" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
|
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
|
||||||
<span class="icon"><i class="fa-solid"
|
<span class="icon"><i class="fa-solid"
|
||||||
|
|
@ -263,7 +294,6 @@
|
||||||
:value="item._id">
|
:value="item._id">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -271,17 +301,20 @@
|
||||||
<figure :class="['image', thumbnail_ratio]">
|
<figure :class="['image', thumbnail_ratio]">
|
||||||
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
|
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
|
||||||
<div class="play-icon"></div>
|
<div class="play-icon"></div>
|
||||||
<img @load="pImg" @error="onImgError" :src="getImage(item)" v-if="getImage(item)" />
|
<img @load="pImg" @error="onImgError" :src="getImage(config.app.download_path, item)"
|
||||||
|
v-if="getImage(config.app.download_path, item)" />
|
||||||
<img v-else src="/images/placeholder.png" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
||||||
class="play-overlay">
|
class="play-overlay">
|
||||||
<div class="play-icon embed-icon"></div>
|
<div class="play-icon embed-icon"></div>
|
||||||
<img @load="pImg" @error="onImgError" :src="getImage(item)" v-if="getImage(item)" />
|
<img @load="pImg" @error="onImgError" :src="getImage(config.app.download_path, item)"
|
||||||
|
v-if="getImage(config.app.download_path, item)" />
|
||||||
<img v-else src="/images/placeholder.png" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</span>
|
</span>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<img @load="pImg" @error="onImgError" v-if="getImage(item)" :src="getImage(item)" />
|
<img @load="pImg" @error="onImgError" v-if="getImage(config.app.download_path, item)"
|
||||||
|
:src="getImage(config.app.download_path, item)" />
|
||||||
<img v-else src="/images/placeholder.png" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</template>
|
</template>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
@ -294,21 +327,20 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||||
<span v-tooltip="`Preset: ${item.preset}`" class="user-hint">{{ item.preset }}</span>
|
<span v-tooltip="`Preset: ${item.preset}`" class="has-tooltip">{{ item.preset }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"
|
||||||
v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)">
|
v-if="'not_live' === item.status && (item.live_in || item.extras?.release_in)">
|
||||||
<span :date-datetime="item.live_in || item.extras?.release_in" class="user-hint"
|
<span :date-datetime="item.live_in || item.extras?.release_in" class="has-tooltip"
|
||||||
v-tooltip="'Will be downloaded at: ' + moment(item.live_in || item.extras?.release_in).format('YYYY-M-DD H:mm Z')"
|
v-tooltip="`Retry at: ${moment(item.live_in || item.extras?.release_in).format('YYYY-M-DD H:mm Z')}`"
|
||||||
v-rtime="item.live_in || item.extras?.release_in" />
|
v-rtime="item.live_in || item.extras?.release_in" />
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||||
<span class="user-hint" :date-datetime="item.datetime"
|
<span class="has-tooltip" :date-datetime="item.datetime"
|
||||||
v-tooltip="moment(item.datetime).format('YYYY-M-DD H:mm Z')" v-rtime="item.datetime" />
|
v-tooltip="moment(item.datetime).format('YYYY-M-DD H:mm Z')" v-rtime="item.datetime" />
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable" v-if="item.file_size">
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable" v-if="item.file_size">
|
||||||
<span class="has-tooltip" v-tooltip="`Path: ${makePath(item)}`">{{
|
<span class="has-tooltip">{{ formatBytes(item.file_size) }}</span>
|
||||||
formatBytes(item.file_size) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="columns is-mobile is-multiline">
|
<div class="columns is-mobile is-multiline">
|
||||||
|
|
@ -477,7 +509,7 @@ import moment from 'moment'
|
||||||
import { useStorage, useIntersectionObserver } from '@vueuse/core'
|
import { useStorage, useIntersectionObserver } from '@vueuse/core'
|
||||||
import type { StoreItem } from '~/types/store'
|
import type { StoreItem } from '~/types/store'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
import { deepIncludes } from '~/utils'
|
import { deepIncludes, getPath, getImage } from '~/utils'
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'getInfo', url: string, preset: string, cli: string): void
|
(e: 'getInfo', url: string, preset: string, cli: string): void
|
||||||
|
|
@ -504,6 +536,7 @@ const display_style = useStorage<'grid' | 'list'>('display_style', 'grid')
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
||||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
||||||
|
const show_popover = useStorage<boolean>('show_popover', true)
|
||||||
|
|
||||||
const selectedElms = ref<string[]>([])
|
const selectedElms = ref<string[]>([])
|
||||||
const masterSelectAll = ref(false)
|
const masterSelectAll = ref(false)
|
||||||
|
|
@ -979,24 +1012,4 @@ const is_queued = (item: StoreItem) => {
|
||||||
}
|
}
|
||||||
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
|
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const makePath = (item: StoreItem) => {
|
|
||||||
if (!item?.filename) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
const real_path = eTrim(item.download_dir, '/') + '/' + sTrim(item.filename, '/')
|
|
||||||
return stripPath(config.app.download_path, real_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getImage = (item: StoreItem): string => {
|
|
||||||
if (item.sidecar?.image && item.sidecar.image.length > 0) {
|
|
||||||
return uri('/api/download/' + encodeURIComponent(stripPath(config.app.download_path, item.sidecar.image[0]?.file || '')))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item?.extras?.thumbnail) {
|
|
||||||
return '/images/placeholder.png'
|
|
||||||
}
|
|
||||||
|
|
||||||
return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,20 @@
|
||||||
<span class="icon"><i class="fa-solid fa-link" /></span>
|
<span class="icon"><i class="fa-solid fa-link" /></span>
|
||||||
<span class="has-tooltip" v-tooltip="'Use Shift+Enter to switch to multiline input mode.'">
|
<span class="has-tooltip" v-tooltip="'Use Shift+Enter to switch to multiline input mode.'">
|
||||||
URLs separated by newlines or <span class="is-bold is-lowercase">{{ getSeparatorsName(separator)
|
URLs separated by newlines or <span class="is-bold is-lowercase">{{ getSeparatorsName(separator)
|
||||||
}}</span>
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<textarea v-if="isMultiLineInput" ref="urlTextarea" class="textarea" id="url"
|
<textarea v-if="isMultiLineInput" ref="urlTextarea" class="textarea" id="url"
|
||||||
:disabled="!socket.isConnected || addInProgress" v-model="form.url" @keydown="handleKeyDown"
|
:disabled="addInProgress" v-model="form.url" @keydown="handleKeyDown" @input="adjustTextareaHeight"
|
||||||
@input="adjustTextareaHeight"
|
|
||||||
style="resize: none; overflow-y: auto; min-height: 38px; max-height: 300px;" />
|
style="resize: none; overflow-y: auto; min-height: 38px; max-height: 300px;" />
|
||||||
<input v-else type="text" class="input" id="url" placeholder="URLs to download"
|
<input v-else type="text" class="input" id="url" placeholder="URLs to download"
|
||||||
:disabled="!socket.isConnected || addInProgress" v-model="form.url" @keydown="handleKeyDown"
|
:disabled="addInProgress" v-model="form.url" @keydown="handleKeyDown" @paste="handlePaste">
|
||||||
@paste="handlePaste">
|
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="submit" class="button is-primary" :class="{ 'is-loading': addInProgress }"
|
<button type="submit" class="button is-primary" :class="{ 'is-loading': addInProgress }"
|
||||||
:disabled="!socket.isConnected || addInProgress || !hasValidUrl">
|
:disabled="addInProgress || !hasValidUrl">
|
||||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||||
<span>Add</span>
|
<span>Add</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -45,8 +43,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="preset" class="is-fullwidth"
|
<select id="preset" class="is-fullwidth" :disabled="addInProgress || hasFormatInConfig"
|
||||||
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="form.preset"
|
v-model="form.preset"
|
||||||
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command options for yt-dlp.' : ''">
|
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command options for yt-dlp.' : ''">
|
||||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||||
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
|
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
|
||||||
|
|
@ -77,15 +75,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input type="text" class="input is-fullwidth" id="folder" v-model="form.folder"
|
<input type="text" class="input is-fullwidth" id="folder" v-model="form.folder"
|
||||||
:placeholder="getDefault('folder', '/')" :disabled="!socket.isConnected || addInProgress"
|
:placeholder="getDefault('folder', '/')" :disabled="addInProgress" list="folders">
|
||||||
list="folders">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-align-content-end">
|
<div class="column is-align-content-end">
|
||||||
<button type="button" class="button is-info" @click="showAdvanced = !showAdvanced"
|
<button type="button" class="button is-info" @click="showAdvanced = !showAdvanced">
|
||||||
:disabled="!socket.isConnected">
|
|
||||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
||||||
<span>Advanced Options</span>
|
<span>Advanced Options</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -104,24 +100,22 @@
|
||||||
<div class="columns is-multiline is-mobile" v-if="showAdvanced">
|
<div class="columns is-multiline is-mobile" v-if="showAdvanced">
|
||||||
<div class="column is-2-tablet is-12-mobile">
|
<div class="column is-2-tablet is-12-mobile">
|
||||||
<DLInput id="force_download" type="bool" label="Force download" icon="fa-solid fa-download"
|
<DLInput id="force_download" type="bool" label="Force download" icon="fa-solid fa-download"
|
||||||
v-model="dlFields['--no-download-archive']" :disabled="!socket.isConnected || addInProgress"
|
v-model="dlFields['--no-download-archive']" :disabled="addInProgress" description="Ignore archive." />
|
||||||
description="Ignore archive." />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-2-tablet is-12-mobile">
|
<div class="column is-2-tablet is-12-mobile">
|
||||||
<DLInput id="auto_start" type="bool" label="Auto start" v-model="auto_start" icon="fa-solid fa-play"
|
<DLInput id="auto_start" type="bool" label="Auto start" v-model="auto_start" icon="fa-solid fa-play"
|
||||||
:disabled="!socket.isConnected || addInProgress" description="Download automatically." />
|
:disabled="addInProgress" description="Download automatically." />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-2-tablet is-12-mobile">
|
<div class="column is-2-tablet is-12-mobile">
|
||||||
<DLInput id="no_cache" type="bool" label="Bypass cache" icon="fa-solid fa-broom"
|
<DLInput id="no_cache" type="bool" label="Bypass cache" icon="fa-solid fa-broom"
|
||||||
v-model="dlFields['--no-continue']" :disabled="!socket.isConnected || addInProgress"
|
v-model="dlFields['--no-continue']" :disabled="addInProgress" description="Remove temporary files." />
|
||||||
description="Remove temporary files." />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-6-tablet is-12-mobile">
|
<div class="column is-6-tablet is-12-mobile">
|
||||||
<DLInput id="output_template" type="string" label="Output template" v-model="form.template"
|
<DLInput id="output_template" type="string" label="Output template" v-model="form.template"
|
||||||
icon="fa-solid fa-file" :disabled="!socket.isConnected || addInProgress"
|
icon="fa-solid fa-file" :disabled="addInProgress"
|
||||||
:placeholder="getDefault('template', config.app.output_template || '%(title)s.%(ext)s')">
|
:placeholder="getDefault('template', config.app.output_template || '%(title)s.%(ext)s')">
|
||||||
<template #help>
|
<template #help>
|
||||||
<span class="help is-bold is-unselectable">
|
<span class="help is-bold is-unselectable">
|
||||||
|
|
@ -140,7 +134,7 @@
|
||||||
<span>Command options for yt-dlp</span>
|
<span>Command options for yt-dlp</span>
|
||||||
</label>
|
</label>
|
||||||
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
<TextareaAutocomplete id="cli_options" v-model="form.cli" :options="ytDlpOpt"
|
||||||
:placeholder="getDefault('cli', '')" :disabled="!socket.isConnected || addInProgress" />
|
:placeholder="getDefault('cli', '')" :disabled="addInProgress" />
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -161,7 +155,7 @@
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<TextDropzone ref="cookiesDropzoneRef" id="ytdlpCookies" v-model="form.cookies"
|
<TextDropzone ref="cookiesDropzoneRef" id="ytdlpCookies" v-model="form.cookies"
|
||||||
:disabled="!socket.isConnected || addInProgress" @error="(msg: string) => toast.error(msg)"
|
:disabled="addInProgress" @error="(msg: string) => toast.error(msg)"
|
||||||
:placeholder="getDefault('cookies', 'Leave empty to use default cookies. Or drag & drop a cookie file here.')" />
|
:placeholder="getDefault('cookies', 'Leave empty to use default cookies. Or drag & drop a cookie file here.')" />
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -177,8 +171,7 @@
|
||||||
<div class="column is-6-tablet is-12-mobile" v-for="(fi, index) in sortedDLFields"
|
<div class="column is-6-tablet is-12-mobile" v-for="(fi, index) in sortedDLFields"
|
||||||
:key="fi.id || `dlf-${index}`">
|
:key="fi.id || `dlf-${index}`">
|
||||||
<DLInput :id="fi?.id || `dlf-${index}`" :type="fi.kind" :description="fi.description" :label="fi.name"
|
<DLInput :id="fi?.id || `dlf-${index}`" :type="fi.kind" :description="fi.description" :label="fi.name"
|
||||||
:icon="fi.icon" v-model="dlFields[fi.field]" :field="fi.field"
|
:icon="fi.icon" v-model="dlFields[fi.field]" :field="fi.field" :disabled="addInProgress" />
|
||||||
:disabled="!socket.isConnected || addInProgress" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="column is-12 is-hidden-tablet">
|
<div class="column is-12 is-hidden-tablet">
|
||||||
|
|
@ -216,14 +209,13 @@
|
||||||
<div class="field is-grouped is-justify-self-end is-hidden-mobile">
|
<div class="field is-grouped is-justify-self-end is-hidden-mobile">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="button" class="button is-purple" @click="() => showFields = true"
|
<button type="button" class="button is-purple" @click="() => showFields = true"
|
||||||
:disabled="!socket.isConnected" v-tooltip="'Manage custom fields'">
|
v-tooltip="'Manage custom fields'">
|
||||||
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
<span class="icon"><i class="fa-solid fa-plus" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control" v-if="config.app.console_enabled" v-tooltip="'Run directly in console'">
|
<div class="control" v-if="config.app.console_enabled" v-tooltip="'Run directly in console'">
|
||||||
<button type="button" class="button is-warning" @click="runCliCommand"
|
<button type="button" class="button is-warning" @click="runCliCommand" :disabled="!hasValidUrl">
|
||||||
:disabled="!socket.isConnected || !hasValidUrl">
|
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -232,21 +224,21 @@
|
||||||
<button type="button" class="button is-info"
|
<button type="button" class="button is-info"
|
||||||
v-tooltip="'Get yt-dlp information for the provided URL.'"
|
v-tooltip="'Get yt-dlp information for the provided URL.'"
|
||||||
@click="emitter('getInfo', splitUrls(form.url || '')[0] || '', form.preset, form.cli)"
|
@click="emitter('getInfo', splitUrls(form.url || '')[0] || '', form.preset, form.cli)"
|
||||||
:disabled="!socket.isConnected || addInProgress || !hasValidUrl">
|
:disabled="addInProgress || !hasValidUrl">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control" v-if="config.app.console_enabled">
|
<div class="control" v-if="config.app.console_enabled">
|
||||||
<button type="button" class="button is-success" @click="testDownloadOptions"
|
<button type="button" class="button is-success" @click="testDownloadOptions" :disabled="!hasValidUrl"
|
||||||
:disabled="!socket.isConnected || !hasValidUrl" v-tooltip="'Show compiled yt-dlp options.'">
|
v-tooltip="'Show compiled yt-dlp options.'">
|
||||||
<span class="icon"><i class="fa-solid fa-flask" /></span>
|
<span class="icon"><i class="fa-solid fa-flask" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="button" class="button is-danger" @click="resetConfig"
|
<button type="button" class="button is-danger" @click="resetConfig" :disabled="!!(form?.id)"
|
||||||
:disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'">
|
v-tooltip="'Reset local settings'">
|
||||||
<span class="icon"><i class="fa-solid fa-rotate-left" /></span>
|
<span class="icon"><i class="fa-solid fa-rotate-left" /></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -286,7 +278,6 @@ const emitter = defineEmits<{
|
||||||
(e: 'clear_form'): void
|
(e: 'clear_form'): void
|
||||||
}>()
|
}>()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const socket = useSocketStore()
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column is-12" v-if="!isApprise">
|
<div class="column" :class="isApprise ? 'is-12' : 'is-6-tablet is-12-mobile'">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label is-inline" for="enabled">
|
||||||
|
<span class="icon"><i class="fa-solid fa-power-off" /></span>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<div class="control is-unselectable">
|
||||||
|
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress"
|
||||||
|
class="switch is-success" />
|
||||||
|
<label for="enabled" class="is-unselectable">
|
||||||
|
{{ form.enabled ? 'Yes' : 'No' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span class="help">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
<span class="is-bold">Whether the notification target is enabled.</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-6-tablet is-12-mobile" v-if="!isApprise">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="data_key">
|
<label class="label is-inline" for="data_key">
|
||||||
Data field
|
Data field
|
||||||
|
|
@ -308,7 +328,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import type { notification, notificationImport } from '~/types/notification'
|
import type { notification, notificationImport } from '~/types/notification'
|
||||||
import {useConfirm} from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
|
|
||||||
const emitter = defineEmits(['cancel', 'submit'])
|
const emitter = defineEmits(['cancel', 'submit'])
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
|
@ -346,6 +366,9 @@ onMounted(() => {
|
||||||
if (!form.request.data_key) {
|
if (!form.request.data_key) {
|
||||||
form.request.data_key = 'data'
|
form.request.data_key = 'data'
|
||||||
}
|
}
|
||||||
|
if (form.enabled === undefined) {
|
||||||
|
form.enabled = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkInfo = async () => {
|
const checkInfo = async () => {
|
||||||
|
|
@ -449,6 +472,10 @@ const importItem = async () => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.enabled !== undefined) {
|
||||||
|
form.enabled = item.enabled
|
||||||
|
}
|
||||||
|
|
||||||
import_string.value = ''
|
import_string.value = ''
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
|
||||||
350
ui/app/components/Popover.vue
Normal file
350
ui/app/components/Popover.vue
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
<template>
|
||||||
|
<div class="popover-wrapper" ref="triggerElm" :tabindex="triggerTabIndex" @mouseenter="handleHoverEnter"
|
||||||
|
@mouseleave="handleHoverLeave" @focusin="handleFocusIn" @focusout="handleFocusOut" @click="handleClick">
|
||||||
|
<div class="popover-trigger">
|
||||||
|
<slot name="trigger">
|
||||||
|
<button type="button" class="button is-small is-rounded" aria-label="More information">
|
||||||
|
<span class="icon is-small">
|
||||||
|
<i class="fas fa-info-circle" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="isOpen" class="popover-portal" ref="portal">
|
||||||
|
<div ref="popover" class="popover-card box" :class="placementClass" role="tooltip" :style="portalStyle"
|
||||||
|
@mouseenter="handleHoverEnter" @mouseleave="handleHoverLeave">
|
||||||
|
<header v-if="hasTitleContent" class="popover-title mb-2">
|
||||||
|
<slot name="title">
|
||||||
|
<p class="title is-6 mb-1">{{ title }}</p>
|
||||||
|
</slot>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section v-if="hasBodyContent" class="popover-body">
|
||||||
|
<slot>
|
||||||
|
<p class="is-size-7 mb-0">{{ description }}</p>
|
||||||
|
</slot>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PopoverProps } from '~/types/popover'
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (event: 'shown' | 'hidden'): void }>()
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<PopoverProps>(), {
|
||||||
|
placement: 'top',
|
||||||
|
trigger: 'hover',
|
||||||
|
offset: 10,
|
||||||
|
disabled: false,
|
||||||
|
closeOnClickOutside: true,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
minWidth: 220,
|
||||||
|
maxWidth: 340,
|
||||||
|
maxHeight: 360,
|
||||||
|
showDelay: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const triggerRef = useTemplateRef<HTMLElement>('triggerElm')
|
||||||
|
const popover = useTemplateRef<HTMLDivElement>('popover')
|
||||||
|
const portalStyle = ref<Record<string, string>>({})
|
||||||
|
const hoverTimeout = ref<number | null>(null)
|
||||||
|
const openTimeout = ref<number | null>(null)
|
||||||
|
|
||||||
|
const placementClass = computed(() => `is-${props.placement}`)
|
||||||
|
const triggerTabIndex = computed(() => props.trigger === 'hover' ? -1 : 0)
|
||||||
|
|
||||||
|
const hasTitleContent = computed(() => Boolean(props.title) || Boolean(slots.title))
|
||||||
|
const hasBodyContent = computed(() => Boolean(props.description) || Boolean(slots.default))
|
||||||
|
|
||||||
|
const clearHoverTimeout = () => {
|
||||||
|
if (null !== hoverTimeout.value) {
|
||||||
|
window.clearTimeout(hoverTimeout.value)
|
||||||
|
hoverTimeout.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearOpenTimeout = () => {
|
||||||
|
if (null !== openTimeout.value) {
|
||||||
|
window.clearTimeout(openTimeout.value)
|
||||||
|
openTimeout.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleClose = () => {
|
||||||
|
clearHoverTimeout()
|
||||||
|
clearOpenTimeout()
|
||||||
|
hoverTimeout.value = window.setTimeout(() => {
|
||||||
|
isOpen.value = false
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPopoverImmediate = async () => {
|
||||||
|
if (props.disabled || isOpen.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen.value = true
|
||||||
|
await nextTick()
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPopover = () => {
|
||||||
|
clearOpenTimeout()
|
||||||
|
if (props.showDelay && 0 < props.showDelay) {
|
||||||
|
openTimeout.value = window.setTimeout(() => {
|
||||||
|
openTimeout.value = null
|
||||||
|
void openPopoverImmediate()
|
||||||
|
}, props.showDelay)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void openPopoverImmediate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePopover = async () => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
closePopover()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openPopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHoverEnter = () => {
|
||||||
|
if ('hover' !== props.trigger || props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHoverTimeout()
|
||||||
|
openPopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHoverLeave = () => {
|
||||||
|
if ('hover' !== props.trigger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOpenTimeout()
|
||||||
|
scheduleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFocusIn = () => {
|
||||||
|
if ('focus' !== props.trigger || props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openPopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFocusOut = () => {
|
||||||
|
if ('focus' !== props.trigger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOpenTimeout()
|
||||||
|
scheduleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if ('click' !== props.trigger || props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
void togglePopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocumentClick = (event: MouseEvent) => {
|
||||||
|
if (!props.closeOnClickOutside || 'click' !== props.trigger) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target as Node | null
|
||||||
|
const isInsideTrigger = Boolean(triggerRef.value && triggerRef.value.contains(target))
|
||||||
|
const isInsidePopover = Boolean(popover.value && popover.value.contains(target))
|
||||||
|
|
||||||
|
if (!isInsideTrigger && !isInsidePopover) {
|
||||||
|
clearOpenTimeout()
|
||||||
|
closePopover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if ('Escape' === event.key && isOpen.value) {
|
||||||
|
closePopover()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!triggerRef.value || !popover.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
popover.value.style.minWidth = `${props.minWidth}px`
|
||||||
|
popover.value.style.maxWidth = `${props.maxWidth}px`
|
||||||
|
popover.value.style.maxHeight = `${props.maxHeight}px`
|
||||||
|
popover.value.style.overflowY = 'auto'
|
||||||
|
|
||||||
|
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||||
|
const popoverRect = popover.value.getBoundingClientRect()
|
||||||
|
const offset = props.offset
|
||||||
|
|
||||||
|
let top = triggerRect.bottom + offset
|
||||||
|
let left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2
|
||||||
|
|
||||||
|
if ('top' == props.placement) {
|
||||||
|
top = triggerRect.top - popoverRect.height - offset
|
||||||
|
} else if ('left' == props.placement) {
|
||||||
|
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2
|
||||||
|
left = triggerRect.left - popoverRect.width - offset
|
||||||
|
} else if ('right' == props.placement) {
|
||||||
|
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2
|
||||||
|
left = triggerRect.right + offset
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
const maxLeft = viewportWidth - popoverRect.width - 8
|
||||||
|
const maxTop = viewportHeight - popoverRect.height - 8
|
||||||
|
|
||||||
|
const clampedTop = clamp(Math.round(top), 8, maxTop)
|
||||||
|
const clampedLeft = clamp(Math.round(left), 8, maxLeft)
|
||||||
|
|
||||||
|
portalStyle.value = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${clampedTop}px`,
|
||||||
|
left: `${clampedLeft}px`,
|
||||||
|
minWidth: `${props.minWidth}px`,
|
||||||
|
maxWidth: `${props.maxWidth}px`,
|
||||||
|
maxHeight: `${props.maxHeight}px`,
|
||||||
|
overflowY: 'auto'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, async (value) => {
|
||||||
|
if (value) {
|
||||||
|
await nextTick()
|
||||||
|
updatePosition()
|
||||||
|
await nextTick()
|
||||||
|
emit('shown')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleDocumentClick, true)
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
window.addEventListener('resize', updatePosition)
|
||||||
|
window.addEventListener('scroll', updatePosition, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick, true)
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
window.removeEventListener('resize', updatePosition)
|
||||||
|
window.removeEventListener('scroll', updatePosition, true)
|
||||||
|
clearHoverTimeout()
|
||||||
|
clearOpenTimeout()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.popover-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-trigger > * {
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-portal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1050;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-card {
|
||||||
|
position: fixed;
|
||||||
|
background-color: var(--bulma-scheme-main, #fff);
|
||||||
|
color: var(--bulma-text, #363636);
|
||||||
|
border: 1px solid var(--bulma-border, rgba(0, 0, 0, 0.08));
|
||||||
|
box-shadow: var(--bulma-shadow, 0 0.5em 1em -0.125em rgba(10, 10, 10, 0.1), 0 0px 0 1px rgba(10, 10, 10, 0.02));
|
||||||
|
border-radius: var(--bulma-radius-large, 0.5rem);
|
||||||
|
pointer-events: auto;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-title {
|
||||||
|
color: var(--bulma-text-strong, #1f1f1f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: inherit;
|
||||||
|
border-left: inherit;
|
||||||
|
border-top: inherit;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-card.is-top::after {
|
||||||
|
bottom: -6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-card.is-bottom::after {
|
||||||
|
top: -6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-card.is-left::after {
|
||||||
|
right: -6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-card.is-right::after {
|
||||||
|
left: -6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -73,18 +73,31 @@
|
||||||
<span class="tag is-info" v-if="item.extras?.duration">
|
<span class="tag is-info" v-if="item.extras?.duration">
|
||||||
{{ formatTime(item.extras.duration) }}
|
{{ formatTime(item.extras.duration) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="icon is-pointer" v-if="item.download_dir"
|
||||||
|
v-tooltip="`Path: ${getPath(config.app.download_path, item)}`">
|
||||||
|
<i class="fa-solid fa-folder-open" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showThumbnails && item.extras?.thumbnail">
|
<div v-if="show_popover">
|
||||||
<FloatingImage
|
<div class="is-text-overflow">
|
||||||
:image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
<Popover :showDelay="400" :maxWidth="450">
|
||||||
:title="item.title">
|
<template #trigger>
|
||||||
<div class="is-text-overflow">
|
<NuxtLink class="is-text-overflow" target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
</template>
|
||||||
</div>
|
<template #title>
|
||||||
</FloatingImage>
|
<strong>
|
||||||
|
{{ item.title }}
|
||||||
|
<span class="tag is-info is-unselectable">{{ item.preset }}</span>
|
||||||
|
</strong>
|
||||||
|
</template>
|
||||||
|
<img v-if="showThumbnails && getImage(config.app.download_path, item)"
|
||||||
|
:src="getImage(config.app.download_path, item)" class="mt-2 mb-2" />
|
||||||
|
<p v-if="item.description">{{ item.description }}</p>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="is-text-overflow" v-tooltip="item.title">
|
<div class="is-text-overflow" v-tooltip="`[${item.preset}] - ${item.title}`">
|
||||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -162,17 +175,30 @@
|
||||||
v-for="item in filteredItems" :key="item._id">
|
v-for="item in filteredItems" :key="item._id">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
|
<div class="card-header-title is-text-overflow is-block">
|
||||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
<Popover :showDelay="400" :maxWidth="550" v-if="show_popover">
|
||||||
|
<template #trigger>
|
||||||
|
<NuxtLink class="is-text-overflow" target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template #title><strong>{{ item.title }}</strong></template>
|
||||||
|
<p v-if="item.description">{{ item.description }}</p>
|
||||||
|
</Popover>
|
||||||
|
<template v-else>
|
||||||
|
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
|
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<span class="tag is-info" v-if="item.extras?.duration">
|
<span class="tag is-info is-unselectable" v-if="item.extras?.duration">
|
||||||
{{ formatTime(item.extras.duration) }}
|
{{ formatTime(item.extras.duration) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control" v-if="item.download_dir">
|
||||||
|
<span class="icon" v-tooltip="`Path: ${getPath(config.app.download_path, item)}`">
|
||||||
|
<i class="fa-solid fa-folder-open" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
|
<button @click="hideThumbnail = !hideThumbnail" v-if="thumbnails">
|
||||||
<span class="icon"><i class="fa-solid"
|
<span class="icon"><i class="fa-solid"
|
||||||
|
|
@ -193,14 +219,13 @@
|
||||||
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
||||||
class="play-overlay">
|
class="play-overlay">
|
||||||
<div class="play-icon embed-icon"></div>
|
<div class="play-icon embed-icon"></div>
|
||||||
<img @load="pImg" @error="onImgError"
|
<img @load="pImg" @error="onImgError" v-if="getImage(config.app.download_path, item)"
|
||||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
:src="getImage(config.app.download_path, item)" />
|
||||||
v-if="item.extras?.thumbnail" />
|
|
||||||
<img v-else src="/images/placeholder.png" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</span>
|
</span>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<img @load="pImg" @error="onImgError" v-if="item.extras?.thumbnail"
|
<img @load="pImg" @error="onImgError" v-if="getImage(config.app.download_path, item)"
|
||||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
|
:src="getImage(config.app.download_path, item)" />
|
||||||
<img v-else src="/images/placeholder.png" />
|
<img v-else src="/images/placeholder.png" />
|
||||||
</template>
|
</template>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
@ -221,7 +246,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||||
<span v-tooltip="`Preset: ${item.preset}`" class="user-hint">{{ item.preset }}</span>
|
<span v-tooltip="`Preset: ${item.preset}`" class="has-tooltip">{{ item.preset }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||||
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime"
|
<span v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')" :data-datetime="item.datetime"
|
||||||
|
|
@ -344,6 +369,7 @@ const display_style = useStorage('display_style', 'grid')
|
||||||
const bg_enable = useStorage('random_bg', true)
|
const bg_enable = useStorage('random_bg', true)
|
||||||
const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
const bg_opacity = useStorage('random_bg_opacity', 0.95)
|
||||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
||||||
|
const show_popover = useStorage<boolean>('show_popover', true)
|
||||||
|
|
||||||
const selectedElms = ref<string[]>([])
|
const selectedElms = ref<string[]>([])
|
||||||
const masterSelectAll = ref(false)
|
const masterSelectAll = ref(false)
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,19 @@
|
||||||
Choose the aspect ratio for thumbnail display.
|
Choose the aspect ratio for thumbnail display.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Popover</label>
|
||||||
|
<div class="control">
|
||||||
|
<input id="show_popover" type="checkbox" class="switch is-success" v-model="show_popover">
|
||||||
|
<label for="show_popover" class="is-unselectable">
|
||||||
|
{{ show_popover ? 'Yes' : 'No' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
||||||
|
Show additional information over certain elements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
|
|
@ -244,6 +257,7 @@ const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT
|
||||||
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true)
|
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true)
|
||||||
const toast_target = useStorage<notificationTarget>('toast_target', 'toast')
|
const toast_target = useStorage<notificationTarget>('toast_target', 'toast')
|
||||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
||||||
|
const show_popover = useStorage<boolean>('show_popover', true)
|
||||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
||||||
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
|
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
|
|
||||||
<template v-if="simpleMode">
|
<template v-if="simpleMode">
|
||||||
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
|
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
|
||||||
<Simple @show_settings="() =>show_settings = true"
|
<Simple @show_settings="() => show_settings = true" :class="{ 'settings-open': show_settings }" />
|
||||||
:class="{ 'settings-open': show_settings }" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<SettingsPanel :isOpen="show_settings" :isLoading="loadingImage" @close="closeSettings()"
|
<SettingsPanel :isOpen="show_settings" :isLoading="loadingImage" @close="closeSettings()"
|
||||||
|
|
@ -179,7 +178,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow" v-if="config.app?.started">
|
<div class="column is-narrow" v-if="config.app?.started">
|
||||||
<div class="has-text-right">
|
<div class="has-text-right">
|
||||||
<span class="user-hint"
|
<span class="has-tooltip"
|
||||||
v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')">
|
v-tooltip="'App Started: ' + moment.unix(config.app?.started).format('YYYY-M-DD H:mm Z')">
|
||||||
{{ moment.unix(config.app?.started).fromNow() }}
|
{{ moment.unix(config.app?.started).fromNow() }}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -428,10 +427,7 @@ const useVersionUpdate = () => {
|
||||||
|
|
||||||
const { newVersionIsAvailable } = useVersionUpdate()
|
const { newVersionIsAvailable } = useVersionUpdate()
|
||||||
|
|
||||||
const closeSettings = () => {
|
const closeSettings = () => show_settings.value = false
|
||||||
show_settings.value = false
|
|
||||||
navigateTo('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const shutdownApp = async () => {
|
const shutdownApp = async () => {
|
||||||
const { alertDialog, confirmDialog: confirm_message } = useDialog()
|
const { alertDialog, confirmDialog: confirm_message } = useDialog()
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,9 @@
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-info" @click="reloadContent(path, true)" :class="{ 'is-loading': isLoading }"
|
<button class="button is-info" @click="reloadContent(path, true)" :class="{ 'is-loading': isLoading }"
|
||||||
:disabled="!socket.isConnected || isLoading">
|
:disabled="isLoading">
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,7 +187,7 @@
|
||||||
<td class="has-text-centered is-vcentered" v-if="config.app.browser_control_enabled">
|
<td class="has-text-centered is-vcentered" v-if="config.app.browser_control_enabled">
|
||||||
<input type="checkbox" v-model="selectedElms" :value="item.path" />
|
<input type="checkbox" v-model="selectedElms" :value="item.path" />
|
||||||
</td>
|
</td>
|
||||||
<td class="has-text-centered is-vcentered user-hint" v-tooltip="item.name">
|
<td class="has-text-centered is-vcentered has-tooltip" v-tooltip="item.name">
|
||||||
<span class="icon"><i class="fas fa-2x fa-solid" :class="setIcon(item)" /></span>
|
<span class="icon"><i class="fas fa-2x fa-solid" :class="setIcon(item)" /></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-text-overflow is-vcentered">
|
<td class="is-text-overflow is-vcentered">
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,20 @@
|
||||||
<span v-if="!isMobile">New Condition</span>
|
<span v-if="!isMobile">New Condition</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</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">
|
<p class="control">
|
||||||
<button class="button is-info" @click="reloadContent(false)" :class="{ 'is-loading': isLoading }"
|
<button class="button is-info" @click="reloadContent(false)" :class="{ 'is-loading': isLoading }"
|
||||||
:disabled="!socket.isConnected || isLoading" v-if="items && items.length > 0">
|
:disabled="isLoading" v-if="items && items.length > 0">
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
<span v-if="!isMobile">Reload</span>
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -45,73 +56,175 @@
|
||||||
|
|
||||||
<div class="column is-12" v-if="!toggleForm">
|
<div class="column is-12" v-if="!toggleForm">
|
||||||
<div class="columns is-multiline" v-if="items.length > 0">
|
<div class="columns is-multiline" v-if="items.length > 0">
|
||||||
<div class="column is-6" v-for="cond in items" :key="cond.id">
|
<template v-if="'list' === display_style">
|
||||||
<div class="card is-flex is-full-height is-flex-direction-column">
|
<div class="column is-12">
|
||||||
<header class="card-header">
|
<div class="table-container">
|
||||||
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
|
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
|
||||||
<div class="card-header-icon">
|
style="min-width: 850px; table-layout: fixed;">
|
||||||
<div class="field is-grouped">
|
<thead>
|
||||||
<div class="control" v-if="cond.priority > 0">
|
<tr class="has-text-centered is-unselectable">
|
||||||
<span class="tag is-dark">
|
<th width="80%">
|
||||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||||
<span v-text="cond.priority" />
|
<span>Condition</span>
|
||||||
</span>
|
</th>
|
||||||
</div>
|
<th width="20%">
|
||||||
<div class="control" @click="toggleEnabled(cond)">
|
<span class="icon"><i class="fa-solid fa-gear" /></span>
|
||||||
<span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
|
<span>Actions</span>
|
||||||
v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
</th>
|
||||||
<i class="fa-solid" :class="cond.enabled ? 'fa-check-circle' : 'fa-times-circle'" />
|
</tr>
|
||||||
</span>
|
</thead>
|
||||||
</div>
|
<tbody>
|
||||||
<div class="control">
|
<tr v-for="cond in items" :key="cond.id">
|
||||||
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(cond)">
|
<td class="is-vcentered">
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<div class="is-text-overflow is-bold">
|
||||||
</a>
|
{{ cond.name }}
|
||||||
|
</div>
|
||||||
|
<div class="is-unselectable">
|
||||||
|
<span class="icon-text is-clickable" @click="toggleEnabled(cond)"
|
||||||
|
v-tooltip="'Click to ' + (cond.enabled !== false ? 'disable' : 'enable') + ' condition'">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fa-solid fa-power-off"
|
||||||
|
:class="{ 'has-text-success': cond.enabled !== false, 'has-text-danger': cond.enabled === false }" />
|
||||||
|
</span>
|
||||||
|
<span>{{ cond.enabled !== false ? 'Enabled' : 'Disabled' }}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Popover :maxWidth="450">
|
||||||
|
<template #trigger>
|
||||||
|
<span class="is-clickable">
|
||||||
|
<span class="icon"> <i class="fa-solid fa-info-circle" /></span>
|
||||||
|
<span>Show Details</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #title><strong>Condition Details</strong></template>
|
||||||
|
|
||||||
|
<div v-if="cond.filter">
|
||||||
|
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||||
|
<code>{{ cond.filter }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cond.cli">
|
||||||
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
|
<code>{{ cond.cli }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span v-if="cond.extras && Object.keys(cond.extras).length > 0">
|
||||||
|
<template v-for="(value, key) in cond.extras" :key="key">
|
||||||
|
<div>
|
||||||
|
<span class="icon"><i class="fa-solid fa-list" /></span>
|
||||||
|
<code>{{ key }}: {{ value }}</code>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</Popover>
|
||||||
|
<template v-if="cond.priority > 0">
|
||||||
|
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
||||||
|
<span>Priority: {{ cond.priority }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="is-vcentered is-items-center">
|
||||||
|
<div class="field is-grouped is-grouped-centered">
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-info is-small is-fullwidth" @click="exportItem(cond)">
|
||||||
|
<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-warning is-small is-fullwidth" @click="editItem(cond)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
||||||
|
<span v-if="!isMobile">Edit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(cond)">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="column is-6" v-for="cond in items" :key="cond.id">
|
||||||
|
<div class="card is-flex is-full-height is-flex-direction-column">
|
||||||
|
<header class="card-header">
|
||||||
|
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
|
||||||
|
<div class="card-header-icon">
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control" v-if="cond.priority > 0">
|
||||||
|
<span class="tag is-dark">
|
||||||
|
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
||||||
|
<span v-text="cond.priority" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="control" @click="toggleEnabled(cond)">
|
||||||
|
<span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||||
|
v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||||
|
<i class="fa-solid fa-power-off" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(cond)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
<div class="card-content is-flex-grow-1">
|
||||||
<div class="card-content is-flex-grow-1">
|
<div class="content">
|
||||||
<div class="content">
|
<p class="is-text-overflow">
|
||||||
<p class="is-text-overflow">
|
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
<span v-text="cond.filter" />
|
||||||
<span v-text="cond.filter" />
|
</p>
|
||||||
</p>
|
<p class="is-text-overflow" v-if="cond.cli">
|
||||||
<p class="is-text-overflow" v-if="cond.cli">
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span>{{ cond.cli }}</span>
|
||||||
<span>{{ cond.cli }}</span>
|
</p>
|
||||||
</p>
|
<p class="is-text-overflow" v-if="cond.extras && Object.keys(cond.extras).length > 0">
|
||||||
<p class="is-text-overflow" v-if="cond.extras && Object.keys(cond.extras).length > 0">
|
<span class="icon"><i class="fa-solid fa-list" /></span>
|
||||||
<span class="icon"><i class="fa-solid fa-list" /></span>
|
<span>Extras:
|
||||||
<span>Extras:
|
<span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2">
|
||||||
<span v-for="(value, key) in cond.extras" :key="key" class="tag is-info mr-2">
|
<strong>{{ key }}</strong>: {{ value }}
|
||||||
<strong>{{ key }}</strong>: {{ value }}
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</p>
|
||||||
</p>
|
<p class="is-clickable" :class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }"
|
||||||
<p class="is-clickable" :class="{ 'is-text-overflow': !isExpanded(cond.id, 'description') }"
|
v-if="cond.description" @click="toggleExpand(cond.id, 'description')">
|
||||||
v-if="cond.description" @click="toggleExpand(cond.id, 'description')">
|
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
||||||
<span class="icon"><i class="fa-solid fa-comment" /></span>
|
<span>{{ cond.description }}</span>
|
||||||
<span>{{ cond.description }}</span>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="card-footer mt-auto">
|
||||||
<div class="card-footer mt-auto">
|
<div class="card-footer-item">
|
||||||
<div class="card-footer-item">
|
<button class="button is-warning is-fullwidth" @click="editItem(cond)">
|
||||||
<button class="button is-warning is-fullwidth" @click="editItem(cond)">
|
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
<span>Edit</span>
|
||||||
<span>Edit</span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
<div class="card-footer-item">
|
||||||
<div class="card-footer-item">
|
<button class="button is-danger is-fullwidth" @click="deleteItem(cond)">
|
||||||
<button class="button is-danger is-fullwidth" @click="deleteItem(cond)">
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
<span>Delete</span>
|
||||||
<span>Delete</span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<Message title="No items" message="There are no custom conditions defined."
|
<Message title="No items" message="There are no custom conditions defined."
|
||||||
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
|
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
|
||||||
|
|
@ -148,6 +261,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
|
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
|
|
||||||
|
|
@ -157,6 +271,7 @@ const toast = useNotification()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
|
const display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid')
|
||||||
|
|
||||||
const items = ref<ConditionItemWithUI[]>([])
|
const items = ref<ConditionItemWithUI[]>([])
|
||||||
const item = ref<Partial<ConditionItem>>({})
|
const item = ref<Partial<ConditionItem>>({})
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="is-pulled-right" v-if="socket.isConnected">
|
<div class="is-pulled-right">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
<p class="control has-icons-left" v-if="toggleFilter">
|
<p class="control has-icons-left" v-if="toggleFilter">
|
||||||
<input type="search" v-model.lazy="query" class="input" id="filter"
|
<input type="search" v-model.lazy="query" class="input" id="filter"
|
||||||
|
|
@ -114,7 +114,6 @@ import type { StoreItem } from '~/types/store'
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const stateStore = useStateStore()
|
const stateStore = useStateStore()
|
||||||
const socket = useSocketStore()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,14 @@
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-primary" @click="resetForm(false); toggleForm = true"
|
<button class="button is-primary" @click="resetForm(false); toggleForm = true"
|
||||||
v-tooltip="'Add new notification target.'">
|
v-tooltip="'Add new notification target.'">
|
||||||
<span class="icon"><i class="fas fa-add"></i></span>
|
<span class="icon"><i class="fas fa-add" /></span>
|
||||||
<span v-if="!isMobile">New Notification</span>
|
<span v-if="!isMobile">New Notification</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="control" v-if="notifications.length > 0">
|
<p class="control" v-if="notifications.length > 0">
|
||||||
<button class="button is-warning" @click="sendTest" v-tooltip="'Send test notification.'"
|
<button class="button is-warning" @click="sendTest" v-tooltip="'Send test notification.'"
|
||||||
:class="{ 'is-loading': isLoading }" :disabled="!socket.isConnected || isLoading">
|
:class="{ 'is-loading': isLoading }" :disabled="isLoading">
|
||||||
<span class="icon"><i class="fas fa-paper-plane"></i></span>
|
<span class="icon"><i class="fas fa-paper-plane" /></span>
|
||||||
<span v-if="!isMobile">Send Test</span>
|
<span v-if="!isMobile">Send Test</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -44,8 +44,9 @@
|
||||||
|
|
||||||
<p class="control" v-if="notifications.length > 0">
|
<p class="control" v-if="notifications.length > 0">
|
||||||
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
||||||
:disabled="!socket.isConnected || isLoading || notifications.length < 1">
|
:disabled="isLoading || notifications.length < 1">
|
||||||
<span class="icon"><i class="fas fa-refresh"></i></span>
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,26 +97,34 @@
|
||||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||||
<span>Presets: {{ join_presets(item.presets) }}</span>
|
<span>Presets: {{ join_presets(item.presets) }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span class="icon-text is-clickable" @click="toggleEnabled(item)">
|
||||||
|
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||||
|
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||||
|
<i class="fa-solid fa-power-off" />
|
||||||
|
</span>
|
||||||
|
<span>{{ item.enabled ? 'Enabled' : 'Disabled' }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-info is-small is-fullwidth" v-tooltip="'Export'"
|
<button class="button is-info is-small is-fullwidth" @click="exportItem(item)">
|
||||||
@click="exportItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'"
|
<button class="button is-warning is-small is-fullwidth" @click="editItem(item)">
|
||||||
@click="editItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
<span class="icon"><i class="fa-solid fa-edit" /></span>
|
||||||
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-danger is-small is-fullwidth" v-tooltip="'Delete'"
|
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(item)">
|
||||||
@click="deleteItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -135,13 +144,25 @@
|
||||||
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<a class="has-text-info" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
|
<div class="field is-grouped">
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<div class="control" @click="toggleEnabled(item)">
|
||||||
</a>
|
<span class="icon" :class="item.enabled ? 'has-text-success' : 'has-text-danger'"
|
||||||
<button @click="item.raw = !item.raw">
|
v-tooltip="`Notification is ${item.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
|
||||||
<span class="icon"><i class="fa-solid"
|
<i class="fa-solid fa-power-off" />
|
||||||
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<a class="has-text-info" v-tooltip="'Export target.'" @click.prevent="exportItem(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button @click="item.raw = !item.raw">
|
||||||
|
<span class="icon"><i class="fa-solid"
|
||||||
|
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="card-content is-flex-grow-1">
|
<div class="card-content is-flex-grow-1">
|
||||||
|
|
@ -156,13 +177,13 @@
|
||||||
</p>
|
</p>
|
||||||
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
||||||
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
||||||
<span>{{item.request.headers.map(h => h.key).join(', ')}}</span>
|
<span>Headers: {{item.request.headers.map(h => h.key).join(', ')}}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content" v-if="item?.raw">
|
<div class="card-content" v-if="item?.raw">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<pre><code>{{ filterItem(item) }}</code></pre>
|
<pre><code>{{ JSON.stringify(cleanObject(item, ['raw']), null, 2) }}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer mt-auto">
|
<div class="card-footer mt-auto">
|
||||||
|
|
@ -226,6 +247,8 @@ import { useStorage } from '@vueuse/core'
|
||||||
import type { notification, notificationImport } from '~/types/notification'
|
import type { notification, notificationImport } from '~/types/notification'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
|
|
||||||
|
type notificationItemWithUI = notification & { raw?: boolean }
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
|
|
@ -233,12 +256,13 @@ const display_style = useStorage<string>("tasks_display_style", "cards")
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
|
|
||||||
const allowedEvents = ref<string[]>([])
|
const allowedEvents = ref<string[]>([])
|
||||||
const notifications = ref<notification[]>([])
|
const notifications = ref<notificationItemWithUI[]>([])
|
||||||
|
|
||||||
const defaultState = (): notification => ({
|
const defaultState = (): notification => ({
|
||||||
name: '',
|
name: '',
|
||||||
on: [],
|
on: [],
|
||||||
presets: [],
|
presets: [],
|
||||||
|
enabled: true,
|
||||||
request: { method: 'POST', url: '', type: 'json', headers: [], data_key: 'data' },
|
request: { method: 'POST', url: '', type: 'json', headers: [], data_key: 'data' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -333,6 +357,26 @@ const deleteItem = async (item: notification) => {
|
||||||
toast.success('Notification target deleted.')
|
toast.success('Notification target deleted.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleEnabled = async (item: notification) => {
|
||||||
|
const index = notifications.value.findIndex(i => i?.id === item.id)
|
||||||
|
if (-1 === index) {
|
||||||
|
toast.error('Notification target not found.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = notifications.value[index]
|
||||||
|
if (!target) {
|
||||||
|
toast.error('Notification target not found.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target.enabled = !target.enabled
|
||||||
|
const status = await updateData(notifications.value)
|
||||||
|
if (status) {
|
||||||
|
toast[target.enabled ? 'success' : 'warning'](`Notification is ${target.enabled ? 'enabled' : 'disabled'}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateItem = async ({ reference, item }: { reference: string | null, item: notification }) => {
|
const updateItem = async ({ reference, item }: { reference: string | null, item: notification }) => {
|
||||||
if (reference) {
|
if (reference) {
|
||||||
const index = notifications.value.findIndex(i => i?.id === reference)
|
const index = notifications.value.findIndex(i => i?.id === reference)
|
||||||
|
|
@ -354,11 +398,6 @@ const updateItem = async ({ reference, item }: { reference: string | null, item:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterItem = (item: notification) => {
|
|
||||||
const { raw, ...rest } = item as any
|
|
||||||
return JSON.stringify(rest, null, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
const editItem = (item: notification) => {
|
const editItem = (item: notification) => {
|
||||||
target.value = item
|
target.value = item
|
||||||
targetRef.value = item.id ?? null
|
targetRef.value = item.id ?? null
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
</p>
|
</p>
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
||||||
:disabled="!socket.isConnected || isLoading" v-if="presets && presets.length > 0">
|
:disabled="isLoading" v-if="presets && presets.length > 0">
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
<span v-if="!isMobile">Reload</span>
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -82,21 +82,21 @@
|
||||||
<td class="is-vcentered is-items-center">
|
<td class="is-vcentered is-items-center">
|
||||||
<div class="field is-grouped is-grouped-centered">
|
<div class="field is-grouped is-grouped-centered">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-info is-small is-fullwidth" v-tooltip="'Export'"
|
<button class="button is-info is-small is-fullwidth" @click="exportItem(item)">
|
||||||
@click="exportItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||||
|
<span v-if="!isMobile">Export</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'"
|
<button class="button is-warning is-small is-fullwidth" @click="editItem(item)">
|
||||||
@click="editItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
<span class="icon"><i class="fa-solid fa-cog" /></span>
|
||||||
|
<span v-if="!isMobile">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button class="button is-danger is-small is-fullwidth" v-tooltip="'Delete'"
|
<button class="button is-danger is-small is-fullwidth" @click="deleteItem(item)">
|
||||||
@click="deleteItem(item)">
|
|
||||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||||
|
<span v-if="!isMobile">Delete</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button @click="() => inspect = true" class="button is-primary is-light">
|
<button @click="() => inspect = true" class="button is-warning">
|
||||||
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
||||||
<span v-if="!isMobile">Inspect</span>
|
<span v-if="!isMobile">Inspect</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
</td>
|
</td>
|
||||||
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
|
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
|
||||||
<td class="is-vcentered has-text-centered">
|
<td class="is-vcentered has-text-centered">
|
||||||
<span class="user-hint" :date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
<span class="has-tooltip" :date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||||
v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||||
v-rtime="definition.updated_at" />
|
v-rtime="definition.updated_at" />
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
<p>
|
<p>
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid fa-clock" /></span>
|
<span class="icon"><i class="fa-solid fa-clock" /></span>
|
||||||
<span>Updated: <span class="user-hint"
|
<span>Updated: <span class="has-tooltip"
|
||||||
:date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
:date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||||
v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
|
||||||
v-rtime="definition.updated_at" />
|
v-rtime="definition.updated_at" />
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
|
|
||||||
<p class="control">
|
<p class="control">
|
||||||
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
<button class="button is-info" @click="reloadContent()" :class="{ 'is-loading': isLoading }"
|
||||||
:disabled="!socket.isConnected || isLoading" v-if="tasks && tasks.length > 0">
|
:disabled="isLoading" v-if="tasks && tasks.length > 0">
|
||||||
<span class="icon"><i class="fas fa-refresh" /></span>
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
<span v-if="!isMobile">Reload</span>
|
<span v-if="!isMobile">Reload</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
2
ui/app/types/notification.d.ts
vendored
2
ui/app/types/notification.d.ts
vendored
|
|
@ -17,7 +17,7 @@ type notification = {
|
||||||
request: notificationRequest;
|
request: notificationRequest;
|
||||||
on: Array<string>;
|
on: Array<string>;
|
||||||
presets: Array<string>;
|
presets: Array<string>;
|
||||||
raw?: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type notificationImport = notification & {
|
type notificationImport = notification & {
|
||||||
|
|
|
||||||
16
ui/app/types/popover.d.ts
vendored
Normal file
16
ui/app/types/popover.d.ts
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
export type PopoverTrigger = 'hover' | 'click' | 'focus'
|
||||||
|
|
||||||
|
export interface PopoverProps {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
placement?: PopoverPlacement
|
||||||
|
trigger?: PopoverTrigger
|
||||||
|
offset?: number
|
||||||
|
disabled?: boolean
|
||||||
|
closeOnClickOutside?: boolean
|
||||||
|
minWidth?: number
|
||||||
|
maxWidth?: number
|
||||||
|
maxHeight?: number
|
||||||
|
showDelay?: number
|
||||||
|
}
|
||||||
|
|
@ -791,10 +791,33 @@ const deepIncludes = (
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPath = (basePath: string, item: StoreItem): string => {
|
||||||
|
if (!item.download_dir) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item?.filename) {
|
||||||
|
return item.download_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripPath(basePath, eTrim(item.download_dir, '/') + '/' + sTrim(item.filename, '/'))
|
||||||
|
}
|
||||||
|
const getImage = (basePath: string, item: StoreItem): string => {
|
||||||
|
if (item.sidecar?.image && item.sidecar.image.length > 0) {
|
||||||
|
return uri('/api/download/' + encodeURIComponent(stripPath(basePath, item.sidecar.image[0]?.file || '')))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item?.extras?.thumbnail) {
|
||||||
|
return '/images/placeholder.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
separators, convertCliOptions, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst,
|
separators, convertCliOptions, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst,
|
||||||
getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath,
|
getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath,
|
||||||
request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams,
|
request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams,
|
||||||
makeDownload, formatBytes, has_data, toggleClass, cleanObject, uri, formatTime,
|
makeDownload, formatBytes, has_data, toggleClass, cleanObject, uri, formatTime,
|
||||||
sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath, shortPath, deepIncludes
|
sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath, shortPath, deepIncludes, getPath, getImage
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue