mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-05 13:20:57 +00:00
Initial commit
This commit is contained in:
308
lib/components/cron-editor.svelte
Normal file
308
lib/components/cron-editor.svelte
Normal file
@@ -0,0 +1,308 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user