feat: start on modal
This commit is contained in:
1 parent
39a37096dd
commit
c48a229900
8 files changed
+873
-53
No files matched your search
@@ -11,7 +11,7 @@ import {
|
||||
WSModule,
|
||||
FSModule,
|
||||
} from "./modules/index.ts";
|
||||
import { ScheudlingModule } from "./modules/scheduling.ts";
|
||||
import { SchedulingModule } from "./modules/scheduling.ts";
|
||||
|
||||
export function handleError(err: any) {
|
||||
if (err instanceof ModrinthServerError && err.v1Error) {
|
||||
@@ -41,7 +41,7 @@ export class ModrinthServer {
|
||||
readonly startup: StartupModule;
|
||||
readonly ws: WSModule;
|
||||
readonly fs: FSModule;
|
||||
readonly scheduling: ScheudlingModule;
|
||||
readonly scheduling: SchedulingModule;
|
||||
|
||||
constructor(serverId: string) {
|
||||
this.serverId = serverId;
|
||||
@@ -53,7 +53,7 @@ export class ModrinthServer {
|
||||
this.startup = new StartupModule(this);
|
||||
this.ws = new WSModule(this);
|
||||
this.fs = new FSModule(this);
|
||||
this.scheduling = new ScheudlingModule(this);
|
||||
this.scheduling = new SchedulingModule(this);
|
||||
}
|
||||
|
||||
async createMissingFolders(path: string): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ScheduledTask } from "@modrinth/utils";
|
||||
import { useServersFetch } from "../servers-fetch.ts";
|
||||
import { ServerModule } from "./base.ts";
|
||||
|
||||
export class ScheudlingModule extends ServerModule {
|
||||
export class SchedulingModule extends ServerModule {
|
||||
tasks: ScheduledTask[] = [];
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
@@ -12,15 +12,34 @@ export class ScheudlingModule extends ServerModule {
|
||||
);
|
||||
}
|
||||
|
||||
async deleteTask(_task: ScheduledTask): Promise<void> {
|
||||
// ...
|
||||
async deleteTask(task: ScheduledTask): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/options/schedules`, {
|
||||
method: "DELETE",
|
||||
body: { title: task.title },
|
||||
version: 1,
|
||||
});
|
||||
this.tasks = this.tasks.filter((t) => t.title !== task.title);
|
||||
}
|
||||
|
||||
async createTask(_task: ScheduledTask): Promise<number> {
|
||||
return await 1;
|
||||
async createTask(task: ScheduledTask): Promise<number> {
|
||||
await useServersFetch(`servers/${this.serverId}/options/schedules`, {
|
||||
method: "POST",
|
||||
body: task,
|
||||
version: 1,
|
||||
});
|
||||
this.tasks.push(task);
|
||||
return this.tasks.length;
|
||||
}
|
||||
|
||||
async editTask(_taskID: number, _task: Partial<ScheduledTask>) {
|
||||
// ...
|
||||
async editTask(taskTitle: string, updatedTask: Partial<ScheduledTask>): Promise<void> {
|
||||
await useServersFetch(`servers/${this.serverId}/options/schedules`, {
|
||||
method: "PATCH",
|
||||
body: { title: taskTitle, ...updatedTask },
|
||||
version: 1,
|
||||
});
|
||||
const index = this.tasks.findIndex((t) => t.title === taskTitle);
|
||||
if (index !== -1) {
|
||||
this.tasks[index] = { ...this.tasks[index], ...updatedTask };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,11 +154,13 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScheduleTaskModal ref="scheduleModal" @save="handleTaskSave" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, defineProps } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import { Multiselect } from "vue-multiselect";
|
||||
import {
|
||||
PlusIcon,
|
||||
@@ -170,7 +172,7 @@ import {
|
||||
SortAscendingIcon as AscendingIcon,
|
||||
SortDescendingIcon as DescendingIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Toggle, Checkbox, RaisedBadge, ButtonStyled } from "@modrinth/ui";
|
||||
import { Toggle, Checkbox, RaisedBadge, ButtonStyled, ScheduleTaskModal } from "@modrinth/ui";
|
||||
import cronstrue from "cronstrue";
|
||||
import type { ScheduledTask } from "@modrinth/utils";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
@@ -179,10 +181,16 @@ const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
|
||||
const tasks = ref<ScheduledTask[]>(props.server.scheduling.tasks);
|
||||
onBeforeMount(async () => {
|
||||
await props.server.scheduling.fetch();
|
||||
});
|
||||
|
||||
const selectedTasks = ref<ScheduledTask[]>([]);
|
||||
const sortBy = ref("Name");
|
||||
const descending = ref(false);
|
||||
const scheduleModal = ref();
|
||||
|
||||
const tasks = computed(() => props.server.scheduling.tasks);
|
||||
|
||||
const sortedTasks = computed(() => {
|
||||
const sorted = [...tasks.value];
|
||||
@@ -208,88 +216,112 @@ const sortedTasks = computed(() => {
|
||||
return descending.value ? sorted.reverse() : sorted;
|
||||
});
|
||||
|
||||
const handleSelectAll = (selected: boolean) => {
|
||||
function handleSelectAll(selected: boolean): void {
|
||||
selectedTasks.value = selected ? [...tasks.value] : [];
|
||||
};
|
||||
}
|
||||
|
||||
const handleTaskSelect = (task: ScheduledTask, selected: boolean) => {
|
||||
function handleTaskSelect(task: ScheduledTask, selected: boolean): void {
|
||||
if (selected) {
|
||||
selectedTasks.value.push(task);
|
||||
} else {
|
||||
selectedTasks.value = selectedTasks.value.filter((t) => t !== task);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleTaskToggle = async (task: ScheduledTask, enabled: boolean) => {
|
||||
async function handleTaskToggle(task: ScheduledTask, enabled: boolean): Promise<void> {
|
||||
try {
|
||||
task.enabled = enabled;
|
||||
await props.server.scheduling.editTask(task.title, { enabled });
|
||||
console.log("Toggle task:", task.title, enabled);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle task:", error);
|
||||
task.enabled = !enabled;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleTaskEdit = (task: ScheduledTask) => {
|
||||
console.log("Edit task:", task.title);
|
||||
};
|
||||
function handleTaskEdit(task: ScheduledTask): void {
|
||||
scheduleModal.value?.show(task);
|
||||
}
|
||||
|
||||
const handleTaskDelete = async (task: ScheduledTask) => {
|
||||
async function handleTaskDelete(task: ScheduledTask): Promise<void> {
|
||||
if (confirm(`Are you sure you want to delete "${task.title}"?`)) {
|
||||
try {
|
||||
const index = tasks.value.indexOf(task);
|
||||
if (index > -1) {
|
||||
tasks.value.splice(index, 1);
|
||||
}
|
||||
await props.server.scheduling.deleteTask(task);
|
||||
selectedTasks.value = selectedTasks.value.filter((t) => t !== task);
|
||||
console.log("Delete task:", task.title);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete task:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleCreateTask = () => {
|
||||
console.log("Create new task");
|
||||
};
|
||||
function handleCreateTask(): void {
|
||||
scheduleModal.value?.showNew();
|
||||
}
|
||||
|
||||
const handleBulkToggle = async () => {
|
||||
async function handleBulkToggle(): Promise<void> {
|
||||
const enabledCount = selectedTasks.value.filter((t) => t.enabled).length;
|
||||
const shouldEnable = enabledCount < selectedTasks.value.length / 2;
|
||||
|
||||
try {
|
||||
selectedTasks.value.forEach((task: ScheduledTask) => {
|
||||
task.enabled = shouldEnable;
|
||||
});
|
||||
await Promise.all(
|
||||
selectedTasks.value.map((task) =>
|
||||
props.server.scheduling.editTask(task.title, { enabled: shouldEnable }),
|
||||
),
|
||||
);
|
||||
console.log("Bulk toggle tasks:", selectedTasks.value.length, "to", shouldEnable);
|
||||
} catch (error) {
|
||||
console.error("Failed to bulk toggle tasks:", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
// TODO: Confirm delete modal.
|
||||
// `Are you sure you want to delete ${selectedTasks.value.length} selected tasks?`
|
||||
};
|
||||
async function handleBulkDelete(): Promise<void> {
|
||||
if (confirm(`Are you sure you want to delete ${selectedTasks.value.length} selected tasks?`)) {
|
||||
try {
|
||||
await Promise.all(
|
||||
selectedTasks.value.map((task) => props.server.scheduling.deleteTask(task)),
|
||||
);
|
||||
selectedTasks.value = [];
|
||||
console.log("Bulk delete completed");
|
||||
} catch (error) {
|
||||
console.error("Failed to bulk delete tasks:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateSort = () => {
|
||||
// Trigger reactivity for sortedTasks somehow.
|
||||
};
|
||||
async function handleTaskSave(data: ScheduledTask): Promise<void> {
|
||||
try {
|
||||
if (scheduleModal.value?.mode === "edit") {
|
||||
await props.server.scheduling.editTask(
|
||||
scheduleModal.value.originalData?.title || data.title,
|
||||
data,
|
||||
);
|
||||
console.log("Updated task:", data.title);
|
||||
} else {
|
||||
await props.server.scheduling.createTask(data);
|
||||
console.log("Created task:", data.title);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save task:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const updateDescending = () => {
|
||||
function updateSort(): void {
|
||||
// Trigger reactivity for sortedTasks
|
||||
}
|
||||
|
||||
function updateDescending(): void {
|
||||
descending.value = !descending.value;
|
||||
};
|
||||
}
|
||||
|
||||
const getHumanReadableCron = (cronExpression: string) => {
|
||||
function getHumanReadableCron(cronExpression: string): string {
|
||||
try {
|
||||
// eslint-disable-next-line import/no-named-as-default-member
|
||||
return cronstrue.toString(cronExpression);
|
||||
} catch {
|
||||
return cronExpression;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const formatWarningIntervals = (intervals: number[]) => {
|
||||
function formatWarningIntervals(intervals: number[]): string {
|
||||
return intervals
|
||||
.sort((a, b) => b - a)
|
||||
.map((seconds) => {
|
||||
@@ -298,7 +330,7 @@ const formatWarningIntervals = (intervals: number[]) => {
|
||||
return `${seconds}s`;
|
||||
})
|
||||
.join(", ");
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -321,12 +353,10 @@ const formatWarningIntervals = (intervals: number[]) => {
|
||||
justify-content: center;
|
||||
padding: var(--spacing-card-sm);
|
||||
|
||||
// Left edge of table
|
||||
&:first-child {
|
||||
padding-left: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
// Right edge of table
|
||||
&:last-child {
|
||||
padding-right: var(--spacing-card-bg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock-icon lucide-clock"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -66,6 +66,7 @@ import _ChevronRightIcon from './icons/chevron-right.svg?component'
|
||||
import _ClearIcon from './icons/clear.svg?component'
|
||||
import _ClientIcon from './icons/client.svg?component'
|
||||
import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component'
|
||||
import _ClockIcon from './icons/clock.svg?component'
|
||||
import _CodeIcon from './icons/code.svg?component'
|
||||
import _CoffeeIcon from './icons/coffee.svg?component'
|
||||
import _CoinsIcon from './icons/coins.svg?component'
|
||||
@@ -287,6 +288,7 @@ export const ChevronRightIcon = _ChevronRightIcon
|
||||
export const ClearIcon = _ClearIcon
|
||||
export const ClientIcon = _ClientIcon
|
||||
export const ClipboardCopyIcon = _ClipboardCopyIcon
|
||||
export const ClockIcon = _ClockIcon
|
||||
export const CodeIcon = _CodeIcon
|
||||
export const CoffeeIcon = _CoffeeIcon
|
||||
export const CoinsIcon = _CoinsIcon
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
ref="dropdown"
|
||||
:disabled="disabled"
|
||||
placement="bottom-start"
|
||||
theme="ribbit-popout"
|
||||
:distance="8"
|
||||
no-arrow
|
||||
@apply-show="onDropdownShow"
|
||||
>
|
||||
<ButtonStyled
|
||||
:class="['w-full justify-start text-left font-normal', !modelValue && 'text-secondary']"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<button>
|
||||
<ClockIcon class="h-4 w-4" />
|
||||
{{ modelValue ? formatTime(modelValue) : placeholder }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<template #popper>
|
||||
<div class="flex flex-col gap-4 p-4 w-64">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="hour" class="text-sm font-medium text-contrast"> Hour </label>
|
||||
<input
|
||||
id="hour"
|
||||
v-model="currentTime.hour"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="00"
|
||||
class="bg-bg-input w-full rounded-lg p-2 text-center"
|
||||
maxlength="2"
|
||||
@input="handleHourChange"
|
||||
/>
|
||||
<div class="text-xs text-secondary text-center">0-23</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="minute" class="text-sm font-medium text-contrast"> Minute </label>
|
||||
<input
|
||||
id="minute"
|
||||
v-model="currentTime.minute"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="00"
|
||||
class="bg-bg-input w-full rounded-lg p-2 text-center"
|
||||
maxlength="2"
|
||||
@input="handleMinuteChange"
|
||||
/>
|
||||
<div class="text-xs text-secondary text-center">0-59</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center p-3 bg-bg-raised rounded-lg">
|
||||
<div class="text-sm text-secondary">Selected Time (Local)</div>
|
||||
<div class="text-2xl font-bold text-contrast">
|
||||
{{ formatTime(currentTime) }}
|
||||
</div>
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
{{ getUTCTime(currentTime) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-medium text-contrast">Quick Select</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<ButtonStyled v-for="preset in quickSelects" :key="preset.label">
|
||||
<button class="" @click="handleQuickSelect(preset.hour, preset.minute)">
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<ButtonStyled class="flex-1">
|
||||
<button @click="handleCancel">Cancel</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand" class="flex-1">
|
||||
<button @click="handleDone">Done</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { ClockIcon } from '@modrinth/assets'
|
||||
|
||||
interface TimeValue {
|
||||
hour: string
|
||||
minute: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: TimeValue
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => ({ hour: '12', minute: '00' }),
|
||||
disabled: false,
|
||||
placeholder: 'Select time',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: TimeValue]
|
||||
}>()
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
currentTime.hour = newValue.hour
|
||||
currentTime.minute = newValue.minute
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function formatTime(time: TimeValue): string {
|
||||
const hour = (time.hour === '' ? '0' : time.hour).padStart(2, '0')
|
||||
const minute = (time.minute === '' ? '0' : time.minute).padStart(2, '0')
|
||||
return `${hour}:${minute}`
|
||||
}
|
||||
|
||||
function getUTCTime(time: TimeValue): string {
|
||||
const today = new Date()
|
||||
const hourValue = time.hour === '' ? 0 : parseInt(time.hour)
|
||||
const minuteValue = time.minute === '' ? 0 : parseInt(time.minute)
|
||||
|
||||
const localDate = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
hourValue,
|
||||
minuteValue,
|
||||
)
|
||||
|
||||
const utcHour = localDate.getUTCHours().toString().padStart(2, '0')
|
||||
const utcMinute = localDate.getUTCMinutes().toString().padStart(2, '0')
|
||||
|
||||
return `${utcHour}:${utcMinute} UTC`
|
||||
}
|
||||
|
||||
function handleHourChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const cleanValue = target.value.replace(/\D/g, '').slice(0, 2)
|
||||
let hour = cleanValue
|
||||
|
||||
if (cleanValue !== '' && (parseInt(cleanValue) > 23 || parseInt(cleanValue) < 0)) {
|
||||
hour = Math.min(23, Math.max(0, parseInt(cleanValue))).toString()
|
||||
}
|
||||
|
||||
currentTime.hour = hour
|
||||
emit('update:modelValue', { ...currentTime })
|
||||
}
|
||||
|
||||
function handleMinuteChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const cleanValue = target.value.replace(/\D/g, '').slice(0, 2)
|
||||
let minute = cleanValue
|
||||
|
||||
if (cleanValue !== '' && (parseInt(cleanValue) > 59 || parseInt(cleanValue) < 0)) {
|
||||
minute = Math.min(59, Math.max(0, parseInt(cleanValue))).toString()
|
||||
}
|
||||
|
||||
currentTime.minute = minute
|
||||
emit('update:modelValue', { ...currentTime })
|
||||
}
|
||||
|
||||
function handleQuickSelect(hour: string, minute: string) {
|
||||
currentTime.hour = hour
|
||||
currentTime.minute = minute
|
||||
emit('update:modelValue', { ...currentTime })
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
currentTime.hour = originalTime.value.hour
|
||||
currentTime.minute = originalTime.value.minute
|
||||
emit('update:modelValue', { ...originalTime.value })
|
||||
dropdown.value?.hide()
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
dropdown.value?.hide()
|
||||
}
|
||||
|
||||
function onDropdownShow() {
|
||||
originalTime.value = { ...currentTime }
|
||||
}
|
||||
|
||||
const quickSelects = [
|
||||
{ label: '00:00', hour: '0', minute: '0' },
|
||||
{ label: '04:00', hour: '4', minute: '0' },
|
||||
{ label: '12:00', hour: '12', minute: '0' },
|
||||
{ label: '21:00', hour: '21', minute: '0' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.v-popper--theme-dropdown .v-popper__arrow-container {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -43,6 +43,7 @@ export { default as StatItem } from './base/StatItem.vue'
|
||||
export { default as TagItem } from './base/TagItem.vue'
|
||||
export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue'
|
||||
export { default as Timeline } from './base/Timeline.vue'
|
||||
export { default as TimePicker } from "./base/TimePicker.vue"
|
||||
export { default as Toggle } from './base/Toggle.vue'
|
||||
|
||||
// Branding
|
||||
@@ -111,3 +112,4 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
|
||||
// Servers
|
||||
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
|
||||
export { default as ServersSpecs } from './billing/ServersSpecs.vue'
|
||||
export { default as ScheduleTaskModal } from "./servers/scheduling/ScheduleTaskModal.vue"
|
||||
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="mode === 'edit' ? 'Edit Schedule' : 'Create Schedule'"
|
||||
class="schedule-modal"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex 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="data.title"
|
||||
type="text"
|
||||
placeholder="Enter schedule title"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="action_kind">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Action Type
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="action_kind"
|
||||
v-model="data.action_kind"
|
||||
:options="actionTypes"
|
||||
name="Action Type"
|
||||
/>
|
||||
<div class="text-sm text-secondary action-type-description">
|
||||
{{ getActionTypeDescription(data.action_kind) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.action_kind === 'game-command'" class="flex flex-col gap-2">
|
||||
<label for="command">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Command
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="command"
|
||||
v-model="data.options.command"
|
||||
type="text"
|
||||
placeholder="/give @a diamond 64"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="text-lg font-semibold text-contrast">Schedule</label>
|
||||
|
||||
<RadioButtons v-model="scheduleType" :items="['daily', 'custom']" force-selection>
|
||||
<template #default="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<ClockIcon v-if="item === 'daily'" class="h-4 w-4" />
|
||||
<CodeIcon v-if="item === 'custom'" class="h-4 w-4" />
|
||||
{{ item === 'daily' ? 'Every X day(s) at specific time' : 'Custom cron expression' }}
|
||||
</div>
|
||||
</template>
|
||||
</RadioButtons>
|
||||
|
||||
<!-- Fixed width container for schedule cards -->
|
||||
<div class="schedule-card-container">
|
||||
<div v-if="scheduleType === 'daily'" class="card p-4 space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="dayInterval">
|
||||
<span class="text-lg font-semibold text-contrast">Every X day(s)</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="dayInterval"
|
||||
v-model="dayInterval"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="1"
|
||||
class="w-20"
|
||||
maxlength="3"
|
||||
@input="handleDayIntervalChange"
|
||||
/>
|
||||
<span class="text-sm text-secondary">
|
||||
day{{ parseInt(dayInterval || '1') > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-secondary">1-365 days</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-lg font-semibold text-contrast">At time</label>
|
||||
<TimePicker v-model="selectedTime" placeholder="Select time" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-2 border-t border-divider">
|
||||
<p class="text-sm text-secondary">{{ getCronDescription(cronString) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="scheduleType === 'custom'" class="card p-4 space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="customCron">
|
||||
<span class="text-lg font-semibold text-contrast">Cron Expression</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="customCron"
|
||||
v-model="customCron"
|
||||
placeholder="0 0 9 * * *"
|
||||
class="font-mono"
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-secondary">
|
||||
Format: seconds minutes hours dayOfMonth month dayOfWeek
|
||||
</div>
|
||||
<div v-if="!isValidCron" class="text-xs text-brand-red">
|
||||
Invalid cron format. Please use 6 space-separated values.
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-2 border-t border-divider">
|
||||
<p class="text-sm text-secondary">{{ getCronDescription(customCron) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="warn_msg">
|
||||
<span class="text-lg font-semibold text-contrast">Warning Message (Optional)</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="warn_msg"
|
||||
v-model="data.warn_msg"
|
||||
placeholder="/tellraw Warning message with {} placeholder"
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.warn_msg && data.warn_msg.trim() !== ''" class="flex flex-col gap-2">
|
||||
<label class="text-lg font-semibold text-contrast">Warning Intervals</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="newInterval" type="number" placeholder="Seconds" min="1" class="flex-1" />
|
||||
<ButtonStyled
|
||||
:disabled="!newInterval || (data.warn_intervals?.length || 0) >= 6"
|
||||
@click="addWarningInterval"
|
||||
>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<!-- Fixed height container for warning intervals -->
|
||||
<div class="warning-intervals-container">
|
||||
<div
|
||||
v-if="data.warn_intervals && data.warn_intervals.length > 0"
|
||||
class="warning-intervals-content"
|
||||
>
|
||||
<RaisedBadge
|
||||
v-for="(interval, index) in data.warn_intervals"
|
||||
:key="index"
|
||||
:text="formatInterval(interval)"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 hover:text-brand-red"
|
||||
@click="removeWarningInterval(index)"
|
||||
>
|
||||
<XIcon class="h-3 w-3" />
|
||||
</button>
|
||||
</RaisedBadge>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-secondary">Maximum 6 intervals. Values in seconds.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
color="brand"
|
||||
:disabled="isLoading || !isValid || !hasChanges"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<button>
|
||||
<PlusIcon v-if="mode === 'new'" class="h-4 w-4" />
|
||||
<EditIcon v-else class="h-4 w-4" />
|
||||
{{ isLoading ? 'Saving...' : mode === 'edit' ? 'Update' : 'Create' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :disabled="isLoading" @click="modal?.hide()">
|
||||
<button>
|
||||
<XIcon class="h-4 w-4" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
NewModal,
|
||||
ButtonStyled,
|
||||
DropdownSelect,
|
||||
RaisedBadge,
|
||||
TimePicker,
|
||||
RadioButtons,
|
||||
} from '@modrinth/ui'
|
||||
import { XIcon, PlusIcon, EditIcon, ClockIcon, CodeIcon } from '@modrinth/assets'
|
||||
import cronstrue from 'cronstrue'
|
||||
import type { ScheduledTask } from '@modrinth/utils'
|
||||
|
||||
interface TimeValue {
|
||||
hour: string
|
||||
minute: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [data: ScheduledTask]
|
||||
}>()
|
||||
|
||||
const modal = ref()
|
||||
const isLoading = ref(false)
|
||||
const mode = ref<'new' | 'edit'>('new')
|
||||
const originalData = ref<ScheduledTask | null>(null)
|
||||
|
||||
const data = ref<ScheduledTask>({
|
||||
title: '',
|
||||
action_kind: 'game-command',
|
||||
options: {},
|
||||
enabled: true,
|
||||
warn_msg: '',
|
||||
warn_intervals: [],
|
||||
every: '0 0 9 * * *',
|
||||
})
|
||||
|
||||
const newInterval = ref('')
|
||||
|
||||
const scheduleType = ref<'daily' | 'custom'>('daily')
|
||||
const dayInterval = ref('1')
|
||||
const selectedTime = ref<TimeValue>({ hour: '9', minute: '0' })
|
||||
const customCron = ref('0 0 9 * * *')
|
||||
|
||||
const actionTypes = ref(['game-command', 'restart'])
|
||||
|
||||
const cronString = computed(() => generateCronString())
|
||||
|
||||
const isValidCron = computed(() => {
|
||||
return scheduleType.value === 'custom' ? validateCustomCron(customCron.value) : true
|
||||
})
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!data.value.title.trim()) return false
|
||||
|
||||
if (!isValidCron.value) return false
|
||||
|
||||
if (
|
||||
data.value.action_kind === 'game-command' &&
|
||||
(!data.value.options.command || !data.value.options.command.trim())
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (mode.value === 'new') return true
|
||||
|
||||
if (!originalData.value) return false
|
||||
|
||||
if (data.value.title !== originalData.value.title) return true
|
||||
if (data.value.action_kind !== originalData.value.action_kind) return true
|
||||
if (data.value.every !== originalData.value.every) return true
|
||||
if (data.value.warn_msg !== originalData.value.warn_msg) return true
|
||||
|
||||
if (
|
||||
data.value.action_kind === 'game-command' &&
|
||||
data.value.options.command !== originalData.value.options?.command
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const originalIntervals = originalData.value.warn_intervals || []
|
||||
const currentIntervals = data.value.warn_intervals || []
|
||||
|
||||
if (originalIntervals.length !== currentIntervals.length) return true
|
||||
|
||||
for (let i = 0; i < originalIntervals.length; i++) {
|
||||
if (originalIntervals[i] !== currentIntervals[i]) return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
watch(
|
||||
[scheduleType, dayInterval, selectedTime, customCron],
|
||||
() => {
|
||||
const newCron = generateCronString()
|
||||
data.value.every = newCron
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function resetData() {
|
||||
data.value = {
|
||||
title: '',
|
||||
action_kind: 'game-command',
|
||||
options: {},
|
||||
enabled: true,
|
||||
warn_msg: '',
|
||||
warn_intervals: [],
|
||||
every: '0 0 9 * * *',
|
||||
}
|
||||
scheduleType.value = 'daily'
|
||||
dayInterval.value = '1'
|
||||
selectedTime.value = { hour: '9', minute: '0' }
|
||||
customCron.value = '0 0 9 * * *'
|
||||
originalData.value = null
|
||||
}
|
||||
|
||||
function parseCronExpression(cron: string) {
|
||||
const parts = cron.trim().split(/\s+/)
|
||||
if (parts.length === 6) {
|
||||
const [_seconds, minutes, hours, dayOfMonth, month, dayOfWeek] = parts
|
||||
|
||||
if (dayOfMonth.startsWith('*/') && month === '*' && dayOfWeek === '*') {
|
||||
scheduleType.value = 'daily'
|
||||
dayInterval.value = dayOfMonth.substring(2)
|
||||
selectedTime.value = { hour: hours, minute: minutes }
|
||||
} else if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
||||
scheduleType.value = 'daily'
|
||||
dayInterval.value = '1'
|
||||
selectedTime.value = { hour: hours, minute: minutes }
|
||||
} else {
|
||||
scheduleType.value = 'custom'
|
||||
customCron.value = cron
|
||||
}
|
||||
} else {
|
||||
scheduleType.value = 'custom'
|
||||
customCron.value = cron
|
||||
}
|
||||
}
|
||||
|
||||
function generateCronString(): string {
|
||||
if (scheduleType.value === 'custom') {
|
||||
return customCron.value
|
||||
}
|
||||
|
||||
const minute = selectedTime.value.minute === '' ? '0' : selectedTime.value.minute
|
||||
const hour = selectedTime.value.hour === '' ? '0' : selectedTime.value.hour
|
||||
const days = dayInterval.value === '' ? '1' : dayInterval.value
|
||||
|
||||
if (days === '1') {
|
||||
return `0 ${minute} ${hour} * * *`
|
||||
} else {
|
||||
return `0 ${minute} ${hour} */${days} * *`
|
||||
}
|
||||
}
|
||||
|
||||
function getCronDescription(cronExpression: string): string {
|
||||
try {
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length === 6) {
|
||||
const fiveFieldCron = parts.slice(1).join(' ')
|
||||
return cronstrue.toString(fiveFieldCron, {
|
||||
throwExceptionOnParseError: false,
|
||||
verbose: false,
|
||||
use24HourTimeFormat: true,
|
||||
})
|
||||
}
|
||||
return cronstrue.toString(cronExpression, {
|
||||
throwExceptionOnParseError: false,
|
||||
verbose: false,
|
||||
use24HourTimeFormat: true,
|
||||
})
|
||||
} catch {
|
||||
return 'Invalid cron expression'
|
||||
}
|
||||
}
|
||||
|
||||
function handleDayIntervalChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const cleanValue = target.value.replace(/\D/g, '')
|
||||
if (cleanValue === '' || (parseInt(cleanValue) > 0 && parseInt(cleanValue) <= 365)) {
|
||||
dayInterval.value = cleanValue
|
||||
}
|
||||
}
|
||||
|
||||
function validateCustomCron(cron: string): boolean {
|
||||
const parts = cron.trim().split(/\s+/)
|
||||
return parts.length === 6
|
||||
}
|
||||
|
||||
function addWarningInterval() {
|
||||
const interval = parseInt(newInterval.value)
|
||||
if (interval > 0 && data.value.warn_intervals && data.value.warn_intervals.length < 6) {
|
||||
data.value.warn_intervals = [...(data.value.warn_intervals || []), interval].sort(
|
||||
(a, b) => b - a,
|
||||
)
|
||||
newInterval.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeWarningInterval(index: number) {
|
||||
data.value.warn_intervals = data.value.warn_intervals?.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
function formatInterval(seconds: number) {
|
||||
if (seconds >= 60) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function getActionTypeDescription(actionType: string): string {
|
||||
switch (actionType) {
|
||||
case 'game-command':
|
||||
return 'Execute a custom command in the game server'
|
||||
case 'restart':
|
||||
return 'Restart the game server'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
const cleanData = {
|
||||
...data.value,
|
||||
options: data.value.action_kind === 'restart' ? {} : data.value.options,
|
||||
warn_msg: data.value.warn_msg || undefined,
|
||||
warn_intervals: data.value.warn_intervals?.length ? data.value.warn_intervals : undefined,
|
||||
}
|
||||
|
||||
emit('save', cleanData)
|
||||
modal.value?.hide()
|
||||
} catch (error) {
|
||||
console.error('Failed to save schedule:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showNew(event?: Event) {
|
||||
mode.value = 'new'
|
||||
resetData()
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function show(task: ScheduledTask, event?: Event) {
|
||||
mode.value = 'edit'
|
||||
|
||||
originalData.value = JSON.parse(JSON.stringify(task))
|
||||
|
||||
data.value = {
|
||||
title: task.title,
|
||||
action_kind: task.action_kind,
|
||||
options: { ...task.options },
|
||||
enabled: task.enabled,
|
||||
warn_msg: task.warn_msg || '',
|
||||
warn_intervals: task.warn_intervals || [],
|
||||
every: task.every,
|
||||
}
|
||||
|
||||
parseCronExpression(task.every)
|
||||
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showNew,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.schedule-modal {
|
||||
:deep(.modal-content) {
|
||||
min-width: 600px;
|
||||
max-width: 800px;
|
||||
width: 90vw;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--color-raised-bg);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.schedule-card-container {
|
||||
min-height: 200px;
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-intervals-container {
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
|
||||
.warning-intervals-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.action-type-description {
|
||||
font-style: italic;
|
||||
min-height: 1.25rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textarea-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex.flex-col.gap-2,
|
||||
.flex.flex-col.gap-3,
|
||||
.flex.flex-col.gap-4 {
|
||||
min-height: fit-content;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user