feat: add Cron Editor tool
Visual cron expression editor with field-by-field builder, presets select, human-readable description, and live schedule preview showing next occurrences. Registered in tools registry with CronIcon. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
370
components/cron/CronEditor.tsx
Normal file
370
components/cron/CronEditor.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Copy, Check, BookmarkPlus, Clock, Trash2, ChevronRight,
|
||||
AlertCircle, CalendarClock,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { cardBtn } from '@/lib/utils/styles';
|
||||
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
||||
import { CronFieldEditor } from './CronFieldEditor';
|
||||
import { CronPresets } from './CronPresets';
|
||||
import { useCronStore } from '@/lib/cron/store';
|
||||
import {
|
||||
FIELD_CONFIGS,
|
||||
splitCronFields,
|
||||
buildCronExpression,
|
||||
describeCronExpression,
|
||||
validateCronExpression,
|
||||
getNextOccurrences,
|
||||
type FieldType,
|
||||
type CronFields,
|
||||
} from '@/lib/cron/cron-engine';
|
||||
|
||||
const FIELD_ORDER: FieldType[] = ['minute', 'hour', 'dom', 'month', 'dow'];
|
||||
|
||||
function getFieldValue(fields: CronFields, type: FieldType): string {
|
||||
switch (type) {
|
||||
case 'minute': return fields.minute;
|
||||
case 'hour': return fields.hour;
|
||||
case 'dom': return fields.dom;
|
||||
case 'month': return fields.month;
|
||||
case 'dow': return fields.dow;
|
||||
case 'second': return fields.second ?? '*';
|
||||
}
|
||||
}
|
||||
|
||||
function formatOccurrence(d: Date): { relative: string; absolute: string; dow: string } {
|
||||
const now = new Date();
|
||||
const diffMs = d.getTime() - now.getTime();
|
||||
const diffMins = Math.round(diffMs / 60_000);
|
||||
const diffH = Math.round(diffMs / 3_600_000);
|
||||
const diffD = Math.round(diffMs / 86_400_000);
|
||||
|
||||
let relative: string;
|
||||
if (diffMins < 60) relative = `in ${diffMins}m`;
|
||||
else if (diffH < 24) relative = `in ${diffH}h`;
|
||||
else if (diffD === 1) relative = 'tomorrow';
|
||||
else relative = `in ${diffD}d`;
|
||||
|
||||
const absolute = d.toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: 'numeric', minute: '2-digit', hour12: true,
|
||||
});
|
||||
const dow = d.toLocaleDateString('en-US', { weekday: 'short' });
|
||||
return { relative, absolute, dow };
|
||||
}
|
||||
|
||||
// ── Schedule list ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ScheduleList({ schedule, isValid }: { schedule: Date[]; isValid: boolean }) {
|
||||
if (!isValid) return (
|
||||
<p className="text-xs text-muted-foreground/40 text-center py-8 font-mono">
|
||||
Fix the expression to see upcoming runs
|
||||
</p>
|
||||
);
|
||||
if (schedule.length === 0) return (
|
||||
<p className="text-xs text-muted-foreground/40 text-center py-8 font-mono">
|
||||
No occurrences in the next 5 years
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{schedule.map((d, i) => {
|
||||
const { relative, absolute, dow } = formatOccurrence(d);
|
||||
const isFirst = i === 0;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 py-2.5 border-b border-border/10 last:border-0',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'font-mono text-[10px] px-1.5 py-0.5 rounded border shrink-0 w-[36px] text-center',
|
||||
isFirst
|
||||
? 'bg-primary/20 text-primary border-primary/30'
|
||||
: 'bg-muted/15 text-muted-foreground/50 border-border/10',
|
||||
)}>
|
||||
{dow}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-xs font-mono flex-1',
|
||||
isFirst ? 'text-foreground font-medium' : 'text-muted-foreground',
|
||||
)}>
|
||||
{absolute}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground/35 shrink-0">
|
||||
{relative}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CronEditor() {
|
||||
const { expression, setExpression, addToHistory, history, removeFromHistory, clearHistory } =
|
||||
useCronStore();
|
||||
|
||||
const [activeField, setActiveField] = useState<FieldType>('minute');
|
||||
const [mobileTab, setMobileTab] = useState<'editor' | 'preview'>('editor');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [editingRaw, setEditingRaw] = useState(false);
|
||||
const [rawExpr, setRawExpr] = useState('');
|
||||
const rawInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isValid = useMemo(() => validateCronExpression(expression).valid, [expression]);
|
||||
const fields = useMemo(() => splitCronFields(expression), [expression]);
|
||||
const description = useMemo(() => describeCronExpression(expression), [expression]);
|
||||
const schedule = useMemo(
|
||||
() => (isValid ? getNextOccurrences(expression, 10) : []),
|
||||
[expression, isValid],
|
||||
);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(type: FieldType, value: string) => {
|
||||
if (!fields) return;
|
||||
const updated: CronFields = { ...fields, [type]: value };
|
||||
setExpression(buildCronExpression(updated));
|
||||
},
|
||||
[fields, setExpression],
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(expression);
|
||||
setCopied(true);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
addToHistory(expression);
|
||||
toast.success('Saved to history');
|
||||
};
|
||||
|
||||
const handleRawKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (validateCronExpression(rawExpr).valid) setExpression(rawExpr);
|
||||
setEditingRaw(false);
|
||||
}
|
||||
if (e.key === 'Escape') setEditingRaw(false);
|
||||
};
|
||||
|
||||
const startEditRaw = () => {
|
||||
setRawExpr(expression);
|
||||
setEditingRaw(true);
|
||||
setTimeout(() => rawInputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
// ── Expression bar (rendered inside right panel) ──────────────────────────
|
||||
const expressionBar = (
|
||||
<div className="glass rounded-xl border border-border/40 p-4">
|
||||
{/* Row 1: Field chips + actions */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-3">
|
||||
{FIELD_ORDER.map((type) => {
|
||||
const active = activeField === type;
|
||||
const fValue = fields ? getFieldValue(fields, type) : '*';
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => { setActiveField(type); setMobileTab('editor'); }}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2 py-1 rounded-md border transition-all',
|
||||
active
|
||||
? 'bg-primary/15 border-primary/50 shadow-[0_0_8px_rgba(139,92,246,0.2)]'
|
||||
: 'glass border-border/25 hover:border-primary/30 hover:bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-[8px] font-mono uppercase tracking-[0.1em]',
|
||||
active ? 'text-primary/60' : 'text-muted-foreground/40',
|
||||
)}>
|
||||
{FIELD_CONFIGS[type].shortLabel}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'font-mono text-[10px] font-semibold',
|
||||
active ? 'text-primary' : fValue === '*' ? 'text-muted-foreground/50' : 'text-foreground',
|
||||
)}>
|
||||
{fValue}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button onClick={handleCopy} className={cardBtn}>
|
||||
{copied
|
||||
? <><Check className="w-3 h-3" /> Copied</>
|
||||
: <><Copy className="w-3 h-3" /> Copy</>}
|
||||
</button>
|
||||
<button onClick={handleSave} className={cardBtn}>
|
||||
<BookmarkPlus className="w-3 h-3" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Expression + description (stacked on mobile, inline on lg) */}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-text font-mono text-sm tracking-[0.15em] rounded px-1 -mx-1 py-0.5 transition-colors w-full',
|
||||
!editingRaw && 'hover:bg-white/3',
|
||||
!isValid && !editingRaw && 'text-destructive/70',
|
||||
)}
|
||||
onClick={!editingRaw ? startEditRaw : undefined}
|
||||
>
|
||||
{editingRaw ? (
|
||||
<input
|
||||
ref={rawInputRef}
|
||||
value={rawExpr}
|
||||
onChange={(e) => setRawExpr(e.target.value)}
|
||||
onKeyDown={handleRawKey}
|
||||
onBlur={() => setEditingRaw(false)}
|
||||
className={cn(
|
||||
'w-full bg-transparent font-mono text-sm tracking-[0.15em] focus:outline-none',
|
||||
validateCronExpression(rawExpr).valid ? 'text-foreground' : 'text-destructive/80',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
expression
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isValid
|
||||
? <CalendarClock className="w-3 h-3 text-muted-foreground/30 shrink-0" />
|
||||
: <AlertCircle className="w-3 h-3 text-destructive/50 shrink-0" />}
|
||||
<p className={cn(
|
||||
'text-xs truncate',
|
||||
isValid ? 'text-muted-foreground' : 'text-destructive/60',
|
||||
)}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Presets select */}
|
||||
<div className="mt-3 pt-3 border-t border-border/10">
|
||||
<CronPresets onSelect={(expr) => setExpression(expr)} current={expression} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 animate-fade-in">
|
||||
|
||||
{/* ── Mobile tabs ─────────────────────────────────────────────────── */}
|
||||
<MobileTabs
|
||||
tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
|
||||
active={mobileTab}
|
||||
onChange={(v) => setMobileTab(v as 'editor' | 'preview')}
|
||||
/>
|
||||
|
||||
{/* ── Main content ────────────────────────────────────────────────── */}
|
||||
<div className="grid lg:grid-cols-5 gap-4">
|
||||
|
||||
{/* Left: Field editor + Presets ──────────────────────────────── */}
|
||||
<div className={cn(
|
||||
'lg:col-span-3 flex flex-col gap-4',
|
||||
mobileTab === 'preview' && 'hidden lg:flex',
|
||||
)}>
|
||||
{/* Field selector tabs */}
|
||||
<div className="flex glass rounded-lg p-0.5 gap-0.5">
|
||||
{FIELD_ORDER.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setActiveField(type)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
|
||||
activeField === type
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{FIELD_CONFIGS[type].shortLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Field editor panel */}
|
||||
<div className="glass rounded-xl p-5 border border-border/40">
|
||||
{fields ? (
|
||||
<CronFieldEditor
|
||||
fieldType={activeField}
|
||||
value={getFieldValue(fields, activeField)}
|
||||
onChange={(v) => handleFieldChange(activeField, v)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Invalid expression — fix it above to edit fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Saved history */}
|
||||
{history.length > 0 && (
|
||||
<div className="glass rounded-xl p-4 border border-border/40">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[9px] font-mono text-muted-foreground/40 uppercase tracking-widest">
|
||||
Saved
|
||||
</span>
|
||||
<button onClick={clearHistory} className={cardBtn}>
|
||||
<Trash2 className="w-2.5 h-2.5" /> Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{history.slice(0, 8).map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2 group">
|
||||
<button
|
||||
onClick={() => setExpression(entry.expression)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all text-left',
|
||||
entry.expression === expression
|
||||
? 'bg-primary/10 border-primary/30 text-primary'
|
||||
: 'glass border-border/20 text-muted-foreground hover:border-primary/30 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{entry.expression === expression && <ChevronRight className="w-3 h-3 shrink-0" />}
|
||||
<span className="font-mono text-xs truncate">{entry.expression}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeFromHistory(entry.id)}
|
||||
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center text-muted-foreground/40 hover:text-destructive transition-all rounded"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Expression bar + Schedule preview ───────────────────── */}
|
||||
<div className={cn(
|
||||
'lg:col-span-2 flex flex-col gap-4',
|
||||
mobileTab === 'editor' && 'hidden lg:flex',
|
||||
)}>
|
||||
{expressionBar}
|
||||
|
||||
<div className="glass rounded-xl p-4 border border-border/40">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground/40" />
|
||||
<span className="text-[9px] font-mono text-muted-foreground/50 uppercase tracking-widest">
|
||||
Next Occurrences
|
||||
</span>
|
||||
</div>
|
||||
<ScheduleList schedule={schedule} isValid={isValid} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
components/cron/CronFieldEditor.tsx
Normal file
262
components/cron/CronFieldEditor.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import {
|
||||
parseField,
|
||||
rebuildFieldFromValues,
|
||||
validateCronField,
|
||||
FIELD_CONFIGS,
|
||||
MONTH_SHORT_NAMES,
|
||||
DOW_SHORT_NAMES,
|
||||
type FieldType,
|
||||
} from '@/lib/cron/cron-engine';
|
||||
|
||||
// ── Per-field presets ─────────────────────────────────────────────────────────
|
||||
|
||||
interface Preset { label: string; value: string }
|
||||
|
||||
const FIELD_PRESETS: Record<FieldType, Preset[]> = {
|
||||
second: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: '*/5', value: '*/5' },
|
||||
{ label: '*/10', value: '*/10' },
|
||||
{ label: '*/15', value: '*/15' },
|
||||
{ label: '*/30', value: '*/30' },
|
||||
],
|
||||
minute: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: ':00', value: '0' },
|
||||
{ label: ':30', value: '30' },
|
||||
{ label: '*/5', value: '*/5' },
|
||||
{ label: '*/10', value: '*/10' },
|
||||
{ label: '*/15', value: '*/15' },
|
||||
{ label: '*/30', value: '*/30' },
|
||||
],
|
||||
hour: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: 'Midnight', value: '0' },
|
||||
{ label: '6 AM', value: '6' },
|
||||
{ label: '9 AM', value: '9' },
|
||||
{ label: 'Noon', value: '12' },
|
||||
{ label: '6 PM', value: '18' },
|
||||
{ label: 'Every 4h', value: '*/4' },
|
||||
{ label: 'Every 6h', value: '*/6' },
|
||||
{ label: '9–17', value: '9-17' },
|
||||
],
|
||||
dom: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: '1st', value: '1' },
|
||||
{ label: '10th', value: '10' },
|
||||
{ label: '15th', value: '15' },
|
||||
{ label: '20th', value: '20' },
|
||||
{ label: '1,15', value: '1,15' },
|
||||
{ label: '1–7', value: '1-7' },
|
||||
],
|
||||
month: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: 'Q1', value: '1-3' },
|
||||
{ label: 'Q2', value: '4-6' },
|
||||
{ label: 'Q3', value: '7-9' },
|
||||
{ label: 'Q4', value: '10-12' },
|
||||
{ label: 'H1', value: '1-6' },
|
||||
{ label: 'H2', value: '7-12' },
|
||||
],
|
||||
dow: [
|
||||
{ label: 'Any (*)', value: '*' },
|
||||
{ label: 'Weekdays', value: '1-5' },
|
||||
{ label: 'Weekends', value: '0,6' },
|
||||
{ label: 'Mon', value: '1' },
|
||||
{ label: 'Wed', value: '3' },
|
||||
{ label: 'Fri', value: '5' },
|
||||
{ label: 'Sun', value: '0' },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Grid configuration ────────────────────────────────────────────────────────
|
||||
|
||||
const GRID_COLS: Record<FieldType, string> = {
|
||||
second: 'grid-cols-10',
|
||||
minute: 'grid-cols-10',
|
||||
hour: 'grid-cols-8',
|
||||
dom: 'grid-cols-7',
|
||||
month: 'grid-cols-4',
|
||||
dow: 'grid-cols-7',
|
||||
};
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CronFieldEditorProps {
|
||||
fieldType: FieldType;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function CronFieldEditor({ fieldType, value, onChange }: CronFieldEditorProps) {
|
||||
const [rawInput, setRawInput] = useState('');
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [rawError, setRawError] = useState('');
|
||||
|
||||
const config = FIELD_CONFIGS[fieldType];
|
||||
const parsed = useMemo(() => parseField(value, config), [value, config]);
|
||||
const presets = FIELD_PRESETS[fieldType];
|
||||
|
||||
const isWildcard = parsed?.isWildcard ?? false;
|
||||
const isSelected = (v: number) => parsed?.values.has(v) ?? false;
|
||||
|
||||
const cellLabel = (v: number): string => {
|
||||
if (fieldType === 'month') return MONTH_SHORT_NAMES[v - 1];
|
||||
if (fieldType === 'dow') return DOW_SHORT_NAMES[v];
|
||||
return String(v).padStart(fieldType === 'second' || fieldType === 'minute' ? 2 : 1, '0');
|
||||
};
|
||||
|
||||
const handleCellClick = (v: number) => {
|
||||
if (!parsed) return;
|
||||
if (isWildcard) { onChange(String(v)); return; }
|
||||
const next = new Set(parsed.values);
|
||||
if (next.has(v)) {
|
||||
next.delete(v);
|
||||
if (next.size === 0) { onChange('*'); return; }
|
||||
} else {
|
||||
next.add(v);
|
||||
if (next.size === config.max - config.min + 1) { onChange('*'); return; }
|
||||
}
|
||||
onChange(rebuildFieldFromValues(next, config));
|
||||
};
|
||||
|
||||
const handleRawSubmit = () => {
|
||||
const { valid, error } = validateCronField(rawInput, fieldType);
|
||||
if (valid) {
|
||||
onChange(rawInput);
|
||||
setShowRaw(false);
|
||||
setRawInput('');
|
||||
setRawError('');
|
||||
} else {
|
||||
setRawError(error ?? 'Invalid');
|
||||
}
|
||||
};
|
||||
|
||||
const cells = Array.from({ length: config.max - config.min + 1 }, (_, i) => i + config.min);
|
||||
// Pad to complete rows for DOM (31 cells, 7 cols → pad to 35)
|
||||
const colCount = parseInt(GRID_COLS[fieldType].replace('grid-cols-', ''), 10);
|
||||
const rem = cells.length % colCount;
|
||||
const padded: (number | null)[] = [...cells, ...(rem === 0 ? [] : Array<null>(colCount - rem).fill(null))];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50 font-mono">
|
||||
{config.min}–{config.max}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWildcard && (
|
||||
<span className="text-[10px] font-mono text-primary/60 bg-primary/5 px-2 py-0.5 rounded border border-primary/15">
|
||||
any value
|
||||
</span>
|
||||
)}
|
||||
<span className="font-mono text-sm text-primary bg-primary/10 px-2.5 py-1 rounded-lg border border-primary/25">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => onChange(preset.value)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 text-[11px] font-mono rounded-lg border transition-all',
|
||||
value === preset.value
|
||||
? 'bg-primary/20 border-primary/50 text-primary shadow-[0_0_8px_rgba(139,92,246,0.2)]'
|
||||
: 'glass border-border/30 text-muted-foreground hover:border-primary/40 hover:text-foreground hover:bg-primary/5',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Value grid */}
|
||||
<div className={cn('grid gap-1', GRID_COLS[fieldType])}>
|
||||
{padded.map((v, i) => {
|
||||
if (v === null) return <div key={`pad-${i}`} />;
|
||||
const selected = isSelected(v);
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => handleCellClick(v)}
|
||||
title={fieldType === 'month' ? MONTH_SHORT_NAMES[v - 1] : fieldType === 'dow' ? DOW_SHORT_NAMES[v] : String(v)}
|
||||
className={cn(
|
||||
'flex items-center justify-center text-[10px] font-mono rounded-md border transition-all',
|
||||
fieldType === 'month' || fieldType === 'dow'
|
||||
? 'py-2 px-1'
|
||||
: 'aspect-square',
|
||||
isWildcard
|
||||
? 'bg-primary/8 border-primary/20 text-primary/50 hover:bg-primary/15 hover:border-primary/40 hover:text-primary'
|
||||
: selected
|
||||
? 'bg-primary/25 border-primary/55 text-primary font-semibold shadow-[0_0_6px_rgba(139,92,246,0.25)]'
|
||||
: 'glass border-border/20 text-muted-foreground/50 hover:border-primary/35 hover:text-foreground hover:bg-primary/5',
|
||||
)}
|
||||
>
|
||||
{cellLabel(v)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom raw input */}
|
||||
<div className="pt-1 border-t border-border/10">
|
||||
{showRaw ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={rawInput}
|
||||
onChange={(e) => { setRawInput(e.target.value); setRawError(''); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRawSubmit();
|
||||
if (e.key === 'Escape') { setShowRaw(false); setRawError(''); }
|
||||
}}
|
||||
placeholder={`e.g. ${fieldType === 'minute' ? '*/15 or 0,30' : fieldType === 'hour' ? '9-17' : fieldType === 'dow' ? '1-5' : '*'}`}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-1.5 text-xs font-mono bg-muted/20 border rounded-lg focus:outline-none transition-colors',
|
||||
rawError ? 'border-destructive/50 focus:border-destructive' : 'border-border/30 focus:border-primary/50',
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleRawSubmit}
|
||||
className="px-3 py-1.5 text-xs font-mono bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-all"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowRaw(false); setRawError(''); }}
|
||||
className="px-3 py-1.5 text-xs font-mono glass border-border/30 text-muted-foreground rounded-lg hover:text-foreground transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{rawError && (
|
||||
<p className="text-[10px] text-destructive font-mono">{rawError}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setRawInput(value); setShowRaw(true); }}
|
||||
className="text-[11px] font-mono text-muted-foreground/40 hover:text-primary/70 transition-colors"
|
||||
>
|
||||
Enter custom expression →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
components/cron/CronPresets.tsx
Normal file
91
components/cron/CronPresets.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
interface Preset {
|
||||
label: string;
|
||||
expr: string;
|
||||
}
|
||||
|
||||
interface PresetGroup {
|
||||
label: string;
|
||||
items: Preset[];
|
||||
}
|
||||
|
||||
const PRESET_GROUPS: PresetGroup[] = [
|
||||
{
|
||||
label: 'Common',
|
||||
items: [
|
||||
{ label: 'Every minute', expr: '* * * * *' },
|
||||
{ label: 'Every 5 min', expr: '*/5 * * * *' },
|
||||
{ label: 'Every 15 min', expr: '*/15 * * * *' },
|
||||
{ label: 'Every 30 min', expr: '*/30 * * * *' },
|
||||
{ label: 'Every hour', expr: '0 * * * *' },
|
||||
{ label: 'Every 6 hours', expr: '0 */6 * * *' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Daily',
|
||||
items: [
|
||||
{ label: 'Midnight', expr: '0 0 * * *' },
|
||||
{ label: '6 AM', expr: '0 6 * * *' },
|
||||
{ label: '9 AM', expr: '0 9 * * *' },
|
||||
{ label: 'Noon', expr: '0 12 * * *' },
|
||||
{ label: 'Twice daily', expr: '0 6,18 * * *' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Weekly',
|
||||
items: [
|
||||
{ label: 'Weekdays 9 AM', expr: '0 9 * * 1-5' },
|
||||
{ label: 'Monday 9 AM', expr: '0 9 * * 1' },
|
||||
{ label: 'Friday 5 PM', expr: '0 17 * * 5' },
|
||||
{ label: 'Sunday 0 AM', expr: '0 0 * * 0' },
|
||||
{ label: 'Weekends 9 AM', expr: '0 9 * * 0,6' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Periodic',
|
||||
items: [
|
||||
{ label: 'Monthly 1st', expr: '0 0 1 * *' },
|
||||
{ label: '1st & 15th', expr: '0 0 1,15 * *' },
|
||||
{ label: 'Quarterly', expr: '0 0 1 */3 *' },
|
||||
{ label: 'Bi-annual', expr: '0 0 1 1,7 *' },
|
||||
{ label: 'January 1st', expr: '0 0 1 1 *' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface CronPresetsProps {
|
||||
onSelect: (expr: string) => void;
|
||||
current: string;
|
||||
}
|
||||
|
||||
export function CronPresets({ onSelect, current }: CronPresetsProps) {
|
||||
const allExprs = PRESET_GROUPS.flatMap(g => g.items.map(i => i.expr));
|
||||
const isPreset = allExprs.includes(current);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={isPreset ? current : ''}
|
||||
onChange={(e) => { if (e.target.value) onSelect(e.target.value); }}
|
||||
className="w-full appearance-none bg-muted/20 border border-border/30 rounded-lg px-3 py-1.5 pr-8 text-xs font-mono text-muted-foreground focus:border-primary/50 focus:outline-none transition-colors cursor-pointer hover:border-border/50"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{isPreset ? '' : 'Quick preset…'}
|
||||
</option>
|
||||
{PRESET_GROUPS.map((group) => (
|
||||
<optgroup key={group.label} label={group.label}>
|
||||
{group.items.map((preset) => (
|
||||
<option key={preset.expr} value={preset.expr}>
|
||||
{preset.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3 h-3 pointer-events-none text-muted-foreground/40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user