tag colors and tag list tracking fix
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
import { type Component, createSignal, For, Show } from "solid-js";
|
||||
import { Search, X, Hash, ArrowUpCircle, Clock } from "lucide-solid";
|
||||
import { Search, X, Hash, ArrowUpCircle, Clock, Minus } from "lucide-solid";
|
||||
import { store, setFilter, clearFilter } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
|
||||
export const FilterBar: Component = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
const hasActiveFilters = () => {
|
||||
@@ -15,7 +18,7 @@ export const FilterBar: Component = () => {
|
||||
return f.query !== "" || f.tags.length > 0 || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10;
|
||||
};
|
||||
|
||||
const allTags = () => Object.keys(store.tagDefinitions || {}).sort();
|
||||
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
|
||||
|
||||
return (
|
||||
<div class="fixed top-4 right-5 sm:right-6 z-[60]">
|
||||
@@ -162,27 +165,63 @@ export const FilterBar: Component = () => {
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-muted-foreground ml-1 flex items-center justify-between">
|
||||
Filter by Tags
|
||||
<span class="text-[9px] lowercase font-medium opacity-50 tracking-normal">Click to remove</span>
|
||||
<span class="text-[9px] lowercase font-medium opacity-50 tracking-normal text-right">
|
||||
Click tag name to toggle exclude<br />
|
||||
Click X to remove
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
|
||||
<For each={store.filter.tags}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-7 px-2.5 rounded-lg text-xs gap-2 cursor-pointer transition-all hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30 border-border/40"
|
||||
onClick={() => setFilter({ tags: store.filter.tags.filter(t => t !== tag) })}
|
||||
class={cn(
|
||||
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
|
||||
tag.excluded
|
||||
? "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20 hover:bg-red-500/20"
|
||||
: "bg-indigo-500/10 text-indigo-600 dark:text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20"
|
||||
)}
|
||||
style={!tag.excluded ? (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag.name);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? {
|
||||
"background-color": `${color}15`,
|
||||
"color": color,
|
||||
"border-color": `${color}30`
|
||||
} : {};
|
||||
})() : {}}
|
||||
>
|
||||
<Hash size={12} class="opacity-50" />
|
||||
{tag}
|
||||
<X size={10} />
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer h-full"
|
||||
onClick={() => {
|
||||
const newTags = store.filter.tags.map(t =>
|
||||
t.name === tag.name ? { ...t, excluded: !t.excluded } : t
|
||||
);
|
||||
setFilter({ tags: newTags });
|
||||
}}
|
||||
>
|
||||
<Show when={tag.excluded} fallback={<Hash size={12} class="opacity-70" />}>
|
||||
<Minus size={12} class="opacity-100" />
|
||||
</Show>
|
||||
<span class={cn(tag.excluded && "line-through opacity-70")}>{tag.name}</span>
|
||||
</div>
|
||||
<button
|
||||
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter({ tags: store.filter.tags.filter(t => t.name !== tag.name) });
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
<Select
|
||||
value=""
|
||||
onChange={(val) => {
|
||||
if (val && !store.filter.tags.includes(val)) {
|
||||
setFilter({ tags: [...store.filter.tags, val] });
|
||||
if (val && !store.filter.tags.some(t => t.name === val)) {
|
||||
setFilter({ tags: [...store.filter.tags, { name: val, excluded: false }] });
|
||||
}
|
||||
}}
|
||||
options={allTags()}
|
||||
|
||||
@@ -58,7 +58,7 @@ export const Sidebar: Component<{
|
||||
>
|
||||
<div class={cn("p-4 flex items-center justify-between", !props.isLocked && "pl-14")}>
|
||||
<h1 class={cn(
|
||||
"text-xl font-bold tracking-tighter text-primary px-2 transition-opacity",
|
||||
"text-xl font-bold tracking-tighter text-foreground px-2 transition-opacity",
|
||||
(props.isLocked || props.isPeeking)
|
||||
? "opacity-100 duration-500 delay-[50ms]"
|
||||
: "opacity-0 duration-116 delay-0"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { type Component, createSignal, createEffect, onCleanup, onMount, For, Show } from "solid-js";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertMultipleTagDefinitions, type TaskTemplate } from "@/store";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate } from "@/store";
|
||||
import { Search, Command, Clock, ArrowUpCircle, Plus, Copy, ChevronDown } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TextField, TextFieldInput } from "@/components/ui/textfield";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { TagPicker } from "@/components/TagPicker";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
import { lazy, Suspense } from "solid-js";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { toast } from "solid-sonner";
|
||||
@@ -15,6 +16,7 @@ import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants";
|
||||
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||
|
||||
export const QuickEntry: Component = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
// Diff tracking for reactive tags
|
||||
let lastParsedTags: string[] = [];
|
||||
|
||||
@@ -156,8 +158,6 @@ export const QuickEntry: Component = () => {
|
||||
const segments = text.split(/\/t\s*/);
|
||||
let title = segments[0];
|
||||
|
||||
const tagsToUpsert: Record<string, number> = {};
|
||||
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
const firstColon = seg.indexOf(':');
|
||||
@@ -185,18 +185,13 @@ export const QuickEntry: Component = () => {
|
||||
finalTags.push(tagName);
|
||||
}
|
||||
if (tagValue !== undefined) {
|
||||
tagsToUpsert[tagName] = tagValue;
|
||||
} else if (store.tagDefinitions?.[tagName] === undefined) {
|
||||
tagsToUpsert[tagName] = 5;
|
||||
// update global definition
|
||||
await upsertTagDefinition(tagName, tagValue, undefined, resolvedTheme());
|
||||
}
|
||||
}
|
||||
title += " " + remainingText;
|
||||
}
|
||||
|
||||
if (Object.keys(tagsToUpsert).length > 0) {
|
||||
upsertMultipleTagDefinitions(tagsToUpsert);
|
||||
}
|
||||
|
||||
let finalDueDate: Date | null = null;
|
||||
if (dateString()) {
|
||||
finalDueDate = new Date(dateString());
|
||||
@@ -365,9 +360,30 @@ export const QuickEntry: Component = () => {
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-7 px-2 text-xs gap-1 bg-background border border-border/50 cursor-pointer hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
|
||||
style={{
|
||||
"background-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}15` : undefined;
|
||||
})(),
|
||||
"border-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}30` : undefined;
|
||||
})(),
|
||||
"color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
|
||||
})()
|
||||
}}
|
||||
onClick={() => setTags(tags().filter(t => t !== tag))}
|
||||
>
|
||||
<div class={cn("w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive"
|
||||
style={{
|
||||
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
|
||||
}}
|
||||
/>
|
||||
{tag}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Component, createSignal, For, createEffect } from "solid-js";
|
||||
import { store, upsertTagDefinition } from "@/store";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Hash, Plus } from "lucide-solid";
|
||||
@@ -10,20 +11,21 @@ interface TagPickerProps {
|
||||
}
|
||||
|
||||
export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [selectedTagName, setSelectedTagName] = createSignal("");
|
||||
const [valueScore, setValueScore] = createSignal(5);
|
||||
|
||||
// Filter tags for the "Name" part
|
||||
const allTagNames = () => Object.keys(store.tagDefinitions || {}).sort();
|
||||
const allTagNames = () => store.tagDefinitions.map(d => d.name).sort();
|
||||
|
||||
// When a tag name is selected, update the value score to match its current definition
|
||||
createEffect(() => {
|
||||
const name = selectedTagName().trim();
|
||||
if (name) {
|
||||
const existingVal = store.tagDefinitions?.[name];
|
||||
if (existingVal !== undefined) {
|
||||
setValueScore(existingVal);
|
||||
const existing = store.tagDefinitions.find(d => d.name === name);
|
||||
if (existing) {
|
||||
setValueScore(existing.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -33,7 +35,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
if (!name) return;
|
||||
|
||||
// update global definition
|
||||
upsertTagDefinition(name, valueScore());
|
||||
upsertTagDefinition(name, valueScore(), undefined, resolvedTheme());
|
||||
|
||||
// Toggle in current task selection if not already there
|
||||
const currentSelected = props.selectedTags;
|
||||
@@ -87,7 +89,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
|
||||
<Button size="sm" class="w-full mt-1" onClick={handleApply}>
|
||||
<Plus size={14} class="mr-2" />
|
||||
{store.tagDefinitions?.[selectedTagName().trim()] !== undefined ? "Update & Add" : "Create & Add"}
|
||||
{store.tagDefinitions.find(d => d.name === selectedTagName().trim()) ? "Update & Add" : "Create & Add"}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -4,8 +4,11 @@ import { cn } from "@/lib/utils";
|
||||
import { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy } from "lucide-solid";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { For } from "solid-js";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
|
||||
export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
|
||||
|
||||
const urgencyColor = () => {
|
||||
@@ -59,8 +62,32 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
<div class="flex flex-wrap items-center gap-1 min-w-0">
|
||||
<For each={props.task.tags}>
|
||||
{(tag) => (
|
||||
<Badge variant="secondary" class="h-4 px-1.5 text-[9px] gap-1 bg-muted/50 border-border/50 shrink-0">
|
||||
<div class={cn("w-1 h-1 rounded-full", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-4 px-1.5 text-[9px] gap-1 bg-muted/50 border-border/50 shrink-0"
|
||||
style={{
|
||||
"background-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}15` : undefined;
|
||||
})(),
|
||||
"border-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}30` : undefined;
|
||||
})(),
|
||||
"color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
|
||||
})()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-1 h-1 rounded-full"
|
||||
style={{
|
||||
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
|
||||
}}
|
||||
/>
|
||||
{tag}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { store } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "solid-sonner";
|
||||
import { lazy, Suspense } from "solid-js";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
|
||||
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||
|
||||
@@ -22,6 +24,7 @@ interface TaskDetailProps {
|
||||
}
|
||||
|
||||
export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
// Local state for immediate feedback, synced with props
|
||||
const [title, setTitle] = createSignal(props.task.title);
|
||||
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||
@@ -247,10 +250,32 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap shrink-0"
|
||||
style={{
|
||||
"background-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}15` : undefined;
|
||||
})(),
|
||||
"border-color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
||||
return color ? `${color}30` : undefined;
|
||||
})(),
|
||||
"color": (() => {
|
||||
const def = store.tagDefinitions.find(d => d.name === tag);
|
||||
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
|
||||
})()
|
||||
}}
|
||||
onClick={() => updateTags((props.task.tags || []).filter(t => t !== tag))}
|
||||
>
|
||||
<div class={cn("w-1.5 h-1.5 rounded-full transition-colors group-hover/tag:bg-destructive", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
|
||||
}}
|
||||
/>
|
||||
{tag}
|
||||
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -5,12 +5,14 @@ type Theme = "dark" | "light" | "system";
|
||||
interface ThemeProviderState {
|
||||
theme: () => Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: () => "light" | "dark";
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>();
|
||||
|
||||
export const ThemeProvider: ParentComponent = (props) => {
|
||||
const [theme, setTheme] = createSignal<Theme>("system");
|
||||
const [resolvedTheme, setResolvedTheme] = createSignal<"light" | "dark">("light");
|
||||
|
||||
// Load from local storage on mount
|
||||
const storedTheme = localStorage.getItem("tasgrid-theme") as Theme | null;
|
||||
@@ -23,19 +25,22 @@ export const ThemeProvider: ParentComponent = (props) => {
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
const currentTheme = theme();
|
||||
let resolved: "light" | "dark";
|
||||
|
||||
if (currentTheme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
root.classList.add(systemTheme);
|
||||
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
root.classList.add(resolved);
|
||||
} else {
|
||||
root.classList.add(currentTheme);
|
||||
resolved = currentTheme === "dark" ? "dark" : "light";
|
||||
root.classList.add(resolved);
|
||||
}
|
||||
|
||||
setResolvedTheme(resolved);
|
||||
localStorage.setItem("tasgrid-theme", currentTheme);
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={{ theme, setTheme }}>
|
||||
<ThemeProviderContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
||||
{props.children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
|
||||
+44
-44
@@ -57,51 +57,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(222.2 84% 4.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||
--primary: hsl(222.2 47.4% 11.2%);
|
||||
--primary-foreground: hsl(210 40% 98%);
|
||||
--secondary: hsl(210 40% 96.1%);
|
||||
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
--accent: hsl(210 40% 96.1%);
|
||||
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--ring: hsl(222.2 84% 4.9%);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(222.2 84% 4.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||
--primary: hsl(250 89% 60%);
|
||||
/* Vibrant Indigo */
|
||||
--primary-foreground: hsl(0 0% 100%);
|
||||
--secondary: hsl(210 40% 96.1%);
|
||||
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
--accent: hsl(210 40% 96.1%);
|
||||
--accent-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--ring: hsl(250 89% 60%);
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(222.2 84% 4.9%);
|
||||
--foreground: hsl(210 40% 98%);
|
||||
--card: hsl(222.2 84% 4.9%);
|
||||
--card-foreground: hsl(210 40% 98%);
|
||||
--popover: hsl(222.2 84% 4.9%);
|
||||
--popover-foreground: hsl(210 40% 98%);
|
||||
--primary: hsl(210 40% 98%);
|
||||
--primary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--secondary: hsl(217.2 32.6% 17.5%);
|
||||
--secondary-foreground: hsl(210 40% 98%);
|
||||
--muted: hsl(217.2 32.6% 17.5%);
|
||||
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||
--accent: hsl(217.2 32.6% 17.5%);
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--border: hsl(217.2 32.6% 17.5%);
|
||||
--input: hsl(217.2 32.6% 17.5%);
|
||||
--ring: hsl(212.7 26.8% 83.9%);
|
||||
}
|
||||
.dark {
|
||||
--background: hsl(222.2 84% 4.9%);
|
||||
--foreground: hsl(210 40% 98%);
|
||||
--card: hsl(222.2 84% 4.9%);
|
||||
--card-foreground: hsl(210 40% 98%);
|
||||
--popover: hsl(222.2 84% 4.9%);
|
||||
--popover-foreground: hsl(210 40% 98%);
|
||||
--primary: hsl(250 89% 65%);
|
||||
/* Brighter Indigo for Dark Mode */
|
||||
--primary-foreground: hsl(0 0% 100%);
|
||||
--secondary: hsl(217.2 32.6% 17.5%);
|
||||
--secondary-foreground: hsl(210 40% 98%);
|
||||
--muted: hsl(217.2 32.6% 17.5%);
|
||||
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||
--accent: hsl(217.2 32.6% 17.5%);
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--border: hsl(217.2 32.6% 17.5%);
|
||||
--input: hsl(217.2 32.6% 17.5%);
|
||||
--ring: hsl(212.7 26.8% 83.9%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Converts a hex color string to HSL.
|
||||
*/
|
||||
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts HSL values to a hex string.
|
||||
*/
|
||||
export function hslToHex(h: number, s: number, l: number): string {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
if (h >= 0 && h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h >= 60 && h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h >= 120 && h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h >= 180 && h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h >= 240 && h < 300) { r = x; g = 0; b = c; }
|
||||
else if (h >= 300 && h < 360) { r = c; g = 0; b = x; }
|
||||
|
||||
const toHex = (n: number) => {
|
||||
const hex = Math.round((n + m) * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts a color based on the theme change.
|
||||
* If baseTheme matches currentTheme, returns the original color.
|
||||
* Otherwise, inverts the lightness (L = 100 - L).
|
||||
*/
|
||||
export function getThemeAdjustedColor(
|
||||
color: string | undefined,
|
||||
baseTheme: "light" | "dark" | undefined,
|
||||
currentTheme: "light" | "dark"
|
||||
): string | undefined {
|
||||
if (!color || !color.startsWith("#")) return color;
|
||||
if (!baseTheme || baseTheme === currentTheme) return color;
|
||||
|
||||
const { h, s, l } = hexToHsl(color);
|
||||
|
||||
// Invert lightness: 100 -> 0, 0 -> 100
|
||||
// To keep it readable, we might want to clamp it or shift it
|
||||
// But the user asked for "invert brightness", so 100 - L is the most direct implementation.
|
||||
// However, 100-L can make 50% stay 50%.
|
||||
// For tags, we usually want them to be "light" in dark mode (bright on dark)
|
||||
// and "dark" in light mode (dark on light).
|
||||
// If L = 20 (dark blue) in dark mode, it's 80 (light blue) in light mode.
|
||||
const newL = 100 - l;
|
||||
|
||||
return hslToHex(h, s, newL);
|
||||
}
|
||||
@@ -3,3 +3,4 @@ import PocketBase from 'pocketbase';
|
||||
export const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
|
||||
export const TASGRID_COLLECTION = 'TasGrid';
|
||||
export const TAGS_COLLECTION = 'TasGrid_Tags';
|
||||
|
||||
+246
-114
@@ -1,6 +1,6 @@
|
||||
import { createStore, reconcile, produce } from "solid-js/store";
|
||||
import { createStore, reconcile } from "solid-js/store";
|
||||
import { createSignal, createEffect, createRoot } from "solid-js";
|
||||
import { pb, TASGRID_COLLECTION } from "@/lib/pocketbase";
|
||||
import { pb, TASGRID_COLLECTION, TAGS_COLLECTION } from "@/lib/pocketbase";
|
||||
import { toast } from "solid-sonner";
|
||||
import { URGENCY_HOURS } from "@/lib/constants";
|
||||
|
||||
@@ -38,9 +38,22 @@ export interface TaskTemplate {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
theme?: "light" | "dark";
|
||||
}
|
||||
|
||||
export interface FilterTag {
|
||||
name: string;
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
query: string;
|
||||
tags: string[];
|
||||
tags: FilterTag[];
|
||||
priorityMin: number;
|
||||
priorityMax: number;
|
||||
urgencyMin: number;
|
||||
@@ -52,9 +65,9 @@ interface TaskStore {
|
||||
pWeight: number;
|
||||
uWeight: number;
|
||||
matrixScaleDays: number;
|
||||
prefId?: string; // ID of the user_preferences record
|
||||
tagDefinitions?: Record<string, number>;
|
||||
templates?: TaskTemplate[];
|
||||
prefId?: string; // ID of the user record
|
||||
tagDefinitions: TagDefinition[];
|
||||
templates: TaskTemplate[];
|
||||
filter: Filter;
|
||||
}
|
||||
|
||||
@@ -64,7 +77,7 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
pWeight: 1.0,
|
||||
uWeight: 1.0,
|
||||
matrixScaleDays: 30,
|
||||
tagDefinitions: {}, // Map<Name, Value 0-10>
|
||||
tagDefinitions: [],
|
||||
templates: [],
|
||||
filter: {
|
||||
query: "",
|
||||
@@ -77,6 +90,9 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
});
|
||||
|
||||
export const matchesFilter = (task: Task) => {
|
||||
// Hide templates from all regular views
|
||||
if (task.tags?.includes("__template__")) return false;
|
||||
|
||||
const f = store.filter;
|
||||
|
||||
// Query search (title or content)
|
||||
@@ -87,10 +103,22 @@ export const matchesFilter = (task: Task) => {
|
||||
if (!inTitle && !inContent) return false;
|
||||
}
|
||||
|
||||
// Tags (OR logic if multiple selected?)
|
||||
// Tags
|
||||
if (f.tags.length > 0) {
|
||||
const hasTag = f.tags.some(tag => task.tags?.includes(tag));
|
||||
if (!hasTag) return false;
|
||||
const includedTags = f.tags.filter(t => !t.excluded).map(t => t.name);
|
||||
const excludedTags = f.tags.filter(t => t.excluded).map(t => t.name);
|
||||
|
||||
// 1. Task must NOT have any of the excluded tags
|
||||
if (excludedTags.length > 0) {
|
||||
const hasExcluded = excludedTags.some(tag => task.tags?.includes(tag));
|
||||
if (hasExcluded) return false;
|
||||
}
|
||||
|
||||
// 2. Task must have at least one of the included tags (if any are specified)
|
||||
if (includedTags.length > 0) {
|
||||
const hasIncluded = includedTags.some(tag => task.tags?.includes(tag));
|
||||
if (!hasIncluded) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority
|
||||
@@ -157,11 +185,11 @@ export const getCombinedScore = (task: Task): number => {
|
||||
// Tag adjustments
|
||||
if (task.tags && task.tags.length > 0) {
|
||||
task.tags.forEach(tagName => {
|
||||
const defVal = store.tagDefinitions?.[tagName];
|
||||
if (defVal !== undefined) {
|
||||
const def = store.tagDefinitions.find(d => d.name === tagName);
|
||||
if (def) {
|
||||
// Formula: (Value - 5) * 0.1
|
||||
// 5 -> 0, 10 -> 0.5, 0 -> -0.5
|
||||
baseScore += (defVal - 5) * 0.1;
|
||||
baseScore += (def.value - 5) * 0.1;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -194,61 +222,103 @@ export const initStore = async () => {
|
||||
heartbeatInterval = setInterval(() => setNow(Date.now()), 60000);
|
||||
|
||||
try {
|
||||
// 1. Fetch Preferences from User Profile
|
||||
// 1. Fetch Preferences from User Profile (Weights, Scale, Tags)
|
||||
try {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
// Fetch fresh user record
|
||||
const user = await pb.collection('users').getOne(userId, { requestKey: null });
|
||||
|
||||
// Field: Taskgrid_pref (JSON)
|
||||
const prefs = user.Taskgrid_pref || {};
|
||||
|
||||
setStore({
|
||||
pWeight: prefs.pWeight || 1.0,
|
||||
uWeight: prefs.uWeight || 1.0,
|
||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||
tagDefinitions: prefs.tagDefinitions || {},
|
||||
templates: prefs.templates || [],
|
||||
prefId: userId // The ID to update is the User ID
|
||||
prefId: userId
|
||||
});
|
||||
}
|
||||
} catch (prefErr) {
|
||||
// Non-blocking error for preferences
|
||||
console.warn("Failed to load preferences from user profile:", prefErr);
|
||||
console.warn("Failed to load preferences:", prefErr);
|
||||
}
|
||||
|
||||
// 2. Fetch Tasks - Explicitly filtered by user and sorted
|
||||
// 1.5. Fetch Tag Definitions
|
||||
try {
|
||||
const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({
|
||||
filter: `user = "${pb.authStore.model?.id}"`,
|
||||
});
|
||||
const loadedTags: TagDefinition[] = tagRecords.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
value: r.value,
|
||||
color: r.color,
|
||||
theme: r.theme
|
||||
}));
|
||||
setStore("tagDefinitions", reconcile(loadedTags));
|
||||
} catch (tagErr) {
|
||||
console.warn("Failed to load tags:", tagErr);
|
||||
}
|
||||
|
||||
// 2. Fetch Tasks & Templates
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `user = "${pb.authStore.model?.id}"`,
|
||||
sort: '-created',
|
||||
});
|
||||
|
||||
// Map PB records to Task interface
|
||||
const loadedTasks: Task[] = records.map(r => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
startDate: r.startDate,
|
||||
dueDate: r.dueDate,
|
||||
priority: r.priority,
|
||||
completed: r.completed,
|
||||
tags: r.tags || [],
|
||||
content: r.content,
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
||||
created: r.created,
|
||||
updated: r.updated
|
||||
}));
|
||||
const allTasks: Task[] = [];
|
||||
const loadedTemplates: TaskTemplate[] = [];
|
||||
|
||||
// Use reconcile for efficient store update (keeps observers happy)
|
||||
setStore("tasks", reconcile(loadedTasks));
|
||||
records.forEach(r => {
|
||||
const tags = r.tags || [];
|
||||
if (tags.includes("__template__")) {
|
||||
let meta: any = {};
|
||||
try { meta = JSON.parse(r.content || "{}"); } catch (e) { }
|
||||
loadedTemplates.push({
|
||||
id: r.id,
|
||||
name: r.title,
|
||||
title: meta.title || "",
|
||||
priority: r.priority,
|
||||
urgency: meta.urgency || 5,
|
||||
tags: meta.tags || [],
|
||||
content: meta.content || ""
|
||||
});
|
||||
} else {
|
||||
allTasks.push({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
startDate: r.startDate,
|
||||
dueDate: r.dueDate,
|
||||
priority: r.priority,
|
||||
completed: r.completed,
|
||||
tags: tags,
|
||||
content: r.content,
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined,
|
||||
created: r.created,
|
||||
updated: r.updated
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setStore("tasks", reconcile(allTasks));
|
||||
setStore("templates", loadedTemplates);
|
||||
|
||||
// 4. Reconstructive Migration: Verify all tags in tasks have definitions
|
||||
const foundTagNames = new Set<string>();
|
||||
allTasks.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
|
||||
loadedTemplates.forEach(t => t.tags?.forEach(tag => foundTagNames.add(tag)));
|
||||
|
||||
for (const tagName of foundTagNames) {
|
||||
if (tagName === "__template__") continue;
|
||||
const exists = store.tagDefinitions.find(d => d.name === tagName);
|
||||
if (!exists) {
|
||||
console.log(`Reconstructing missing tag: ${tagName}`);
|
||||
// Default to dark mode for reconstruction if we can't tell
|
||||
const currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
await upsertTagDefinition(tagName, 5, undefined, currentTheme);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If we fail but have cached data, don't toast an error unless it's a hard failure
|
||||
if (store.tasks.length === 0) {
|
||||
console.error("Failed to load data:", err);
|
||||
toast.error("Failed to sync with server.");
|
||||
} else {
|
||||
console.warn("Sync failed, using cached data", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -448,86 +518,94 @@ export const deleteTaskPermanently = async (id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertTagDefinition = async (name: string, value: number) => {
|
||||
// Optimistic update
|
||||
setStore("tagDefinitions", name, value);
|
||||
export const upsertTagDefinition = async (name: string, value: number, color?: string, theme?: "light" | "dark") => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
// Persist
|
||||
const map = { ...store.tagDefinitions };
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
try {
|
||||
const prefs = {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: map
|
||||
const existing = store.tagDefinitions.find(d => d.name === name);
|
||||
const finalTheme = theme || (document.documentElement.classList.contains("dark") ? "dark" : "light");
|
||||
|
||||
try {
|
||||
if (existing) {
|
||||
const updateData: any = { value };
|
||||
if (color !== undefined) updateData.color = color;
|
||||
if (theme !== undefined) updateData.theme = theme;
|
||||
|
||||
await pb.collection(TAGS_COLLECTION).update(existing.id, updateData, { requestKey: null });
|
||||
|
||||
setStore("tagDefinitions", (d) => d.id === existing.id, {
|
||||
value,
|
||||
color: color !== undefined ? color : existing.color,
|
||||
theme: theme !== undefined ? theme : existing.theme
|
||||
});
|
||||
} else {
|
||||
const record = await pb.collection(TAGS_COLLECTION).create({
|
||||
user: pb.authStore.model?.id,
|
||||
name,
|
||||
value,
|
||||
color: color || "#6366f1", // Default indigo
|
||||
theme: finalTheme
|
||||
});
|
||||
|
||||
const newDef: TagDefinition = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
value: record.value,
|
||||
color: record.color,
|
||||
theme: record.theme as "light" | "dark"
|
||||
};
|
||||
|
||||
await pb.collection('users').update(userId, {
|
||||
Taskgrid_pref: prefs
|
||||
}, { requestKey: null });
|
||||
} catch (e: any) {
|
||||
// Ignore abort errors
|
||||
if (e.isAbort) return;
|
||||
console.error("Failed to persist tag definition", e);
|
||||
toast.error("Failed to save tag definition");
|
||||
setStore("tagDefinitions", (prev) => [...prev, newDef]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to upsert tag:", err);
|
||||
toast.error("Failed to save tag definition.");
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertMultipleTagDefinitions = async (updates: Record<string, number>) => {
|
||||
// Optimistic update
|
||||
setStore("tagDefinitions", (defs) => ({ ...(defs || {}), ...updates }));
|
||||
|
||||
// Persist
|
||||
await syncPreferences();
|
||||
};
|
||||
|
||||
export const removeTagDefinition = async (name: string) => {
|
||||
// 1. Remove from all tasks
|
||||
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name));
|
||||
for (const task of tasksWithTag) {
|
||||
const newTags = task.tags.filter(t => t !== name);
|
||||
updateTask(task.id, { tags: newTags });
|
||||
if (!pb.authStore.isValid) return;
|
||||
const def = store.tagDefinitions.find(d => d.name === name);
|
||||
if (!def) return;
|
||||
|
||||
try {
|
||||
await pb.collection(TAGS_COLLECTION).delete(def.id);
|
||||
setStore("tagDefinitions", (prev) => prev.filter(d => d.id !== def.id));
|
||||
|
||||
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(name));
|
||||
for (const task of tasksWithTag) {
|
||||
const nextTags = (task.tags || []).filter(t => t !== name);
|
||||
updateTask(task.id, { tags: nextTags });
|
||||
}
|
||||
|
||||
toast.success(`Tag "${name}" deleted`);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete tag:", err);
|
||||
toast.error("Failed to delete tag.");
|
||||
}
|
||||
|
||||
// 2. Remove definition locally
|
||||
setStore("tagDefinitions", produce((defs) => {
|
||||
if (defs) delete defs[name];
|
||||
}));
|
||||
|
||||
// 3. Persist
|
||||
await syncPreferences();
|
||||
toast.success(`Tag "${name}" deleted`);
|
||||
};
|
||||
|
||||
export const renameTagDefinition = async (oldName: string, newName: string) => {
|
||||
if (!newName || !newName.trim() || newName === oldName) return;
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const def = store.tagDefinitions.find(d => d.name === oldName);
|
||||
if (!def) return;
|
||||
|
||||
const finalName = newName.trim();
|
||||
|
||||
// 1. Get current value
|
||||
const val = store.tagDefinitions?.[oldName] ?? 5;
|
||||
try {
|
||||
await pb.collection(TAGS_COLLECTION).update(def.id, { name: finalName });
|
||||
setStore("tagDefinitions", (d) => d.id === def.id, { name: finalName });
|
||||
|
||||
// 2. Update all tasks
|
||||
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(oldName));
|
||||
for (const task of tasksWithTag) {
|
||||
const newTags = task.tags.map(t => t === oldName ? finalName : t);
|
||||
// Dedupe
|
||||
const uniqueTags = [...new Set(newTags)];
|
||||
updateTask(task.id, { tags: uniqueTags });
|
||||
}
|
||||
|
||||
// 3. Update definition locally
|
||||
setStore("tagDefinitions", produce((defs) => {
|
||||
if (defs) {
|
||||
defs[finalName] = val;
|
||||
delete defs[oldName];
|
||||
const tasksWithTag = store.tasks.filter(t => t.tags?.includes(oldName));
|
||||
for (const task of tasksWithTag) {
|
||||
const newTags = task.tags.map(t => t === oldName ? finalName : t);
|
||||
const uniqueTags = [...new Set(newTags)];
|
||||
updateTask(task.id, { tags: uniqueTags });
|
||||
}
|
||||
}));
|
||||
|
||||
// 4. Persist
|
||||
await syncPreferences();
|
||||
} catch (err) {
|
||||
console.error("Failed to rename tag:", err);
|
||||
toast.error("Failed to rename tag.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -542,8 +620,7 @@ export const syncPreferences = async () => {
|
||||
pWeight: store.pWeight,
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: store.tagDefinitions,
|
||||
templates: store.templates
|
||||
tagDefinitions: store.tagDefinitions
|
||||
};
|
||||
|
||||
await pb.collection('users').update(userId, {
|
||||
@@ -557,20 +634,75 @@ export const syncPreferences = async () => {
|
||||
};
|
||||
|
||||
export const addTemplate = async (template: Omit<TaskTemplate, "id">) => {
|
||||
const newTemplate = { ...template, id: crypto.randomUUID() };
|
||||
setStore("templates", (prev) => [...(prev || []), newTemplate]);
|
||||
await syncPreferences();
|
||||
return newTemplate;
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
const meta = {
|
||||
title: template.title,
|
||||
urgency: template.urgency,
|
||||
tags: template.tags,
|
||||
content: template.content
|
||||
};
|
||||
|
||||
try {
|
||||
const record = await pb.collection(TASGRID_COLLECTION).create({
|
||||
user: pb.authStore.model?.id,
|
||||
title: template.name,
|
||||
priority: template.priority,
|
||||
tags: ["__template__"],
|
||||
content: JSON.stringify(meta),
|
||||
completed: false,
|
||||
startDate: new Date().toISOString(),
|
||||
dueDate: new Date().toISOString()
|
||||
});
|
||||
|
||||
const newTemplate = { ...template, id: record.id };
|
||||
setStore("templates", (prev) => [...(prev || []), newTemplate]);
|
||||
return newTemplate;
|
||||
} catch (err) {
|
||||
console.error("Failed to add template:", err);
|
||||
toast.error("Failed to save template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTemplate = async (id: string, updates: Partial<TaskTemplate>) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
// Optimistic
|
||||
setStore("templates", (t) => t!.id === id, updates);
|
||||
await syncPreferences();
|
||||
|
||||
const template = store.templates?.find(t => t.id === id);
|
||||
if (!template) return;
|
||||
|
||||
const meta = {
|
||||
title: template.title,
|
||||
urgency: template.urgency,
|
||||
tags: template.tags,
|
||||
content: template.content
|
||||
};
|
||||
|
||||
try {
|
||||
await pb.collection(TASGRID_COLLECTION).update(id, {
|
||||
title: template.name,
|
||||
priority: template.priority,
|
||||
content: JSON.stringify(meta)
|
||||
}, { requestKey: null });
|
||||
} catch (err) {
|
||||
console.error("Failed to update template:", err);
|
||||
toast.error("Failed to update template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const removeTemplate = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
setStore("templates", (t) => (t || []).filter(tmpl => tmpl.id !== id));
|
||||
await syncPreferences();
|
||||
|
||||
try {
|
||||
await pb.collection(TASGRID_COLLECTION).delete(id);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete template:", err);
|
||||
toast.error("Failed to delete template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const saveTaskAsTemplate = async (taskId: string, name: string) => {
|
||||
|
||||
+28
-14
@@ -1,6 +1,7 @@
|
||||
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition } from "@/store";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type } from "lucide-solid";
|
||||
import { TagPicker } from "@/components/TagPicker";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,6 +14,7 @@ import { pb } from "@/lib/pocketbase";
|
||||
const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||
|
||||
export const SettingsView: Component = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [isTagsOpen, setIsTagsOpen] = createSignal(false);
|
||||
const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
|
||||
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
|
||||
@@ -98,18 +100,18 @@ export const SettingsView: Component = () => {
|
||||
|
||||
<Show when={isTagsOpen()}>
|
||||
<div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<For each={Object.entries(store.tagDefinitions || {}).sort((a, b) => a[0].localeCompare(b[0]))} fallback={
|
||||
<For each={store.tagDefinitions.sort((a, b) => a.name.localeCompare(b.name))} fallback={
|
||||
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
||||
No specific tag definitions found.
|
||||
</div>
|
||||
}>
|
||||
{([tagName, val]) => {
|
||||
{(tag) => {
|
||||
const [isEditingName, setIsEditingName] = createSignal(false);
|
||||
const [tempName, setTempName] = createSignal(tagName);
|
||||
const [tempName, setTempName] = createSignal(tag.name);
|
||||
|
||||
const handleRename = () => {
|
||||
if (tempName() && tempName() !== tagName) {
|
||||
renameTagDefinition(tagName, tempName());
|
||||
if (tempName() && tempName() !== tag.name) {
|
||||
renameTagDefinition(tag.name, tempName());
|
||||
}
|
||||
setIsEditingName(false);
|
||||
};
|
||||
@@ -117,7 +119,13 @@ export const SettingsView: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3 rounded-xl bg-muted/30 border border-border/50 gap-3 sm:gap-4 group">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class={cn("w-2 h-2 rounded-full shrink-0", val > 5 ? "bg-green-500" : val < 5 ? "bg-red-500" : "bg-gray-400")} />
|
||||
<input
|
||||
type="color"
|
||||
value={tag.color || "#6366f1"}
|
||||
onInput={(e) => upsertTagDefinition(tag.name, tag.value, e.currentTarget.value, resolvedTheme())}
|
||||
class="w-4 h-4 rounded-full border-none p-0 bg-transparent cursor-pointer shrink-0 overflow-hidden"
|
||||
title="Tag accent color"
|
||||
/>
|
||||
|
||||
{isEditingName() ? (
|
||||
<input
|
||||
@@ -134,7 +142,7 @@ export const SettingsView: Component = () => {
|
||||
onClick={() => setIsEditingName(true)}
|
||||
title="Click to rename"
|
||||
>
|
||||
{tagName}
|
||||
{tag.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -143,8 +151,8 @@ export const SettingsView: Component = () => {
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-muted-foreground font-bold uppercase tracking-wider">Val</span>
|
||||
<select
|
||||
value={String(val)}
|
||||
onChange={(e) => upsertTagDefinition(tagName, parseInt(e.currentTarget.value))}
|
||||
value={String(tag.value)}
|
||||
onChange={(e) => upsertTagDefinition(tag.name, parseInt(e.currentTarget.value), tag.color, resolvedTheme())}
|
||||
class="flex h-8 w-16 items-center justify-between rounded-md border border-input bg-background px-2 py-1 text-xs shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring appearance-none"
|
||||
>
|
||||
<For each={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1", "0"]}>
|
||||
@@ -156,10 +164,10 @@ export const SettingsView: Component = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete tag "${tagName}" globally? This will remove it from all tasks.`)) {
|
||||
removeTagDefinition(tagName);
|
||||
if (confirm(`Delete tag definition for "${tag.name}" ? `)) {
|
||||
removeTagDefinition(tag.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -291,7 +299,7 @@ export const SettingsView: Component = () => {
|
||||
class="h-8 w-8 hover:bg-red-500/10 hover:text-red-600 rounded-xl"
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(`Delete template "${template.name}"?`)) {
|
||||
if (confirm(`Delete template "${template.name}" ? `)) {
|
||||
removeTemplate(template.id);
|
||||
toast.error("Template removed");
|
||||
}
|
||||
@@ -356,7 +364,13 @@ export const SettingsView: Component = () => {
|
||||
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-background border border-border/50 text-xs hover:bg-destructive/10 hover:border-destructive/30 group/tag transition-all"
|
||||
onClick={() => updateTemplate(template.id, { tags: (template.tags || []).filter(t => t !== tag) })}
|
||||
>
|
||||
<div class={cn("w-1.5 h-1.5 rounded-full", (store.tagDefinitions?.[tag] ?? 5) > 5 ? "bg-green-500" : (store.tagDefinitions?.[tag] ?? 5) < 5 ? "bg-red-500" : "bg-gray-400")} />
|
||||
<div
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
style={{
|
||||
"background-color": store.tagDefinitions.find(d => d.name === tag)?.color ||
|
||||
((store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8")
|
||||
}}
|
||||
/>
|
||||
{tag}
|
||||
</button>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user