feat: move to modal
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
<NuxtLink
|
||||
:to="link.href"
|
||||
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
|
||||
:class="{ 'bg-button-bg text-contrast': route.path === link.href }"
|
||||
:class="{ 'bg-button-bg text-contrast': isLinkActive(link) }"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<component :is="link.icon" class="size-6" />
|
||||
@@ -39,13 +39,33 @@ import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const emit = defineEmits(["reinstall"]);
|
||||
|
||||
defineProps<{
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||
const props = defineProps<{
|
||||
navLinks: {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: Component;
|
||||
external?: boolean;
|
||||
matches?: RegExp[];
|
||||
}[];
|
||||
route: RouteLocationNormalized;
|
||||
server: ModrinthServer;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const isLinkActive = (link: (typeof props.navLinks)[0]) => {
|
||||
if (props.route.path === link.href) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (link.matches && link.matches.length > 0) {
|
||||
return link.matches.some((regex: RegExp) => {
|
||||
return regex.test(props.route.path);
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const onReinstall = (...args: any[]) => {
|
||||
emit("reinstall", ...args);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="isNew ? 'New scheduled task' : 'Editing scheduled task'"
|
||||
@hide="onModalHide"
|
||||
>
|
||||
<div class="modal-content flex flex-col gap-2">
|
||||
<div v-if="error" class="text-sm text-brand-red">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-md flex-col gap-2">
|
||||
<label for="title">
|
||||
<span class="text-lg font-bold text-contrast">
|
||||
Title <span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="task.title"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
placeholder="Enter task title..."
|
||||
autocomplete="off"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label><span class="text-lg font-bold text-contrast">Schedule Type</span></label>
|
||||
<RadioButtons v-model="scheduleType" :items="['daily', 'custom']" class="mb-2">
|
||||
<template #default="{ item }">
|
||||
<span class="flex items-center gap-2">
|
||||
<ClockIcon v-if="item === 'daily'" class="size-5" />
|
||||
<CodeIcon v-else class="size-5" />
|
||||
<span>{{
|
||||
item === "daily" ? "Every X day(s) at specific time" : "Custom cron expression"
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</div>
|
||||
|
||||
<div class="card flex h-[140px] items-center overflow-hidden rounded-lg !bg-bg">
|
||||
<Transition
|
||||
name="schedule-content"
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-in-out"
|
||||
leave-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="scheduleType === 'daily'" key="daily" class="flex w-full flex-col gap-2">
|
||||
<div class="flex w-full gap-8">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="dayInterval" class="text-md font-medium">Every X day(s)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="dayInterval"
|
||||
v-model="dayInterval"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="3"
|
||||
class="input input-bordered w-20"
|
||||
placeholder="1"
|
||||
@input="onDayIntervalInput"
|
||||
/>
|
||||
<span class="text-muted-foreground text-sm">day(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-md font-medium">At time</label>
|
||||
<TimePicker v-model="selectedTime" use-utc-values placeholder="Select time" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
{{ humanReadableDescription }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else key="custom" class="flex w-full flex-col gap-2">
|
||||
<label for="customCron" class="text-md font-medium"
|
||||
>Cron Expression
|
||||
<nuxt-link
|
||||
v-tooltip="
|
||||
`Click to read more about what cron expressions are, and how to create them.`
|
||||
"
|
||||
class="align-middle text-link"
|
||||
target="_blank"
|
||||
to="https://www.geeksforgeeks.org/writing-cron-expressions-for-scheduling-tasks/"
|
||||
><UnknownIcon class="size-4" />
|
||||
</nuxt-link>
|
||||
</label>
|
||||
<input
|
||||
id="customCron"
|
||||
v-model="customCron"
|
||||
type="text"
|
||||
class="input input-bordered font-mono"
|
||||
placeholder="0 0 9 * * *"
|
||||
/>
|
||||
<div v-if="!isValidCron" class="text-xs text-brand-red">
|
||||
Invalid cron format. Please use 6 space-separated values (e.g.,
|
||||
<code>second minute hour day month day-of-week</code>).
|
||||
</div>
|
||||
<div v-else class="text-xs">
|
||||
{{ humanReadableDescription }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-md flex-col gap-2">
|
||||
<label for="action_kind"><span class="text-lg font-bold text-contrast">Action</span></label>
|
||||
<RadioButtons v-model="task.action_kind" :items="actionKinds" class="mb-2">
|
||||
<template #default="{ item }">
|
||||
<span>{{ item === "restart" ? "Restart server" : "Run game command" }}</span>
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
name="command-section"
|
||||
enter-active-class="transition-all duration-300 ease-in-out"
|
||||
leave-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0 max-h-0 overflow-hidden"
|
||||
enter-to-class="opacity-100 max-h-96 overflow-visible"
|
||||
leave-from-class="opacity-100 max-h-96 overflow-visible"
|
||||
leave-to-class="opacity-0 max-h-0 overflow-hidden"
|
||||
>
|
||||
<div v-if="task.action_kind === 'game-command'" class="flex max-w-lg flex-col gap-4">
|
||||
<label for="command" class="flex flex-col gap-2"
|
||||
><span class="text-lg font-bold text-contrast">
|
||||
Command <span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span
|
||||
>The command that will be ran when the task is executed. If the command is invalid
|
||||
nothing will happen.</span
|
||||
></label
|
||||
>
|
||||
<div class="max-w-md">
|
||||
<input
|
||||
id="command"
|
||||
v-model="commandValue"
|
||||
type="text"
|
||||
maxlength="256"
|
||||
placeholder="Enter command to run..."
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="flex max-w-lg flex-col gap-4">
|
||||
<label for="warn_msg" class="flex flex-col gap-2"
|
||||
><span class="text-lg font-bold text-contrast">
|
||||
Warning Command
|
||||
<span
|
||||
v-if="task.warn_intervals && task.warn_intervals.length > 0"
|
||||
class="text-brand-red"
|
||||
>*</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
>You can use <code>{}</code> to insert the time until the task is executed. If the
|
||||
command is invalid nothing will happen.</span
|
||||
></label
|
||||
>
|
||||
<div class="max-w-md">
|
||||
<input
|
||||
id="warn_msg"
|
||||
v-model="task.warn_msg"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
:placeholder="`/tellraw @a \u0022Restarting in {}!\u0022`"
|
||||
class="input input-bordered max-w-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex max-w-lg flex-col gap-4">
|
||||
<label for="warn_msg" class="flex flex-col gap-2"
|
||||
><span class="text-lg font-bold text-contrast">Warning Intervals</span>
|
||||
<span
|
||||
>When, at the time before the scheduled task is executed, should the warning command be
|
||||
executed?</span
|
||||
></label
|
||||
>
|
||||
<div class="flex flex-row flex-wrap gap-4">
|
||||
<Chips
|
||||
v-model="task.warn_intervals"
|
||||
:items="[5, 15, 30, 60, 120, 300, 600]"
|
||||
multi
|
||||
:never-empty="false"
|
||||
:format-label="formatWarningIntervalLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex max-w-md gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canSave" @click="saveTask">
|
||||
<SaveIcon />
|
||||
Save
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="loading" @click="closeModal">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Schedule, ServerSchedule, ActionKind } from "@modrinth/utils";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { RadioButtons, TimePicker, Chips, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ClockIcon, CodeIcon, SaveIcon, XIcon, UnknownIcon } from "@modrinth/assets";
|
||||
import { toString as cronToString } from "cronstrue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<{ server: ModrinthServer }>();
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const task = ref<Partial<ServerSchedule | Schedule>>({});
|
||||
const isNew = ref(true);
|
||||
const originalTaskId = ref<number | null>(null);
|
||||
|
||||
const scheduleType = ref<"daily" | "custom">("daily");
|
||||
const dayInterval = ref("1");
|
||||
const selectedTime = ref({ hour: "9", minute: "0" });
|
||||
const customCron = ref("0 0 9 * * *");
|
||||
const actionKinds: ActionKind[] = ["restart", "game-command"];
|
||||
|
||||
const commandValue = computed({
|
||||
get() {
|
||||
return task.value.options && "command" in task.value.options ? task.value.options.command : "";
|
||||
},
|
||||
set(val: string) {
|
||||
if (task.value.options && "command" in task.value.options) {
|
||||
task.value.options.command = val;
|
||||
} else if (task.value.action_kind === "game-command") {
|
||||
task.value.options = { command: val };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const cronString = computed(() => {
|
||||
if (scheduleType.value === "custom") {
|
||||
return customCron.value.trim();
|
||||
}
|
||||
const minute = selectedTime.value.minute === "" ? "0" : selectedTime.value.minute;
|
||||
const hour = selectedTime.value.hour === "" ? "0" : selectedTime.value.hour;
|
||||
const days = dayInterval.value === "" || Number(dayInterval.value) < 1 ? "1" : dayInterval.value;
|
||||
if (days === "1") {
|
||||
return `0 ${minute} ${hour} * * *`;
|
||||
} else {
|
||||
return `0 ${minute} ${hour} */${days} * *`;
|
||||
}
|
||||
});
|
||||
|
||||
const CRON_REGEX =
|
||||
/^\s*([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s*$/;
|
||||
|
||||
const isValidCron = computed(() => {
|
||||
const cronToTest = scheduleType.value === "custom" ? customCron.value.trim() : cronString.value;
|
||||
|
||||
if (!CRON_REGEX.test(cronToTest)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scheduleType.value === "custom") {
|
||||
try {
|
||||
const parts = cronToTest.split(/\s+/);
|
||||
if (parts.length === 6) {
|
||||
cronToString(parts.slice(1).join(" "));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const isValidGameCommand = computed(() => {
|
||||
if (task.value.action_kind === "game-command") {
|
||||
return commandValue.value && commandValue.value.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const isValidWarningCommand = computed(() => {
|
||||
if (task.value.warn_intervals && task.value.warn_intervals.length > 0) {
|
||||
return task.value.warn_msg && task.value.warn_msg.trim().length > 0;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const canSave = computed(() => {
|
||||
return (
|
||||
!loading.value &&
|
||||
task.value.title &&
|
||||
task.value.title.trim().length > 0 &&
|
||||
isValidCron.value &&
|
||||
isValidGameCommand.value &&
|
||||
isValidWarningCommand.value
|
||||
);
|
||||
});
|
||||
|
||||
const humanReadableDescription = computed<string | undefined>(() => {
|
||||
if (!isValidCron.value && scheduleType.value === "custom") return undefined;
|
||||
if (scheduleType.value === "custom") {
|
||||
try {
|
||||
const parts = customCron.value.trim().split(/\s+/);
|
||||
if (parts.length === 6) {
|
||||
return cronToString(parts.slice(1).join(" "));
|
||||
}
|
||||
return "Invalid cron expression";
|
||||
} catch {
|
||||
return "Invalid cron expression";
|
||||
}
|
||||
}
|
||||
const minute = selectedTime.value.minute === "" ? "0" : selectedTime.value.minute;
|
||||
const hour = selectedTime.value.hour === "" ? "0" : selectedTime.value.hour;
|
||||
const daysNum = Number(dayInterval.value || "1");
|
||||
const timeStr = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
|
||||
if (daysNum <= 1) {
|
||||
return `Every day at ${timeStr}`;
|
||||
} else {
|
||||
return `Every ${daysNum} days at ${timeStr}`;
|
||||
}
|
||||
});
|
||||
|
||||
function formatWarningIntervalLabel(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
function onDayIntervalInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
let value = input.value.replace(/\D/g, "");
|
||||
|
||||
if (value === "") {
|
||||
dayInterval.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.length > 1 && value.startsWith("0")) {
|
||||
value = value.replace(/^0+/, "");
|
||||
}
|
||||
|
||||
const num = parseInt(value);
|
||||
|
||||
if (num < 1) {
|
||||
dayInterval.value = "1";
|
||||
} else if (num > 365) {
|
||||
dayInterval.value = "365";
|
||||
} else {
|
||||
dayInterval.value = String(num);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
task.value = {
|
||||
title: "",
|
||||
action_kind: "restart",
|
||||
options: {},
|
||||
enabled: true,
|
||||
warn_msg: '/tellraw @a "Restarting in {}!"',
|
||||
warn_intervals: [],
|
||||
};
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = "1";
|
||||
selectedTime.value = { hour: "9", minute: "0" };
|
||||
customCron.value = "0 0 9 * * *";
|
||||
isNew.value = true;
|
||||
originalTaskId.value = null;
|
||||
error.value = null;
|
||||
if (isValidCron.value) {
|
||||
task.value.every = cronString.value;
|
||||
}
|
||||
}
|
||||
|
||||
function loadTaskData(taskData: ServerSchedule | Schedule) {
|
||||
task.value = { ...taskData };
|
||||
isNew.value = false;
|
||||
originalTaskId.value = "id" in taskData ? taskData.id : null;
|
||||
|
||||
if (task.value.every && typeof task.value.every === "string") {
|
||||
const parts = task.value.every.split(/\s+/);
|
||||
if (parts.length === 6) {
|
||||
const second = parts[0];
|
||||
const minute = parts[1];
|
||||
const hour = parts[2];
|
||||
const dayOfMonth = parts[3];
|
||||
|
||||
if (second === "0" && dayOfMonth.startsWith("*/")) {
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = dayOfMonth.substring(2);
|
||||
selectedTime.value = { hour, minute };
|
||||
} else if (second === "0" && dayOfMonth === "*" && parts[4] === "*" && parts[5] === "*") {
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = "1";
|
||||
selectedTime.value = { hour, minute };
|
||||
} else {
|
||||
scheduleType.value = "custom";
|
||||
customCron.value = task.value.every;
|
||||
}
|
||||
} else if (parts.length === 5) {
|
||||
const minute = parts[0];
|
||||
const hour = parts[1];
|
||||
const dayOfMonth = parts[2];
|
||||
|
||||
if (dayOfMonth.startsWith("*/")) {
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = dayOfMonth.substring(2);
|
||||
selectedTime.value = { hour, minute };
|
||||
} else if (dayOfMonth === "*" && parts[3] === "*" && parts[4] === "*") {
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = "1";
|
||||
selectedTime.value = { hour, minute };
|
||||
} else {
|
||||
scheduleType.value = "custom";
|
||||
customCron.value = `0 ${task.value.every}`;
|
||||
}
|
||||
} else {
|
||||
scheduleType.value = "custom";
|
||||
customCron.value = task.value.every;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function show(taskData?: ServerSchedule | Schedule) {
|
||||
if (taskData) {
|
||||
loadTaskData(taskData);
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.value?.hide();
|
||||
}
|
||||
|
||||
function onModalHide() {
|
||||
// Reset form when modal is hidden
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function saveTask() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
if (isValidCron.value) {
|
||||
task.value.every = cronString.value;
|
||||
} else {
|
||||
error.value = "Cannot save with invalid cron expression.";
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const scheduleToCreate: Schedule = {
|
||||
title: task.value.title || "",
|
||||
every: task.value.every!,
|
||||
action_kind: task.value.action_kind!,
|
||||
options: task.value.options!,
|
||||
enabled: task.value.enabled !== undefined ? task.value.enabled : true,
|
||||
warn_msg: task.value.warn_msg !== undefined ? task.value.warn_msg : "",
|
||||
warn_intervals: task.value.warn_intervals || [],
|
||||
};
|
||||
await props.server.scheduling.createTask(scheduleToCreate);
|
||||
closeModal();
|
||||
} else if (originalTaskId.value != null) {
|
||||
await props.server.scheduling.editTask(originalTaskId.value, task.value);
|
||||
closeModal();
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || "Failed to save task.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([cronString, isValidCron], ([cron, valid]) => {
|
||||
if (valid) {
|
||||
task.value.every = cron;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => task.value.action_kind,
|
||||
(kind) => {
|
||||
if (kind === "game-command") {
|
||||
if (
|
||||
!task.value.options ||
|
||||
typeof task.value.options !== "object" ||
|
||||
!("command" in task.value.options)
|
||||
) {
|
||||
task.value.options = { command: "" };
|
||||
}
|
||||
} else {
|
||||
task.value.options = {};
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide: closeModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -5,41 +5,76 @@ import { ServerModule } from "./base.ts";
|
||||
export class SchedulingModule extends ServerModule {
|
||||
tasks: ServerSchedule[] = [];
|
||||
|
||||
private optimisticUpdate(action: () => void): () => void {
|
||||
const originalTasks = [...this.tasks];
|
||||
action();
|
||||
return () => {
|
||||
this.tasks = originalTasks;
|
||||
};
|
||||
}
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
const response = await useServersFetch<{ items: ServerSchedule[] }>(
|
||||
`servers/${this.serverId}/options/schedules`,
|
||||
const response = await useServersFetch<{ schedules: { quota: 32; items: ServerSchedule[] } }>(
|
||||
`servers/${this.serverId}/options`,
|
||||
{ version: 1 },
|
||||
);
|
||||
this.tasks = response.items;
|
||||
this.tasks = response.schedules.items;
|
||||
}
|
||||
|
||||
async deleteTask(task: ServerSchedule): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/options/schedules/${task.id}`, {
|
||||
method: "DELETE",
|
||||
version: 1,
|
||||
const rollback = this.optimisticUpdate(() => {
|
||||
this.tasks = this.tasks.filter((t) => t.id !== task.id);
|
||||
});
|
||||
this.tasks = this.tasks.filter((t) => t.id !== task.id);
|
||||
|
||||
try {
|
||||
await useServersFetch(`servers/${this.serverId}/options/schedules/${task.id}`, {
|
||||
method: "DELETE",
|
||||
version: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createTask(task: Schedule): Promise<number> {
|
||||
const response = await useServersFetch<{ id: number }>(
|
||||
`servers/${this.serverId}/options/schedules`,
|
||||
{
|
||||
method: "POST",
|
||||
body: task,
|
||||
version: 1,
|
||||
},
|
||||
);
|
||||
await this.fetch();
|
||||
return response.id;
|
||||
const rollback = this.optimisticUpdate(() => {});
|
||||
|
||||
try {
|
||||
const response = await useServersFetch<{ id: number }>(
|
||||
`servers/${this.serverId}/options/schedules`,
|
||||
{
|
||||
method: "POST",
|
||||
body: task,
|
||||
version: 1,
|
||||
},
|
||||
);
|
||||
|
||||
this.tasks.push({ ...task, id: response.id } as ServerSchedule);
|
||||
return response.id;
|
||||
} catch (error) {
|
||||
rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async editTask(taskId: number, updatedTask: Partial<Schedule>): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/options/schedules/${taskId}`, {
|
||||
method: "PATCH",
|
||||
body: updatedTask,
|
||||
version: 1,
|
||||
const rollback = this.optimisticUpdate(() => {
|
||||
const taskIndex = this.tasks.findIndex((t) => t.id === taskId);
|
||||
if (taskIndex !== -1) {
|
||||
this.tasks[taskIndex] = { ...this.tasks[taskIndex], ...updatedTask };
|
||||
}
|
||||
});
|
||||
await this.fetch();
|
||||
|
||||
try {
|
||||
await useServersFetch(`servers/${this.serverId}/options/schedules/${taskId}`, {
|
||||
method: "PATCH",
|
||||
body: updatedTask,
|
||||
version: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full overflow-y-auto">
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div class="p-2 pt-0">
|
||||
<section v-if="error" class="universal-card">
|
||||
<h2>An error occurred whilst opening the task editor.</h2>
|
||||
<p>{{ error }}</p>
|
||||
</section>
|
||||
<section v-else class="universal-card">
|
||||
<h2>{{ isNew ? "New scheduled task" : "Editing scheduled task" }}</h2>
|
||||
<p class="mb-4">
|
||||
{{
|
||||
isNew
|
||||
? "Create a new scheduled task to automatically perform actions at specific times."
|
||||
: "Modify this scheduled task's settings, timing, and actions."
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="mb-4 flex max-w-md flex-col gap-2">
|
||||
<label for="title">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Title <span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="task.title"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
placeholder="Enter task title..."
|
||||
autocomplete="off"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex flex-col gap-2">
|
||||
<label><h3>Schedule Type</h3></label>
|
||||
<RadioButtons v-model="scheduleType" :items="['daily', 'custom']" class="mb-2">
|
||||
<template #default="{ item }">
|
||||
<span class="flex items-center gap-2">
|
||||
<ClockIcon v-if="item === 'daily'" class="size-5" />
|
||||
<CodeIcon v-else class="size-5" />
|
||||
<span>{{
|
||||
item === "daily" ? "Every X day(s) at specific time" : "Custom cron expression"
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="scheduleType === 'daily'"
|
||||
class="card mb-4 flex max-w-md flex-row gap-16 rounded-lg !bg-bg p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="dayInterval" class="text-md font-medium">Every X day(s)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="dayInterval"
|
||||
v-model="dayInterval"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="3"
|
||||
class="input input-bordered w-20"
|
||||
placeholder="1"
|
||||
@input="onDayIntervalInput"
|
||||
/>
|
||||
<span class="text-muted-foreground text-sm">day(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-md font-medium">At time</label>
|
||||
<TimePicker v-model="selectedTime" placeholder="Select time" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="scheduleType === 'custom'"
|
||||
class="card mb-4 flex max-w-md flex-col gap-2 rounded-lg !bg-bg p-4"
|
||||
>
|
||||
<label for="customCron" class="text-md font-medium"
|
||||
>Cron Expression
|
||||
<nuxt-link
|
||||
v-tooltip="
|
||||
`Click to read more about what cron expressions are, and how to create them.`
|
||||
"
|
||||
class="align-middle text-link"
|
||||
target="_blank"
|
||||
to="https://www.geeksforgeeks.org/writing-cron-expressions-for-scheduling-tasks/"
|
||||
><UnknownIcon class="size-4" />
|
||||
</nuxt-link>
|
||||
</label>
|
||||
<input
|
||||
id="customCron"
|
||||
v-model="customCron"
|
||||
type="text"
|
||||
class="input input-bordered font-mono"
|
||||
placeholder="0 0 9 * * *"
|
||||
/>
|
||||
<div v-if="!isValidCron" class="text-xs text-brand-red">
|
||||
Invalid cron format. Please use 6 space-separated values (e.g.,
|
||||
<code>second minute hour day month day-of-week</code>).
|
||||
</div>
|
||||
<div v-else class="text-xs">
|
||||
{{ humanReadableDescription }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex max-w-md flex-col gap-2">
|
||||
<label for="action_kind"><h3>Action</h3></label>
|
||||
<RadioButtons v-model="task.action_kind" :items="actionKinds" class="mb-2">
|
||||
<template #default="{ item }">
|
||||
<span>{{ item === "restart" ? "Restart server" : "Run game command" }}</span>
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</div>
|
||||
|
||||
<div v-if="task.action_kind === 'game-command'" class="mb-4 flex max-w-md flex-col gap-2">
|
||||
<label for="command"><h3>Command</h3></label>
|
||||
<input
|
||||
id="command"
|
||||
v-model="commandValue"
|
||||
type="text"
|
||||
maxlength="256"
|
||||
placeholder="Enter command to run..."
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex max-w-md flex-col gap-2">
|
||||
<label for="warn_msg"
|
||||
><h3>Warning Message</h3>
|
||||
<p>
|
||||
You can use <code>{}</code> to insert the time until the task is executed.
|
||||
</p></label
|
||||
>
|
||||
<input
|
||||
id="warn_msg"
|
||||
v-model="task.warn_msg"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
:placeholder="`/tellraw @a \u0022Restarting in {}!\u0022`"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex max-w-md gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="loading || !isValidCron || !task.title" @click="saveTask">
|
||||
<SaveIcon />
|
||||
Save
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="loading"
|
||||
@click="() => router.push(`/servers/manage/${route.params.id}/options/scheduling`)"
|
||||
>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Schedule, ServerSchedule, ActionKind } from "@modrinth/utils";
|
||||
import { ref, computed, onBeforeMount, watch } from "vue";
|
||||
import RadioButtons from "@modrinth/ui/src/components/base/RadioButtons.vue";
|
||||
import TimePicker from "@modrinth/ui/src/components/base/TimePicker.vue";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ClockIcon, CodeIcon, SaveIcon, XIcon, UnknownIcon } from "@modrinth/assets";
|
||||
import { toString as cronToString } from "cronstrue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
import { useRoute, useRouter } from "#imports";
|
||||
|
||||
const props = defineProps<{ server: ModrinthServer }>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isNew = computed(() => route.params.taskId === "new");
|
||||
const taskId = computed(() => (isNew.value ? null : Number(route.params.taskId)));
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const task = ref<Partial<ServerSchedule | Schedule>>({});
|
||||
|
||||
const scheduleType = ref<"daily" | "custom">("daily");
|
||||
const dayInterval = ref("1");
|
||||
const selectedTime = ref({ hour: "9", minute: "0" });
|
||||
const customCron = ref("0 0 9 * * *");
|
||||
const actionKinds: ActionKind[] = ["restart", "game-command"];
|
||||
|
||||
const commandValue = computed({
|
||||
get() {
|
||||
return task.value.options && "command" in task.value.options ? task.value.options.command : "";
|
||||
},
|
||||
set(val: string) {
|
||||
if (task.value.options && "command" in task.value.options) {
|
||||
task.value.options.command = val;
|
||||
} else if (task.value.action_kind === "game-command") {
|
||||
task.value.options = { command: val };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const cronString = computed(() => {
|
||||
if (scheduleType.value === "custom") {
|
||||
return customCron.value.trim();
|
||||
}
|
||||
const minute = selectedTime.value.minute === "" ? "0" : selectedTime.value.minute;
|
||||
const hour = selectedTime.value.hour === "" ? "0" : selectedTime.value.hour;
|
||||
const days = dayInterval.value === "" || Number(dayInterval.value) < 1 ? "1" : dayInterval.value;
|
||||
if (days === "1") {
|
||||
return `0 ${minute} ${hour} * * *`;
|
||||
} else {
|
||||
return `0 ${minute} ${hour} */${days} * *`;
|
||||
}
|
||||
});
|
||||
|
||||
const CRON_REGEX =
|
||||
/^\s*([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s*$/;
|
||||
|
||||
const isValidCron = computed(() => {
|
||||
const cronToTest = scheduleType.value === "custom" ? customCron.value.trim() : cronString.value;
|
||||
|
||||
if (!CRON_REGEX.test(cronToTest)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scheduleType.value === "custom") {
|
||||
try {
|
||||
const parts = cronToTest.split(/\s+/);
|
||||
if (parts.length === 6) {
|
||||
cronToString(parts.slice(1).join(" "));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const humanReadableDescription = computed<string | undefined>(() => {
|
||||
if (!isValidCron.value && scheduleType.value === "custom") return undefined;
|
||||
if (scheduleType.value === "custom") {
|
||||
try {
|
||||
const parts = customCron.value.trim().split(/\s+/);
|
||||
if (parts.length === 6) {
|
||||
return cronToString(parts.slice(1).join(" "));
|
||||
}
|
||||
return "Invalid cron expression";
|
||||
} catch {
|
||||
return "Invalid cron expression";
|
||||
}
|
||||
}
|
||||
const minute = selectedTime.value.minute === "" ? "0" : selectedTime.value.minute;
|
||||
const hour = selectedTime.value.hour === "" ? "0" : selectedTime.value.hour;
|
||||
const daysNum = Number(dayInterval.value || "1");
|
||||
const timeStr = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
|
||||
if (daysNum <= 1) {
|
||||
return `Every day at ${timeStr}`;
|
||||
} else {
|
||||
return `Every ${daysNum} days at ${timeStr}`;
|
||||
}
|
||||
});
|
||||
|
||||
function onDayIntervalInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
let value = input.value.replace(/\D/g, "");
|
||||
|
||||
if (value === "") {
|
||||
dayInterval.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.length > 1 && value.startsWith("0")) {
|
||||
value = value.replace(/^0+/, "");
|
||||
}
|
||||
|
||||
const num = parseInt(value);
|
||||
|
||||
if (num < 1) {
|
||||
dayInterval.value = "1";
|
||||
} else if (num > 365) {
|
||||
dayInterval.value = "365";
|
||||
} else {
|
||||
dayInterval.value = String(num);
|
||||
}
|
||||
}
|
||||
|
||||
watch([cronString, isValidCron], ([cron, valid]) => {
|
||||
if (valid) {
|
||||
task.value.every = cron;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => task.value.action_kind,
|
||||
(kind) => {
|
||||
if (kind === "game-command") {
|
||||
if (
|
||||
!task.value.options ||
|
||||
typeof task.value.options !== "object" ||
|
||||
!("command" in task.value.options)
|
||||
) {
|
||||
task.value.options = { command: "" };
|
||||
}
|
||||
} else {
|
||||
task.value.options = {};
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
if (!isNew.value && taskId.value != null) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
if (!props.server.scheduling.tasks.length) {
|
||||
await props.server.scheduling.fetch();
|
||||
}
|
||||
const found = props.server.scheduling.tasks.find((t) => t.id === taskId.value);
|
||||
if (found) {
|
||||
task.value = { ...found };
|
||||
if (task.value.every && typeof task.value.every === "string") {
|
||||
const parts = task.value.every.split(/\s+/);
|
||||
if (parts.length === 6) {
|
||||
const second = parts[0];
|
||||
const minute = parts[1];
|
||||
const hour = parts[2];
|
||||
const dayOfMonth = parts[3];
|
||||
|
||||
if (second === "0" && dayOfMonth.startsWith("*/")) {
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = dayOfMonth.substring(2);
|
||||
selectedTime.value = { hour, minute };
|
||||
} else if (
|
||||
second === "0" &&
|
||||
dayOfMonth === "*" &&
|
||||
parts[4] === "*" &&
|
||||
parts[5] === "*"
|
||||
) {
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = "1";
|
||||
selectedTime.value = { hour, minute };
|
||||
} else {
|
||||
scheduleType.value = "custom";
|
||||
customCron.value = task.value.every;
|
||||
}
|
||||
} else if (parts.length === 5) {
|
||||
const minute = parts[0];
|
||||
const hour = parts[1];
|
||||
const dayOfMonth = parts[2];
|
||||
|
||||
if (dayOfMonth.startsWith("*/")) {
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = dayOfMonth.substring(2);
|
||||
selectedTime.value = { hour, minute };
|
||||
} else if (dayOfMonth === "*" && parts[3] === "*" && parts[4] === "*") {
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = "1";
|
||||
selectedTime.value = { hour, minute };
|
||||
} else {
|
||||
scheduleType.value = "custom";
|
||||
customCron.value = `0 ${task.value.every}`;
|
||||
}
|
||||
} else {
|
||||
scheduleType.value = "custom";
|
||||
customCron.value = task.value.every;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error.value = "Task not found.";
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || "Failed to load task.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
} else {
|
||||
task.value = {
|
||||
title: "",
|
||||
action_kind: "restart",
|
||||
options: {},
|
||||
enabled: true,
|
||||
warn_msg: '/tellraw @a "Restarting in {}!"',
|
||||
warn_intervals: [],
|
||||
};
|
||||
scheduleType.value = "daily";
|
||||
dayInterval.value = "1";
|
||||
selectedTime.value = { hour: "9", minute: "0" };
|
||||
customCron.value = "0 0 9 * * *";
|
||||
if (isValidCron.value) {
|
||||
task.value.every = cronString.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function saveTask() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
if (isValidCron.value) {
|
||||
task.value.every = cronString.value;
|
||||
} else {
|
||||
error.value = "Cannot save with invalid cron expression.";
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const scheduleToCreate: Schedule = {
|
||||
title: task.value.title || "",
|
||||
every: task.value.every!,
|
||||
action_kind: task.value.action_kind!,
|
||||
options: task.value.options!,
|
||||
enabled: task.value.enabled !== undefined ? task.value.enabled : true,
|
||||
warn_msg: task.value.warn_msg !== undefined ? task.value.warn_msg : "",
|
||||
warn_intervals: task.value.warn_intervals || [],
|
||||
};
|
||||
await props.server.scheduling.createTask(scheduleToCreate);
|
||||
} else if (taskId.value != null) {
|
||||
await props.server.scheduling.editTask(taskId.value, task.value as Partial<Schedule>);
|
||||
}
|
||||
router.push(`/servers/manage/${route.params.id}/options/scheduling`);
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || "Failed to save task.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,25 +1,20 @@
|
||||
<template>
|
||||
<EditScheduledTaskModal ref="modal" :server="server"></EditScheduledTaskModal>
|
||||
<div class="relative h-full w-full overflow-y-auto">
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<section class="universal-card">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex h-full flex-col justify-center">
|
||||
<h3 class="font-semibold leading-tight">Task Scheduling</h3>
|
||||
<p v-if="tasks.length < 1" class="mt-1 text-secondary">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Task scheduling</h2>
|
||||
<span v-if="tasks.length < 1">
|
||||
No scheduled tasks yet. Click the button to create your first task.
|
||||
</p>
|
||||
<p v-else class="mt-1 text-secondary">
|
||||
You can manage multiple tasks at once by selecting them below.
|
||||
</p>
|
||||
</span>
|
||||
<span v-else>You can manage multiple tasks at once by selecting them below.</span>
|
||||
</div>
|
||||
<div>
|
||||
<NuxtLink
|
||||
:to="`/servers/manage/${route.params.id}/options/scheduling/new`"
|
||||
class="iconified-button brand-button"
|
||||
<ButtonStyled color="green"
|
||||
><button @click="modal?.show()"><PlusIcon /> Create task</button></ButtonStyled
|
||||
>
|
||||
<PlusIcon />
|
||||
Create Task
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,13 +23,13 @@
|
||||
<ButtonStyled>
|
||||
<button :disabled="selectedTasks.length === 0" @click="handleBulkToggle">
|
||||
<ToggleRightIcon />
|
||||
Toggle Selected
|
||||
Toggle delected
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="selectedTasks.length === 0" @click="handleBulkDelete">
|
||||
<TrashIcon />
|
||||
Delete Selected
|
||||
Delete selected
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="push-right">
|
||||
@@ -134,23 +129,12 @@
|
||||
<div>
|
||||
<div class="flex gap-1">
|
||||
<ButtonStyled icon-only circular>
|
||||
<NuxtLink
|
||||
v-tooltip="'Edit task'"
|
||||
:to="`/servers/manage/${route.params.id}/options/scheduling/${task.id}`"
|
||||
>
|
||||
<button :v-tooltip="'Edit Task'" @click="modal?.show(task)">
|
||||
<EditIcon />
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled icon-only circular color="red">
|
||||
<button
|
||||
v-tooltip="
|
||||
task.title === 'Auto Restart'
|
||||
? 'You cant delete the automatic restart task.'
|
||||
: 'Delete task'
|
||||
"
|
||||
:disabled="task.title === 'Auto Restart'"
|
||||
@click="handleTaskDelete(task)"
|
||||
>
|
||||
<button @click="handleTaskDelete(task)">
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -181,6 +165,7 @@ import { Toggle, Checkbox, RaisedBadge, ButtonStyled } from "@modrinth/ui";
|
||||
import cronstrue from "cronstrue";
|
||||
import type { ServerSchedule } from "@modrinth/utils";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
import EditScheduledTaskModal from "~/components/ui/servers/scheduling/EditScheduledTaskModal.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
@@ -195,6 +180,7 @@ onBeforeMount(async () => {
|
||||
const selectedTasks = ref<ServerSchedule[]>([]);
|
||||
const sortBy = ref("Name");
|
||||
const descending = ref(false);
|
||||
const modal = ref<typeof EditScheduledTaskModal>();
|
||||
|
||||
const tasks = computed(() => props.server.scheduling.tasks as ServerSchedule[]);
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
v-for="item in items"
|
||||
:key="formatLabel(item)"
|
||||
class="btn"
|
||||
:class="{ selected: selected === item, capitalize: capitalize }"
|
||||
:class="{ selected: isSelected(item), capitalize: capitalize }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<CheckIcon v-if="selected === item" />
|
||||
<CheckIcon v-if="isSelected(item)" />
|
||||
<span>{{ formatLabel(item) }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -23,26 +23,48 @@ const props = withDefaults(
|
||||
formatLabel?: (item: T) => string
|
||||
neverEmpty?: boolean
|
||||
capitalize?: boolean
|
||||
multi?: boolean
|
||||
}>(),
|
||||
{
|
||||
neverEmpty: true,
|
||||
// Intentional any type, as this default should only be used for primitives (string or number)
|
||||
formatLabel: (item) => item.toString(),
|
||||
capitalize: true,
|
||||
multi: false,
|
||||
},
|
||||
)
|
||||
const selected = defineModel<T | null>()
|
||||
const selected = defineModel<T | null | T[]>()
|
||||
|
||||
// If one always has to be selected, default to the first one
|
||||
if (props.items.length > 0 && props.neverEmpty && !selected.value) {
|
||||
selected.value = props.items[0]
|
||||
selected.value = props.multi ? [props.items[0]] : props.items[0]
|
||||
}
|
||||
|
||||
function isSelected(item: T): boolean {
|
||||
if (props.multi) {
|
||||
return Array.isArray(selected.value) && selected.value.includes(item)
|
||||
}
|
||||
return selected.value === item
|
||||
}
|
||||
|
||||
function toggleItem(item: T) {
|
||||
if (selected.value === item && !props.neverEmpty) {
|
||||
selected.value = null
|
||||
if (props.multi) {
|
||||
const currentSelection = Array.isArray(selected.value) ? selected.value : []
|
||||
const isCurrentlySelected = currentSelection.includes(item)
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
if (!props.neverEmpty || currentSelection.length > 1) {
|
||||
selected.value = currentSelection.filter((i) => i !== item)
|
||||
}
|
||||
} else {
|
||||
selected.value = [...currentSelection, item]
|
||||
}
|
||||
} else {
|
||||
selected.value = item
|
||||
if (selected.value === item && !props.neverEmpty) {
|
||||
selected.value = null
|
||||
} else {
|
||||
selected.value = item
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
>
|
||||
<button>
|
||||
<ClockIcon class="h-4 w-4" />
|
||||
{{ modelValue ? formatTime(modelValue) : placeholder }}
|
||||
{{ modelValue ? formatTime(modelValue) : placeholder }} {{ useUtcValues ? 'UTC' : '' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -100,31 +100,78 @@ interface Props {
|
||||
modelValue?: TimeValue
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
useUtcValues?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => ({ hour: '12', minute: '00' }),
|
||||
disabled: false,
|
||||
placeholder: 'Select time',
|
||||
useUtcValues: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: TimeValue]
|
||||
}>()
|
||||
|
||||
function utcToLocal(utcTime: TimeValue): TimeValue {
|
||||
const today = new Date()
|
||||
const utcHour = utcTime.hour === '' ? 0 : parseInt(utcTime.hour)
|
||||
const utcMinute = utcTime.minute === '' ? 0 : parseInt(utcTime.minute)
|
||||
|
||||
const utcDate = new Date(
|
||||
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate(), utcHour, utcMinute),
|
||||
)
|
||||
|
||||
return {
|
||||
hour: utcDate.getHours().toString(),
|
||||
minute: utcDate.getMinutes().toString(),
|
||||
}
|
||||
}
|
||||
|
||||
function localToUtc(localTime: TimeValue): TimeValue {
|
||||
const today = new Date()
|
||||
const localHour = localTime.hour === '' ? 0 : parseInt(localTime.hour)
|
||||
const localMinute = localTime.minute === '' ? 0 : parseInt(localTime.minute)
|
||||
|
||||
const localDate = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
localHour,
|
||||
localMinute,
|
||||
)
|
||||
|
||||
return {
|
||||
hour: localDate.getUTCHours().toString(),
|
||||
minute: localDate.getUTCMinutes().toString(),
|
||||
}
|
||||
}
|
||||
|
||||
function emitTime(localTime: TimeValue) {
|
||||
const timeToEmit = props.useUtcValues ? localToUtc(localTime) : localTime
|
||||
emit('update:modelValue', timeToEmit)
|
||||
}
|
||||
|
||||
const dropdown = ref()
|
||||
const originalTime = ref<TimeValue>({ hour: '12', minute: '00' })
|
||||
const currentTime = reactive<TimeValue>({
|
||||
hour: props.modelValue?.hour || '12',
|
||||
minute: props.modelValue?.minute || '00',
|
||||
})
|
||||
|
||||
const getInitialTime = (): TimeValue => {
|
||||
const defaultTime = { hour: '12', minute: '00' }
|
||||
if (!props.modelValue) return defaultTime
|
||||
|
||||
return props.useUtcValues ? utcToLocal(props.modelValue) : props.modelValue
|
||||
}
|
||||
|
||||
const currentTime = reactive<TimeValue>(getInitialTime())
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
currentTime.hour = newValue.hour
|
||||
currentTime.minute = newValue.minute
|
||||
const displayTime = props.useUtcValues ? utcToLocal(newValue) : newValue
|
||||
currentTime.hour = displayTime.hour
|
||||
currentTime.minute = displayTime.minute
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
@@ -165,7 +212,7 @@ function handleHourChange(event: Event) {
|
||||
}
|
||||
|
||||
currentTime.hour = hour
|
||||
emit('update:modelValue', { ...currentTime })
|
||||
emitTime(currentTime)
|
||||
}
|
||||
|
||||
function handleMinuteChange(event: Event) {
|
||||
@@ -178,19 +225,19 @@ function handleMinuteChange(event: Event) {
|
||||
}
|
||||
|
||||
currentTime.minute = minute
|
||||
emit('update:modelValue', { ...currentTime })
|
||||
emitTime(currentTime)
|
||||
}
|
||||
|
||||
function handleQuickSelect(hour: string, minute: string) {
|
||||
currentTime.hour = hour
|
||||
currentTime.minute = minute
|
||||
emit('update:modelValue', { ...currentTime })
|
||||
emitTime(currentTime)
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
currentTime.hour = originalTime.value.hour
|
||||
currentTime.minute = originalTime.value.minute
|
||||
emit('update:modelValue', { ...originalTime.value })
|
||||
emitTime(originalTime.value)
|
||||
dropdown.value?.hide()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="overflow-y-auto p-6">
|
||||
<div class="modal-content overflow-y-auto p-6">
|
||||
<slot> You just lost the game.</slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,6 +237,10 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
.modal-content {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user