initial commit 3??
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -1,2 +1,28 @@
|
|||||||
# TasGrid
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install # or pnpm install or yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm run dev`
|
||||||
|
|
||||||
|
Runs the app in the development mode.<br>
|
||||||
|
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `dist` folder.<br>
|
||||||
|
It correctly bundles Solid in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.<br>
|
||||||
|
Your app is ready to be deployed!
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-solid.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>tasgrid</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "tasgrid",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@kobalte/core": "^0.13.11",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tiptap/core": "^3.18.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.18.0",
|
||||||
|
"@tiptap/extension-task-item": "^3.18.0",
|
||||||
|
"@tiptap/extension-task-list": "^3.18.0",
|
||||||
|
"@tiptap/starter-kit": "^3.18.0",
|
||||||
|
"@tiptap/suggestion": "^3.18.0",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-solid": "^0.563.0",
|
||||||
|
"pocketbase": "^0.26.8",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"solid-js": "^1.9.10",
|
||||||
|
"solid-sonner": "^0.2.8",
|
||||||
|
"solid-tiptap": "^0.8.0",
|
||||||
|
"solid-transition-group": "^0.3.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
|
"vite-plugin-pwa": "^1.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vite-plugin-solid": "^2.11.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.solid:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import { type Component, createSignal, Show, onMount, onCleanup } from 'solid-js';
|
||||||
|
import { Layout } from './components/Layout';
|
||||||
|
import { CriticalView } from './views/CriticalView';
|
||||||
|
import { UrgencyView } from './views/UrgencyView';
|
||||||
|
import { PriorityView } from './views/PriorityView';
|
||||||
|
import { MatrixView } from './views/MatrixView';
|
||||||
|
import { SettingsView } from './views/SettingsView';
|
||||||
|
import { AuthCallback } from './components/Auth';
|
||||||
|
import { pb } from './lib/pocketbase';
|
||||||
|
import { initStore } from './store';
|
||||||
|
|
||||||
|
const App: Component = () => {
|
||||||
|
// Basic routing state
|
||||||
|
const [currentView, setCurrentView] = createSignal("matrix");
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const removeListener = pb.authStore.onChange((token) => {
|
||||||
|
setIsAuthenticated(!!token);
|
||||||
|
if (token) {
|
||||||
|
initStore();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
onCleanup(() => removeListener());
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isAuthenticated()} fallback={<AuthCallback onSuccess={() => { }} />}>
|
||||||
|
<Layout currentView={currentView()} setView={setCurrentView}>
|
||||||
|
<Show when={currentView() === "critical"}><CriticalView /></Show>
|
||||||
|
<Show when={currentView() === "urgency"}><UrgencyView /></Show>
|
||||||
|
<Show when={currentView() === "priority"}><PriorityView /></Show>
|
||||||
|
<Show when={currentView() === "matrix"}><MatrixView /></Show>
|
||||||
|
<Show when={currentView() === "settings"}><SettingsView /></Show>
|
||||||
|
</Layout>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 166 155.3"><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" fill="#76b3e1"/><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="27.5" y1="3" x2="152" y2="63.5"><stop offset=".1" stop-color="#76b3e1"/><stop offset=".3" stop-color="#dcf2fd"/><stop offset="1" stop-color="#76b3e1"/></linearGradient><path d="M163 35S110-4 69 5l-3 1c-6 2-11 5-14 9l-2 3-15 26 26 5c11 7 25 10 38 7l46 9 18-30z" opacity=".3" fill="url(#a)"/><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" fill="#518ac8"/><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="95.8" y1="32.6" x2="74" y2="105.2"><stop offset="0" stop-color="#76b3e1"/><stop offset=".5" stop-color="#4377bb"/><stop offset="1" stop-color="#1f3b77"/></linearGradient><path d="M52 35l-4 1c-17 5-22 21-13 35 10 13 31 20 48 15l62-21S92 26 52 35z" opacity=".3" fill="url(#b)"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="18.4" y1="64.2" x2="144.3" y2="149.8"><stop offset="0" stop-color="#315aa9"/><stop offset=".5" stop-color="#518ac8"/><stop offset="1" stop-color="#315aa9"/></linearGradient><path d="M134 80a45 45 0 00-48-15L24 85 4 120l112 19 20-36c4-7 3-15-2-23z" fill="url(#c)"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="75.2" y1="74.5" x2="24.4" y2="260.8"><stop offset="0" stop-color="#4377bb"/><stop offset=".5" stop-color="#1a336b"/><stop offset="1" stop-color="#1a336b"/></linearGradient><path d="M114 115a45 45 0 00-48-15L4 120s53 40 94 30l3-1c17-5 23-21 13-34z" fill="url(#d)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
||||||
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-exit-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter,
|
||||||
|
.fade-exit-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { render } from 'solid-js/web'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import { ThemeProvider } from './components/ThemeProvider'
|
||||||
|
import { Toaster } from './components/ui/sonner'
|
||||||
|
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
|
||||||
|
render(() => {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}, root!)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
|
export const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||||
|
|
||||||
|
export const TASGRID_COLLECTION = 'TasGrid';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Extension } from "@tiptap/core";
|
||||||
|
import Suggestion from "@tiptap/suggestion";
|
||||||
|
|
||||||
|
export const SlashCommands = Extension.create({
|
||||||
|
name: "slashCommands",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: "/",
|
||||||
|
command: ({ editor, range, props }: any) => {
|
||||||
|
props.command({ editor, range });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { render } from "solid-js/web";
|
||||||
|
import tippy, { type Instance as TippyInstance } from "tippy.js";
|
||||||
|
import { SlashMenu, getSuggestionItems } from "../components/SlashMenu";
|
||||||
|
|
||||||
|
export const suggestion = {
|
||||||
|
items: getSuggestionItems,
|
||||||
|
|
||||||
|
render: () => {
|
||||||
|
let component: (() => void) | undefined;
|
||||||
|
let popup: TippyInstance | undefined;
|
||||||
|
let container: HTMLDivElement | undefined;
|
||||||
|
let componentHandlers: { onKeyDown: (e: KeyboardEvent) => boolean } | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: any) => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
|
||||||
|
component = render(
|
||||||
|
() => <SlashMenu
|
||||||
|
items={props.items}
|
||||||
|
command={props.command}
|
||||||
|
editor={props.editor}
|
||||||
|
ref={(h) => componentHandlers = h}
|
||||||
|
/>,
|
||||||
|
container
|
||||||
|
);
|
||||||
|
|
||||||
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => props.editor.options.element || document.body,
|
||||||
|
content: container,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
|
})[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate(props: any) {
|
||||||
|
// Re-rendering to update items
|
||||||
|
if (component) component();
|
||||||
|
|
||||||
|
component = render(
|
||||||
|
() => <SlashMenu
|
||||||
|
items={props.items}
|
||||||
|
command={props.command}
|
||||||
|
editor={props.editor}
|
||||||
|
ref={(h) => componentHandlers = h}
|
||||||
|
/>,
|
||||||
|
container!
|
||||||
|
);
|
||||||
|
|
||||||
|
if (props.clientRect) {
|
||||||
|
popup?.setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(props: any) {
|
||||||
|
if (props.event.key === "Escape") {
|
||||||
|
popup?.hide();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return componentHandlers?.onKeyDown(props.event) ?? false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onExit() {
|
||||||
|
if (popup && !popup.state.isDestroyed) {
|
||||||
|
popup.destroy();
|
||||||
|
}
|
||||||
|
popup = undefined;
|
||||||
|
if (component) {
|
||||||
|
component();
|
||||||
|
component = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// ... (imports)
|
||||||
|
import { createStore } from "solid-js/store";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import { pb, TASGRID_COLLECTION } from "@/lib/pocketbase";
|
||||||
|
import { toast } from "solid-sonner";
|
||||||
|
|
||||||
|
|
||||||
|
export const [now, setNow] = createSignal(Date.now());
|
||||||
|
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
||||||
|
|
||||||
|
export type UrgencyLevel = number;
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
priority: number; // 1-10
|
||||||
|
completed: boolean;
|
||||||
|
tags: string[];
|
||||||
|
content?: string; // HTML content from Tiptap
|
||||||
|
deletedAt?: number; // Timestamp of soft delete
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskStore {
|
||||||
|
tasks: Task[];
|
||||||
|
pWeight: number;
|
||||||
|
uWeight: number;
|
||||||
|
matrixScaleDays: number;
|
||||||
|
prefId?: string; // ID of the user_preferences record
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
export const [store, setStore] = createStore<TaskStore>({
|
||||||
|
tasks: [],
|
||||||
|
pWeight: 1.0,
|
||||||
|
uWeight: 1.0,
|
||||||
|
matrixScaleDays: 30, // Default requested by user
|
||||||
|
});
|
||||||
|
|
||||||
|
// -- Calibration --
|
||||||
|
export const calculateUrgencyScore = (dueDateStr: string): number => {
|
||||||
|
const due = new Date(dueDateStr).getTime();
|
||||||
|
const diffHours = (due - now()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
// Geometric formulation
|
||||||
|
// Base is derived so that urgency 1 ~ matrixScaleDays
|
||||||
|
const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9);
|
||||||
|
|
||||||
|
// Reverse formula: Level = 10 - (ln(hours/24) / ln(base))
|
||||||
|
// If hours <= 24, log(<=1) is negative or 0, so Level >= 10.
|
||||||
|
if (diffHours <= 24) return 10;
|
||||||
|
|
||||||
|
const level = 10 - (Math.log(diffHours / 24) / Math.log(base));
|
||||||
|
return Math.max(1, Math.min(10, level));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateDateFromUrgency = (level: number): string => {
|
||||||
|
// Inverse of above
|
||||||
|
if (level >= 10) {
|
||||||
|
return new Date(now() + 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9);
|
||||||
|
const hours = 24 * Math.pow(base, 10 - level);
|
||||||
|
return new Date(now() + hours * 60 * 60 * 1000).toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateUrgencyFromDate = (dateStr: string): number => {
|
||||||
|
return Math.round(calculateUrgencyScore(dateStr));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCombinedScore = (task: Task): number => {
|
||||||
|
const urgencyScore = calculateUrgencyScore(task.dueDate);
|
||||||
|
return (task.priority * store.pWeight) + (urgencyScore * store.uWeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Persistence & Sync --
|
||||||
|
|
||||||
|
let heartbeatInterval: number | undefined;
|
||||||
|
|
||||||
|
export const initStore = async () => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
// Heartbeat - Ensure only one interval is running
|
||||||
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = setInterval(() => setNow(Date.now()), 60000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch Preferences from User Profile
|
||||||
|
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,
|
||||||
|
prefId: userId // The ID to update is the User ID
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (prefErr) {
|
||||||
|
// Non-blocking error for preferences
|
||||||
|
console.warn("Failed to load preferences from user profile:", prefErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch Tasks
|
||||||
|
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
|
||||||
|
setStore("tasks", loadedTasks);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load data:", err);
|
||||||
|
toast.error("Failed to sync with server.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Actions --
|
||||||
|
|
||||||
|
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[] }) => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
// Default to ~24h from now if no date provided
|
||||||
|
let dueDate = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
if (options?.dueDate) {
|
||||||
|
dueDate = options.dueDate instanceof Date ? options.dueDate.toISOString() : options.dueDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date().toISOString();
|
||||||
|
const priority = options?.priority ?? 5;
|
||||||
|
const tags = options?.tags ?? ["work"];
|
||||||
|
|
||||||
|
const tempId = "temp-" + Date.now();
|
||||||
|
const newTask: Task = {
|
||||||
|
id: tempId,
|
||||||
|
title,
|
||||||
|
startDate,
|
||||||
|
dueDate,
|
||||||
|
priority,
|
||||||
|
completed: false,
|
||||||
|
tags
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistic UI update
|
||||||
|
setStore("tasks", (t) => [newTask, ...t]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await pb.collection(TASGRID_COLLECTION).create({
|
||||||
|
user: pb.authStore.model?.id,
|
||||||
|
title,
|
||||||
|
startDate,
|
||||||
|
dueDate,
|
||||||
|
priority,
|
||||||
|
completed: false,
|
||||||
|
tags
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replace temp task with real record
|
||||||
|
setStore("tasks", (t) => t.map(task => task.id === tempId ? { ...task, id: record.id } : task));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("create failed", err);
|
||||||
|
toast.error("Failed to save task.");
|
||||||
|
// Rollback?
|
||||||
|
setStore("tasks", (t) => t.filter(task => task.id !== tempId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTask = async (id: string, updates: Partial<Task>) => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
// Optimistic
|
||||||
|
setStore("tasks", (t) => t.id === id, updates);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Map Task fields to PB fields if needed (dates are ISO strings, so should matches)
|
||||||
|
// Check for specific fields being updated
|
||||||
|
const pbUpdates: any = { ...updates };
|
||||||
|
if (updates.deletedAt !== undefined) {
|
||||||
|
// PB expects a date string or null for date fields usually,
|
||||||
|
// but if we defined deletedAt as a 'date' field in PB.
|
||||||
|
// If we defined it as a number in PB? User didn't specify.
|
||||||
|
// Assuming it's a 'Date' field in PB based on previous conversation
|
||||||
|
// "deletedAt | Date | No"
|
||||||
|
if (updates.deletedAt) {
|
||||||
|
pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString();
|
||||||
|
} else {
|
||||||
|
pbUpdates.deletedAt = null; // restore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable auto-cancellation for updates to prevent aborted errors during rapid typing
|
||||||
|
await pb.collection(TASGRID_COLLECTION).update(id, pbUpdates, { requestKey: null });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("update failed", err);
|
||||||
|
toast.error("Failed to update task.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrapper for updateTask to fit setStore style usage in components if needed,
|
||||||
|
// but for now we'll just intercept the setStore calls or provide specific actions.
|
||||||
|
// The components currently use `setStore("tasks", ...)` directly in some places.
|
||||||
|
// We need to verify if we should refactor those components to use actions or use a store effect.
|
||||||
|
// Ideally, we use a createEffect to watch the store, but that can trigger on every load.
|
||||||
|
// Better to export specific actions.
|
||||||
|
|
||||||
|
// -- Legacy Adapters --
|
||||||
|
// Many components use setStore directly. To support them without full refactor,
|
||||||
|
// we can watch specific fields or just migrate them to use actions.
|
||||||
|
// Given strict instructions, let's look at what we've seen.
|
||||||
|
// TaskDetail uses `removeTask`.
|
||||||
|
// QuickEntry uses `addTask`.
|
||||||
|
// TaskDetail uses `setStore` for title/priority updates. THIS IS A PROBLEM for sync.
|
||||||
|
// We should provide a way to sync these changes.
|
||||||
|
|
||||||
|
// Let's adding a store effect to sync changes?
|
||||||
|
// It's risky. Let's provide an explicit hook or functions and fix the components.
|
||||||
|
// OR, we overwrite the `setStore` export? No, that's internal.
|
||||||
|
// Let's updated `setStore` usage in this file to be purely local,
|
||||||
|
// and add a `syncTask` helper that components should call?
|
||||||
|
// Or better: Replace the `setStore` export with a wrapped version? Hard with type inference.
|
||||||
|
//
|
||||||
|
// Best approach for now:
|
||||||
|
// Keep `setStore` for local UI state.
|
||||||
|
// Add a `createEffect` that watches `store.tasks`?
|
||||||
|
// No, deep watching is expensive.
|
||||||
|
//
|
||||||
|
// Let's sticking to: Components SHOULD call actions.
|
||||||
|
// But TaskDetail calls `setStore`.
|
||||||
|
// I will start by modifying `setStore` usages in TaskDetail/etc in a subsequent step if needed.
|
||||||
|
// FOR NOW: I will export a `updateTaskField` helper and update components to use it.
|
||||||
|
// Actually, `TaskDetail` was edited to use `setStore` in Step 1520.
|
||||||
|
// line 33: setStore("tasks", (t) => t.id === props.task.id, "content", html);
|
||||||
|
// line 38, 44, 52, 60... all use setStore.
|
||||||
|
// I MUST refactor TaskDetail to use an action.
|
||||||
|
|
||||||
|
export const updateTaskField = (id: string, field: keyof Task, value: any) => {
|
||||||
|
setStore("tasks", (t) => t.id === id, field, value);
|
||||||
|
// Debounce this?
|
||||||
|
// For now, fire and forget update
|
||||||
|
updateTask(id, { [field]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Matrix Scale --
|
||||||
|
|
||||||
|
export const setMatrixScaleDays = async (days: number) => {
|
||||||
|
setStore("matrixScaleDays", days);
|
||||||
|
|
||||||
|
// Sync to users collection
|
||||||
|
if (store.prefId) {
|
||||||
|
try {
|
||||||
|
const prefs = {
|
||||||
|
pWeight: store.pWeight,
|
||||||
|
uWeight: store.uWeight,
|
||||||
|
matrixScaleDays: days
|
||||||
|
};
|
||||||
|
|
||||||
|
await pb.collection('users').update(store.prefId, {
|
||||||
|
Taskgrid_pref: prefs
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update preferences", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleTask = (id: string) => {
|
||||||
|
const task = store.tasks.find(t => t.id === id);
|
||||||
|
if (task) {
|
||||||
|
updateTask(id, { completed: !task.completed });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeTask = (id: string) => {
|
||||||
|
// Soft delete
|
||||||
|
const nowTs = Date.now();
|
||||||
|
updateTask(id, { deletedAt: nowTs });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restoreTask = (id: string) => {
|
||||||
|
// This requires passing undefined or null to updateTask
|
||||||
|
// createStore handles undefined ok.
|
||||||
|
setStore("tasks", (t) => t.id === id, "deletedAt", undefined);
|
||||||
|
|
||||||
|
// For PB, we need to send null explicitly for date clear?
|
||||||
|
// updateTask handles this check.
|
||||||
|
updateTask(id, { deletedAt: undefined });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTaskPermanently = async (id: string) => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
// Optimistic
|
||||||
|
setStore("tasks", (t) => t.filter((t) => t.id !== id));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection(TASGRID_COLLECTION).delete(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("delete failed", err);
|
||||||
|
toast.error("Failed to delete task.");
|
||||||
|
// Reload tasks?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Legacy cleanup - We don't need local storage persistence setup anymore
|
||||||
|
export const setupPersistence = () => {
|
||||||
|
// Moved logic to initStore which is called on auth.
|
||||||
|
// Keeping function signature to avoid breaking index.js calls immediately,
|
||||||
|
// but it will be empty or a no-op if called before auth.
|
||||||
|
// Actually, index.tsx calls this. We can leave it empty or have it check auth.
|
||||||
|
initStore();
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { type Component, For, createMemo } from "solid-js";
|
||||||
|
import { store, getCombinedScore } from "@/store";
|
||||||
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
|
||||||
|
export const CriticalView: Component = () => {
|
||||||
|
const sortedTasks = createMemo(() => {
|
||||||
|
return [...store.tasks].filter(t => !t.deletedAt).sort((a, b) => {
|
||||||
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
|
return getCombinedScore(b) - getCombinedScore(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Focus</h2>
|
||||||
|
<p class="text-muted-foreground mt-1 text-lg">Your tasks ranked by highest urgency + priority.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<For each={sortedTasks()}>
|
||||||
|
{(task) => <TaskCard task={task} />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedTasks().length === 0 && (
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
|
<span class="text-2xl">✨</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground">Clear horizon. No critical tasks pending.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { type Component, For, createMemo } from "solid-js";
|
||||||
|
import { store, calculateUrgencyScore, setActiveTaskId, setStore, getCombinedScore } from "@/store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||||
|
|
||||||
|
export const MatrixView: Component = () => {
|
||||||
|
const activeTasks = createMemo(() => store.tasks.filter(t => !t.completed && !t.deletedAt));
|
||||||
|
|
||||||
|
const scaleOptions = ["1", "7", "30", "60", "90"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-[calc(100vh-12rem)] md:h-[calc(100vh-8rem)] flex flex-col">
|
||||||
|
<header class="mb-6">
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Strategy Matrix</h2>
|
||||||
|
<p class="text-muted-foreground mt-1 text-lg">Urgency vs Priority mapping.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 relative border-2 border-dashed border-muted rounded-3xl overflow-hidden bg-muted/20">
|
||||||
|
{/* Axis Labels */}
|
||||||
|
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50 flex items-center gap-2 z-30">
|
||||||
|
<span>← Soon</span>
|
||||||
|
<div class="flex items-center gap-1 bg-background/50 backdrop-blur-sm px-2 py-0.5 rounded-full border border-border/50 group hover:bg-background/80 transition-colors cursor-pointer">
|
||||||
|
<span class="text-[9px]">Time:</span>
|
||||||
|
<Select
|
||||||
|
value={store.matrixScaleDays.toString()}
|
||||||
|
onChange={(val) => val && setStore("matrixScaleDays", parseInt(val))}
|
||||||
|
options={scaleOptions}
|
||||||
|
placeholder="Scale"
|
||||||
|
itemComponent={(props) => (
|
||||||
|
<SelectItem item={props.item}>
|
||||||
|
{props.item.rawValue} {props.item.rawValue === "1" ? "day" : "days"}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-auto p-0 min-w-[50px] bg-transparent border-none shadow-none focus:ring-0 text-[10px] font-black text-foreground">
|
||||||
|
<span>{store.matrixScaleDays} {store.matrixScaleDays === 1 ? 'day' : 'days'}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent class="z-[150]" />
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<span>Later →</span>
|
||||||
|
</div>
|
||||||
|
<div class="absolute left-4 top-1/2 -translate-y-1/2 -rotate-90 text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50 pointer-events-none">
|
||||||
|
← Low (Priority) High →
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quadrant Lines */}
|
||||||
|
<div class="absolute inset-0 flex pointer-events-none">
|
||||||
|
<div class="flex-1 border-r border-muted/30" />
|
||||||
|
<div class="flex-1" />
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-0 flex flex-col pointer-events-none">
|
||||||
|
<div class="flex-1 border-b border-muted/30" />
|
||||||
|
<div class="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quadrant Labels */}
|
||||||
|
<div class="absolute top-4 left-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">DO FIRST</div>
|
||||||
|
<div class="absolute top-4 right-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">SCHEDULE</div>
|
||||||
|
<div class="absolute bottom-4 left-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">DELEGATE</div>
|
||||||
|
<div class="absolute bottom-4 right-4 text-[10px] font-bold text-muted-foreground/30 pointer-events-none">ELIMINATE</div>
|
||||||
|
|
||||||
|
{/* Task Pips */}
|
||||||
|
<For each={activeTasks()}>
|
||||||
|
{(task) => {
|
||||||
|
const getPosition = () => {
|
||||||
|
// Continuous geometric urgency mapping
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const due = new Date(task.dueDate).getTime();
|
||||||
|
const diffHours = Math.max(0.1, (due - now) / (1000 * 60 * 60));
|
||||||
|
|
||||||
|
// Level formula: H = 24 * base^(10-v) => v = 10 - logBase(H/24)
|
||||||
|
const base = Math.pow(Math.max(1, store.matrixScaleDays), 1 / 9);
|
||||||
|
const continuousLevel = 10 - (Math.log(diffHours / 24) / Math.log(base));
|
||||||
|
const clampedLevel = Math.min(10, Math.max(1, continuousLevel));
|
||||||
|
|
||||||
|
// Map 1-10 level to 10-90% X coordinate (10 on left, 1 on right)
|
||||||
|
const x = 100 - (((clampedLevel - 1) / 9) * 80 + 10);
|
||||||
|
|
||||||
|
// Y-axis: Priority (1 to 10)
|
||||||
|
// 10 = 10%, 1 = 90%
|
||||||
|
const y = 100 - (((task.priority - 1) / 9) * 80 + 10);
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
const urgencyScore = calculateUrgencyScore(task.dueDate);
|
||||||
|
const combinedScore = getCombinedScore(task);
|
||||||
|
|
||||||
|
// Map 1-10 to HSL Hue (0 = Red, 240 = Blue)
|
||||||
|
// Using a slightly limited range for better aesthetics (0 to 220)
|
||||||
|
const hue = ((10 - combinedScore) / 9) * 220;
|
||||||
|
const dotColor = `hsl(${hue}, 85%, 55%)`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="absolute group cursor-pointer z-10"
|
||||||
|
onClick={() => setActiveTaskId(task.id)}
|
||||||
|
style={{
|
||||||
|
left: `${getPosition().x}%`,
|
||||||
|
top: `${getPosition().y}%`,
|
||||||
|
transform: 'translate(-50%, -50%)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 rounded-full border-2 border-background shadow-lg transition-all duration-300 group-hover:scale-150 group-hover:ring-4"
|
||||||
|
style={{
|
||||||
|
"background-color": dotColor,
|
||||||
|
"--tw-ring-color": `${dotColor}40` // 25% opacity for ring
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div class={cn(
|
||||||
|
"absolute left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-all pointer-events-none z-20 w-48",
|
||||||
|
getPosition().y < 25 ? "top-full mt-3" : "bottom-full mb-3"
|
||||||
|
)}>
|
||||||
|
<div class={cn(
|
||||||
|
"w-2 h-2 bg-popover border-l border-t border-border rotate-45 mx-auto",
|
||||||
|
getPosition().y < 25 ? "-mb-1 order-first" : "-mt-1 order-last"
|
||||||
|
)} />
|
||||||
|
<div class="bg-popover text-popover-foreground border border-border rounded-xl p-3 shadow-2xl scale-95 group-hover:scale-100 transition-transform">
|
||||||
|
<p class="text-xs font-bold leading-tight">{task.title}</p>
|
||||||
|
<div class="flex items-center justify-between mt-2 pt-2 border-t border-border">
|
||||||
|
<span class="text-[9px] text-muted-foreground">U: {urgencyScore}</span>
|
||||||
|
<span class="text-[9px] text-muted-foreground">P: {task.priority}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class={cn(
|
||||||
|
"w-2 h-2 bg-popover border-r border-b border-border rotate-45 mx-auto",
|
||||||
|
getPosition().y < 25 ? "hidden" : "-mt-1"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { type Component, For, createMemo } from "solid-js";
|
||||||
|
import { store, calculateUrgencyScore } from "@/store";
|
||||||
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
|
||||||
|
export const PriorityView: Component = () => {
|
||||||
|
const sortedTasks = createMemo(() => {
|
||||||
|
return [...store.tasks].filter(t => !t.deletedAt).sort((a, b) => {
|
||||||
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
|
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||||
|
const uA = calculateUrgencyScore(a.dueDate);
|
||||||
|
const uB = calculateUrgencyScore(b.dueDate);
|
||||||
|
return uB - uA;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Priority</h2>
|
||||||
|
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<For each={sortedTasks()}>
|
||||||
|
{(task) => <TaskCard task={task} />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedTasks().length === 0 && (
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
|
<span class="text-2xl">🎯</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground">Focus sharp. No high-priority tasks yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { type Component, For } from "solid-js";
|
||||||
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
|
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays } from "@/store";
|
||||||
|
import { Trash2, Undo2, ArrowLeftRight } from "lucide-solid";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "solid-sonner";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
|
||||||
|
export const SettingsView: Component = () => {
|
||||||
|
return (
|
||||||
|
<div class="px-6 py-10 max-w-2xl mx-auto space-y-10 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||||
|
<header class="space-y-1 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Settings</h1>
|
||||||
|
<p class="text-muted-foreground">{pb.authStore.model?.email}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { pb.authStore.clear(); location.reload(); }}>Sign Out</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<section class="p-5 rounded-2xl border border-border bg-card shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<h3 class="text-base font-semibold">Appearance</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">Select your preferred color scheme.</p>
|
||||||
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Matrix Configuration */}
|
||||||
|
<section class="p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||||
|
<ArrowLeftRight size={16} />
|
||||||
|
Matrix Horizon
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">Adjust the time scale for your strategy grid.</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-[120px]">
|
||||||
|
<Select
|
||||||
|
value={store.matrixScaleDays.toString()}
|
||||||
|
onChange={(val) => val && setMatrixScaleDays(parseInt(val))}
|
||||||
|
options={["7", "14", "30", "60", "90"]}
|
||||||
|
placeholder="Select days"
|
||||||
|
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue} days</SelectItem>}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-9">
|
||||||
|
<span class="text-sm font-medium">{store.matrixScaleDays} days</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
Trash
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">Items are permanently deleted after 7 days.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
|
||||||
|
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
||||||
|
Trash is empty.
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{(task) => {
|
||||||
|
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
|
return (
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/30 border border-border/50 group hover:bg-muted/50 transition-colors">
|
||||||
|
<div class="min-w-0 flex-1 mr-4">
|
||||||
|
<p class="font-medium truncate">{task.title || "Untitled"}</p>
|
||||||
|
<p class="text-[10px] text-muted-foreground">Expires in {Math.max(0, daysLeft)} days</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 px-2 text-xs hover:bg-green-500/10 hover:text-green-600"
|
||||||
|
onClick={() => {
|
||||||
|
restoreTask(task.id);
|
||||||
|
toast.success("Task restored");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Undo2 size={14} class="mr-1" />
|
||||||
|
Recover
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 hover:bg-red-500/10 hover:text-red-600"
|
||||||
|
onClick={() => {
|
||||||
|
// For permanent deletion, still using confirm for safety but ensuring toast follows
|
||||||
|
if (confirm("Permanently delete this task? This cannot be undone.")) {
|
||||||
|
deleteTaskPermanently(task.id);
|
||||||
|
toast.error("Task permanently deleted", {
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { type Component, For, createMemo } from "solid-js";
|
||||||
|
import { store, calculateUrgencyScore } from "@/store";
|
||||||
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
|
|
||||||
|
export const UrgencyView: Component = () => {
|
||||||
|
const sortedTasks = createMemo(() => {
|
||||||
|
return [...store.tasks].filter(t => !t.deletedAt).sort((a, b) => {
|
||||||
|
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||||
|
const uA = calculateUrgencyScore(a.dueDate);
|
||||||
|
const uB = calculateUrgencyScore(b.dueDate);
|
||||||
|
if (uA !== uB) return uB - uA;
|
||||||
|
return b.priority - a.priority;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h2 class="text-3xl font-bold tracking-tight">Time</h2>
|
||||||
|
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<For each={sortedTasks()}>
|
||||||
|
{(task) => <TaskCard task={task} />}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedTasks().length === 0 && (
|
||||||
|
<div class="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||||
|
<span class="text-2xl">⏳</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground">All clear. No time-sensitive tasks.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"jsxImportSource": "solid-js",
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import solid from 'vite-plugin-solid'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
solid(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Tasgrid',
|
||||||
|
short_name: 'Tasgrid',
|
||||||
|
description: 'Precision Productivity Task Manager',
|
||||||
|
theme_color: '#000000',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user