Compare commits

...

3 Commits

Author SHA1 Message Date
94919878ea fix: fix issues 2025-07-02 01:33:32 +08:00
81bf27ed28 fix: fix 2025-07-08 00:54:34 +08:00
5be5b9f634 fix: fix cron builder 2025-07-07 01:34:56 +08:00
45 changed files with 2089 additions and 1239 deletions

View File

@ -41,4 +41,4 @@
],
"rust-analyzer.cargo.features": "all",
"rust-analyzer.testExplorer": true
}
}

52
Cargo.lock generated
View File

@ -551,7 +551,7 @@ dependencies = [
"derive_builder",
"diligent-date-parser",
"never",
"quick-xml",
"quick-xml 0.37.5",
"serde",
]
@ -1260,9 +1260,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.40"
version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
dependencies = [
"clap_builder",
"clap_derive",
@ -1270,9 +1270,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.40"
version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
dependencies = [
"anstream",
"anstyle",
@ -1282,9 +1282,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.40"
version = "4.5.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@ -1922,6 +1922,17 @@ dependencies = [
"serde",
]
[[package]]
name = "derivative"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
@ -2332,11 +2343,12 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fancy-regex"
version = "0.14.0"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
checksum = "d6215aee357f8c7c989ebb4b8466ca4d7dc93b3957039f2fc3ea2ade8ea5f279"
dependencies = [
"bit-set",
"derivative",
"regex-automata 0.4.9",
"regex-syntax 0.8.5",
]
@ -4394,7 +4406,7 @@ dependencies = [
"futures",
"httparse",
"network-interface",
"quick-xml",
"quick-xml 0.37.5",
"reqwest",
"serde",
"tokio",
@ -5166,7 +5178,7 @@ dependencies = [
"itertools 0.14.0",
"parking_lot 0.12.4",
"percent-encoding",
"quick-xml",
"quick-xml 0.37.5",
"rand 0.9.1",
"reqwest",
"ring",
@ -5219,7 +5231,7 @@ dependencies = [
"log",
"md-5",
"percent-encoding",
"quick-xml",
"quick-xml 0.37.5",
"reqwest",
"serde",
"serde_json",
@ -6505,6 +6517,16 @@ dependencies = [
"serde",
]
[[package]]
name = "quick-xml"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "quinn"
version = "0.11.8"
@ -6798,7 +6820,7 @@ dependencies = [
"paste",
"percent-encoding",
"polars",
"quick-xml",
"quick-xml 0.38.0",
"quirks_path",
"rand 0.9.1",
"regex",
@ -7233,7 +7255,7 @@ dependencies = [
"atom_syndication",
"derive_builder",
"never",
"quick-xml",
"quick-xml 0.37.5",
"serde",
]
@ -7660,7 +7682,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "seaography"
version = "1.1.4"
source = "git+https://github.com/dumtruck/seaography.git?rev=9f7fc7c#9f7fc7cf05234abe35fd9144c895321dd2b5db62"
source = "git+https://github.com/dumtruck/seaography.git?rev=292cdd2#292cdd248217fdcf81c41aa97fe1c047c9b5f4de"
dependencies = [
"async-graphql",
"fnv",

View File

@ -31,22 +31,22 @@ reqwest = { version = "0.12.20", features = [
"macos-system-configuration",
"cookies",
] }
moka = "0.12"
futures = "0.3"
quirks_path = "0.1"
snafu = { version = "0.8", features = ["futures"] }
testcontainers = { version = "0.24" }
moka = "0.12.10"
futures = "0.3.31"
quirks_path = "0.1.1"
snafu = { version = "0.8.0", features = ["futures"] }
testcontainers = { version = "0.24.0" }
testcontainers-modules = { version = "0.12.1" }
testcontainers-ext = { version = "0.1.0", features = ["tracing"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1.45.1", features = [
serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.46", features = [
"macros",
"fs",
"rt-multi-thread",
"signal",
] }
serde_json = "1"
async-trait = "0.1"
serde_json = "1.0.140"
async-trait = "0.1.88"
tracing = "0.1"
url = "2.5.2"
anyhow = "1"
@ -77,7 +77,7 @@ http = "1.2.0"
async-stream = "0.3.6"
serde_variant = "0.1.3"
tracing-appender = "0.2.3"
clap = "4.5.40"
clap = "4.5.41"
ipnetwork = "0.21.1"
typed-builder = "0.21.0"
nanoid = "0.4.0"
@ -85,4 +85,4 @@ webp = "0.3.0"
[patch.crates-io]
seaography = { git = "https://github.com/dumtruck/seaography.git", rev = "9f7fc7c" }
seaography = { git = "https://github.com/dumtruck/seaography.git", rev = "292cdd2" }

View File

@ -109,7 +109,7 @@ sea-orm = { version = "1.1", features = [
figment = { version = "0.10", features = ["toml", "json", "env", "yaml"] }
sea-orm-migration = { version = "1.1", features = ["runtime-tokio"] }
rss = { version = "2", features = ["builders", "with-serde"] }
fancy-regex = "0.14"
fancy-regex = "0.15"
lightningcss = "1.0.0-alpha.66"
html-escape = "0.2.13"
opendal = { version = "0.53", features = ["default", "services-fs"] }
@ -160,7 +160,7 @@ polars = { version = "0.49.1", features = [
"lazy",
"diagonal_concat",
], optional = true }
quick-xml = { version = "0.37.5", features = [
quick-xml = { version = "0.38", features = [
"serialize",
"serde-types",
"serde",

View File

@ -12,6 +12,7 @@ const config: CodegenConfig = {
},
config: {
enumsAsConst: true,
useTypeImports: true,
scalars: {
SubscriberTaskType: {
input: 'recorder/bindings/SubscriberTaskInput#SubscriberTaskInput',

View File

@ -1,4 +1,3 @@
import type { NavMainGroup } from '@/infra/routes/nav';
import {
BookOpen,
Folders,
@ -9,6 +8,7 @@ import {
Telescope,
Tv,
} from 'lucide-react';
import type { NavMainGroup } from '@/infra/routes/nav';
export const AppNavMainData: NavMainGroup[] = [
{
@ -49,13 +49,13 @@ export const AppNavMainData: NavMainGroup[] = [
{
title: 'Manage',
link: {
to: '/bangumi/recorder',
to: '/bangumi',
},
},
{
title: 'Feed',
link: {
to: '/bangumi/feed',
to: '/bangumi',
},
},
],

View File

@ -1,643 +0,0 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { cn } from '@/presentation/utils';
import { getFutureMatches } from '@datasert/cronjs-matcher';
import { Calendar, Clock, Info, Settings, Zap } from 'lucide-react';
import {
type CSSProperties,
type FC,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
type CronBuilderProps,
CronField,
type CronFieldConfig,
CronPeriod,
type CronPreset,
} from './types';
const CRON_PRESETS: CronPreset[] = [
{
label: 'Every minute',
value: '0 * * * * *',
description: 'Runs every minute',
category: 'common',
},
{
label: 'Every 5 minutes',
value: '0 */5 * * * *',
description: 'Runs every 5 minutes',
category: 'common',
},
{
label: 'Every 15 minutes',
value: '0 */15 * * * *',
description: 'Runs every 15 minutes',
category: 'common',
},
{
label: 'Every 30 minutes',
value: '0 */30 * * * *',
description: 'Runs every 30 minutes',
category: 'common',
},
{
label: 'Every hour',
value: '0 0 * * * *',
description: 'Runs at the top of every hour',
category: 'common',
},
{
label: 'Every 6 hours',
value: '0 0 */6 * * *',
description: 'Runs every 6 hours',
category: 'common',
},
{
label: 'Daily at midnight',
value: '0 0 0 * * *',
description: 'Runs once daily at 00:00',
category: 'daily',
},
{
label: 'Daily at 9 AM',
value: '0 0 9 * * *',
description: 'Runs daily at 9:00 AM',
category: 'daily',
},
{
label: 'Weekdays at 9 AM',
value: '0 0 9 * * 1-5',
description: 'Runs Monday to Friday at 9:00 AM',
category: 'weekly',
},
{
label: 'Every Sunday',
value: '0 0 0 * * 0',
description: 'Runs every Sunday at midnight',
category: 'weekly',
},
{
label: 'First day of month',
value: '0 0 0 1 * *',
description: 'Runs on the 1st day of every month',
category: 'monthly',
},
{
label: 'Every year',
value: '0 0 0 1 1 *',
description: 'Runs on January 1st every year',
category: 'yearly',
},
];
const FIELD_CONFIGS: Record<CronField, CronFieldConfig> = {
seconds: {
min: 0,
max: 59,
step: 1,
allowSpecial: ['*', '?'],
},
minutes: {
min: 0,
max: 59,
step: 1,
allowSpecial: ['*', '?'],
},
hours: {
min: 0,
max: 23,
step: 1,
allowSpecial: ['*', '?'],
},
dayOfMonth: {
min: 1,
max: 31,
step: 1,
allowSpecial: ['*', '?', 'L', 'W'],
options: [
{ label: 'Any day', value: '*' },
{ label: 'No specific day', value: '?' },
{ label: 'Last day', value: 'L' },
{ label: 'Weekday', value: 'W' },
],
},
month: {
min: 1,
max: 12,
step: 1,
allowSpecial: ['*'],
options: [
{ label: 'January', value: 1 },
{ label: 'February', value: 2 },
{ label: 'March', value: 3 },
{ label: 'April', value: 4 },
{ label: 'May', value: 5 },
{ label: 'June', value: 6 },
{ label: 'July', value: 7 },
{ label: 'August', value: 8 },
{ label: 'September', value: 9 },
{ label: 'October', value: 10 },
{ label: 'November', value: 11 },
{ label: 'December', value: 12 },
],
},
dayOfWeek: {
min: 0,
max: 6,
step: 1,
allowSpecial: ['*', '?'],
options: [
{ label: 'Sunday', value: 0 },
{ label: 'Monday', value: 1 },
{ label: 'Tuesday', value: 2 },
{ label: 'Wednesday', value: 3 },
{ label: 'Thursday', value: 4 },
{ label: 'Friday', value: 5 },
{ label: 'Saturday', value: 6 },
],
},
year: {
min: 0,
max: 9999,
step: 1,
allowSpecial: ['*', '?'],
},
};
const PERIOD_CONFIGS = {
minute: {
label: CronPeriod.Minute,
description: 'Run every minute',
template: '0 * * * * *',
fields: [CronField.Minutes],
},
hourly: {
label: CronPeriod.Hourly,
description: 'Run every hour',
template: '0 0 * * * *',
fields: [CronField.Minutes, CronField.Hours],
},
daily: {
label: CronPeriod.Daily,
description: 'Run every day',
template: '0 0 0 * * *',
fields: [CronField.Seconds, CronField.Minutes, CronField.Hours],
},
weekly: {
label: CronPeriod.Weekly,
description: 'Run every week',
template: '0 0 0 * * 0',
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfWeek,
],
},
monthly: {
label: CronPeriod.Monthly,
description: 'Run every month',
template: '0 0 0 1 * *',
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfMonth,
],
},
yearly: {
label: CronPeriod.Yearly,
description: 'Run every year',
template: '0 0 0 1 1 *',
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfMonth,
CronField.Month,
],
},
custom: {
label: CronPeriod.Custom,
description: 'Custom expression',
template: '0 0 * * * *',
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfMonth,
CronField.Month,
CronField.DayOfWeek,
],
},
} as const;
const CronBuilder: FC<CronBuilderProps> = ({
timezone = 'UTC',
value = '0 0 * * * *',
onChange,
className,
disabled = false,
showPreview = true,
showPresets = true,
displayPeriods = [
CronPeriod.Custom,
CronPeriod.Minute,
CronPeriod.Hourly,
CronPeriod.Daily,
CronPeriod.Weekly,
CronPeriod.Monthly,
CronPeriod.Yearly,
],
defaultTab = CronPeriod.Custom,
presets = CRON_PRESETS,
showGeneratedExpression = true,
withCard = true,
}) => {
const [activeTab, setActiveTab] = useState<CronPeriod>(defaultTab);
const [cronFields, setCronFields] = useState(() =>
parseCronExpression(value)
);
const currentExpression = useMemo(() => {
return `${cronFields.seconds} ${cronFields.minutes} ${cronFields.hours} ${cronFields.dayOfMonth} ${cronFields.month} ${cronFields.dayOfWeek}`;
}, [cronFields]);
const nextRuns = useMemo(() => {
if (!showPreview) {
return [];
}
try {
const matches = getFutureMatches(`${currentExpression} *`, {
matchCount: 3,
timezone,
formatInTimezone: true,
hasSeconds: true,
});
return matches.map((match) => new Date(match));
} catch (error) {
console.error('Failed to get future matched runs', error);
return [];
}
}, [currentExpression, showPreview, timezone]);
useEffect(() => {
setCronFields(parseCronExpression(value));
}, [value]);
useEffect(() => {
onChange?.(currentExpression);
}, [currentExpression, onChange]);
const handlePresetSelect = useCallback((preset: CronPreset) => {
setCronFields(parseCronExpression(preset.value));
}, []);
const handleFieldChange = useCallback(
(field: CronField, newValue: string) => {
setCronFields((prev) => ({ ...prev, [field]: newValue }));
},
[]
);
const handlePeriodChange = useCallback((period: CronPeriod) => {
setActiveTab(period);
if (period !== 'custom') {
const config = PERIOD_CONFIGS[period];
setCronFields(parseCronExpression(config.template));
}
}, []);
const filteredPresets = useMemo(() => {
return presets.filter((preset) => {
if (activeTab === 'custom') {
return true;
}
return preset.category === activeTab;
});
}, [presets, activeTab]);
return (
<div className={cn(withCard && 'space-y-6', className)}>
<Tabs
value={activeTab}
onValueChange={(value) => handlePeriodChange(value as CronPeriod)}
>
<div className="overflow-x-auto">
<TabsList
className="grid w-(--all-grids-width) grid-cols-7 whitespace-nowrap lg:w-full"
style={
{
'--my-grid-cols': `grid-template-columns: repeat(${displayPeriods.length}, minmax(0, 1fr))`,
'--all-grids-width':
displayPeriods.length > 4
? `${displayPeriods.length * 25 - 20}%`
: '100%',
} as CSSProperties
}
>
{displayPeriods.map((period) => (
<TabsTrigger
key={period}
value={period}
disabled={disabled}
className="text-xs capitalize"
>
{PERIOD_CONFIGS[period].label}
</TabsTrigger>
))}
</TabsList>
</div>
{displayPeriods.map((period) => (
<TabsContent
key={period}
value={period}
className={cn(withCard ? 'space-y-4' : 'px-0')}
>
<Card className={cn(!withCard && 'border-none shadow-none')}>
<CardHeader className={cn('pb-1', !withCard && 'px-0')}>
<CardTitle className="flex items-center gap-2 text-base">
<Settings className="h-4 w-4" />
<span className="capitalize">
{PERIOD_CONFIGS[period].label} Configuration
</span>
</CardTitle>
<CardDescription>
{PERIOD_CONFIGS[period].description}
</CardDescription>
</CardHeader>
<CardContent className={cn('space-y-4', !withCard && 'px-0')}>
<CronFieldEditor
period={period}
fields={cronFields}
onChange={handleFieldChange}
disabled={disabled}
/>
</CardContent>
</Card>
{showPresets && filteredPresets.length > 0 && (
<Card className={cn(!withCard && 'border-none shadow-none')}>
<CardHeader className={cn(!withCard && 'px-0')}>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4" />
Quick Presets
</CardTitle>
<CardDescription>
Common cron expressions for quick setup
</CardDescription>
</CardHeader>
<CardContent className={cn(!withCard && 'px-0')}>
<div className="grid gap-3 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{filteredPresets.map((preset, index) => (
<Button
key={index}
variant="outline"
className="h-auto justify-start p-4 text-left"
onClick={() => handlePresetSelect(preset)}
disabled={disabled}
>
<div className="w-full space-y-2">
<div className="font-medium text-sm">
{preset.label}
</div>
<div className="whitespace-normal break-words text-muted-foreground text-xs leading-relaxed">
{preset.description}
</div>
<Badge
variant="secondary"
className="mt-1 break-all font-mono text-xs"
>
{preset.value}
</Badge>
</div>
</Button>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
))}
</Tabs>
{/* Current Expression & Preview */}
{showGeneratedExpression && (
<Card className={cn(!withCard && 'border-none shadow-none')}>
<CardHeader className={cn(!withCard && 'px-0')}>
<CardTitle className="flex items-center gap-2 text-base">
<Clock className="h-4 w-4" />
Generated Expression
</CardTitle>
</CardHeader>
<CardContent className={cn('space-y-4', !withCard && 'px-0')}>
<div className="flex items-center gap-2">
<Badge variant="outline" className="px-3 py-1 font-mono text-sm">
{currentExpression}
</Badge>
</div>
{showPreview && nextRuns.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Calendar className="h-4 w-4" />
Next Runs({timezone})
</h4>
<div className="space-y-1">
{nextRuns.map((date, index) => (
<div
key={index}
className="flex items-center justify-between rounded bg-muted/50 px-3 py-2 text-sm"
>
<span className="font-medium text-muted-foreground">
#{index + 1}
</span>
<span className="font-mono">
{date.toLocaleString()}
</span>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
);
};
interface CronFieldEditorProps {
period: CronPeriod;
fields: Record<CronField, string>;
onChange: (field: CronField, value: string) => void;
disabled?: boolean;
}
const CronFieldEditor: FC<CronFieldEditorProps> = ({
period,
fields,
onChange,
disabled = false,
}) => {
const relevantFields = [...PERIOD_CONFIGS[period].fields] as CronField[];
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{relevantFields.map((field) => {
const config = FIELD_CONFIGS[field];
const currentValue = fields[field];
return (
<div key={field} className="space-y-2">
<Label className="font-medium text-sm capitalize">
{field.replace(/([A-Z])/g, ' $1').toLowerCase()}
</Label>
{field === 'month' || field === 'dayOfWeek' ? (
<Select
value={currentValue}
onValueChange={(value) => onChange(field, value)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="*">Any</SelectItem>
{config.options?.map((option, index) => (
<SelectItem key={index} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
// biome-ignore lint/nursery/noNestedTernary: <explanation>
) : field === 'dayOfMonth' ? (
<div className="space-y-2">
<Select
value={currentValue}
onValueChange={(value) => onChange(field, value)}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.options?.map((option, index) => (
<SelectItem key={index} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => (
<SelectItem key={day} value={day.toString()}>
{day}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-2">
<ToggleGroup
type="single"
value={currentValue === '*' ? '*' : 'specific'}
onValueChange={(value) => {
if (value === '*') {
onChange(field, '*');
} else if (value === 'specific' && currentValue === '*') {
onChange(field, '0');
}
}}
disabled={disabled}
>
<ToggleGroupItem value="*" className="min-w-fit text-xs">
Any
</ToggleGroupItem>
<ToggleGroupItem
value="specific"
className="min-w-fit text-xs"
>
Specific
</ToggleGroupItem>
</ToggleGroup>
{currentValue !== '*' && (
<Input
type="text"
value={currentValue}
onChange={(e) => onChange(field, e.target.value)}
placeholder={`0-${config.max}`}
disabled={disabled}
className="font-mono text-sm"
/>
)}
<div className="text-muted-foreground text-xs">
<div className="flex items-center gap-1">
<Info className="h-3 w-3" />
<span>
Range: {config.min}-{config.max}
</span>
</div>
<div className="mt-1">
Supports: *, numbers, ranges (1-5), lists (1,3,5), steps
(*/5)
</div>
</div>
</div>
)}
</div>
);
})}
</div>
);
};
function parseCronExpression(expression: string): Record<CronField, string> {
const parts = expression.split(' ');
// Ensure we have 6 parts, pad with defaults if needed
while (parts.length < 6) {
parts.push('*');
}
return {
seconds: parts[0] || '0',
minutes: parts[1] || '*',
hours: parts[2] || '*',
dayOfMonth: parts[3] || '*',
month: parts[4] || '*',
dayOfWeek: parts[5] || '*',
year: parts[6] || '*',
};
}
export { CronBuilder };

View File

@ -1,7 +1,7 @@
'use client';
import { useMatches } from '@tanstack/react-router';
import { ChevronRight } from 'lucide-react';
import {
Collapsible,
CollapsibleContent,
@ -27,13 +27,8 @@ import {
useSidebar,
} from '@/components/ui/sidebar';
import type { NavMainGroup, NavMainItem } from '@/infra/routes/nav';
import { useMatches } from '@tanstack/react-router';
export function NavMain({
groups,
}: {
groups: NavMainGroup[];
}) {
export function NavMain({ groups }: { groups: NavMainGroup[] }) {
const matches = useMatches();
const { state } = useSidebar();

View File

@ -1,4 +1,4 @@
import { type VariantProps, cva } from "class-variance-authority";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/presentation/utils";

View File

@ -0,0 +1,743 @@
import { getFutureMatches } from "@datasert/cronjs-matcher";
import { Calendar, Clock, Info, Settings, Zap } from "lucide-react";
import {
type CSSProperties,
type FC,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/presentation/utils";
import {
type CronBuilderProps,
CronField,
type CronFieldConfig,
CronPeriod,
type CronPreset,
} from "./types.js";
const CRON_PRESETS: CronPreset[] = [
{
label: "Every minute",
value: "0 * * * * *",
description: "Runs every minute",
category: "common",
},
{
label: "Every 5 minutes",
value: "0 */5 * * * *",
description: "Runs every 5 minutes",
category: "common",
},
{
label: "Every 15 minutes",
value: "0 */15 * * * *",
description: "Runs every 15 minutes",
category: "common",
},
{
label: "Every 30 minutes",
value: "0 */30 * * * *",
description: "Runs every 30 minutes",
category: "common",
},
{
label: "Every hour",
value: "0 0 * * * *",
description: "Runs at the top of every hour",
category: "common",
},
{
label: "Every 6 hours",
value: "0 0 */6 * * *",
description: "Runs every 6 hours",
category: "common",
},
{
label: "Daily at midnight",
value: "0 0 0 * * *",
description: "Runs once daily at 00:00",
category: "daily",
},
{
label: "Daily at 9 AM",
value: "0 0 9 * * *",
description: "Runs daily at 9:00 AM",
category: "daily",
},
{
label: "Weekdays at 9 AM",
value: "0 0 9 * * 1-5",
description: "Runs Monday to Friday at 9:00 AM",
category: "weekly",
},
{
label: "Every Sunday",
value: "0 0 0 * * 0",
description: "Runs every Sunday at midnight",
category: "weekly",
},
{
label: "First day of month",
value: "0 0 0 1 * *",
description: "Runs on the 1st day of every month",
category: "monthly",
},
{
label: "Every year",
value: "0 0 0 1 1 *",
description: "Runs on January 1st every year",
category: "yearly",
},
];
const FIELD_CONFIGS: Record<CronField, CronFieldConfig> = {
seconds: {
min: 0,
max: 59,
step: 1,
allowSpecial: ["*", "?"],
},
minutes: {
min: 0,
max: 59,
step: 1,
allowSpecial: ["*", "?"],
},
hours: {
min: 0,
max: 23,
step: 1,
allowSpecial: ["*", "?"],
},
dayOfMonth: {
min: 1,
max: 31,
step: 1,
allowSpecial: ["*", "?", "L", "W"],
options: [
{ label: "Any day", value: "*" },
{ label: "No specific day", value: "?" },
{ label: "Last day", value: "L" },
{ label: "Weekday", value: "W" },
],
},
month: {
min: 1,
max: 12,
step: 1,
allowSpecial: ["*"],
options: [
{ label: "January", value: 1 },
{ label: "February", value: 2 },
{ label: "March", value: 3 },
{ label: "April", value: 4 },
{ label: "May", value: 5 },
{ label: "June", value: 6 },
{ label: "July", value: 7 },
{ label: "August", value: 8 },
{ label: "September", value: 9 },
{ label: "October", value: 10 },
{ label: "November", value: 11 },
{ label: "December", value: 12 },
],
},
dayOfWeek: {
min: 0,
max: 6,
step: 1,
allowSpecial: ["*", "?"],
options: [
{ label: "Sunday", value: 0 },
{ label: "Monday", value: 1 },
{ label: "Tuesday", value: 2 },
{ label: "Wednesday", value: 3 },
{ label: "Thursday", value: 4 },
{ label: "Friday", value: 5 },
{ label: "Saturday", value: 6 },
],
},
year: {
min: 0,
max: 9999,
step: 1,
allowSpecial: ["*", "?"],
},
};
const PERIOD_CONFIGS = {
minute: {
label: CronPeriod.Minute,
description: "Run every minute",
template: "0 * * * * *",
fields: [CronField.Minutes],
},
hourly: {
label: CronPeriod.Hourly,
description: "Run every hour",
template: "0 0 * * * *",
fields: [CronField.Minutes, CronField.Hours],
},
daily: {
label: CronPeriod.Daily,
description: "Run every day",
template: "0 0 0 * * *",
fields: [CronField.Seconds, CronField.Minutes, CronField.Hours],
},
weekly: {
label: CronPeriod.Weekly,
description: "Run every week",
template: "0 0 0 * * 0",
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfWeek,
],
},
monthly: {
label: CronPeriod.Monthly,
description: "Run every month",
template: "0 0 0 1 * *",
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfMonth,
],
},
yearly: {
label: CronPeriod.Yearly,
description: "Run every year",
template: "0 0 0 1 1 *",
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfMonth,
CronField.Month,
],
},
custom: {
label: CronPeriod.Custom,
description: "Custom expression",
template: "0 0 * * * *",
fields: [
CronField.Seconds,
CronField.Minutes,
CronField.Hours,
CronField.DayOfMonth,
CronField.Month,
CronField.DayOfWeek,
],
},
} as const;
const CronBuilder: FC<CronBuilderProps> = ({
timezone = "UTC",
value = "0 0 * * * *",
onChange,
className,
disabled = false,
showPreview = true,
showPresets = true,
displayPeriods = [
CronPeriod.Custom,
CronPeriod.Minute,
CronPeriod.Hourly,
CronPeriod.Daily,
CronPeriod.Weekly,
CronPeriod.Monthly,
CronPeriod.Yearly,
],
defaultTab = CronPeriod.Custom,
presets = CRON_PRESETS,
showGeneratedExpression = true,
withCard = true,
}) => {
const [activeTab, setActiveTab] = useState<CronPeriod>(defaultTab);
const [cronFields, setCronFields] = useState(() =>
parseCronExpression(value)
);
const currentExpression = useMemo(() => {
return `${cronFields.seconds} ${cronFields.minutes} ${cronFields.hours} ${cronFields.dayOfMonth} ${cronFields.month} ${cronFields.dayOfWeek}`;
}, [cronFields]);
const nextRuns = useMemo(() => {
if (!showPreview) {
return [];
}
try {
const matches = getFutureMatches(`${currentExpression} *`, {
matchCount: 3,
timezone,
formatInTimezone: true,
hasSeconds: true,
});
return matches.map((match) => new Date(match));
} catch (error) {
console.error("Failed to get future matched runs", error);
return [];
}
}, [currentExpression, showPreview, timezone]);
useEffect(() => {
setCronFields(parseCronExpression(value));
}, [value]);
useEffect(() => {
onChange?.(currentExpression);
}, [currentExpression, onChange]);
const handlePresetSelect = useCallback((preset: CronPreset) => {
setCronFields(parseCronExpression(preset.value));
}, []);
const handleFieldChange = useCallback(
(field: CronField, newValue: string) => {
setCronFields((prev) => ({ ...prev, [field]: newValue }));
},
[]
);
const handlePeriodChange = useCallback((period: CronPeriod) => {
setActiveTab(period);
if (period !== "custom") {
const config = PERIOD_CONFIGS[period];
setCronFields(parseCronExpression(config.template));
}
}, []);
const filteredPresets = useMemo(() => {
return presets.filter((preset) => {
if (activeTab === "custom") {
return true;
}
return preset.category === activeTab;
});
}, [presets, activeTab]);
return (
<div className={cn(withCard && "space-y-6", className)}>
<Tabs
value={activeTab}
onValueChange={(v) => handlePeriodChange(v as CronPeriod)}
>
<div className="overflow-x-auto">
<TabsList
className="grid w-(--all-grids-width) grid-cols-7 whitespace-nowrap lg:w-full"
style={
{
"--my-grid-cols": `grid-template-columns: repeat(${displayPeriods.length}, minmax(0, 1fr))`,
"--all-grids-width":
displayPeriods.length > 4
? `${displayPeriods.length * 25 - 20}%`
: "100%",
} as CSSProperties
}
>
{displayPeriods.map((period) => (
<TabsTrigger
key={period}
value={period}
disabled={disabled}
className="text-xs capitalize"
>
{PERIOD_CONFIGS[period].label}
</TabsTrigger>
))}
</TabsList>
</div>
{displayPeriods.map((period) => (
<TabsContent
key={period}
value={period}
className={cn(withCard ? "space-y-4" : "px-0")}
>
<Card className={cn(!withCard && "border-none shadow-none")}>
<CardHeader className={cn("pb-1", !withCard && "px-0")}>
<CardTitle className="flex items-center gap-2 text-base">
<Settings className="h-4 w-4" />
<span className="capitalize">
{PERIOD_CONFIGS[period].label} Configuration
</span>
</CardTitle>
<CardDescription>
{PERIOD_CONFIGS[period].description}
</CardDescription>
</CardHeader>
<CardContent className={cn("space-y-4", !withCard && "px-0")}>
<CronFieldEditor
period={period}
fields={cronFields}
onChange={handleFieldChange}
disabled={disabled}
/>
</CardContent>
</Card>
{showPresets && filteredPresets.length > 0 && (
<Card className={cn(!withCard && "border-none shadow-none")}>
<CardHeader className={cn(!withCard && "px-0")}>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4" />
Quick Presets
</CardTitle>
<CardDescription>
Common cron expressions for quick setup
</CardDescription>
</CardHeader>
<CardContent className={cn(!withCard && "px-0")}>
<div className="grid gap-3 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{filteredPresets.map((preset, index) => (
<Button
key={index}
variant="outline"
className="h-auto justify-start p-4 text-left"
onClick={() => handlePresetSelect(preset)}
disabled={disabled}
>
<div className="w-full space-y-2">
<div className="font-medium text-sm">
{preset.label}
</div>
<div className="whitespace-normal break-words text-muted-foreground text-xs leading-relaxed">
{preset.description}
</div>
<Badge
variant="secondary"
className="mt-1 break-all font-mono text-xs"
>
{preset.value}
</Badge>
</div>
</Button>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
))}
</Tabs>
{/* Current Expression & Preview */}
{showGeneratedExpression && (
<Card className={cn(!withCard && "border-none shadow-none")}>
<CardHeader className={cn(!withCard && "px-0")}>
<CardTitle className="flex items-center gap-2 text-base">
<Clock className="h-4 w-4" />
Generated Expression
</CardTitle>
</CardHeader>
<CardContent className={cn("space-y-4", !withCard && "px-0")}>
<div className="flex items-center gap-2">
<Badge variant="outline" className="px-3 py-1 font-mono text-sm">
{currentExpression}
</Badge>
</div>
{showPreview && nextRuns.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Calendar className="h-4 w-4" />
Next Runs({timezone})
</h4>
<div className="space-y-1">
{nextRuns.map((date, index) => (
<div
key={index}
className="flex items-center justify-between rounded bg-muted/50 px-3 py-2 text-sm"
>
<span className="font-medium text-muted-foreground">
#{index + 1}
</span>
<span className="font-mono">
{date.toLocaleString()}
</span>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
);
};
interface CronFieldEditorProps {
period: CronPeriod;
fields: Record<CronField, string>;
onChange: (field: CronField, value: string) => void;
disabled?: boolean;
}
const CronFieldEditor: FC<CronFieldEditorProps> = ({
period,
fields,
onChange,
disabled = false,
}) => {
const relevantFields = [...PERIOD_CONFIGS[period].fields] as CronField[];
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{relevantFields.map((field) => {
const config = FIELD_CONFIGS[field];
const currentValue = fields[field];
return (
<CronFieldItemEditor
key={field}
config={config}
field={field}
value={currentValue}
onChange={onChange}
disabled={disabled}
/>
);
})}
</div>
);
};
const CronFieldItemAnyOrSpecificOption = {
Any: "any",
Specific: "specific",
} as const;
type CronFieldItemAnyOrSpecificOption =
(typeof CronFieldItemAnyOrSpecificOption)[keyof typeof CronFieldItemAnyOrSpecificOption];
interface CronFieldItemEditorProps {
config: CronFieldConfig;
field: CronField;
value: string;
onChange: (field: CronField, value: string) => void;
disabled?: boolean;
}
function encodeCronFieldItem(value: string): string {
if (value === "") {
return "<meta:empty>";
}
if (value.includes(" ")) {
return `<meta:contains-space:${encodeURIComponent(value)}>`;
}
return value;
}
function decodeCronFieldItem(value: string): string {
if (value.startsWith("<meta:contains")) {
return decodeURIComponent(
// biome-ignore lint/performance/useTopLevelRegex: false
value.replace(/^<meta:contains-space:([^>]+)>$/, "$1")
);
}
if (value === "<meta:empty>") {
return "";
}
return value;
}
export const CronFieldItemEditor: FC<CronFieldItemEditorProps> = memo(
({ field, value, onChange, config, disabled = false }) => {
const [innerValue, _setInnerValue] = useState(() =>
decodeCronFieldItem(value)
);
const [anyOrSpecificOption, _setAnyOrSpecificOption] =
useState<CronFieldItemAnyOrSpecificOption>(() =>
innerValue === "*"
? CronFieldItemAnyOrSpecificOption.Any
: CronFieldItemAnyOrSpecificOption.Specific
);
// biome-ignore lint/correctness/useExhaustiveDependencies: false
useEffect(() => {
const nextValue = decodeCronFieldItem(value);
if (nextValue !== innerValue) {
_setInnerValue(nextValue);
}
}, [value]);
const handleChange = useCallback(
(v: string) => {
_setInnerValue(v);
onChange(field, encodeCronFieldItem(v));
},
[field, onChange]
);
const setAnyOrSpecificOption = useCallback(
(v: CronFieldItemAnyOrSpecificOption) => {
_setAnyOrSpecificOption(v);
if (v === CronFieldItemAnyOrSpecificOption.Any) {
handleChange("*");
} else if (v === CronFieldItemAnyOrSpecificOption.Specific) {
handleChange("0");
}
},
[handleChange]
);
return (
<div className="space-y-2">
<Label className="font-medium text-sm capitalize">
{field.replace(/([A-Z])/g, " $1").toLowerCase()}
</Label>
{(field === "month" || field === "dayOfWeek") && (
<Select
value={innerValue}
onValueChange={handleChange}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="*">Any</SelectItem>
{config.options?.map((option, index) => (
<SelectItem key={index} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field === "dayOfMonth" && (
<div className="space-y-2">
<Select
value={innerValue}
onValueChange={handleChange}
disabled={disabled}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.options?.map((option, index) => (
<SelectItem key={index} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => (
<SelectItem key={day} value={day.toString()}>
{day}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{!(
field === "month" ||
field === "dayOfWeek" ||
field === "dayOfMonth"
) && (
<div className="space-y-2">
<ToggleGroup
type="single"
value={anyOrSpecificOption}
onValueChange={setAnyOrSpecificOption}
disabled={disabled}
>
<ToggleGroupItem
value={CronFieldItemAnyOrSpecificOption.Any}
className="min-w-fit text-xs"
>
Any
</ToggleGroupItem>
<ToggleGroupItem
value={CronFieldItemAnyOrSpecificOption.Specific}
className="min-w-fit text-xs"
>
Specific
</ToggleGroupItem>
</ToggleGroup>
{anyOrSpecificOption ===
CronFieldItemAnyOrSpecificOption.Specific && (
<Input
type="text"
value={innerValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={`0-${config.max}`}
disabled={disabled}
className="font-mono text-sm"
/>
)}
<div className="text-muted-foreground text-xs">
<div className="flex items-center gap-1">
<Info className="h-3 w-3" />
<span>
Range: {config.min}-{config.max}
</span>
</div>
<div className="mt-1">
Supports: *, numbers, ranges (1-5), lists (1,3,5), steps (*/5)
</div>
</div>
</div>
)}
</div>
);
}
);
function parseCronExpression(expression: string): Record<CronField, string> {
const parts = expression.split(" ");
// Ensure we have 6 parts, pad with defaults if needed
while (parts.length < 6) {
parts.push("*");
}
return {
seconds: parts[0] || "0",
minutes: parts[1] || "*",
hours: parts[2] || "*",
dayOfMonth: parts[3] || "*",
month: parts[4] || "*",
dayOfWeek: parts[5] || "*",
year: parts[6] || "*",
};
}
export { CronBuilder };

View File

@ -1,67 +1,67 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Code2, Play, Settings, Type } from "lucide-react";
import { type FC, useCallback, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Code2, Play, Settings, Type } from 'lucide-react';
import { type FC, useCallback, useState } from 'react';
import { CronBuilder } from './cron-builder.js';
import { CronDisplay } from './cron-display.js';
import { CronInput } from './cron-input.js';
import { Cron } from './cron.js';
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Cron } from "./cron.jsx";
import { CronBuilder } from "./cron-builder.jsx";
import { CronDisplay } from "./cron-display.jsx";
import { CronInput } from "./cron-input.jsx";
const CronExample: FC = () => {
const [inputValue, setInputValue] = useState('0 30 9 * * 1-5');
const [builderValue, setBuilderValue] = useState('0 0 */6 * * *');
const [fullValue, setFullValue] = useState('0 */15 * * * *');
const [displayValue] = useState('0 0 0 * * 0');
const [inputValue, setInputValue] = useState("0 30 9 * * 1-5");
const [builderValue, setBuilderValue] = useState("0 0 */6 * * *");
const [fullValue, setFullValue] = useState("0 */15 * * * *");
const [displayValue] = useState("0 0 0 * * 0");
const examples = [
{
label: 'Every minute',
expression: '0 * * * * *',
description: 'Runs at the start of every minute',
label: "Every minute",
expression: "0 * * * * *",
description: "Runs at the start of every minute",
},
{
label: 'Every 5 minutes',
expression: '0 */5 * * * *',
description: 'Runs every 5 minutes',
label: "Every 5 minutes",
expression: "0 */5 * * * *",
description: "Runs every 5 minutes",
},
{
label: 'Every hour',
expression: '0 0 * * * *',
description: 'Runs at the start of every hour',
label: "Every hour",
expression: "0 0 * * * *",
description: "Runs at the start of every hour",
},
{
label: 'Daily at 9 AM',
expression: '0 0 9 * * *',
description: 'Runs every day at 9:00 AM',
label: "Daily at 9 AM",
expression: "0 0 9 * * *",
description: "Runs every day at 9:00 AM",
},
{
label: 'Weekdays at 9:30 AM',
expression: '0 30 9 * * 1-5',
description: 'Runs Monday through Friday at 9:30 AM',
label: "Weekdays at 9:30 AM",
expression: "0 30 9 * * 1-5",
description: "Runs Monday through Friday at 9:30 AM",
},
{
label: 'Every Sunday',
expression: '0 0 0 * * 0',
description: 'Runs every Sunday at midnight',
label: "Every Sunday",
expression: "0 0 0 * * 0",
description: "Runs every Sunday at midnight",
},
{
label: 'First day of month',
expression: '0 0 0 1 * *',
description: 'Runs on the 1st day of every month',
label: "First day of month",
expression: "0 0 0 1 * *",
description: "Runs on the 1st day of every month",
},
{
label: 'Every quarter',
expression: '0 0 0 1 */3 *',
description: 'Runs on the 1st day of every quarter',
label: "Every quarter",
expression: "0 0 0 1 */3 *",
description: "Runs on the 1st day of every quarter",
},
];
@ -69,7 +69,7 @@ const CronExample: FC = () => {
try {
await navigator.clipboard.writeText(expression);
} catch (error) {
console.warn('Failed to copy to clipboard:', error);
console.warn("Failed to copy to clipboard:", error);
}
}, []);
@ -165,7 +165,7 @@ const CronExample: FC = () => {
<div className="rounded bg-muted p-4">
<h4 className="mb-2 font-medium text-sm">Current Value:</h4>
<Badge variant="outline" className="font-mono">
{fullValue || 'No expression set'}
{fullValue || "No expression set"}
</Badge>
</div>
</div>
@ -196,7 +196,7 @@ const CronExample: FC = () => {
<div className="rounded bg-muted p-4">
<h4 className="mb-2 font-medium text-sm">Current Value:</h4>
<Badge variant="outline" className="font-mono">
{inputValue || 'No expression set'}
{inputValue || "No expression set"}
</Badge>
</div>
</div>
@ -244,7 +244,7 @@ const CronExample: FC = () => {
<div className="rounded bg-muted p-4">
<h4 className="mb-2 font-medium text-sm">Current Value:</h4>
<Badge variant="outline" className="font-mono">
{builderValue || 'No expression set'}
{builderValue || "No expression set"}
</Badge>
</div>
</div>

View File

@ -33,7 +33,11 @@ const CronInput = forwardRef<HTMLInputElement, CronInputProps>(
const validationResult = useMemo((): CronValidationResult => {
if (!internalValue.trim()) {
return { isValid: false, error: 'Expression is required' };
return {
isValid: false,
error: 'Expression is required',
isEmpty: true,
};
}
try {

View File

@ -1,16 +1,4 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/presentation/utils';
import { parse } from '@datasert/cronjs-parser';
import { parse } from "@datasert/cronjs-parser";
import {
AlertCircle,
Bolt,
@ -19,33 +7,45 @@ import {
Copy,
Settings,
Type,
} from 'lucide-react';
import { type FC, useCallback, useEffect, useMemo, useState } from 'react';
import { CronBuilder } from './cron-builder';
import { CronDisplay } from './cron-display';
import { CronInput } from './cron-input';
} from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/presentation/utils";
import { CronBuilder } from "./cron-builder.js";
import { CronDisplay } from "./cron-display.js";
import { CronInput } from "./cron-input.js";
import {
CronMode,
type CronPrimitiveMode,
type CronProps,
type CronValidationResult,
} from './types';
} from "./types.js";
const PLACEHOLDER = '0 0 * * * *';
const PLACEHOLDER = "0 0 * * * *";
const Cron: FC<CronProps> = ({
value = '',
value = "",
onChange,
activeMode = 'input',
activeMode = "input",
onActiveModeChange,
onValidate,
className,
mode = 'both',
mode = "both",
disabled = false,
placeholder = PLACEHOLDER,
showPreview = true,
showDescription = true,
timezone = 'UTC',
timezone = "UTC",
error,
children,
showHelp = true,
@ -55,9 +55,9 @@ const Cron: FC<CronProps> = ({
showPresets,
withCard = true,
isFirstSibling = false,
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: <explanation>
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: false
}) => {
const [internalValue, setInternalValue] = useState(value || '');
const [internalValue, setInternalValue] = useState(value || "");
const [internalActiveMode, setInternalActiveMode] =
useState<CronPrimitiveMode>(
mode === CronMode.Both ? activeMode : (mode as CronPrimitiveMode)
@ -66,7 +66,7 @@ const Cron: FC<CronProps> = ({
const validationResult = useMemo((): CronValidationResult => {
if (!internalValue.trim()) {
return { isValid: false, error: 'Expression is required', isEmpty: true };
return { isValid: false, error: "Expression is required", isEmpty: true };
}
try {
@ -78,13 +78,13 @@ const Cron: FC<CronProps> = ({
error:
parseError instanceof Error
? parseError.message
: 'Invalid cron expression',
: "Invalid cron expression",
};
}
}, [internalValue]);
useEffect(() => {
setInternalValue(value || '');
setInternalValue(value || "");
}, [value]);
useEffect(() => {
@ -92,7 +92,7 @@ const Cron: FC<CronProps> = ({
}, [validationResult.isValid, onValidate]);
useEffect(() => {
if (mode === 'both') {
if (mode === "both") {
setInternalActiveMode(activeMode);
}
}, [activeMode, mode]);
@ -106,9 +106,9 @@ const Cron: FC<CronProps> = ({
);
const handleActiveModeChange = useCallback(
(mode: CronPrimitiveMode) => {
setInternalActiveMode(mode);
onActiveModeChange?.(mode);
(m: CronPrimitiveMode) => {
setInternalActiveMode(m);
onActiveModeChange?.(m);
},
[onActiveModeChange]
);
@ -122,17 +122,17 @@ const Cron: FC<CronProps> = ({
await navigator.clipboard.writeText(internalValue);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.warn('Failed to copy to clipboard:', error);
} catch (e) {
console.warn("Failed to copy to clipboard:", e);
}
}, [internalValue]);
const hasError =
!!error || !!(!validationResult.isValid && internalValue.trim());
if (mode === 'input') {
if (mode === "input") {
return (
<div className={cn(withCard && 'space-y-4', className)}>
<div className={cn(withCard && "space-y-4", className)}>
<CronInput
value={internalValue}
onChange={handleChange}
@ -161,9 +161,9 @@ const Cron: FC<CronProps> = ({
);
}
if (mode === 'builder') {
if (mode === "builder") {
return (
<div className={cn(withCard && 'space-y-4', className)}>
<div className={cn(withCard && "space-y-4", className)}>
<CronBuilder
value={internalValue}
onChange={handleChange}
@ -184,14 +184,14 @@ const Cron: FC<CronProps> = ({
}
return (
<div className={cn(withCard && 'space-y-6', className)}>
<div className={cn(withCard && "space-y-6", className)}>
<Card
className={cn(
!withCard && 'border-none shadow-none',
!withCard && isFirstSibling && 'pt-0'
!withCard && "border-none shadow-none",
!withCard && isFirstSibling && "pt-0"
)}
>
<CardHeader className={cn(!withCard && 'px-0')}>
<CardHeader className={cn(!withCard && "px-0")}>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-base">
@ -207,7 +207,7 @@ const Cron: FC<CronProps> = ({
<div className="flex items-center gap-2">
<Badge
variant={
validationResult.isValid ? 'secondary' : 'destructive'
validationResult.isValid ? "secondary" : "destructive"
}
className="font-mono text-sm"
>
@ -238,11 +238,11 @@ const Cron: FC<CronProps> = ({
)}
</CardHeader>
<CardContent className={cn(!withCard && 'px-0')}>
<CardContent className={cn(!withCard && "px-0")}>
<Tabs
value={internalActiveMode}
onValueChange={(value) =>
handleActiveModeChange(value as 'input' | 'builder')
onValueChange={(v) =>
handleActiveModeChange(v as "input" | "builder")
}
>
<TabsList className="grid w-full grid-cols-2">
@ -314,14 +314,14 @@ const Cron: FC<CronProps> = ({
{showHelp && (
<>
{!withCard && <Separator />}
<Card className={cn(!withCard && 'border-none shadow-none')}>
<CardHeader className={cn(!withCard && 'px-0')}>
<Card className={cn(!withCard && "border-none shadow-none")}>
<CardHeader className={cn(!withCard && "px-0")}>
<CardTitle className="flex items-center gap-2 text-base">
<Code2 className="h-4 w-4" />
Cron Expression Format
</CardTitle>
</CardHeader>
<CardContent className={cn(!withCard && 'px-0')}>
<CardContent className={cn(!withCard && "px-0")}>
<div className="space-y-4">
<div className="grid grid-cols-6 gap-2 text-center text-sm">
<div className="space-y-1">

View File

@ -1,20 +1,20 @@
export { Cron } from './cron';
export { CronInput } from './cron-input';
export { CronBuilder } from './cron-builder';
export { CronDisplay } from './cron-display';
export { CronExample } from './cron-example';
export { Cron } from "./cron.js";
export { CronBuilder } from "./cron-builder.js";
export { CronDisplay } from "./cron-display.js";
export { CronExample } from "./cron-example.js";
export { CronInput } from "./cron-input.js";
export {
type CronProps,
type CronInputProps,
type CronBuilderProps,
type CronDisplayProps,
type CronExpression,
CronField,
type CronFieldConfig,
type CronInputProps,
type CronNextRun,
CronPeriod,
type CronPreset,
type CronProps,
type CronValidationResult,
type CronNextRun,
type CronFieldConfig,
CronField,
type PeriodConfig,
} from './types';
} from "./types.js";

View File

@ -1,20 +1,32 @@
import { type LinkComponent, createLink } from "@tanstack/react-router";
import type { AnchorHTMLAttributes, ComponentProps } from "react";
import { createLink, type LinkComponentProps } from "@tanstack/react-router";
import type { AnchorHTMLAttributes } from "react";
export interface BasicLinkProps
extends AnchorHTMLAttributes<HTMLAnchorElement> {}
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
href: string;
to?: undefined;
}
const BasicLinkComponent = (props: ComponentProps<"a">) => {
const BasicLinkComponent = (props: BasicLinkProps) => {
return <a {...props} />;
};
const CreatedLinkComponent = createLink(BasicLinkComponent);
export const ProLink: LinkComponent<typeof BasicLinkComponent> = (props) => {
export const ProLink = (
props: LinkComponentProps<typeof BasicLinkComponent> | BasicLinkProps
) => {
if (props.href) {
return <BasicLinkComponent {...(props as any)} />;
}
return <CreatedLinkComponent preload={"intent"} {...props} />;
return (
<CreatedLinkComponent
preload={"intent"}
{...(props as LinkComponentProps<typeof BasicLinkComponent>)}
/>
);
};
export type ProLinkProps = ComponentProps<typeof ProLink>;
export type ProLinkProps =
| LinkComponentProps<typeof BasicLinkComponent>
| BasicLinkProps;

View File

@ -1,6 +1,6 @@
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { AlertCircle } from "lucide-react";
export interface QueryErrorViewProps {
title?: string;

View File

@ -1,6 +1,5 @@
import type { CronPreset } from '@/components/domains/cron';
import type { GetCronsQuery } from '@/infra/graphql/gql/graphql';
import { gql } from '@apollo/client';
import type { GetCronsQuery } from '@/infra/graphql/gql/graphql';
export const GET_CRONS = gql`
query GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {

View File

@ -1,16 +1,16 @@
import { gql } from '@apollo/client';
import { type } from 'arktype';
import { arkValidatorToTypeNarrower } from '@/infra/errors/arktype';
import {
type GetSubscriptionsQuery,
SubscriptionCategoryEnum,
} from '@/infra/graphql/gql/graphql';
import { gql } from '@apollo/client';
import { type } from 'arktype';
import {
extractMikanSubscriptionBangumiSourceUrl,
extractMikanSubscriptionSubscriberSourceUrl,
MikanSubscriptionBangumiSourceUrlSchema,
MikanSubscriptionSeasonSourceUrlSchema,
MikanSubscriptionSubscriberSourceUrlSchema,
extractMikanSubscriptionBangumiSourceUrl,
extractMikanSubscriptionSubscriberSourceUrl,
} from './mikan';
export const GET_SUBSCRIPTIONS = gql`
@ -83,10 +83,8 @@ export const DELETE_SUBSCRIPTIONS = gql`
`;
export const GET_SUBSCRIPTION_DETAIL = gql`
query GetSubscriptionDetail ($id: Int!) {
subscriptions(filter: { id: {
eq: $id
} }) {
query GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {
subscriptions(filter: $filter) {
nodes {
id
subscriberId
@ -106,7 +104,15 @@ query GetSubscriptionDetail ($id: Int!) {
feedSource
}
}
subscriberTask {
subscriberTask(pagination: {
page: {
page: 0,
limit: 3,
}
},
orderBy: {
runAt: DESC,
}) {
nodes {
id
taskType
@ -117,7 +123,15 @@ query GetSubscriptionDetail ($id: Int!) {
id
username
}
cron {
cron (pagination: {
page: {
page: 0,
limit: 3,
}
},
orderBy: {
createdAt: DESC,
}) {
nodes {
id
cronExpr

View File

@ -1,5 +1,5 @@
import type { GetTasksQuery } from '@/infra/graphql/gql/graphql';
import { gql } from '@apollo/client';
import type { GetTasksQuery } from '@/infra/graphql/gql/graphql';
export const GET_TASKS = gql`
query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {

View File

@ -0,0 +1,30 @@
type AllKeys<T> = T extends any ? keyof T : never;
type ToDefaultable<T> = Exclude<
T extends string | undefined
? T | ''
: T extends number | undefined
? T | number
: T extends undefined
? T | null
: T,
undefined
>;
type PickFieldFormUnion<T, K extends keyof T> = T extends any
? T[keyof T & K]
: never;
// compact more types;
export type FormDefaultValues<T> = {
-readonly [K in AllKeys<T>]-?: ToDefaultable<PickFieldFormUnion<T, K>>;
};
/**
* https://github.com/shadcn-ui/ui/issues/427
*/
export function compatFormDefaultValues<T, K extends AllKeys<T> = AllKeys<T>>(
d: FormDefaultValues<Pick<T, K>>
): T {
return d as unknown as T;
}

View File

@ -1,7 +1,7 @@
/* eslint-disable */
import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
import { FragmentDefinitionNode } from 'graphql';
import { Incremental } from './graphql';
import type { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
import type { FragmentDefinitionNode } from 'graphql';
import type { Incremental } from './graphql';
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<

View File

@ -1,6 +1,6 @@
/* eslint-disable */
import * as types from './graphql';
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
/**
* Map of all GraphQL operations in the project.
@ -30,8 +30,8 @@ type Documents = {
"\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": typeof types.InsertSubscriptionDocument,
"\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": typeof types.UpdateSubscriptionsDocument,
"\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": typeof types.DeleteSubscriptionsDocument,
"\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": typeof types.GetSubscriptionDetailDocument,
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n cron {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetTasksDocument,
"\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": typeof types.GetSubscriptionDetailDocument,
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n cron {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetTasksDocument,
"\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": typeof types.InsertSubscriberTaskDocument,
"\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": typeof types.DeleteTasksDocument,
"\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority\n }\n }\n": typeof types.RetryTasksDocument,
@ -53,8 +53,8 @@ const documents: Documents = {
"\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": types.InsertSubscriptionDocument,
"\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": types.UpdateSubscriptionsDocument,
"\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": types.DeleteSubscriptionsDocument,
"\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": types.GetSubscriptionDetailDocument,
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n cron {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetTasksDocument,
"\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": types.GetSubscriptionDetailDocument,
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n cron {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetTasksDocument,
"\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": types.InsertSubscriberTaskDocument,
"\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": types.DeleteTasksDocument,
"\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority\n }\n }\n": types.RetryTasksDocument,
@ -141,11 +141,11 @@ export function gql(source: "\n mutation DeleteSubscriptions($filter: Subscri
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(source: "\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"): (typeof documents)["\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"];
export function gql(source: "\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"): (typeof documents)["\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(source: "\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n cron {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"): (typeof documents)["\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n cron {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"];
export function gql(source: "\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n cron {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"): (typeof documents)["\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n cron {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"];
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@ -1,8 +1,8 @@
import { AUTH_PROVIDER } from '@/infra/auth/auth.provider';
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { Injectable, inject } from '@outposts/injection-js';
import { firstValueFrom } from 'rxjs';
import { AUTH_PROVIDER } from '@/infra/auth/auth.provider';
@Injectable()
export class GraphQLService {

View File

@ -4,11 +4,18 @@ import { DOCUMENT } from '../platform/injection';
export class IntlService {
document = inject(DOCUMENT);
get timezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
get Intl(): typeof Intl {
return this.document.defaultView?.Intl as typeof Intl;
}
formatTimestamp(timestamp: number, options?: Intl.DateTimeFormatOptions) {
get timezone() {
return this.Intl.DateTimeFormat().resolvedOptions().timeZone;
}
formatDatetimeWithTz(
timestamp: number | string | Date,
options?: Intl.DateTimeFormatOptions
) {
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: '2-digit',
@ -20,7 +27,7 @@ export class IntlService {
...options,
};
return new Intl.DateTimeFormat(
return new this.Intl.DateTimeFormat(
this.document.defaultView?.navigator.language,
{
...defaultOptions,

View File

@ -1,6 +1,6 @@
import type { ProLinkProps } from '@/components/ui/pro-link';
import { type } from 'arktype';
import type { LucideIcon } from 'lucide-react';
import type { ProLinkProps } from '@/components/ui/pro-link';
export interface NavMainItem {
link?: ProLinkProps;

View File

@ -1,3 +1,13 @@
import { useMutation } from '@apollo/client';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { type } from 'arktype';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Card,
@ -24,7 +34,9 @@ import {
INSERT_CREDENTIAL_3RD,
} from '@/domains/recorder/schema/credential3rd';
import { useInject } from '@/infra/di/inject';
import { compatFormDefaultValues } from '@/infra/forms/compat';
import {
type Credential3rdInsertInput,
Credential3rdTypeEnum,
type InsertCredential3rdMutation,
type InsertCredential3rdMutationVariables,
@ -35,16 +47,6 @@ import {
CreateCompleteActionSchema,
} from '@/infra/routes/nav';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation } from '@apollo/client';
import {
createFileRoute,
useCanGoBack,
useNavigate,
useRouter,
} from '@tanstack/react-router';
import { type } from 'arktype';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
const RouteSearchSchema = type({
completeAction: CreateCompleteActionSchema.optional(),
@ -98,21 +100,24 @@ function CredentialCreateRouteComponent() {
});
const form = useAppForm({
defaultValues: {
defaultValues: compatFormDefaultValues<
Credential3rdInsertInput,
'credentialType' | 'username' | 'password' | 'userAgent'
>({
credentialType: Credential3rdTypeEnum.Mikan,
username: '',
password: '',
userAgent: '',
},
}),
validators: {
onChangeAsync: Credential3rdInsertSchema,
onChangeAsyncDebounceMs: 300,
onSubmit: Credential3rdInsertSchema,
},
onSubmit: async (form) => {
onSubmit: async (submittedForm) => {
const value = {
...form.value,
userAgent: form.value.userAgent || platformService.userAgent,
...submittedForm.value,
userAgent: submittedForm.value.userAgent || platformService.userAgent,
};
await insertCredential3rd({
variables: {
@ -183,7 +188,7 @@ function CredentialCreateRouteComponent() {
<Input
id={field.name}
name={field.name}
value={field.state.value}
value={field.state.value ?? ''}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Please enter username"
@ -207,7 +212,7 @@ function CredentialCreateRouteComponent() {
id={field.name}
name={field.name}
type="password"
value={field.state.value}
value={field.state.value ?? ''}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Please enter password"

View File

@ -1,3 +1,7 @@
import { useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { CheckIcon, Edit, Eye, EyeOff } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -15,13 +19,10 @@ import { Label } from '@/components/ui/label';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Separator } from '@/components/ui/separator';
import { GET_CREDENTIAL_3RD_DETAIL } from '@/domains/recorder/schema/credential3rd';
import { useInject } from '@/infra/di/inject';
import type { GetCredential3rdDetailQuery } from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { format } from 'date-fns/format';
import { CheckIcon, Edit, Eye, EyeOff } from 'lucide-react';
import { useState } from 'react';
import { Credential3rdCheckAvailableViewDialogContent } from './-check-available';
export const Route = createFileRoute('/_app/credential3rd/detail/$id')({
@ -34,6 +35,7 @@ export const Route = createFileRoute('/_app/credential3rd/detail/$id')({
function Credential3rdDetailRouteComponent() {
const { id } = Route.useParams();
const navigate = useNavigate();
const intlService = useInject(IntlService);
const [showPassword, setShowPassword] = useState(false);
@ -41,7 +43,7 @@ function Credential3rdDetailRouteComponent() {
GET_CREDENTIAL_3RD_DETAIL,
{
variables: {
id: Number.parseInt(id),
id: Number.parseInt(id, 10),
},
}
);
@ -177,10 +179,7 @@ function Credential3rdDetailRouteComponent() {
<Label className="font-medium text-sm">Created at</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{format(
new Date(credential.createdAt),
'yyyy-MM-dd HH:mm:ss'
)}
{intlService.formatDatetimeWithTz(credential.createdAt)}
</span>
</div>
</div>
@ -189,10 +188,7 @@ function Credential3rdDetailRouteComponent() {
<Label className="font-medium text-sm">Updated at</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{format(
new Date(credential.updatedAt),
'yyyy-MM-dd HH:mm:ss'
)}
{intlService.formatDatetimeWithTz(credential.updatedAt)}
</span>
</div>
</div>

View File

@ -1,3 +1,8 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { Eye, EyeOff, Save } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -32,18 +37,15 @@ import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import { compatFormDefaultValues } from '@/infra/forms/compat';
import type {
Credential3rdTypeEnum,
Credential3rdUpdateInput,
GetCredential3rdDetailQuery,
UpdateCredential3rdMutation,
UpdateCredential3rdMutationVariables,
} from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { Eye, EyeOff, Save } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
export const Route = createFileRoute('/_app/credential3rd/edit/$id')({
component: Credential3rdEditRouteComponent,
@ -77,18 +79,21 @@ function FormView({
});
const form = useAppForm({
defaultValues: {
defaultValues: compatFormDefaultValues<
Credential3rdUpdateInput,
'credentialType' | 'username' | 'password' | 'userAgent'
>({
credentialType: credential.credentialType,
username: credential.username,
password: credential.password,
userAgent: credential.userAgent,
},
username: credential.username ?? '',
password: credential.password ?? '',
userAgent: credential.userAgent ?? '',
}),
validators: {
onBlur: Credential3rdUpdateSchema,
onSubmit: Credential3rdUpdateSchema,
},
onSubmit: (form) => {
const value = form.value;
onSubmit: (submittedForm) => {
const value = submittedForm.value;
updateCredential({
variables: {
data: value,
@ -238,7 +243,7 @@ function Credential3rdEditRouteComponent() {
const { loading, error, data, refetch } =
useQuery<GetCredential3rdDetailQuery>(GET_CREDENTIAL_3RD_DETAIL, {
variables: {
id: Number.parseInt(id),
id: Number.parseInt(id, 10),
},
});
@ -246,10 +251,10 @@ function Credential3rdEditRouteComponent() {
const onCompleted = useCallback(async () => {
const refetchResult = await refetch();
const error = getApolloQueryError(refetchResult);
if (error) {
const _error = getApolloQueryError(refetchResult);
if (_error) {
toast.error('Update credential failed', {
description: apolloErrorToMessage(error),
description: apolloErrorToMessage(_error),
});
} else {
toast.success('Update credential successfully');

View File

@ -1,3 +1,20 @@
import { useMutation, useQuery } from '@apollo/client';
import { Dialog } from '@radix-ui/react-dialog';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
type Row,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { Eye, EyeOff, Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContainerHeader } from '@/components/ui/container-header';
@ -21,33 +38,17 @@ import {
DELETE_CREDENTIAL_3RD,
GET_CREDENTIAL_3RD,
} from '@/domains/recorder/schema/credential3rd';
import { useInject } from '@/infra/di/inject';
import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import type { GetCredential3rdQuery } from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { useEvent } from '@/presentation/hooks/use-event';
import { cn } from '@/presentation/utils';
import { useMutation, useQuery } from '@apollo/client';
import { Dialog } from '@radix-ui/react-dialog';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
type PaginationState,
type Row,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import { Eye, EyeOff, Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Credential3rdCheckAvailableViewDialogContent } from './-check-available';
export const Route = createFileRoute('/_app/credential3rd/manage')({
@ -59,6 +60,7 @@ export const Route = createFileRoute('/_app/credential3rd/manage')({
function CredentialManageRouteComponent() {
const navigate = useNavigate();
const intlService = useInject(IntlService);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
createdAt: false,
@ -94,18 +96,18 @@ function CredentialManageRouteComponent() {
const [deleteCredential] = useMutation(DELETE_CREDENTIAL_3RD, {
onCompleted: async () => {
const refetchResult = await refetch();
const error = getApolloQueryError(refetchResult);
if (error) {
const e = getApolloQueryError(refetchResult);
if (e) {
toast.error('Failed to delete credential', {
description: apolloErrorToMessage(error),
description: apolloErrorToMessage(e),
});
return;
}
toast.success('Credential deleted');
},
onError: (error) => {
onError: (e) => {
toast.error('Failed to delete credential', {
description: error.message,
description: e.message,
});
},
});
@ -212,7 +214,7 @@ function CredentialManageRouteComponent() {
const createdAt = row.original.createdAt;
return (
<div className="text-sm">
{format(new Date(createdAt), 'yyyy-MM-dd HH:mm:ss')}
{intlService.formatDatetimeWithTz(createdAt)}
</div>
);
},
@ -224,7 +226,7 @@ function CredentialManageRouteComponent() {
const updatedAt = row.original.updatedAt;
return (
<div className="text-sm">
{format(new Date(updatedAt), 'yyyy-MM-dd HH:mm:ss')}
{intlService.formatDatetimeWithTz(updatedAt)}
</div>
);
},
@ -266,7 +268,13 @@ function CredentialManageRouteComponent() {
},
];
return cs;
}, [handleDeleteRecord, navigate, showPasswords, togglePasswordVisibility]);
}, [
handleDeleteRecord,
navigate,
showPasswords,
togglePasswordVisibility,
intlService.formatDatetimeWithTz,
]);
const table = useReactTable({
data: useMemo(() => credentials?.nodes ?? [], [credentials]),

View File

@ -1,5 +1,8 @@
import { Cron } from '@/components/domains/cron';
import { CronMode } from '@/components/domains/cron/types';
import { useMutation } from '@apollo/client';
import { useNavigate } from '@tanstack/react-router';
import { CalendarIcon } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Card,
@ -9,6 +12,8 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Cron } from '@/components/ui/cron';
import { CronMode } from '@/components/ui/cron/types';
import {
DialogContent,
DialogDescription,
@ -26,11 +31,6 @@ import {
SubscriberTaskTypeEnum,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import { useMutation } from '@apollo/client';
import { useNavigate } from '@tanstack/react-router';
import { CalendarIcon } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
import { toast } from 'sonner';
const SUBSCRIPTION_TASK_CRON_PRESETS = [
{
@ -162,59 +162,53 @@ export const SubscriptionCronCreationView = memo(
const loading = loadingInsert;
return (
<>
<Tabs
defaultValue={CRON_TABS[0].tab}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex w-full shrink-0 overflow-x-auto">
<TabsList className="flex items-center justify-center whitespace-nowrap">
{CRON_TABS.map((tab) => (
<TabsTrigger
key={tab.tab}
value={tab.tab}
className="w-fit px-4"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{CRON_TABS.map((tab) => (
<TabsContent
key={tab.tab}
value={tab.tab}
className="flex-1 space-y-2"
asChild
>
<SubscriptionCronForm
tab={tab}
onComplete={(payload) => {
insertCron({
variables: {
data: {
cronExpr: payload.cronExpr,
cronTimezone: intlService.timezone,
subscriberTaskCron: {
subscriptionId,
taskType: tab.tab,
},
<Tabs
defaultValue={CRON_TABS[0].tab}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex w-full shrink-0 overflow-x-auto">
<TabsList className="flex items-center justify-center whitespace-nowrap">
{CRON_TABS.map((tab) => (
<TabsTrigger key={tab.tab} value={tab.tab} className="w-fit px-4">
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{CRON_TABS.map((tab) => (
<TabsContent
key={tab.tab}
value={tab.tab}
className="flex-1 space-y-2"
asChild
>
<SubscriptionCronForm
tab={tab}
onComplete={(payload) => {
insertCron({
variables: {
data: {
cronExpr: payload.cronExpr,
cronTimezone: intlService.timezone,
subscriberTaskCron: {
subscriptionId,
taskType: tab.tab,
},
},
});
}}
timezone={intlService.timezone}
/>
</TabsContent>
))}
{loading && (
<div className="absolute inset-0 flex flex-row items-center justify-center gap-2">
<Spinner variant="circle-filled" size="16" />
<span>Creating cron...</span>
</div>
)}
</Tabs>
</>
},
});
}}
timezone={intlService.timezone}
/>
</TabsContent>
))}
{loading && (
<div className="absolute inset-0 flex flex-row items-center justify-center gap-2">
<Spinner variant="circle-filled" size="16" />
<span>Creating cron...</span>
</div>
)}
</Tabs>
);
}
);

View File

@ -1,3 +1,7 @@
import { useMutation } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Card,
@ -27,6 +31,7 @@ import {
} from '@/domains/recorder/schema/subscriptions';
import { SubscriptionService } from '@/domains/recorder/services/subscription.service';
import { useInject } from '@/infra/di/inject';
import { compatFormDefaultValues } from '@/infra/forms/compat';
import {
Credential3rdTypeEnum,
type InsertSubscriptionMutation,
@ -34,11 +39,6 @@ import {
SubscriptionCategoryEnum,
} from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { useNavigate } from '@tanstack/react-router';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { Credential3rdSelectContent } from './-credential3rd-select';
export const Route = createFileRoute('/_app/subscriptions/create')({
@ -71,22 +71,24 @@ function SubscriptionCreateRouteComponent() {
});
const form = useAppForm({
defaultValues: {
defaultValues: compatFormDefaultValues<SubscriptionForm>({
displayName: '',
category: undefined,
category: '',
enabled: true,
sourceUrl: '',
credentialId: '',
year: undefined,
credentialId: Number.NaN,
year: Number.NaN,
seasonStr: '',
} as unknown as SubscriptionForm,
}),
validators: {
onChangeAsync: SubscriptionFormSchema,
onChangeAsyncDebounceMs: 300,
onSubmit: SubscriptionFormSchema,
},
onSubmit: async (form) => {
const input = subscriptionService.transformInsertFormToInput(form.value);
onSubmit: async (submittedForm) => {
const input = subscriptionService.transformInsertFormToInput(
submittedForm.value
);
await insertSubscription({
variables: {
data: input,
@ -119,30 +121,6 @@ function SubscriptionCreateRouteComponent() {
}}
className="space-y-6"
>
<form.Field name="displayName">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Display Name *</Label>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Please enter display name"
autoComplete="off"
/>
{field.state.meta.errors && (
<FormFieldErrors
errors={field.state.meta.errors}
isDirty={field.state.meta.isDirty}
submissionAttempts={form.state.submissionAttempts}
/>
)}
</div>
)}
</form.Field>
<form.Field name="category">
{(field) => (
<div className="space-y-2">
@ -192,7 +170,7 @@ function SubscriptionCreateRouteComponent() {
<Select
value={field.state.value.toString()}
onValueChange={(value) =>
field.handleChange(Number.parseInt(value))
field.handleChange(Number.parseInt(value, 10))
}
>
<SelectTrigger>
@ -227,7 +205,7 @@ function SubscriptionCreateRouteComponent() {
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange(
Number.parseInt(e.target.value)
Number.parseInt(e.target.value, 10)
)
}
placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`}
@ -315,6 +293,29 @@ function SubscriptionCreateRouteComponent() {
);
}}
</form.Subscribe>
<form.Field name="displayName">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>Display Name *</Label>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Please enter display name"
autoComplete="off"
/>
{field.state.meta.errors && (
<FormFieldErrors
errors={field.state.meta.errors}
isDirty={field.state.meta.isDirty}
submissionAttempts={form.state.submissionAttempts}
/>
)}
</div>
)}
</form.Field>
<form.Field name="enabled">
{(field) => (
<div className="flex items-center justify-between">

View File

@ -1,3 +1,17 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
Edit,
ExternalLink,
ListIcon,
Pause,
Play,
PlusIcon,
RefreshCcwIcon,
Trash2,
} from 'lucide-react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -13,6 +27,7 @@ import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
import { Img } from '@/components/ui/img';
import { Label } from '@/components/ui/label';
import { ProLink } from '@/components/ui/pro-link';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Separator } from '@/components/ui/separator';
import { UPDATE_CRONS } from '@/domains/recorder/schema/cron';
@ -33,27 +48,14 @@ import {
FeedSourceEnum,
FeedTypeEnum,
type GetSubscriptionDetailQuery,
type GetSubscriptionDetailQueryVariables,
type InsertFeedMutation,
type InsertFeedMutationVariables,
SubscriptionCategoryEnum,
type UpdateCronsMutation,
type UpdateCronsMutationVariables,
} from '@/infra/graphql/gql/graphql';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { format } from 'date-fns';
import {
Edit,
ExternalLink,
ListIcon,
Pause,
Play,
PlusIcon,
RefreshCcwIcon,
Trash2,
} from 'lucide-react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { IntlService } from '@/infra/intl/intl.service';
import { prettyTaskType } from '../tasks/-pretty-task-type';
import { SubscriptionCronCreationDialogContent } from './-cron-creation';
import { SubscriptionTaskCreationDialogContent } from './-task-creation';
@ -66,6 +68,7 @@ function SubscriptionDetailRouteComponent() {
const { id } = Route.useParams();
const navigate = useNavigate();
const subscriptionService = useInject(SubscriptionService);
const intlService = useInject(IntlService);
const handleReload = async () => {
const result = await refetch();
@ -77,12 +80,23 @@ function SubscriptionDetailRouteComponent() {
}
};
const { data, loading, error, refetch } =
useQuery<GetSubscriptionDetailQuery>(GET_SUBSCRIPTION_DETAIL, {
const {
data,
loading,
error: subscriptionError,
refetch,
} = useQuery<GetSubscriptionDetailQuery, GetSubscriptionDetailQueryVariables>(
GET_SUBSCRIPTION_DETAIL,
{
variables: {
id: Number.parseInt(id),
filter: {
id: {
eq: Number.parseInt(id, 10),
},
},
},
});
}
);
const handleEnterEditMode = () => {
navigate({
@ -203,8 +217,8 @@ function SubscriptionDetailRouteComponent() {
return <DetailCardSkeleton />;
}
if (error) {
return <QueryErrorView message={error.message} />;
if (subscriptionError) {
return <QueryErrorView message={subscriptionError.message} />;
}
if (!subscription) {
@ -342,10 +356,7 @@ function SubscriptionDetailRouteComponent() {
<Label className="font-medium text-sm">Created at</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{format(
new Date(subscription.createdAt),
'yyyy-MM-dd HH:mm:ss'
)}
{intlService.formatDatetimeWithTz(subscription.createdAt)}
</span>
</div>
</div>
@ -354,10 +365,7 @@ function SubscriptionDetailRouteComponent() {
<Label className="font-medium text-sm">Updated at</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{format(
new Date(subscription.updatedAt),
'yyyy-MM-dd HH:mm:ss'
)}
{intlService.formatDatetimeWithTz(subscription.updatedAt)}
</span>
</div>
</div>
@ -374,7 +382,7 @@ function SubscriptionDetailRouteComponent() {
insertFeed({
variables: {
data: {
subscriptionId: Number.parseInt(id),
subscriptionId: Number.parseInt(id, 10),
feedType: FeedTypeEnum.Rss,
feedSource: FeedSourceEnum.SubscriptionEpisode,
},
@ -429,7 +437,7 @@ function SubscriptionDetailRouteComponent() {
</code>
<div className="text-muted-foreground text-xs">
{format(new Date(feed.createdAt), 'MM-dd HH:mm')}
{intlService.formatDatetimeWithTz(feed.createdAt)}
</div>
</div>
</Card>
@ -684,24 +692,22 @@ function SubscriptionDetailRouteComponent() {
Updated At
</Label>
<div className="font-mono text-sm">
{format(
new Date(bangumi.updatedAt),
'yyyy-MM-dd'
{intlService.formatDatetimeWithTz(
bangumi.updatedAt
)}
</div>
</div>
</div>
{bangumi.homepage && (
<div className="mt-3 border-t pt-3">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(bangumi.homepage!, '_blank')
}
>
<ExternalLink className="mr-2 h-3 w-3" />
Homepage
<Button variant="outline" size="sm" asChild>
<ProLink
href={bangumi.homepage}
target="_blank"
>
<ExternalLink className="mr-2 h-3 w-3" />
Homepage
</ProLink>
</Button>
</div>
)}

View File

@ -1,3 +1,8 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { Save } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -36,6 +41,7 @@ import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import { compatFormDefaultValues } from '@/infra/forms/compat';
import {
Credential3rdTypeEnum,
type GetSubscriptionDetailQuery,
@ -44,11 +50,6 @@ import {
type UpdateSubscriptionsMutationVariables,
} from '@/infra/graphql/gql/graphql';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { Save } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { Credential3rdSelectContent } from './-credential3rd-select';
export const Route = createFileRoute('/_app/subscriptions/edit/$id')({
@ -100,7 +101,9 @@ function FormView({
category: subscription.category,
enabled: subscription.enabled,
sourceUrl: subscription.sourceUrl,
credentialId: subscription.credential3rd?.id || '',
credentialId: subscription.credential3rd?.id ?? Number.NaN,
year: Number.NaN,
seasonStr: '',
};
if (
@ -118,14 +121,16 @@ function FormView({
}, [subscription, sourceUrlMeta]);
const form = useAppForm({
defaultValues: defaultValues as unknown as SubscriptionForm,
defaultValues: compatFormDefaultValues<SubscriptionForm>(defaultValues),
validators: {
onChangeAsync: SubscriptionFormSchema,
onChangeAsyncDebounceMs: 300,
onSubmit: SubscriptionFormSchema,
},
onSubmit: async (form) => {
const input = subscriptionService.transformInsertFormToInput(form.value);
onSubmit: async (submittedForm) => {
const input = subscriptionService.transformInsertFormToInput(
submittedForm.value
);
await updateSubscription({
variables: {
@ -217,7 +222,7 @@ function FormView({
<Select
value={field.state.value.toString()}
onValueChange={(value) =>
field.handleChange(Number.parseInt(value))
field.handleChange(Number.parseInt(value, 10))
}
>
<SelectTrigger>
@ -249,7 +254,9 @@ function FormView({
min={1970}
onBlur={field.handleBlur}
onChange={(e) =>
field.handleChange(Number.parseInt(e.target.value))
field.handleChange(
Number.parseInt(e.target.value, 10)
)
}
placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`}
autoComplete="off"
@ -359,7 +366,7 @@ function SubscriptionEditRouteComponent() {
const { loading, error, data, refetch } =
useQuery<GetSubscriptionDetailQuery>(GET_SUBSCRIPTION_DETAIL, {
variables: {
id: Number.parseInt(id),
id: Number.parseInt(id, 10),
},
});
@ -367,10 +374,10 @@ function SubscriptionEditRouteComponent() {
const onCompleted = useCallback(async () => {
const refetchResult = await refetch();
const error = getApolloQueryError(refetchResult);
if (error) {
const _error = getApolloQueryError(refetchResult);
if (_error) {
toast.error('Update subscription failed', {
description: apolloErrorToMessage(error),
description: apolloErrorToMessage(_error),
});
} else {
toast.success('Update subscription successfully');

View File

@ -1,3 +1,18 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { ContainerHeader } from '@/components/ui/container-header';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
@ -22,31 +37,19 @@ import {
type SubscriptionDto,
UPDATE_SUBSCRIPTIONS,
} from '@/domains/recorder/schema/subscriptions';
import { useInject } from '@/infra/di/inject';
import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import type { GetSubscriptionsQuery } from '@/infra/graphql/gql/graphql';
import type {
GetSubscriptionsQuery,
GetSubscriptionsQueryVariables,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { cn } from '@/presentation/utils';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
type PaginationState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { SubscriptionTaskCreationDialogContent } from './-task-creation';
export const Route = createFileRoute('/_app/subscriptions/manage')({
@ -58,6 +61,7 @@ export const Route = createFileRoute('/_app/subscriptions/manage')({
function SubscriptionManageRouteComponent() {
const navigate = useNavigate();
const intlService = useInject(IntlService);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
createdAt: false,
@ -69,7 +73,12 @@ function SubscriptionManageRouteComponent() {
pageSize: 10,
});
const { loading, error, data, refetch } = useQuery<GetSubscriptionsQuery>(
const {
loading,
error: subscriptionsError,
data,
refetch,
} = useQuery<GetSubscriptionsQuery, GetSubscriptionsQueryVariables>(
GET_SUBSCRIPTIONS,
{
variables: {
@ -138,11 +147,11 @@ function SubscriptionManageRouteComponent() {
<div className="px-1">
<Switch
checked={enabled}
onCheckedChange={(enabled) =>
onCheckedChange={(checked) =>
updateSubscription({
variables: {
data: {
enabled,
enabled: checked,
},
filter: {
id: {
@ -189,7 +198,7 @@ function SubscriptionManageRouteComponent() {
const createdAt = row.original.createdAt;
return (
<div className="text-sm">
{format(new Date(createdAt), 'yyyy-MM-dd HH:mm:ss')}
{intlService.formatDatetimeWithTz(createdAt)}
</div>
);
},
@ -201,7 +210,7 @@ function SubscriptionManageRouteComponent() {
const updatedAt = row.original.updatedAt;
return (
<div className="text-sm">
{format(new Date(updatedAt), 'yyyy-MM-dd HH:mm:ss')}
{intlService.formatDatetimeWithTz(updatedAt)}
</div>
);
},
@ -247,7 +256,12 @@ function SubscriptionManageRouteComponent() {
},
];
return cs;
}, [updateSubscription, deleteSubscription, navigate]);
}, [
updateSubscription,
deleteSubscription,
navigate,
intlService.formatDatetimeWithTz,
]);
const table = useReactTable({
data: useMemo(() => subscriptions?.nodes ?? [], [subscriptions]),
@ -274,8 +288,10 @@ function SubscriptionManageRouteComponent() {
},
});
if (error) {
return <QueryErrorView message={error.message} onRetry={refetch} />;
if (subscriptionsError) {
return (
<QueryErrorView message={subscriptionsError.message} onRetry={refetch} />
);
}
return (

View File

@ -1,9 +1,314 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { RefreshCw } from 'lucide-react';
import { useMemo } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { ContainerHeader } from '@/components/ui/container-header';
import { CronDisplay } from '@/components/ui/cron';
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { Label } from '@/components/ui/label';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Separator } from '@/components/ui/separator';
import { GET_CRONS } from '@/domains/recorder/schema/cron';
import { useInject } from '@/infra/di/inject';
import {
CronStatusEnum,
type GetCronsQuery,
type GetCronsQueryVariables,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { getStatusBadge } from './-status-badge';
export const Route = createFileRoute('/_app/tasks/cron/detail/$id')({
component: RouteComponent,
})
component: CronDetailRouteComponent,
staticData: {
breadcrumb: { label: 'Detail' },
} satisfies RouteStateDataOption,
});
function RouteComponent() {
return <div>Hello "/_app/tasks/cron/detail/$id"!</div>
function CronDetailRouteComponent() {
const { id } = Route.useParams();
const intlService = useInject(IntlService);
const { data, loading, error, refetch } = useQuery<
GetCronsQuery,
GetCronsQueryVariables
>(GET_CRONS, {
variables: {
filter: {
id: {
eq: Number.parseInt(id, 10),
},
},
pagination: {
page: {
page: 0,
limit: 1,
},
},
orderBy: {},
},
pollInterval: 5000, // Auto-refresh every 5 seconds for running crons
});
const cron = data?.cron?.nodes?.[0];
const subscriberTaskCron = useMemo(() => {
if (!cron) {
return null;
}
return cron.subscriberTaskCron;
}, [cron]);
if (loading) {
return <DetailCardSkeleton />;
}
if (error) {
return <QueryErrorView message={error.message} onRetry={refetch} />;
}
if (!cron) {
return <DetailEmptyView message="Not found Cron task" />;
}
return (
<div className="container mx-auto max-w-4xl py-6">
<ContainerHeader
title="Cron task detail"
description={`View Cron task #${cron.id}`}
defaultBackTo="/tasks/cron/manage"
actions={
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
}
/>
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Cron task information</CardTitle>
<CardDescription className="mt-2">
View Cron task execution details
</CardDescription>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(cron.status)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="font-medium text-sm">ID</Label>
<div className="rounded-md bg-muted p-3">
<code className="text-sm">{cron.id}</code>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Priority</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">{cron.priority}</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Attemps</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.attempts} / {cron.maxAttempts}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Enabled</Label>
<div className="rounded-md bg-muted p-3">
<Badge variant={cron.enabled ? 'default' : 'secondary'}>
{cron.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Next run time</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.nextRun
? intlService.formatDatetimeWithTz(cron.nextRun)
: '-'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Last run time</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.lastRun
? intlService.formatDatetimeWithTz(cron.lastRun)
: '-'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Locked time</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.lockedAt
? intlService.formatDatetimeWithTz(cron.lockedAt)
: '-'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Locked by</Label>
<div className="rounded-md bg-muted p-3">
<code className="text-sm">{cron.lockedBy || '-'}</code>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Timeout</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{cron.timeoutMs ? `${cron.timeoutMs}ms` : 'No limit'}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Created at</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{intlService.formatDatetimeWithTz(cron.createdAt)}
</span>
</div>
</div>
<div className="space-y-2">
<Label className="font-medium text-sm">Updated at</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{intlService.formatDatetimeWithTz(cron.updatedAt)}
</span>
</div>
</div>
</div>
{/* Subscriber Task Details */}
{subscriberTaskCron && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm">
Subscriber task details
</Label>
<div className="rounded-md bg-muted p-3">
<pre className="overflow-x-auto whitespace-pre-wrap text-sm">
<code>
{JSON.stringify(subscriberTaskCron, null, 2)}
</code>
</pre>
</div>
</div>
</>
)}
{/* Cron Expression Display */}
{cron.cronExpr && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm">
Cron expression
</Label>
<CronDisplay
expression={cron.cronExpr}
timezone="UTC"
showDescription={true}
showNextRuns={true}
withCard={false}
/>
</div>
</>
)}
{/* Related Subscriber Tasks */}
{cron.subscriberTask?.nodes &&
cron.subscriberTask.nodes.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm">
Associated tasks
</Label>
<div className="space-y-2">
{cron.subscriberTask.nodes.map((task, index) => (
<div
key={`${task.id}-${index}`}
className="rounded-md border bg-muted/50 p-3"
>
<div className="flex items-center justify-between">
<code className="text-sm">{task.id}</code>
<Badge variant="outline">{task.status}</Badge>
</div>
<div className="mt-2 text-muted-foreground text-sm">
Priority: {task.priority} | Attempts:{' '}
{task.attempts}/{task.maxAttempts}
</div>
{task.subscription && (
<div className="mt-2 text-sm">
<span className="font-medium">
Subscription:
</span>{' '}
{task.subscription.displayName}
</div>
)}
</div>
))}
</div>
</div>
</>
)}
{/* Error Information */}
{cron.status === CronStatusEnum.Failed && cron.lastError && (
<>
<Separator />
<div className="space-y-2">
<Label className="font-medium text-sm"></Label>
<div className="rounded-md bg-destructive/10 p-3">
<p className="text-destructive text-sm">
{cron.lastError}
</p>
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,9 +1,22 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { RefreshCw } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContainerHeader } from '@/components/ui/container-header';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Skeleton } from '@/components/ui/skeleton';
@ -12,6 +25,7 @@ import {
DELETE_CRONS,
GET_CRONS,
} from '@/domains/recorder/schema/cron';
import { useInject } from '@/infra/di/inject';
import {
apolloErrorToMessage,
getApolloQueryError,
@ -23,23 +37,9 @@ import {
type GetCronsQuery,
type GetCronsQueryVariables,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
type PaginationState,
type SortingState,
type VisibilityState,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import { RefreshCw } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { getStatusBadge } from './-status-badge';
export const Route = createFileRoute('/_app/tasks/cron/manage')({
@ -51,6 +51,7 @@ export const Route = createFileRoute('/_app/tasks/cron/manage')({
function TaskCronManageRouteComponent() {
const navigate = useNavigate();
const intlService = useInject(IntlService);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [sorting, setSorting] = useState<SortingState>([]);
@ -88,18 +89,18 @@ function TaskCronManageRouteComponent() {
>(DELETE_CRONS, {
onCompleted: async () => {
const refetchResult = await refetch();
const error = getApolloQueryError(refetchResult);
if (error) {
const errorResult = getApolloQueryError(refetchResult);
if (errorResult) {
toast.error('Failed to delete tasks', {
description: apolloErrorToMessage(error),
description: apolloErrorToMessage(errorResult),
});
return;
}
toast.success('Tasks deleted');
},
onError: (error) => {
onError: (mutationError) => {
toast.error('Failed to delete tasks', {
description: error.message,
description: mutationError.message,
});
},
});
@ -168,16 +169,16 @@ function TaskCronManageRouteComponent() {
<div className="space-y-3">
{showSkeleton &&
Array.from(new Array(10)).map((_, index) => (
<Skeleton key={index} className="h-32 w-full" />
<Skeleton key={`skeleton-${index}`} className="h-32 w-full" />
))}
{!showSkeleton && table.getRowModel().rows?.length > 0 ? (
table.getRowModel().rows.map((row, index) => {
table.getRowModel().rows.map((row) => {
const cron = row.original;
return (
<div
className="space-y-3 rounded-lg border bg-card p-4"
key={`${cron.id}-${index}`}
key={cron.id}
>
{/* Header with status and priority */}
<div className="flex items-center justify-between gap-2">
@ -215,17 +216,7 @@ function TaskCronManageRouteComponent() {
},
})
}
>
{cron.status === CronStatusEnum.Failed && (
<DropdownMenuItem
onSelect={() => {
// TODO: Retry cron
}}
>
Retry
</DropdownMenuItem>
)}
</DropdownMenuActions>
/>
</div>
</div>
@ -235,7 +226,7 @@ function TaskCronManageRouteComponent() {
<span className="text-muted-foreground">Next run: </span>
<span>
{cron.nextRun
? format(new Date(cron.nextRun), 'MM/dd HH:mm')
? intlService.formatDatetimeWithTz(cron.nextRun)
: '-'}
</span>
</div>
@ -244,7 +235,7 @@ function TaskCronManageRouteComponent() {
<span className="text-muted-foreground">Last run: </span>
<span>
{cron.lastRun
? format(new Date(cron.lastRun), 'MM/dd HH:mm')
? intlService.formatDatetimeWithTz(cron.lastRun)
: '-'}
</span>
</div>
@ -262,7 +253,7 @@ function TaskCronManageRouteComponent() {
<span className="text-muted-foreground">Lock at: </span>
<span>
{cron.lockedAt
? format(new Date(cron.lockedAt), 'MM/dd HH:mm')
? intlService.formatDatetimeWithTz(cron.lockedAt)
: '-'}
</span>
</div>

View File

@ -1,3 +1,8 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { RefreshCw } from 'lucide-react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -14,8 +19,11 @@ import { Label } from '@/components/ui/label';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Separator } from '@/components/ui/separator';
import { GET_TASKS, RETRY_TASKS } from '@/domains/recorder/schema/tasks';
import { getApolloQueryError } from '@/infra/errors/apollo';
import { apolloErrorToMessage } from '@/infra/errors/apollo';
import { useInject } from '@/infra/di/inject';
import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import {
type GetTasksQuery,
type GetTasksQueryVariables,
@ -23,13 +31,8 @@ import {
type RetryTasksMutationVariables,
SubscriberTaskStatusEnum,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute } from '@tanstack/react-router';
import { format } from 'date-fns';
import { RefreshCw } from 'lucide-react';
import { useMemo } from 'react';
import { toast } from 'sonner';
import { prettyTaskType } from './-pretty-task-type';
import { getStatusBadge } from './-status-badge';
@ -43,10 +46,14 @@ export const Route = createFileRoute('/_app/tasks/detail/$id')({
function TaskDetailRouteComponent() {
const { id } = Route.useParams();
const { data, loading, error, refetch } = useQuery<
GetTasksQuery,
GetTasksQueryVariables
>(GET_TASKS, {
const intlService = useInject(IntlService);
const {
data,
loading,
error: taskError,
refetch,
} = useQuery<GetTasksQuery, GetTasksQueryVariables>(GET_TASKS, {
variables: {
filter: {
id: {
@ -102,8 +109,8 @@ function TaskDetailRouteComponent() {
return <DetailCardSkeleton />;
}
if (error) {
return <QueryErrorView message={error.message} onRetry={refetch} />;
if (taskError) {
return <QueryErrorView message={taskError.message} onRetry={refetch} />;
}
if (!task) {
@ -195,7 +202,7 @@ function TaskDetailRouteComponent() {
</Label>
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{format(new Date(task.runAt), 'yyyy-MM-dd HH:mm:ss')}
{intlService.formatDatetimeWithTz(task.runAt)}
</span>
</div>
</div>
@ -205,7 +212,7 @@ function TaskDetailRouteComponent() {
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{task.doneAt
? format(new Date(task.doneAt), 'yyyy-MM-dd HH:mm:ss')
? intlService.formatDatetimeWithTz(task.doneAt)
: '-'}
</span>
</div>
@ -216,7 +223,7 @@ function TaskDetailRouteComponent() {
<div className="rounded-md bg-muted p-3">
<span className="text-sm">
{task.lockAt
? format(new Date(task.lockAt), 'yyyy-MM-dd HH:mm:ss')
? intlService.formatDatetimeWithTz(task.lockAt)
: '-'}
</span>
</div>

View File

@ -1,7 +1,23 @@
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
type SortingState,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import { RefreshCw } from 'lucide-react';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ContainerHeader } from '@/components/ui/container-header';
import { DataTablePagination } from '@/components/ui/data-table-pagination';
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
import { QueryErrorView } from '@/components/ui/query-error-view';
import { Skeleton } from '@/components/ui/skeleton';
@ -11,6 +27,11 @@ import {
RETRY_TASKS,
type TaskDto,
} from '@/domains/recorder/schema/tasks';
import { useInject } from '@/infra/di/inject';
import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import {
type DeleteTasksMutation,
type DeleteTasksMutationVariables,
@ -20,30 +41,9 @@ import {
type RetryTasksMutationVariables,
SubscriberTaskStatusEnum,
} from '@/infra/graphql/gql/graphql';
import { IntlService } from '@/infra/intl/intl.service';
import type { RouteStateDataOption } from '@/infra/routes/traits';
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
import { useMutation, useQuery } from '@apollo/client';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
type ColumnDef,
type PaginationState,
type SortingState,
type VisibilityState,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import { format } from 'date-fns';
import { RefreshCw } from 'lucide-react';
import { ContainerHeader } from '@/components/ui/container-header';
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import {
apolloErrorToMessage,
getApolloQueryError,
} from '@/infra/errors/apollo';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { prettyTaskType } from './-pretty-task-type';
import { getStatusBadge } from './-status-badge';
@ -64,10 +64,14 @@ function TaskManageRouteComponent() {
pageSize: 10,
});
const { loading, error, data, refetch } = useQuery<
GetTasksQuery,
GetTasksQueryVariables
>(GET_TASKS, {
const intlService = useInject(IntlService);
const {
loading,
error: tasksError,
data,
refetch,
} = useQuery<GetTasksQuery, GetTasksQueryVariables>(GET_TASKS, {
variables: {
pagination: {
page: {
@ -168,8 +172,8 @@ function TaskManageRouteComponent() {
},
});
if (error) {
return <QueryErrorView message={error.message} onRetry={refetch} />;
if (tasksError) {
return <QueryErrorView message={tasksError.message} onRetry={refetch} />;
}
return (
@ -262,14 +266,14 @@ function TaskManageRouteComponent() {
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-muted-foreground">Run at: </span>
<span>{format(new Date(task.runAt), 'MM/dd HH:mm')}</span>
<span>{intlService.formatDatetimeWithTz(task.runAt)}</span>
</div>
<div>
<span className="text-muted-foreground">Done: </span>
<span>
{task.doneAt
? format(new Date(task.doneAt), 'MM/dd HH:mm')
? intlService.formatDatetimeWithTz(task.doneAt)
: '-'}
</span>
</div>
@ -287,7 +291,7 @@ function TaskManageRouteComponent() {
<span className="text-muted-foreground">Lock at: </span>
<span>
{task.lockAt
? format(new Date(task.lockAt), 'MM/dd HH:mm')
? intlService.formatDatetimeWithTz(task.lockAt)
: '-'}
</span>
</div>

View File

@ -1,31 +1,39 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.1.1/schema.json",
"extends": ["ultracite"],
"javascript": {
"globals": ["Liveblocks"]
},
"assist": {
"actions": {
"source": {
"useSortedAttributes": "off"
}
}
},
"linter": {
"rules": {
"nursery": {
"noEnum": "off"
},
"nursery": {},
"style": {
"noParameterProperties": "off",
"noNonNullAssertion": "off"
"noNonNullAssertion": "off",
"noEnum": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
},
"suspicious": {
"noArrayIndexKey": "off",
"noEmptyBlockStatements": "off",
"noExplicitAny": "off",
"noConsole": "off",
"noConsoleLog": "off"
"noConsole": "off"
},
"a11y": {
"noSvgWithoutTitle": "off"
},
"performance": {
"noNamespaceImport": "off"
},
"complexity": {
"noExcessiveCognitiveComplexity": {
"level": "warn",
@ -36,16 +44,20 @@
"noBannedTypes": "off"
},
"correctness": {
"noUnusedVariables": {
"fix": "none",
"level": "error"
},
"noUnusedImports": {
"fix": "none",
"level": "warn"
"level": "error"
}
}
}
},
"overrides": [
{
"include": ["**/tsconfig.json", "**/tsconfig.*.json"],
"includes": ["**/tsconfig.json", "**/tsconfig.*.json"],
"json": {
"parser": {
"allowComments": true
@ -53,11 +65,17 @@
}
},
{
"include": ["apps/webui/src/infra/graphql/gql/**/*"],
"includes": ["**/apps/webui/src/infra/graphql/gql/**/*"],
"assist": {
"actions": {
"source": {
"organizeImports": "off"
}
}
},
"linter": {
"rules": {
"style": {
"useShorthandArrayType": "off",
"useConsistentArrayType": "off",
"useImportType": "off"
}
@ -65,7 +83,7 @@
}
},
{
"include": ["apps/webui/src/components/ui/**/*"],
"includes": ["**/apps/webui/src/components/ui/**/*"],
"javascript": {
"formatter": {
"quoteStyle": "double"
@ -75,10 +93,10 @@
"rules": {
"style": {
"useBlockStatements": "off",
"useImportType": "off"
"useImportType": "off",
"noNestedTernary": "off"
},
"nursery": {
"noNestedTernary": "off",
"useSortedClasses": "off"
},
"a11y": {
@ -94,6 +112,6 @@
}
],
"files": {
"ignore": [".vscode/*.json"]
"includes": ["**", "!**/.vscode/**/*.json"]
}
}

View File

@ -18,22 +18,23 @@
"bump-deps": "npx --yes npm-check-updates --deep -u && pnpm install",
"clean": "git clean -xdf node_modules"
},
"packageManager": "pnpm@10.12.1",
"packageManager": "pnpm@10.12.4",
"engines": {
"node": ">=22"
"node": ">=24"
},
"dependencies": {
"@typescript/native-preview": "7.0.0-dev.20250712.1",
"es-toolkit": "^1.39.6"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@biomejs/biome": "2.1.1",
"@types/node": "^24.0.10",
"cross-env": "^7.0.3",
"kill-port": "^2.0.1",
"npm-run-all": "^4.1.5",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"ultracite": "^4.2.13"
"ultracite": "^5.0.32"
},
"pnpm": {
"overrides": {

385
pnpm-lock.yaml generated
View File

@ -11,13 +11,16 @@ importers:
.:
dependencies:
'@typescript/native-preview':
specifier: 7.0.0-dev.20250712.1
version: 7.0.0-dev.20250712.1
es-toolkit:
specifier: ^1.39.6
version: 1.39.6
devDependencies:
'@biomejs/biome':
specifier: 1.9.4
version: 1.9.4
specifier: 2.1.1
version: 2.1.1
'@types/node':
specifier: ^24.0.10
version: 24.0.10
@ -37,8 +40,8 @@ importers:
specifier: ^5.8.3
version: 5.8.3
ultracite:
specifier: ^4.2.13
version: 4.2.13
specifier: ^5.0.32
version: 5.0.32(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
apps/docs: {}
@ -601,59 +604,65 @@ packages:
resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==}
engines: {node: '>=6.9.0'}
'@biomejs/biome@1.9.4':
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
'@biomejs/biome@2.1.1':
resolution: {integrity: sha512-HFGYkxG714KzG+8tvtXCJ1t1qXQMzgWzfvQaUjxN6UeKv+KvMEuliInnbZLJm6DXFXwqVi6446EGI0sGBLIYng==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@1.9.4':
resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==}
'@biomejs/cli-darwin-arm64@2.1.1':
resolution: {integrity: sha512-2Muinu5ok4tWxq4nu5l19el48cwCY/vzvI7Vjbkf3CYIQkjxZLyj0Ad37Jv2OtlXYaLvv+Sfu1hFeXt/JwRRXQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@1.9.4':
resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==}
'@biomejs/cli-darwin-x64@2.1.1':
resolution: {integrity: sha512-cC8HM5lrgKQXLAK+6Iz2FrYW5A62pAAX6KAnRlEyLb+Q3+Kr6ur/sSuoIacqlp1yvmjHJqjYfZjPvHWnqxoEIA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@1.9.4':
resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==}
'@biomejs/cli-linux-arm64-musl@2.1.1':
resolution: {integrity: sha512-/7FBLnTswu4jgV9ttI3AMIdDGqVEPIZd8I5u2D4tfCoj8rl9dnjrEQbAIDlWhUXdyWlFSz8JypH3swU9h9P+2A==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-arm64@1.9.4':
resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==}
'@biomejs/cli-linux-arm64@2.1.1':
resolution: {integrity: sha512-tw4BEbhAUkWPe4WBr6IX04DJo+2jz5qpPzpW/SWvqMjb9QuHY8+J0M23V8EPY/zWU4IG8Ui0XESapR1CB49Q7g==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
'@biomejs/cli-linux-x64-musl@1.9.4':
resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==}
'@biomejs/cli-linux-x64-musl@2.1.1':
resolution: {integrity: sha512-kUu+loNI3OCD2c12cUt7M5yaaSjDnGIksZwKnueubX6c/HWUyi/0mPbTBHR49Me3F0KKjWiKM+ZOjsmC+lUt9g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-linux-x64@1.9.4':
resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==}
'@biomejs/cli-linux-x64@2.1.1':
resolution: {integrity: sha512-3WJ1GKjU7NzZb6RTbwLB59v9cTIlzjbiFLDB0z4376TkDqoNYilJaC37IomCr/aXwuU8QKkrYoHrgpSq5ffJ4Q==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
'@biomejs/cli-win32-arm64@1.9.4':
resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==}
'@biomejs/cli-win32-arm64@2.1.1':
resolution: {integrity: sha512-vEHK0v0oW+E6RUWLoxb2isI3rZo57OX9ZNyyGH701fZPj6Il0Rn1f5DMNyCmyflMwTnIQstEbs7n2BxYSqQx4Q==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@1.9.4':
resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==}
'@biomejs/cli-win32-x64@2.1.1':
resolution: {integrity: sha512-i2PKdn70kY++KEF/zkQFvQfX1e8SkA8hq4BgC+yE9dZqyLzB/XStY2MvwI3qswlRgnGpgncgqe0QYKVS1blksg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
'@clack/core@0.5.0':
resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==}
'@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@codemirror/language@6.11.1':
resolution: {integrity: sha512-5kS1U7emOGV84vxC+ruBty5sUgcD0te6dyupyRVG2zaSjhTDM73LhVKUtVwiqSe6QwmEoA4SCiU8AKPFyumAWQ==}
@ -3202,6 +3211,53 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-h5MlpLXr6I6zrKZKZOjyKdUhzUuO2+kKLEYmrR0HWS/U6dlcwvNuS2wUo1lRNgUTCblHJuOKmyWx3Sz+JG8Oxw==}
engines: {node: '>=20.6.0'}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-72SPfl0/U2ra4KlkzLWgA86zLKUIimBPYE1NqZpJHLMoXkC5XtS9aXT8p6ivxkK+p1VV3fIWKM8BbxOUY3af0A==}
engines: {node: '>=20.6.0'}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-LqIRXAn1xC5amD+ypd7xVTyMhgbbhb9XLzLM32Gr8ogJUCvcLBbd8KCHsKnHSR8nc+3b9FKnYdK7YL6a2IqavA==}
engines: {node: '>=20.6.0'}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-XedHV/oRfLKrfU7XE5/Fz8Lf+eKCaQyNINCikQQlAuQWgT4pf8gW9FPkZYmeNvl7y+++43Wr2YJklmwXMrIDiA==}
engines: {node: '>=20.6.0'}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-dCIb4GTXvetTpeVJRsKf0lNnq1udfjkhDmyUX15yWV41mPg+PJUiLeZty2GOwFovSfEUSK+pQSP2iaA6ITbLtw==}
engines: {node: '>=20.6.0'}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-+Wze9OFlre7YxOh/2LePfh6TmdwMJkSyiFD6XRtmm1hkwoB8jk5h1Q5aC5P4a3LTYx6jie6eYSVSYUXtaWZqMw==}
engines: {node: '>=20.6.0'}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-PwLTlosngLgI4O41qjIFFanl5Q+G8bzUIvFdT0yk2vPAPnPyT0N2gLmGbAJouM3nSe7e+id2+iqdY+QCPvh0uA==}
engines: {node: '>=20.6.0'}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20250712.1':
resolution: {integrity: sha512-A8/aOsMpG6H8IcSIKYJSuHzbNkVr8dJOxbb4LMrSfOZ/JWayHQ4O5UJ9mSaKtyPwR6fInE5B8yMt7BYQOz77kA==}
engines: {node: '>=20.6.0'}
hasBin: true
'@vitejs/plugin-react@4.5.2':
resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==}
engines: {node: ^14.18.0 || >=16.0.0}
@ -3211,6 +3267,9 @@ packages:
'@vitest/expect@3.2.3':
resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==}
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
'@vitest/mocker@3.2.3':
resolution: {integrity: sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==}
peerDependencies:
@ -3222,21 +3281,47 @@ packages:
vite:
optional: true
'@vitest/mocker@3.2.4':
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.3':
resolution: {integrity: sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==}
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
'@vitest/runner@3.2.3':
resolution: {integrity: sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==}
'@vitest/runner@3.2.4':
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
'@vitest/snapshot@3.2.3':
resolution: {integrity: sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==}
'@vitest/snapshot@3.2.4':
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
'@vitest/spy@3.2.3':
resolution: {integrity: sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
'@vitest/utils@3.2.3':
resolution: {integrity: sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==}
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@ -4876,6 +4961,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
jsonfile@2.4.0:
resolution: {integrity: sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==}
@ -5002,6 +5090,9 @@ packages:
loupe@3.1.3:
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
loupe@3.1.4:
resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==}
lower-case-first@2.0.2:
resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==}
@ -5952,6 +6043,9 @@ packages:
simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
@ -6260,6 +6354,10 @@ packages:
resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==}
engines: {node: ^18.0.0 || >=20.0.0}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
@ -6384,8 +6482,8 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ultracite@4.2.13:
resolution: {integrity: sha512-j49R1z3xXIPhdvU19x0z0Z4hNewJYn4F1h42ULeaCOylBuxwGVE401piPxe3aVapwue7+Ec3J6wnL/+mW4zwww==}
ultracite@5.0.32:
resolution: {integrity: sha512-JjVNswL1mkIaOkPVh1nuEGnbEaCa94+ftqJ9hpRX2Y+jt72pcv32JeWg3Dqhkz/e3l449f7KAzMHO4x6IbxFZQ==}
hasBin: true
unbox-primitive@1.1.0:
@ -6492,6 +6590,11 @@ packages:
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@5.4.11:
resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -6551,6 +6654,34 @@ packages:
jsdom:
optional: true
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.4
'@vitest/ui': 3.2.4
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-languageserver-types@3.17.5:
resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==}
@ -7133,41 +7264,52 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@biomejs/biome@1.9.4':
'@biomejs/biome@2.1.1':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 1.9.4
'@biomejs/cli-darwin-x64': 1.9.4
'@biomejs/cli-linux-arm64': 1.9.4
'@biomejs/cli-linux-arm64-musl': 1.9.4
'@biomejs/cli-linux-x64': 1.9.4
'@biomejs/cli-linux-x64-musl': 1.9.4
'@biomejs/cli-win32-arm64': 1.9.4
'@biomejs/cli-win32-x64': 1.9.4
'@biomejs/cli-darwin-arm64': 2.1.1
'@biomejs/cli-darwin-x64': 2.1.1
'@biomejs/cli-linux-arm64': 2.1.1
'@biomejs/cli-linux-arm64-musl': 2.1.1
'@biomejs/cli-linux-x64': 2.1.1
'@biomejs/cli-linux-x64-musl': 2.1.1
'@biomejs/cli-win32-arm64': 2.1.1
'@biomejs/cli-win32-x64': 2.1.1
'@biomejs/cli-darwin-arm64@1.9.4':
'@biomejs/cli-darwin-arm64@2.1.1':
optional: true
'@biomejs/cli-darwin-x64@1.9.4':
'@biomejs/cli-darwin-x64@2.1.1':
optional: true
'@biomejs/cli-linux-arm64-musl@1.9.4':
'@biomejs/cli-linux-arm64-musl@2.1.1':
optional: true
'@biomejs/cli-linux-arm64@1.9.4':
'@biomejs/cli-linux-arm64@2.1.1':
optional: true
'@biomejs/cli-linux-x64-musl@1.9.4':
'@biomejs/cli-linux-x64-musl@2.1.1':
optional: true
'@biomejs/cli-linux-x64@1.9.4':
'@biomejs/cli-linux-x64@2.1.1':
optional: true
'@biomejs/cli-win32-arm64@1.9.4':
'@biomejs/cli-win32-arm64@2.1.1':
optional: true
'@biomejs/cli-win32-x64@1.9.4':
'@biomejs/cli-win32-x64@2.1.1':
optional: true
'@clack/core@0.5.0':
dependencies:
picocolors: 1.1.1
sisteransi: 1.0.5
'@clack/prompts@0.11.0':
dependencies:
'@clack/core': 0.5.0
picocolors: 1.1.1
sisteransi: 1.0.5
'@codemirror/language@6.11.1':
dependencies:
'@codemirror/state': 6.5.2
@ -9878,6 +10020,37 @@ snapshots:
dependencies:
'@types/node': 24.0.10
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20250712.1':
optional: true
'@typescript/native-preview@7.0.0-dev.20250712.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20250712.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20250712.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20250712.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20250712.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20250712.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20250712.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20250712.1
'@vitejs/plugin-react@4.5.2(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
dependencies:
'@babel/core': 7.27.4
@ -9898,6 +10071,14 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.2
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.3(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
dependencies:
'@vitest/spy': 3.2.3
@ -9906,32 +10087,66 @@ snapshots:
optionalDependencies:
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
'@vitest/mocker@3.2.4(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
'@vitest/pretty-format@3.2.3':
dependencies:
tinyrainbow: 2.0.0
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.3':
dependencies:
'@vitest/utils': 3.2.3
pathe: 2.0.3
strip-literal: 3.0.0
'@vitest/runner@3.2.4':
dependencies:
'@vitest/utils': 3.2.4
pathe: 2.0.3
strip-literal: 3.0.0
'@vitest/snapshot@3.2.3':
dependencies:
'@vitest/pretty-format': 3.2.3
magic-string: 0.30.17
pathe: 2.0.3
'@vitest/snapshot@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
magic-string: 0.30.17
pathe: 2.0.3
'@vitest/spy@3.2.3':
dependencies:
tinyspy: 4.0.3
'@vitest/spy@3.2.4':
dependencies:
tinyspy: 4.0.3
'@vitest/utils@3.2.3':
dependencies:
'@vitest/pretty-format': 3.2.3
loupe: 3.1.3
tinyrainbow: 2.0.0
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
loupe: 3.1.4
tinyrainbow: 2.0.0
'@webassemblyjs/ast@1.14.1':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@ -11790,6 +12005,8 @@ snapshots:
json5@2.2.3: {}
jsonc-parser@3.3.1: {}
jsonfile@2.4.0:
optionalDependencies:
graceful-fs: 4.2.11
@ -11907,6 +12124,8 @@ snapshots:
loupe@3.1.3: {}
loupe@3.1.4: {}
lower-case-first@2.0.2:
dependencies:
tslib: 2.8.1
@ -12943,6 +13162,8 @@ snapshots:
is-arrayish: 0.3.2
optional: true
sisteransi@1.0.5: {}
slash@3.0.0: {}
slice-ansi@3.0.0:
@ -13264,6 +13485,8 @@ snapshots:
tinypool@1.1.0: {}
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.3: {}
@ -13390,9 +13613,30 @@ snapshots:
uc.micro@2.1.0: {}
ultracite@4.2.13:
ultracite@5.0.32(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
dependencies:
'@clack/prompts': 0.11.0
commander: 14.0.0
deepmerge: 4.3.1
jsonc-parser: 3.3.1
vitest: 3.2.4(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
transitivePeerDependencies:
- '@edge-runtime/vm'
- '@types/debug'
- '@types/node'
- '@vitest/browser'
- '@vitest/ui'
- happy-dom
- jsdom
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
unbox-primitive@1.1.0:
dependencies:
@ -13527,6 +13771,24 @@ snapshots:
- supports-color
- terser
vite-node@3.2.4(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
dependencies:
esbuild: 0.21.5
@ -13578,6 +13840,45 @@ snapshots:
- supports-color
- terser
vitest@3.2.4(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.2.0
debug: 4.4.1
expect-type: 1.2.1
magic-string: 0.30.17
pathe: 2.0.3
picomatch: 4.0.2
std-env: 3.9.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.14
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
vite-node: 3.2.4(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.0.10
jsdom: 25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vscode-languageserver-types@3.17.5: {}
w3c-keyname@2.2.8: {}