initial commit 3??
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "solid-sonner";
|
||||
|
||||
export const AuthCallback = (props: { onSuccess: () => void }) => {
|
||||
const [email, setEmail] = createSignal("");
|
||||
const [password, setPassword] = createSignal("");
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
|
||||
const handleLogin = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await pb.collection("users").authWithPassword(email(), password());
|
||||
toast.success("Welcome back!");
|
||||
props.onSuccess();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
toast.error("Login failed. Please check your credentials.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col items-center justify-center min-h-screen bg-background p-4 animate-in fade-in duration-500">
|
||||
<div class="w-full max-w-sm space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="text-2xl font-bold tracking-tight">Welcome to Tasgrid</h1>
|
||||
<p class="text-sm text-muted-foreground">Sign in to sync your tasks across devices.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email()}
|
||||
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password()}
|
||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" class="w-full" disabled={loading()}>
|
||||
{loading() ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { type Component, type JSX, createSignal, Show } from "solid-js";
|
||||
import { Sidebar, BottomNav } from "./Navigation";
|
||||
import { QuickEntry } from "./QuickEntry";
|
||||
import { TaskDetail } from "./TaskDetail";
|
||||
import { store, activeTaskId, setActiveTaskId } from "@/store";
|
||||
import { PanelLeftOpen } from "lucide-solid";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LayoutProps {
|
||||
children: JSX.Element;
|
||||
currentView: string;
|
||||
setView: (v: string) => void;
|
||||
}
|
||||
|
||||
export const Layout: Component<LayoutProps> = (props) => {
|
||||
const [isSidebarLocked, setIsSidebarLocked] = createSignal(true);
|
||||
const [isSidebarPeeking, setIsSidebarPeeking] = createSignal(false);
|
||||
|
||||
// Derived active task for the detail view
|
||||
const activeTask = () => store.tasks.find(t => t.id === activeTaskId());
|
||||
|
||||
return (
|
||||
<div class="flex h-screen w-full bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||
<Sidebar
|
||||
currentView={props.currentView}
|
||||
setView={props.setView}
|
||||
isLocked={isSidebarLocked()}
|
||||
setIsLocked={setIsSidebarLocked}
|
||||
isPeeking={isSidebarPeeking()}
|
||||
setIsPeeking={setIsSidebarPeeking}
|
||||
/>
|
||||
|
||||
<div class={cn(
|
||||
"flex-1 flex flex-col h-full transition-all duration-300 ease-in-out relative",
|
||||
isSidebarLocked() ? "md:ml-48" : "ml-0"
|
||||
)}>
|
||||
{/* Expand Trigger Button (visible when not locked) */}
|
||||
{!isSidebarLocked() && (
|
||||
<div class="fixed top-4 left-4 z-[60] md:flex hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onMouseEnter={() => setIsSidebarPeeking(true)}
|
||||
onClick={() => setIsSidebarLocked(true)}
|
||||
class="bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm"
|
||||
>
|
||||
<PanelLeftOpen size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main class="flex-1 overflow-auto p-4 md:p-8 pt-4 pb-20 md:pb-8 scroll-smooth relative z-0">
|
||||
{props.children}
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<BottomNav currentView={props.currentView} setView={props.setView} />
|
||||
</div>
|
||||
|
||||
<QuickEntry />
|
||||
|
||||
<Show when={activeTask()}>
|
||||
<TaskDetail
|
||||
task={activeTask()!}
|
||||
isOpen={!!activeTaskId()}
|
||||
onClose={() => setActiveTaskId(null)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { type Component, For } from "solid-js";
|
||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface NavItem {
|
||||
icon: any;
|
||||
label: string;
|
||||
view: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ icon: ListTodo, label: "Focus", view: "critical" },
|
||||
{ icon: Clock, label: "Time", view: "urgency" },
|
||||
{ icon: ArrowUpCircle, label: "Priority", view: "priority" },
|
||||
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" },
|
||||
{ icon: Settings, label: "Settings", view: "settings" },
|
||||
];
|
||||
|
||||
export const BottomNav: Component<{ currentView: string; setView: (v: string) => void }> = (props) => {
|
||||
return (
|
||||
<nav class="fixed bottom-0 left-0 right-0 bg-background border-t border-border flex justify-around items-center h-16 md:hidden z-50">
|
||||
<For each={navItems}>
|
||||
{(item) => (
|
||||
<button
|
||||
onClick={() => props.setView(item.view)}
|
||||
class={cn(
|
||||
"flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors",
|
||||
props.currentView === item.view ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider">{item.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sidebar: Component<{
|
||||
currentView: string;
|
||||
setView: (v: string) => void;
|
||||
isLocked: boolean;
|
||||
setIsLocked: (v: boolean) => void;
|
||||
isPeeking: boolean;
|
||||
setIsPeeking: (v: boolean) => void;
|
||||
}> = (props) => {
|
||||
return (
|
||||
<aside
|
||||
onMouseEnter={() => !props.isLocked && props.setIsPeeking(true)}
|
||||
onMouseLeave={() => !props.isLocked && props.setIsPeeking(false)}
|
||||
class={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-card h-screen fixed left-0 top-0 transition-all duration-300 ease-in-out z-[50]",
|
||||
props.isLocked ? "w-48 translate-x-0" : "w-48 -translate-x-full",
|
||||
!props.isLocked && props.isPeeking && "translate-x-0 shadow-2xl ring-1 ring-border"
|
||||
)}
|
||||
>
|
||||
<div class={cn("p-4 flex items-center justify-between", !props.isLocked && "pl-14")}>
|
||||
<h1 class="text-xl font-bold tracking-tighter text-primary px-2">Tasgrid</h1>
|
||||
{/* Close Button - Only visible when Locked (to collapse) or Peeking (manual close) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
props.setIsLocked(false);
|
||||
props.setIsPeeking(false);
|
||||
}}
|
||||
>
|
||||
<PanelLeftClose size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<nav class="flex-1 px-3 space-y-1 mt-2">
|
||||
<For each={navItems.filter(i => i.view !== "settings")}>
|
||||
{(item) => (
|
||||
<button
|
||||
onClick={() => props.setView(item.view)}
|
||||
class={cn(
|
||||
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
|
||||
props.currentView === item.view
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon size={16} class={cn("transition-transform group-hover:scale-110")} />
|
||||
<span class="font-medium">{item.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
<div class="p-3 border-t border-border mt-auto">
|
||||
<button
|
||||
onClick={() => props.setView("settings")}
|
||||
class={cn(
|
||||
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm",
|
||||
props.currentView === "settings"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Settings size={16} class={cn("transition-transform group-hover:scale-110")} />
|
||||
<span class="font-medium">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
import { type Component, createSignal, createEffect, onCleanup, onMount } from "solid-js";
|
||||
import { Search, Command, Clock, ArrowUpCircle, Plus } from "lucide-solid";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TextField, TextFieldInput } from "@/components/ui/textfield";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
|
||||
export const QuickEntry: Component = () => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [input, setInput] = createSignal("");
|
||||
const [priority, setPriority] = createSignal<string>("5");
|
||||
const [urgency, setUrgency] = createSignal<string>("5");
|
||||
|
||||
// Reactive shorthand parsing
|
||||
createEffect(() => {
|
||||
const text = input();
|
||||
|
||||
const pMatch = text.match(/\/p(\d+)/);
|
||||
if (pMatch) {
|
||||
const val = Math.min(10, Math.max(1, parseInt(pMatch[1]))).toString();
|
||||
setPriority(val);
|
||||
}
|
||||
|
||||
const uMatch = text.match(/\/u(\d+)/);
|
||||
if (uMatch) {
|
||||
const val = Math.min(10, Math.max(1, parseInt(uMatch[1]))).toString();
|
||||
if (val !== urgency()) {
|
||||
onUrgencySimpleChange(val);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit date string for the input (YYYY-MM-DDTHH:mm)
|
||||
const [dateString, setDateString] = createSignal<string>("");
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
|
||||
createEffect(() => {
|
||||
if (isOpen()) {
|
||||
// Slight delay to ensure DOM is ready and animations don't interfere
|
||||
setTimeout(() => inputRef?.focus(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
if (isOpen()) {
|
||||
inputRef?.focus();
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => window.addEventListener("keydown", handleKeyDown));
|
||||
onCleanup(() => window.removeEventListener("keydown", handleKeyDown));
|
||||
|
||||
// When urgency changes via dropdown, update the date
|
||||
const onUrgencySimpleChange = (val: string) => {
|
||||
setUrgency(val);
|
||||
const level = parseInt(val);
|
||||
if (!isNaN(level)) {
|
||||
const newDateIso = calculateDateFromUrgency(level);
|
||||
// Format for datetime-local input: YYYY-MM-DDTHH:mm
|
||||
const dateObj = new Date(newDateIso);
|
||||
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
||||
setDateString(localIso);
|
||||
}
|
||||
};
|
||||
|
||||
// When date changes via input, update urgency level
|
||||
const onDateInputChange = (e: Event) => {
|
||||
const val = (e.target as HTMLInputElement).value;
|
||||
setDateString(val);
|
||||
if (val) {
|
||||
const level = calculateUrgencyFromDate(new Date(val).toISOString());
|
||||
setUrgency(level.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const parseAndAdd = () => {
|
||||
const text = input();
|
||||
if (!text || !priority() || !urgency()) return;
|
||||
|
||||
let finalPriority = parseInt(priority());
|
||||
let finalUrgency = parseInt(urgency());
|
||||
|
||||
// Parse shorthand overrides if present
|
||||
const pMatch = text.match(/\/p(\d+)/);
|
||||
if (pMatch) finalPriority = Math.min(10, Math.max(1, parseInt(pMatch[1])));
|
||||
|
||||
// Check for urgency override
|
||||
const uMatchNumeric = text.match(/\/u(\d+)/);
|
||||
if (uMatchNumeric) {
|
||||
finalUrgency = Math.min(10, Math.max(1, parseInt(uMatchNumeric[1])));
|
||||
}
|
||||
|
||||
let finalDueDate: Date | null = null;
|
||||
if (dateString()) {
|
||||
finalDueDate = new Date(dateString());
|
||||
} else {
|
||||
// If urgency is selected, calculate date
|
||||
const u = parseInt(urgency());
|
||||
if (!isNaN(u)) {
|
||||
// Check if override happened
|
||||
if (uMatchNumeric) {
|
||||
const dateStr = calculateDateFromUrgency(finalUrgency);
|
||||
finalDueDate = new Date(dateStr);
|
||||
} else {
|
||||
const dateStr = calculateDateFromUrgency(u);
|
||||
finalDueDate = new Date(dateStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const title = text
|
||||
.replace(/\/p\d+/, "")
|
||||
.replace(/\/u\d+/, "")
|
||||
.trim();
|
||||
|
||||
// Use the new signature: addTask(title, options)
|
||||
addTask(title, {
|
||||
priority: finalPriority,
|
||||
dueDate: finalDueDate || undefined, // undefined will let store pick default if that was the intent, but here we probably want strictly calculated date
|
||||
tags: []
|
||||
});
|
||||
|
||||
setInput("");
|
||||
setPriority("5");
|
||||
setUrgency("5");
|
||||
setDateString("");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(true)}
|
||||
class="fixed bottom-20 left-[80%] -translate-x-1/2 md:bottom-10 md:right-10 md:left-auto md:translate-x-0 w-14 h-14 rounded-full shadow-2xl z-50 group hover:scale-110 transition-all duration-300"
|
||||
>
|
||||
<Plus size={28} class="group-hover:rotate-90 transition-transform duration-300" />
|
||||
</Button>
|
||||
|
||||
{isOpen() && (
|
||||
<div class="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4">
|
||||
<div class="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
|
||||
|
||||
<div class="relative w-full max-w-xl bg-card border border-border rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
<div class="flex items-center px-4 py-3 border-b border-border">
|
||||
<Search class="text-muted-foreground mr-3" size={20} />
|
||||
<TextField class="flex-1">
|
||||
<TextFieldInput
|
||||
ref={inputRef}
|
||||
value={input()}
|
||||
onInput={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && parseAndAdd()}
|
||||
placeholder="Task title... type /p1-10 for priority and /u1-10 for urgency"
|
||||
class="border-none shadow-none focus-visible:ring-0 text-lg"
|
||||
/>
|
||||
</TextField>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-stretch md:items-center p-4 gap-4 border-b border-border bg-muted/10">
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Priority</label>
|
||||
<Select
|
||||
value={priority()}
|
||||
onChange={(val) => val && setPriority(val)}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
placeholder="Priority"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
{props.item.rawValue}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ArrowUpCircle size={14} class="text-primary shrink-0" />
|
||||
<span class="text-sm font-medium">{priority()}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Urgency (Time)</label>
|
||||
<Select
|
||||
value={urgency()}
|
||||
onChange={(val) => {
|
||||
if (val) onUrgencySimpleChange(val);
|
||||
}}
|
||||
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
|
||||
placeholder="Urgency"
|
||||
itemComponent={(props) => (
|
||||
<SelectItem item={props.item}>
|
||||
{props.item.rawValue}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="w-full bg-background border-none shadow-sm h-10 px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock size={14} class="text-primary shrink-0" />
|
||||
<span class="text-sm font-medium">{urgency()}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">Due Date</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dateString()}
|
||||
onInput={onDateInputChange}
|
||||
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Tab" && e.key !== "Escape") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
step="900"
|
||||
class="w-full h-10 px-3 bg-background border-none rounded-md text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring z-[102] relative cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-muted/30 flex justify-between items-center">
|
||||
<div class="flex items-center space-x-2 text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
<Command size={10} />
|
||||
<span class="hidden sm:inline">K to focus • Enter to Add</span>
|
||||
<span class="sm:hidden">Enter to Add</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={parseAndAdd} disabled={!input() || !priority() || !urgency()}>
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { type Component, createSignal, For, createEffect } from "solid-js";
|
||||
import {
|
||||
Heading1, Heading2, Heading3,
|
||||
List, ListTodo, Type,
|
||||
Code, Quote
|
||||
} from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CommandItem {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
command: (props: { editor: any; range: any }) => void;
|
||||
}
|
||||
|
||||
export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] => {
|
||||
return [
|
||||
{
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
icon: Type,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("paragraph").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
icon: Heading1,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
icon: Heading2,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
icon: Heading3,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
description: "Create a simple bulleted list.",
|
||||
icon: List,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Task List",
|
||||
description: "Track tasks with a checklist.",
|
||||
icon: ListTodo,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
description: "Capture a code snippet.",
|
||||
icon: Code,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
description: "Capture a quote.",
|
||||
icon: Quote,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||
},
|
||||
},
|
||||
].filter(item => item.title.toLowerCase().startsWith(query.toLowerCase()));
|
||||
};
|
||||
|
||||
export const SlashMenu: Component<{
|
||||
items: CommandItem[];
|
||||
command: (item: CommandItem) => void;
|
||||
editor: any;
|
||||
ref: (handlers: { onKeyDown: (e: KeyboardEvent) => boolean }) => void;
|
||||
}> = (props) => {
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
||||
|
||||
// Auto-select the first item when the list changes (filtering)
|
||||
createEffect(() => {
|
||||
// We track props.items. When it changes, we reset.
|
||||
props.items;
|
||||
setSelectedIndex(0);
|
||||
});
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index];
|
||||
if (item) {
|
||||
props.command(item);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose handlers to the parent via the ref prop
|
||||
props.ref({
|
||||
onKeyDown: (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((prev) => (prev + props.items.length - 1) % props.items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((prev) => (prev + 1) % props.items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class="z-[200] w-64 bg-popover border border-border rounded-lg shadow-xl overflow-hidden p-1 bg-background pointer-events-auto"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="max-h-[300px] overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 overscroll-contain">
|
||||
<For each={props.items}>
|
||||
{(item, index) => (
|
||||
<button
|
||||
class={cn(
|
||||
"flex items-center gap-3 w-full px-2 py-1.5 text-left rounded-md transition-colors outline-none",
|
||||
index() === selectedIndex() ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent editor focus loss and side-sheet closure
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectItem(index());
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index())}
|
||||
>
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded border border-border bg-muted/50 text-muted-foreground shrink-0">
|
||||
<item.icon size={16} />
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0 overflow-hidden">
|
||||
<span class="text-sm font-medium leading-none">{item.title}</span>
|
||||
<span class="text-[11px] text-muted-foreground truncate mt-1">{item.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
{props.items.length === 0 && (
|
||||
<div class="p-2 text-xs text-muted-foreground text-center">No results found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type Component, createMemo } from "solid-js";
|
||||
import { type Task, toggleTask, calculateUrgencyFromDate, setActiveTaskId } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircle2, Circle, Clock, ArrowUpCircle } from "lucide-solid";
|
||||
|
||||
export const TaskCard: Component<{ task: Task }> = (props) => {
|
||||
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
|
||||
|
||||
const urgencyColor = () => {
|
||||
const u = urgencyLevel();
|
||||
if (u >= 8) return "text-red-500";
|
||||
if (u >= 6) return "text-orange-500";
|
||||
if (u >= 4) return "text-blue-500";
|
||||
return "text-muted-foreground";
|
||||
};
|
||||
|
||||
const formattedDate = () => {
|
||||
const date = new Date(props.task.dueDate);
|
||||
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"group flex items-center p-4 bg-card border border-border rounded-2xl transition-all duration-300 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer",
|
||||
props.task.completed && "opacity-60"
|
||||
)}
|
||||
onClick={() => setActiveTaskId(props.task.id)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleTask(props.task.id); }}
|
||||
class="mr-4 text-muted-foreground hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
{props.task.completed ? (
|
||||
<CheckCircle2 class="text-green-500" size={24} />
|
||||
) : (
|
||||
<Circle size={24} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class={cn(
|
||||
"text-base font-semibold truncate transition-all",
|
||||
props.task.completed && "line-through"
|
||||
)}>
|
||||
{props.task.title}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-4 mt-1">
|
||||
{/* Priority */}
|
||||
<div class="flex items-center gap-1.5 min-w-[32px]">
|
||||
<ArrowUpCircle size={12} class="text-primary opacity-60" />
|
||||
<span>P{props.task.priority}</span>
|
||||
</div>
|
||||
|
||||
{/* Due Date + Urgency Slide-out */}
|
||||
<div class="relative group/date flex items-center h-6 cursor-help">
|
||||
{/* Due Date - Static Layer on Top */}
|
||||
<div class="flex items-center space-x-1.5 text-[10px] font-bold text-muted-foreground uppercase tracking-wider bg-card relative z-20 pr-1">
|
||||
<Clock size={12} class="text-primary/60" />
|
||||
<span>{formattedDate()}</span>
|
||||
</div>
|
||||
|
||||
{/* Urgency Number - Slides out from behind with fade */}
|
||||
<div class={cn(
|
||||
"absolute left-4 top-0 bottom-0 flex items-center transition-all duration-300 ease-out z-10 opacity-0 group-hover/date:opacity-100 group-hover/date:left-full whitespace-nowrap",
|
||||
urgencyColor()
|
||||
)}>
|
||||
<span class="text-[10px] font-bold ml-2 transition-all duration-300 transform translate-x-[-8px] group-hover/date:translate-x-0 group-hover/date:blur-none blur-[2px]">
|
||||
Urgency {urgencyLevel()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-4 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button class="p-2 hover:bg-muted rounded-full transition-colors">
|
||||
<div class="w-1.5 h-1.5 bg-muted-foreground rounded-full" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import { type Component, createEffect, createSignal } from "solid-js";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { type Task, removeTask, restoreTask, updateTask } from "@/store";
|
||||
import { TaskEditor } from "@/components/TaskEditor";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2 } from "lucide-solid";
|
||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
import { toast } from "solid-sonner";
|
||||
|
||||
interface TaskDetailProps {
|
||||
task: Task;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
// Local state for immediate feedback, synced with props
|
||||
const [title, setTitle] = createSignal(props.task.title);
|
||||
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||
|
||||
// Sync input string for datetime-local
|
||||
const [dateString, setDateString] = createSignal<string>("");
|
||||
|
||||
createEffect(() => {
|
||||
setTitle(props.task.title);
|
||||
// Format for datetime-local: yyyy-MM-ddThh:mm (local time)
|
||||
const dateObj = new Date(props.task.dueDate);
|
||||
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
||||
setDateString(localIso);
|
||||
});
|
||||
|
||||
let debounceTimer: number | undefined;
|
||||
const updateTaskContent = (html: string) => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
updateTask(props.task.id, { content: html });
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const updateTitle = (val: string) => {
|
||||
setTitle(val);
|
||||
// Debouncing could be added here, but direct update is fine for now if PB handles it well
|
||||
updateTask(props.task.id, { title: val });
|
||||
};
|
||||
|
||||
const updatePriority = (val: string) => {
|
||||
const p = parseInt(val);
|
||||
if (!isNaN(p)) {
|
||||
updateTask(props.task.id, { priority: p });
|
||||
}
|
||||
};
|
||||
|
||||
const updateUrgency = (val: string) => {
|
||||
const u = parseInt(val);
|
||||
if (!isNaN(u)) {
|
||||
const newDate = calculateDateFromUrgency(u);
|
||||
updateTask(props.task.id, { dueDate: newDate });
|
||||
}
|
||||
};
|
||||
|
||||
const onDateInputChange = (e: Event) => {
|
||||
const val = (e.currentTarget as HTMLInputElement).value;
|
||||
if (val) {
|
||||
const iso = new Date(val).toISOString();
|
||||
updateTask(props.task.id, { dueDate: iso });
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate current urgency level for display
|
||||
const currentUrgency = () => calculateUrgencyFromDate(props.task.dueDate).toString();
|
||||
|
||||
return (
|
||||
<Sheet open={props.isOpen} onOpenChange={(open) => !open && props.onClose()}>
|
||||
<SheetContent class="w-full sm:max-w-2xl px-0 sm:px-0 flex flex-col h-full bg-background border-l border-border shadow-2xl">
|
||||
{/* Header Area */}
|
||||
<div class="px-6 pt-6 pb-2 shrink-0">
|
||||
<textarea
|
||||
value={title()}
|
||||
onInput={(e) => {
|
||||
updateTitle(e.currentTarget.value);
|
||||
// Auto-resize
|
||||
e.currentTarget.style.height = 'auto';
|
||||
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
||||
}}
|
||||
ref={(el) => {
|
||||
setTimeout(() => {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
}, 0);
|
||||
}}
|
||||
rows={1}
|
||||
class="text-xl sm:text-2xl font-bold bg-transparent border-none focus:outline-none focus:ring-0 w-full placeholder:text-muted-foreground/50 resize-none overflow-hidden leading-tight"
|
||||
placeholder="Untitled Task"
|
||||
/>
|
||||
|
||||
{/* Properties Bar */}
|
||||
<div class="flex flex-row items-center gap-1.5 sm:gap-4 mt-2 text-sm overflow-x-auto no-scrollbar flex-nowrap">
|
||||
{/* Priority */}
|
||||
<div class="flex items-center gap-1 group shrink-0">
|
||||
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Priority</span>
|
||||
<Select
|
||||
value={props.task.priority.toString()}
|
||||
onChange={(val) => val && updatePriority(val)}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
placeholder="Priority"
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-8 min-w-[40px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0">
|
||||
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium">
|
||||
<ArrowUpCircle size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||
<span class="text-xs sm:text-sm">{props.task.priority}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Urgency */}
|
||||
<div class="flex items-center gap-1 group shrink-0">
|
||||
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Time</span>
|
||||
<Select
|
||||
value={currentUrgency()}
|
||||
onChange={(val) => val && updateUrgency(val)}
|
||||
options={["10", "9", "8", "7", "6", "5", "4", "3", "2", "1"]}
|
||||
placeholder="Urgency"
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-8 min-w-[40px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0">
|
||||
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium">
|
||||
<Clock size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity" />
|
||||
<span class="text-xs sm:text-sm">{currentUrgency()}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Commands Shortcut */}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-1 sm:px-2 text-[10px] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) {
|
||||
instance.chain().focus().insertContent("/").run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Type size={14} class="opacity-70" />
|
||||
<span class="hidden sm:inline">Commands</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Date - Editable */}
|
||||
<div class="flex items-center gap-1 text-muted-foreground group relative shrink-0 min-w-0 flex-1 sm:flex-initial">
|
||||
<Calendar size={14} class="group-hover:text-primary transition-colors shrink-0" />
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dateString()}
|
||||
onInput={onDateInputChange}
|
||||
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Tab" && e.key !== "Escape") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
step="900"
|
||||
class="bg-transparent border-none p-0 text-[11px] sm:text-xs font-medium focus:ring-0 focus:outline-none cursor-pointer hover:text-foreground appearance-none min-w-[125px] w-full"
|
||||
title="Change Due Date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<div class="flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 px-0 hover:bg-red-500/10 hover:text-red-500 text-muted-foreground transition-colors"
|
||||
onClick={() => {
|
||||
const taskId = props.task.id;
|
||||
removeTask(taskId);
|
||||
props.onClose();
|
||||
toast.info("Task moved to trash", {
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => restoreTask(taskId)
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px w-full bg-border mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Editor Content */}
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 pb-20">
|
||||
<TaskEditor
|
||||
content={props.task.content}
|
||||
onUpdate={updateTaskContent}
|
||||
onEditorReady={setEditorInstance}
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { type Component, createEffect, untrack } from "solid-js";
|
||||
import { createTiptapEditor } from "solid-tiptap";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SlashCommands } from "@/lib/slash-command";
|
||||
import { suggestion } from "@/lib/slash-renderer";
|
||||
|
||||
interface TaskEditorProps {
|
||||
content?: string;
|
||||
onUpdate: (html: string) => void;
|
||||
onEditorReady?: (editor: any) => void;
|
||||
editable?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
let editorRef: HTMLDivElement | undefined;
|
||||
|
||||
const editor = createTiptapEditor(() => ({
|
||||
element: editorRef!,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: "Type '/' for commands or start writing...",
|
||||
emptyEditorClass: "is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground before:float-left before:pointer-events-none before:h-0",
|
||||
}),
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
HTMLAttributes: {
|
||||
class: "flex gap-2 items-start my-1",
|
||||
},
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
suggestion,
|
||||
}),
|
||||
],
|
||||
// Use untrack so the editor doesn't re-initialize when props.content changes
|
||||
content: untrack(() => props.content) || "",
|
||||
editable: props.editable ?? true,
|
||||
onUpdate: ({ editor }) => {
|
||||
props.onUpdate(editor.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: cn(
|
||||
"prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[150px]",
|
||||
"prose-headings:font-semibold prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
||||
"prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1",
|
||||
"prose-pre:bg-muted prose-pre:rounded-lg prose-pre:p-4",
|
||||
"prose-img:rounded-lg prose-img:border prose-img:border-border",
|
||||
// Custom Task List Styling
|
||||
"[&_ul[data-type='taskList']]:list-none [&_ul[data-type='taskList']]:p-0",
|
||||
"[&_li[data-type='taskItem']]:flex [&_li[data-type='taskItem']]:items-start",
|
||||
"[&_li[data-type='taskItem']>label]:mr-2 [&_li[data-type='taskItem']>label]:mt-0.5 [&_li[data-type='taskItem']>label]:select-none",
|
||||
"[&_li[data-type='taskItem']>div]:flex-1",
|
||||
props.class
|
||||
),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Expose editor to parent
|
||||
createEffect(() => {
|
||||
const e = editor();
|
||||
if (e && props.onEditorReady) {
|
||||
props.onEditorReady(e);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync content updates from outside if needed (but only if NOT focused to avoid jumps)
|
||||
createEffect(() => {
|
||||
const content = props.content;
|
||||
const e = editor();
|
||||
if (e && content !== undefined && !e.isFocused && content !== e.getHTML()) {
|
||||
e.commands.setContent(content, { emitUpdate: false });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={editorRef}
|
||||
class="w-full h-full cursor-text"
|
||||
onClick={() => editor()?.chain().focus().run()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createSignal, createContext, useContext, createEffect, type ParentComponent } from "solid-js";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
interface ThemeProviderState {
|
||||
theme: () => Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>();
|
||||
|
||||
export const ThemeProvider: ParentComponent = (props) => {
|
||||
const [theme, setTheme] = createSignal<Theme>("system");
|
||||
|
||||
// Load from local storage on mount
|
||||
const storedTheme = localStorage.getItem("tasgrid-theme") as Theme | null;
|
||||
if (storedTheme) {
|
||||
setTheme(storedTheme);
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
const currentTheme = theme();
|
||||
|
||||
if (currentTheme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
root.classList.add(systemTheme);
|
||||
} else {
|
||||
root.classList.add(currentTheme);
|
||||
}
|
||||
|
||||
localStorage.setItem("tasgrid-theme", currentTheme);
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider value={{ theme, setTheme }}>
|
||||
{props.children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
if (!context) throw new Error("useTheme must be used within a ThemeProvider");
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type Component } from "solid-js";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Moon, Sun } from "lucide-solid";
|
||||
|
||||
export const ThemeToggle: Component = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme() === "dark" ? "light" : "dark")}
|
||||
class="w-9 h-9 px-0"
|
||||
>
|
||||
<Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { splitProps, type JSX } from "solid-js"
|
||||
import { Button as ButtonPrimitive } from "@kobalte/core/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline"
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps extends VariantProps<typeof buttonVariants> {
|
||||
class?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
onMouseEnter?: (e: MouseEvent) => void
|
||||
onMouseLeave?: (e: MouseEvent) => void
|
||||
children?: JSX.Element
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
type?: "button" | "submit" | "reset"
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const Button = (props: ButtonProps) => {
|
||||
const [local, others] = splitProps(props, ["variant", "size", "class"])
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
class={cn(buttonVariants({ variant: local.variant, size: local.size }), local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,23 @@
|
||||
import { splitProps } from "solid-js"
|
||||
import { Popover as PopoverPrimitive } from "@kobalte/core/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = (props: import("solid-js").ComponentProps<typeof PopoverPrimitive.Content>) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
class={cn(
|
||||
"z-[110] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
@@ -0,0 +1,62 @@
|
||||
import { splitProps, type ComponentProps } from "solid-js"
|
||||
import { Select as SelectPrimitive } from "@kobalte/core/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check, ChevronDown } from "lucide-solid"
|
||||
|
||||
const Select = SelectPrimitive
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = (props: ComponentProps<typeof SelectPrimitive.Trigger>) => {
|
||||
const [local, others] = splitProps(props, ["class", "children"])
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
class={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
<SelectPrimitive.Icon as={ChevronDown} class="h-4 w-4 opacity-50 transition-transform duration-200" />
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectContent = (props: ComponentProps<typeof SelectPrimitive.Content>) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
class={cn(
|
||||
"relative z-[200] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
<SelectPrimitive.Listbox class="p-1" />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectItem = (props: ComponentProps<typeof SelectPrimitive.Item>) => {
|
||||
const [local, others] = splitProps(props, ["class", "children"])
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
class={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemLabel>{local.children}</SelectPrimitive.ItemLabel>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { Select, SelectValue, SelectTrigger, SelectContent, SelectItem }
|
||||
@@ -0,0 +1,98 @@
|
||||
import { type Component, splitProps, type ComponentProps } from "solid-js";
|
||||
import { Dialog as SheetPrimitive } from "@kobalte/core/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { X } from "lucide-solid";
|
||||
|
||||
const Sheet = SheetPrimitive;
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
const SheetClose = SheetPrimitive.CloseButton;
|
||||
|
||||
const SheetOverlay: Component<ComponentProps<typeof SheetPrimitive.Overlay>> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"]);
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
class={cn(
|
||||
"fixed inset-0 z-[150] bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SheetContent: Component<ComponentProps<typeof SheetPrimitive.Content>> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class", "children"]);
|
||||
return (
|
||||
<SheetPrimitive.Portal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
class={cn(
|
||||
"fixed z-[150] gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-xl",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<SheetPrimitive.CloseButton class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</SheetPrimitive.CloseButton>
|
||||
{local.children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPrimitive.Portal>
|
||||
);
|
||||
};
|
||||
|
||||
const SheetHeader: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"]);
|
||||
return (
|
||||
<div
|
||||
class={cn("flex flex-col space-y-2 text-center sm:text-left", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SheetFooter: Component<ComponentProps<"div">> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"]);
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SheetTitle: Component<ComponentProps<typeof SheetPrimitive.Title>> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"]);
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
class={cn("text-lg font-semibold text-foreground", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SheetDescription: Component<ComponentProps<typeof SheetPrimitive.Description>> = (props) => {
|
||||
const [local, others] = splitProps(props, ["class"]);
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
class={cn("text-sm text-muted-foreground", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Toaster as Sonner } from "solid-sonner"
|
||||
import { useTheme } from "../ThemeProvider"
|
||||
|
||||
type ToasterProps = Parameters<typeof Sonner>[0]
|
||||
|
||||
const Toaster = (props: ToasterProps) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme() as ToasterProps["theme"]}
|
||||
class="toaster group"
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
classes: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,60 @@
|
||||
import { splitProps, type ComponentProps } from "solid-js"
|
||||
import { TextField as TextFieldPrimitive } from "@kobalte/core/text-field"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TextField = TextFieldPrimitive
|
||||
|
||||
const TextFieldInput = (props: ComponentProps<"input">) => {
|
||||
const [local, others] = splitProps(props, ["class", "type"])
|
||||
return (
|
||||
<TextFieldPrimitive.Input
|
||||
type={local.type}
|
||||
class={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
local.class
|
||||
)}
|
||||
{...(others as any)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TextFieldLabel = (props: any) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.Label
|
||||
class={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
local.class
|
||||
)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TextFieldDescription = (props: any) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.Description
|
||||
class={cn("text-[0.8rem] text-muted-foreground", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TextFieldErrorMessage = (props: any) => {
|
||||
const [local, others] = splitProps(props, ["class"])
|
||||
return (
|
||||
<TextFieldPrimitive.ErrorMessage
|
||||
class={cn("text-[0.8rem] font-medium text-destructive", local.class)}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
TextField,
|
||||
TextFieldInput,
|
||||
TextFieldLabel,
|
||||
TextFieldDescription,
|
||||
TextFieldErrorMessage
|
||||
}
|
||||
Reference in New Issue
Block a user