Files
TasGrid/src/components/ImportTool.tsx
T

368 lines
19 KiB
TypeScript

import { type Component, createSignal, For, Show } from "solid-js";
import { addTask, calculateDateFromUrgency } from "@/store";
import { Button } from "@/components/ui/button";
import { TagPicker } from "@/components/TagPicker";
import { Badge } from "@/components/ui/badge";
import { toast } from "solid-sonner";
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants";
import { X, ArrowRight, Upload, Loader2 } from "lucide-solid";
interface ImportedTask {
id: number;
title: string;
priority: string;
urgency: string;
size: string;
tags: string[];
}
/**
* Strips common list prefixes from a line of text:
* - Checkboxes: ☐ ☑ ✅ ✓ ✗ ✘ ❌ - [ ] - [x] - [X]
* - Bullets: • ‣ ◦ ▪ ▸ ► ○ ● - * +
* - Numbered: 1. 2) 3 -
*/
const stripPrefix = (line: string): string => {
// Regex matches everything from the start of the string that is NOT a letter or a number
// \p{L} matches any kind of letter from any language
// \p{N} matches any kind of numeric character
// The 'u' flag is required for unicode property escapes
return line.replace(/^[^\p{L}\p{N}]*/u, "").trim();
};
export const ImportTool: Component = () => {
const [step, setStep] = createSignal<"paste" | "configure">("paste");
const [rawText, setRawText] = createSignal("");
const [tasks, setTasks] = createSignal<ImportedTask[]>([]);
const [importing, setImporting] = createSignal(false);
const [importProgress, setImportProgress] = createSignal(0);
// Bulk defaults
const [bulkPriority, setBulkPriority] = createSignal("5");
const [bulkUrgency, setBulkUrgency] = createSignal("5");
const [bulkSize, setBulkSize] = createSignal("5");
const [bulkTags, setBulkTags] = createSignal<string[]>([]);
const parseText = () => {
const lines = rawText()
.split("\n")
.map(l => stripPrefix(l))
.filter(l => l.length > 0);
if (lines.length === 0) {
toast.error("No tasks found in pasted text.");
return;
}
const parsed: ImportedTask[] = lines.map((title, i) => ({
id: i,
title,
priority: bulkPriority(),
urgency: bulkUrgency(),
size: bulkSize(),
tags: [...bulkTags()]
}));
setTasks(parsed);
setStep("configure");
};
const applyBulkDefaults = () => {
setTasks(prev =>
prev.map(t => ({
...t,
priority: bulkPriority(),
urgency: bulkUrgency(),
size: bulkSize(),
tags: [...bulkTags()]
}))
);
};
const updateTask = (id: number, field: keyof ImportedTask, value: any) => {
setTasks(prev => prev.map(t => t.id === id ? { ...t, [field]: value } : t));
};
const removeTask = (id: number) => {
setTasks(prev => prev.filter(t => t.id !== id));
};
const handleImport = async () => {
const taskList = tasks();
if (taskList.length === 0) return;
setImporting(true);
setImportProgress(0);
let successCount = 0;
for (let i = 0; i < taskList.length; i++) {
const t = taskList[i];
try {
const urgencyLevel = parseInt(t.urgency);
const dueDate = new Date(calculateDateFromUrgency(urgencyLevel));
await addTask(t.title, {
priority: parseInt(t.priority),
dueDate,
tags: t.tags.length > 0 ? t.tags : ["work"],
size: parseInt(t.size)
});
successCount++;
} catch (err) {
console.error(`Failed to import task: ${t.title}`, err);
}
setImportProgress(i + 1);
}
setImporting(false);
toast.success(`Imported ${successCount} task${successCount !== 1 ? "s" : ""} successfully!`);
// Reset
setRawText("");
setTasks([]);
setStep("paste");
setBulkPriority("5");
setBulkUrgency("5");
setBulkSize("5");
setBulkTags([]);
};
const reset = () => {
setStep("paste");
setTasks([]);
};
return (
<div class="space-y-4">
<Show when={step() === "paste"}>
<div class="space-y-3 animate-in fade-in duration-300">
<div class="space-y-1.5">
<label class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground ml-1">
Paste your task list
</label>
<textarea
value={rawText()}
onInput={(e) => setRawText(e.currentTarget.value)}
placeholder={"Paste tasks from Apple Notes, a to-do app, or any text...\n\nExamples:\n- [ ] Buy groceries\n- [x] Walk the dog\n• Clean kitchen\n1. Call dentist\n☐ Fix bug"}
class="flex w-full rounded-xl border border-input bg-background px-4 py-3 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring min-h-[180px] resize-y font-mono leading-relaxed"
/>
</div>
<div class="flex items-center justify-between">
<p class="text-xs text-muted-foreground">
{rawText().split("\n").filter(l => stripPrefix(l).length > 0).length} task(s) detected
</p>
<Button
size="sm"
class="gap-2 rounded-xl font-bold uppercase tracking-wider text-[0.625rem] h-9 px-4"
disabled={!rawText().trim()}
onClick={parseText}
>
<ArrowRight size={14} />
Parse & Configure
</Button>
</div>
</div>
</Show>
<Show when={step() === "configure"}>
<div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Bulk defaults */}
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
<div class="flex items-center justify-between">
<p class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground">
Bulk Defaults
</p>
<Button
variant="ghost"
size="sm"
class="h-7 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg"
onClick={applyBulkDefaults}
>
Apply to All
</Button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div class="space-y-1">
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Priority</label>
<select
value={bulkPriority()}
onChange={(e) => setBulkPriority(e.currentTarget.value)}
class="flex h-8 w-full rounded-lg border border-input bg-background px-2 py-1 text-xs shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
>
<For each={PRIORITY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
</div>
<div class="space-y-1">
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Urgency</label>
<select
value={bulkUrgency()}
onChange={(e) => setBulkUrgency(e.currentTarget.value)}
class="flex h-8 w-full rounded-lg border border-input bg-background px-2 py-1 text-xs shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
>
<For each={URGENCY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
</div>
<div class="space-y-1">
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Size</label>
<select
value={bulkSize()}
onChange={(e) => setBulkSize(e.currentTarget.value)}
class="flex h-8 w-full rounded-lg border border-input bg-background px-2 py-1 text-xs shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
>
<For each={SIZE_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
</div>
<div class="space-y-1">
<label class="text-[0.5625rem] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Tags</label>
<div class="flex items-center gap-1 flex-wrap">
<TagPicker selectedTags={bulkTags()} onTagsChange={setBulkTags} />
<For each={bulkTags()}>
{(tag) => (
<Badge
variant="secondary"
class="h-6 px-1.5 text-[0.625rem] gap-1 cursor-pointer hover:bg-destructive/10"
onClick={() => setBulkTags(bulkTags().filter(t => t !== tag))}
>
{tag}
<X size={10} />
</Badge>
)}
</For>
</div>
</div>
</div>
</div>
{/* Task list */}
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-bold text-muted-foreground">
{tasks().length} task{tasks().length !== 1 ? "s" : ""} to import
</p>
<Button
variant="ghost"
size="sm"
class="h-7 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg text-muted-foreground"
onClick={reset}
>
Back
</Button>
</div>
<div class="space-y-2 max-h-[400px] overflow-y-auto pr-1">
<For each={tasks()}>
{(task) => (
<div class="p-3 rounded-xl bg-muted/20 border border-border/40 space-y-2.5 group hover:bg-muted/30 transition-colors">
{/* Title row */}
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
<input
value={task.title}
onInput={(e) => updateTask(task.id, "title", e.currentTarget.value)}
class="flex-1 bg-transparent text-sm font-medium border-none outline-none focus:underline decoration-primary/40 underline-offset-4 min-w-0"
/>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive rounded-lg transition-all"
onClick={() => removeTask(task.id)}
>
<X size={12} />
</Button>
</div>
{/* Controls row */}
<div class="flex flex-wrap items-center gap-2">
<select
value={task.priority}
onChange={(e) => updateTask(task.id, "priority", e.currentTarget.value)}
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[0.6875rem] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
title="Priority"
>
<For each={PRIORITY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
<select
value={task.urgency}
onChange={(e) => updateTask(task.id, "urgency", e.currentTarget.value)}
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[0.6875rem] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
title="Urgency"
>
<For each={URGENCY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
<select
value={task.size}
onChange={(e) => updateTask(task.id, "size", e.currentTarget.value)}
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[0.6875rem] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
title="Size"
>
<For each={SIZE_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
<div class="flex items-center gap-1 flex-wrap">
<TagPicker
selectedTags={task.tags}
onTagsChange={(tags) => updateTask(task.id, "tags", tags)}
/>
<For each={task.tags}>
{(tag) => (
<Badge
variant="secondary"
class="h-6 px-1.5 text-[0.625rem] gap-0.5 cursor-pointer hover:bg-destructive/10"
onClick={() => updateTask(task.id, "tags", task.tags.filter(t => t !== tag))}
>
{tag}
<X size={8} />
</Badge>
)}
</For>
</div>
</div>
</div>
)}
</For>
</div>
</div>
{/* Import button */}
<div class="flex items-center justify-between pt-2 border-t border-border/40">
<Show when={importing()}>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 size={14} class="animate-spin" />
Importing {importProgress()}/{tasks().length}...
</div>
</Show>
<Show when={!importing()}>
<div />
</Show>
<Button
size="sm"
class="gap-2 rounded-xl font-bold uppercase tracking-wider text-[0.625rem] h-9 px-5"
disabled={tasks().length === 0 || importing()}
onClick={handleImport}
>
<Upload size={14} />
Import {tasks().length} Task{tasks().length !== 1 ? "s" : ""}
</Button>
</div>
</div>
</Show>
</div>
);
};