mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-02 21:19:05 +00:00
309 lines
8.4 KiB
Svelte
309 lines
8.4 KiB
Svelte
<script lang="ts">
|
|
import * as Select from '$lib/components/ui/select';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import { Calendar, CalendarDays, Clock } from 'lucide-svelte';
|
|
import { appSettings } from '$lib/stores/settings';
|
|
import cronstrue from 'cronstrue';
|
|
|
|
// Reactive time format from settings
|
|
let is12Hour = $derived($appSettings.timeFormat === '12h');
|
|
|
|
interface Props {
|
|
value: string;
|
|
onchange: (cron: string) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
let { value, onchange, disabled = false }: Props = $props();
|
|
|
|
// Detect schedule type from cron expression
|
|
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
|
|
const parts = cron.split(' ');
|
|
if (parts.length < 5) return 'custom';
|
|
|
|
const [, , day, month, dow] = parts;
|
|
|
|
// Weekly: specific day of week (0-6), day and month are wildcards
|
|
if (dow !== '*' && day === '*' && month === '*') {
|
|
return 'weekly';
|
|
}
|
|
|
|
// Daily: all wildcards except minute and hour
|
|
if (day === '*' && month === '*' && dow === '*') {
|
|
return 'daily';
|
|
}
|
|
|
|
return 'custom';
|
|
}
|
|
|
|
// Parse cron into components for UI
|
|
let minute = $state('0');
|
|
let hour = $state('3');
|
|
let dayOfWeek = $state('1'); // Monday
|
|
let scheduleType = $state<'daily' | 'weekly' | 'custom'>('daily');
|
|
|
|
// Track if component has been initialized
|
|
let initialized = $state(false);
|
|
let previousScheduleType = $state<'daily' | 'weekly' | 'custom'>('daily');
|
|
let isTypingCustom = $state(false); // Track if user is actively typing in custom mode
|
|
|
|
// Update UI when value (cron expression) changes externally
|
|
$effect(() => {
|
|
if (value) {
|
|
const parts = value.split(' ');
|
|
if (parts.length >= 5) {
|
|
minute = parts[0] || '0';
|
|
hour = parts[1] || '3';
|
|
dayOfWeek = parts[4] !== '*' ? parts[4] : '1'; // Default to Monday
|
|
|
|
// Only update schedule type if not actively typing in custom mode
|
|
if (!isTypingCustom) {
|
|
scheduleType = detectScheduleType(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark as initialized after first parse
|
|
if (!initialized) {
|
|
initialized = true;
|
|
previousScheduleType = scheduleType;
|
|
}
|
|
});
|
|
|
|
// Generate cron expression from UI inputs
|
|
function updateCronExpression() {
|
|
let newCron = '';
|
|
|
|
if (scheduleType === 'daily') {
|
|
newCron = `${minute} ${hour} * * *`;
|
|
} else if (scheduleType === 'weekly') {
|
|
newCron = `${minute} ${hour} * * ${dayOfWeek}`;
|
|
} else {
|
|
// For custom, keep the current value
|
|
return;
|
|
}
|
|
|
|
onchange(newCron);
|
|
}
|
|
|
|
// Handle schedule type change
|
|
function handleScheduleTypeChange(newType: string) {
|
|
const type = newType as 'daily' | 'weekly' | 'custom';
|
|
scheduleType = type;
|
|
|
|
// Set flag when switching to custom mode
|
|
if (type === 'custom') {
|
|
isTypingCustom = true;
|
|
} else {
|
|
isTypingCustom = false;
|
|
}
|
|
|
|
// Only reset to defaults if schedule type actually changed after initialization
|
|
if (initialized && type !== previousScheduleType) {
|
|
if (type === 'daily') {
|
|
minute = '0';
|
|
hour = '3';
|
|
onchange('0 3 * * *');
|
|
} else if (type === 'weekly') {
|
|
minute = '0';
|
|
hour = '3';
|
|
dayOfWeek = '1'; // Monday
|
|
onchange('0 3 * * 1');
|
|
}
|
|
previousScheduleType = type;
|
|
}
|
|
}
|
|
|
|
function handleMinuteChange(value: string) {
|
|
minute = value;
|
|
updateCronExpression();
|
|
}
|
|
|
|
function handleHourChange(value: string) {
|
|
hour = value;
|
|
updateCronExpression();
|
|
}
|
|
|
|
function handleDayOfWeekChange(value: string) {
|
|
dayOfWeek = value;
|
|
updateCronExpression();
|
|
}
|
|
|
|
function handleCustomCronInput(e: Event) {
|
|
const newValue = (e.currentTarget as HTMLInputElement).value;
|
|
onchange(newValue);
|
|
}
|
|
|
|
// Validate cron expression
|
|
function isValidCron(cron: string): boolean {
|
|
const parts = cron.trim().split(/\s+/);
|
|
if (parts.length !== 5) return false;
|
|
|
|
const [min, hr, day, month, dow] = parts;
|
|
|
|
// Basic pattern validation (number, *, */n, range, list)
|
|
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
|
|
|
|
return (
|
|
cronFieldPattern.test(min) &&
|
|
cronFieldPattern.test(hr) &&
|
|
cronFieldPattern.test(day) &&
|
|
cronFieldPattern.test(month) &&
|
|
cronFieldPattern.test(dow)
|
|
);
|
|
}
|
|
|
|
// Human-readable description using cronstrue
|
|
let humanReadable = $derived(() => {
|
|
if (!value) return '';
|
|
if (!value.trim()) return '';
|
|
|
|
// Validate first
|
|
if (!isValidCron(value)) {
|
|
return 'Invalid';
|
|
}
|
|
|
|
try {
|
|
// Use cronstrue to parse the cron expression
|
|
// Configure it to use the user's time format preference
|
|
const description = cronstrue.toString(value, {
|
|
use24HourTimeFormat: !is12Hour,
|
|
throwExceptionOnParseError: true,
|
|
locale: 'en' // You can add user locale preference here if needed
|
|
});
|
|
return description;
|
|
} catch (error) {
|
|
return 'Invalid';
|
|
}
|
|
});
|
|
|
|
// Generate hours array based on time format preference
|
|
const hours = $derived(
|
|
Array.from({ length: 24 }, (_, i) => ({
|
|
value: String(i),
|
|
label: is12Hour
|
|
? i === 0 ? '12 AM' : i < 12 ? `${i} AM` : i === 12 ? '12 PM' : `${i - 12} PM`
|
|
: i.toString().padStart(2, '0') + ':00'
|
|
}))
|
|
);
|
|
|
|
const minutes = [
|
|
{ value: '0', label: ':00' },
|
|
{ value: '15', label: ':15' },
|
|
{ value: '30', label: ':30' },
|
|
{ value: '45', label: ':45' }
|
|
];
|
|
|
|
const daysOfWeek = [
|
|
{ value: '1', label: 'Monday' },
|
|
{ value: '2', label: 'Tuesday' },
|
|
{ value: '3', label: 'Wednesday' },
|
|
{ value: '4', label: 'Thursday' },
|
|
{ value: '5', label: 'Friday' },
|
|
{ value: '6', label: 'Saturday' },
|
|
{ value: '0', label: 'Sunday' }
|
|
];
|
|
</script>
|
|
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<!-- Schedule Type Selector -->
|
|
<Select.Root type="single" value={scheduleType} onValueChange={handleScheduleTypeChange} {disabled}>
|
|
<Select.Trigger class="w-[140px] h-9">
|
|
<div class="flex items-center gap-2">
|
|
{#if scheduleType === 'daily'}
|
|
<Calendar class="w-4 h-4" />
|
|
<span>Daily</span>
|
|
{:else if scheduleType === 'weekly'}
|
|
<CalendarDays class="w-4 h-4" />
|
|
<span>Weekly</span>
|
|
{:else}
|
|
<Clock class="w-4 h-4" />
|
|
<span>Custom</span>
|
|
{/if}
|
|
</div>
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
<Select.Item value="daily">
|
|
<div class="flex items-center gap-2">
|
|
<Calendar class="w-4 h-4" />
|
|
<span>Daily</span>
|
|
</div>
|
|
</Select.Item>
|
|
<Select.Item value="weekly">
|
|
<div class="flex items-center gap-2">
|
|
<CalendarDays class="w-4 h-4" />
|
|
<span>Weekly</span>
|
|
</div>
|
|
</Select.Item>
|
|
<Select.Item value="custom">
|
|
<div class="flex items-center gap-2">
|
|
<Clock class="w-4 h-4" />
|
|
<span>Custom</span>
|
|
</div>
|
|
</Select.Item>
|
|
</Select.Content>
|
|
</Select.Root>
|
|
|
|
{#if scheduleType === 'daily' || scheduleType === 'weekly'}
|
|
<!-- Time Selectors -->
|
|
<span class="text-sm text-muted-foreground">at</span>
|
|
<Select.Root type="single" value={hour} onValueChange={handleHourChange} {disabled}>
|
|
<Select.Trigger class="w-[100px] h-9">
|
|
<span>{hours.find((h: { value: string; label: string }) => h.value === hour)?.label || hour}</span>
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{#each hours as h}
|
|
<Select.Item value={h.value} label={h.label} />
|
|
{/each}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
<Select.Root type="single" value={minute} onValueChange={handleMinuteChange} {disabled}>
|
|
<Select.Trigger class="w-[70px] h-9">
|
|
<span>{minutes.find(m => m.value === minute)?.label || `:${minute}`}</span>
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{#each minutes as m}
|
|
<Select.Item value={m.value} label={m.label} />
|
|
{/each}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
|
|
{#if scheduleType === 'weekly'}
|
|
<span class="text-sm text-muted-foreground">on</span>
|
|
<Select.Root type="single" value={dayOfWeek} onValueChange={handleDayOfWeekChange} {disabled}>
|
|
<Select.Trigger class="w-[110px] h-9">
|
|
<span>{daysOfWeek.find(d => d.value === dayOfWeek)?.label || dayOfWeek}</span>
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{#each daysOfWeek as d}
|
|
<Select.Item value={d.value} label={d.label} />
|
|
{/each}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
{/if}
|
|
|
|
{:else}
|
|
<!-- Custom cron input -->
|
|
{@const readable = humanReadable()}
|
|
{@const isInvalid = readable === 'Invalid'}
|
|
<Input
|
|
value={value}
|
|
oninput={handleCustomCronInput}
|
|
placeholder="0 3 * * *"
|
|
class="h-9 font-mono flex-1 min-w-[200px] {isInvalid ? 'border-destructive focus-visible:ring-destructive' : ''}"
|
|
{disabled}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Description area with fixed height -->
|
|
<div class="min-h-[20px] mt-1">
|
|
{#if value}
|
|
{@const readable = humanReadable()}
|
|
{@const isInvalid = readable === 'Invalid'}
|
|
<p class="text-xs {isInvalid ? 'text-destructive' : 'text-muted-foreground'}">
|
|
{readable}
|
|
</p>
|
|
{/if}
|
|
</div>
|