Compare commits
39 Commits
Dev
...
ff9bf7afa4
| Author | SHA1 | Date | |
|---|---|---|---|
| ff9bf7afa4 | |||
| 8ad5499f06 | |||
| bd8c610584 | |||
| 11daf918b6 | |||
| 1d3d9b512a | |||
| bb0fe6c749 | |||
| 55047b5d90 | |||
| cd5fb71899 | |||
| df94685c96 | |||
| f53030294b | |||
| cf188896be | |||
| 1e5f80739c | |||
| 75723ed133 | |||
| 092e139f6e | |||
| 1c8e8b1d54 | |||
| 8dfd0c8274 | |||
| bc9060e00d | |||
| 0a1909804f | |||
| 3c23e2109a | |||
| 09557d49a4 | |||
| 10d76eb3c1 | |||
| e406adf9c5 | |||
| 8c307db57a | |||
| f7b46b2017 | |||
| 9705a12660 | |||
| cf22eda1e9 | |||
| 75dde1b0d5 | |||
| 46d8dd9a4c | |||
| 6a963a32fd | |||
| 4ebd0b5f61 | |||
| 6a76f695fe | |||
| 0e85603699 | |||
| 4c096f18d7 | |||
| 9cbe7dcb12 | |||
| 391f52af23 | |||
| ac119c288b | |||
| 0bc9c74797 | |||
| 50ccbbc564 | |||
| a5ecee084e |
@@ -84,4 +84,4 @@ jobs:
|
||||
run: |
|
||||
sshpass -p "$SSH_PASSWORD" ssh -o StrictHostKeyChecking=accept-new -p ${{ secrets.DEPLOY_SSH_PORT || '22' }} \
|
||||
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST || env.DEPLOY_HOST }} \
|
||||
'bash -lc "cd ${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }} && export PORT=${{ env.APP_PORT }} && (pm2 restart tasgrid 2>/dev/null || pm2 start \"bun run serve\" --name tasgrid --update-env)"'
|
||||
'bash -lc "cd ${{ secrets.DEPLOY_PATH || env.DEPLOY_PATH }} && export PORT=${{ env.APP_PORT }} && (pm2 restart tasgrid 2>/dev/null || pm2 start \"bun run serve\" --name tasgrid --update-env)"'
|
||||
@@ -8,6 +8,7 @@
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"@tiptap/core": "^3.18.0",
|
||||
"@tiptap/extension-bubble-menu": "^3.20.0",
|
||||
"@tiptap/extension-dropcursor": "^3.20.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
"autoprefixer": "^10.4.23",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"heic2any": "^0.0.4",
|
||||
"lucide-solid": "^0.563.0",
|
||||
"pm2": "^6.0.14",
|
||||
"pocketbase": "^0.26.8",
|
||||
@@ -460,6 +462,8 @@
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="],
|
||||
|
||||
"@tiptap/core": ["@tiptap/core@3.18.0", "", { "peerDependencies": { "@tiptap/pm": "^3.18.0" } }, "sha512-Gczd4GbK1DNgy/QUPElMVozoa0GW9mW8E31VIi7Q4a9PHHz8PcrxPmuWwtJ2q0PF8MWpOSLuBXoQTWaXZRPRnQ=="],
|
||||
|
||||
"@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.18.0", "", { "peerDependencies": { "@tiptap/core": "^3.18.0" } }, "sha512-1HjEoM5vZDfFnq2OodNpW13s56a9pbl7jolUv1V9FrE3X5s7n0HCfDzIVpT7z1HgTdPtlN5oSt5uVyBwuwSUfA=="],
|
||||
@@ -822,6 +826,8 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
await pb.admins.authWithPassword("timothy@ccllc.pro", 'pass');
|
||||
const res = await pb.collection('Job_Info_Prod').getList(1, 1);
|
||||
console.log("Got item:", res.items[0]);
|
||||
} catch (e) {
|
||||
console.error("Auth / Fetch Error", e);
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
@@ -18,6 +18,7 @@
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"@tiptap/core": "^3.18.0",
|
||||
"@tiptap/extension-bubble-menu": "^3.20.0",
|
||||
"@tiptap/extension-dropcursor": "^3.20.0",
|
||||
@@ -41,6 +42,7 @@
|
||||
"autoprefixer": "^10.4.23",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"heic2any": "^0.0.4",
|
||||
"lucide-solid": "^0.563.0",
|
||||
"pm2": "^6.0.14",
|
||||
"pocketbase": "^0.26.8",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Accessing Job Files from Sister App
|
||||
|
||||
This plan outlines how a sister application can access job data and physical files managed by Prism.
|
||||
|
||||
## Proposed Strategy
|
||||
|
||||
### 1. Accessing Job Metadata (Database)
|
||||
The source of truth for all job information (names, numbers, addresses, and file paths) is the PocketBase instance.
|
||||
|
||||
**Recommendation: Direct PocketBase SDK Integration**
|
||||
- The sister app should use the [PocketBase JavaScript/TypeScript SDK](https://github.com/pocketbase/js-sdk).
|
||||
- Point the SDK to the same URL as Prism (e.g., `http://127.0.0.1:8090` or your production domain).
|
||||
- **Authentication**: The sister app will need its own authentication or a way to share the `pb_auth` token.
|
||||
|
||||
### 2. Accessing Physical Files (NAS/Local Folders)
|
||||
Prism uses two types of file links:
|
||||
- **`Job_Folder_Link`**: URLs (SharePoint, OneDrive, etc.). These can be opened directly as `<a>` tags or via `window.open()`.
|
||||
- **`Folder_Local`**: UNC paths (e.g., `\\server\share\folder`).
|
||||
|
||||
**Recommendation: Shared Local RPC Tool**
|
||||
- Prism currently uses a local helper running on `http://localhost:8080` to open folders in File Explorer.
|
||||
- The sister app can use the exact same logic found in [nasOpener.ts](file:///c:/Users/TimothyCardoza/Documents/AI-Apps/Prism/PRISM/src/lib/nasOpener.ts) to communicate with this tool.
|
||||
- This allows both apps to provide a seamless "Open Folder" experience on the same machine.
|
||||
|
||||
### 3. Proposed API Enhancements (Optional)
|
||||
If the sister app cannot talk to PocketBase directly (e.g., security restrictions), we can add a "Service Account" or "API Key" mechanism to Prism to proxy requests.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### [Component Name]
|
||||
No changes are strictly required to Prism's code unless you want a dedicated API for the sister app.
|
||||
|
||||
#### [MODIFY] [nasOpener.ts](file:///c:/Users/TimothyCardoza/Documents/AI-Apps/Prism/PRISM/src/lib/nasOpener.ts)
|
||||
- Potentially export the RPC URL as a shared constant if both apps are in the same monorepo or share a library.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
- Test that the sister app can fetch job data from PocketBase using the same collection names (`Job_Info_Prod`).
|
||||
- Verify that `http://localhost:8080/health` is reachable from the sister app's environment.
|
||||
|
||||
### Manual Verification
|
||||
- Attempt to open a job folder from the sister app and confirm it opens in the local system's File Explorer.
|
||||
- Confirm that the sister app correctly resolves job IDs to their respective `Job_Number` or metadata.
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* TasGrid SDK
|
||||
*
|
||||
* A headless utility for creating tasks in the TasGrid PocketBase database
|
||||
* from sister applications, ensuring all TasGrid defaults and date calculations
|
||||
* are applied consistently.
|
||||
*/
|
||||
|
||||
const URGENCY_HOURS = {
|
||||
10: 12,
|
||||
9: 24,
|
||||
8: 36,
|
||||
7: 72,
|
||||
6: 120,
|
||||
5: 240,
|
||||
4: 504,
|
||||
3: 1080,
|
||||
2: 2160,
|
||||
1: 4320
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates a future ISO date string based on the TasGrid urgency decay algorithm.
|
||||
* @param {number} level Urgency level (1-10)
|
||||
* @returns {string} ISO Date string
|
||||
*/
|
||||
export const calculateDateFromUrgency = (level) => {
|
||||
const roundedLevel = Math.max(1, Math.min(10, Math.round(level)));
|
||||
const hours = URGENCY_HOURS[roundedLevel] || 24;
|
||||
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new task in the TasGrid database using the provided PocketBase instance.
|
||||
*
|
||||
* @param {object} pb - An authenticated PocketBase JS SDK instance.
|
||||
* @param {object} payload - The task payload.
|
||||
* @param {string} [payload.title="New Task"] - The task title.
|
||||
* @param {string} [payload.owner] - PocketBase User ID. Defaults to pb.authStore.model.id.
|
||||
* @param {number} [payload.priority=5] - Priority (1-10).
|
||||
* @param {number} [payload.urgency=5] - Urgency (1-10). Used to calculate dueDate if omitted.
|
||||
* @param {number} [payload.size=3] - Size (0-10).
|
||||
* @param {number} [payload.status=0] - Status (0-10).
|
||||
* @param {boolean} [payload.completed=false] - Completion status.
|
||||
* @param {string[]} [payload.tags=[]] - Array of tag strings.
|
||||
* @param {string} [payload.content=""] - HTML string for task description/notes.
|
||||
* @param {string} [payload.dueDate] - Strict ISO Date string. Overrides urgency calculation.
|
||||
* @param {string} [payload.startDate=""] - ISO Date string.
|
||||
* @returns {Promise<object>} The created PocketBase task record.
|
||||
*/
|
||||
export const createTasgridTask = async (pb, payload = {}) => {
|
||||
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
|
||||
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
|
||||
}
|
||||
|
||||
const urgencyVal = payload.urgency ?? 5;
|
||||
|
||||
const taskData = {
|
||||
title: payload.title || "New Task",
|
||||
priority: payload.priority ?? 5,
|
||||
size: payload.size ?? 3,
|
||||
status: payload.status ?? 0,
|
||||
completed: payload.completed ?? false,
|
||||
tags: payload.tags || [],
|
||||
content: payload.content || "",
|
||||
user: payload.owner || pb.authStore.model.id,
|
||||
dueDate: payload.dueDate || calculateDateFromUrgency(urgencyVal),
|
||||
startDate: payload.startDate || "",
|
||||
};
|
||||
|
||||
try {
|
||||
const record = await pb.collection('TasGrid').create(taskData);
|
||||
return record;
|
||||
} catch (err) {
|
||||
console.error("TasGrid SDK: Failed to create task", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new note in the TasGrid database using the provided PocketBase instance.
|
||||
*
|
||||
* @param {object} pb - An authenticated PocketBase JS SDK instance.
|
||||
* @param {object} payload - The note payload.
|
||||
* @param {string} [payload.title="New Note"] - The note title.
|
||||
* @param {string} [payload.content=""] - HTML string for note content.
|
||||
* @param {string[]} [payload.tags=[]] - Array of tag strings.
|
||||
* @param {boolean} [payload.isPrivate=false] - Whether the note is private.
|
||||
* @param {string[]} [payload.tasks=[]] - Array of task IDs to link.
|
||||
* @param {string} [payload.owner] - PocketBase User ID. Defaults to pb.authStore.model.id.
|
||||
* @returns {Promise<object>} The created PocketBase note record.
|
||||
*/
|
||||
export const createTasgridNote = async (pb, payload = {}) => {
|
||||
if (!pb || !pb.authStore || !pb.authStore.isValid || !pb.authStore.model) {
|
||||
throw new Error("Invalid or unauthenticated PocketBase instance provided.");
|
||||
}
|
||||
|
||||
const noteData = {
|
||||
title: payload.title || "New Note",
|
||||
content: payload.content || "",
|
||||
tags: payload.tags || [],
|
||||
isPrivate: payload.isPrivate || false,
|
||||
tasks: payload.tasks || [],
|
||||
user: payload.owner || pb.authStore.model.id,
|
||||
};
|
||||
|
||||
try {
|
||||
const record = await pb.collection('TasGrid_Notes').create(noteData);
|
||||
return record;
|
||||
} catch (err) {
|
||||
console.error("TasGrid SDK: Failed to create note", err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
+381
-122
@@ -1,29 +1,70 @@
|
||||
import { type Component, createSignal, For, Show } from "solid-js";
|
||||
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark } from "lucide-solid";
|
||||
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory } from "@/store";
|
||||
import { Search, X, Tag, ArrowUpCircle, Clock, Minus, Save, Trash2, Bookmark, User, Star } from "lucide-solid";
|
||||
import { store, setFilter, clearFilter, saveFilterTemplate, applyFilterTemplate, removeFilterTemplate, loadAllHistory, setNoteFilter, clearNoteFilter, saveNoteFilterTemplate, applyNoteFilterTemplate, removeNoteFilterTemplate, type Filter } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "./ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
|
||||
export const FilterBar: Component = () => {
|
||||
export const FilterBar: Component<{ mode?: 'tasks' | 'notes', class?: string, triggerClass?: string }> = (props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [isTagOpen, setIsTagOpen] = createSignal(false);
|
||||
const [tagSearch, setTagSearch] = createSignal("");
|
||||
const [newTemplateName, setNewTemplateName] = createSignal("");
|
||||
const [isSaving, setIsSaving] = createSignal(false);
|
||||
const [isJobStatusOpen, setIsJobStatusOpen] = createSignal(false);
|
||||
const [isJobDivisionOpen, setIsJobDivisionOpen] = createSignal(false);
|
||||
|
||||
const mode = () => props.mode || 'tasks';
|
||||
|
||||
const currentFilter = () => mode() === 'tasks' ? store.filter : store.noteFilter;
|
||||
const currentSetFilter = (val: Partial<Filter>) => mode() === 'tasks' ? setFilter(val) : setNoteFilter(val);
|
||||
const currentClearFilter = () => mode() === 'tasks' ? clearFilter() : clearNoteFilter();
|
||||
const currentSaveTemplate = (name: string) => mode() === 'tasks' ? saveFilterTemplate(name) : saveNoteFilterTemplate(name);
|
||||
const currentApplyTemplate = (id: string) => mode() === 'tasks' ? applyFilterTemplate(id) : applyNoteFilterTemplate(id);
|
||||
const currentRemoveTemplate = (id: string) => mode() === 'tasks' ? removeFilterTemplate(id) : removeNoteFilterTemplate(id);
|
||||
|
||||
const hasActiveFilters = () => {
|
||||
const f = store.filter;
|
||||
return f.query !== "" || f.tags.length > 0 || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10 || f.editedToday;
|
||||
const f = currentFilter();
|
||||
const baseActive = (f.query || "") !== "" || (f.tags || []).length > 0 || f.editedToday || f.ownedByMe || f.starred;
|
||||
const jobActive = (f.jobStatus?.length || 0) > 0 || (f.jobDivision?.length || 0) > 0;
|
||||
if (mode() === 'tasks') {
|
||||
return baseActive || f.priorityMin !== 1 || f.priorityMax !== 10 || f.urgencyMin !== 1 || f.urgencyMax !== 10;
|
||||
}
|
||||
return baseActive || jobActive;
|
||||
};
|
||||
|
||||
const allTags = () => store.tagDefinitions.map(d => d.name).sort();
|
||||
const JOB_STATUSES = [
|
||||
"Estimating",
|
||||
"Est Sent",
|
||||
"Est Hold",
|
||||
"Follow Up",
|
||||
"Awarded",
|
||||
"In Progress",
|
||||
"On Hold",
|
||||
"Billed (In Progress)",
|
||||
"Billed (Closed)",
|
||||
"Archive",
|
||||
"Not Awarded",
|
||||
"Not Bidding"
|
||||
];
|
||||
const JOB_DIVISIONS = ["C#", "R#", "S#"];
|
||||
const normalizeFilterTags = (items: Array<string | { name: string; excluded: boolean }> | undefined) => {
|
||||
if (!items) return [] as { name: string; excluded: boolean }[];
|
||||
return items.map((i: any) => {
|
||||
if (typeof i === "string") return { name: i, excluded: false };
|
||||
return { name: i.name, excluded: !!i.excluded };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="fixed top-4 right-5 sm:right-6 z-[60]">
|
||||
<div class={cn(mode() === 'tasks' ? "fixed top-4 right-5 sm:right-6 z-[60]" : "relative", props.class)}>
|
||||
<Popover open={isOpen()} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
@@ -32,18 +73,23 @@ export const FilterBar: Component = () => {
|
||||
class={cn(
|
||||
"bg-background/80 backdrop-blur-sm border border-border hover:bg-muted shadow-sm transition-all duration-300",
|
||||
hasActiveFilters() && "border-primary/50 ring-1 ring-primary/20",
|
||||
isOpen() && "bg-muted ring-1 ring-border"
|
||||
isOpen() && "bg-muted ring-1 ring-border",
|
||||
props.triggerClass
|
||||
)}
|
||||
title="Filters & Search"
|
||||
>
|
||||
<div class="relative">
|
||||
<Search size={18} class={cn("transition-transform duration-300", isOpen() && "scale-110")} />
|
||||
{hasActiveFilters() && !isOpen() && (
|
||||
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-primary animate-pulse border border-background" />
|
||||
)}
|
||||
<Search
|
||||
size={18}
|
||||
class={cn(
|
||||
"transition-transform duration-300",
|
||||
isOpen() && "scale-110",
|
||||
hasActiveFilters() && "text-primary"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<PopoverContent class="w-[calc(100vw-2rem)] sm:w-[480px] p-0 overflow-hidden bg-card border-border shadow-2xl rounded-2xl animate-in fade-in zoom-in-95 duration-200 z-[100]">
|
||||
{/* Header */}
|
||||
<div class="px-4 py-3 border-b border-border/50 bg-muted/20 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -53,37 +99,63 @@ export const FilterBar: Component = () => {
|
||||
</h3>
|
||||
<div class="h-4 w-px bg-border/50" />
|
||||
<button
|
||||
onClick={() => setFilter({ editedToday: !store.filter.editedToday })}
|
||||
onClick={() => currentSetFilter({ editedToday: !currentFilter().editedToday })}
|
||||
class={cn(
|
||||
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
|
||||
store.filter.editedToday
|
||||
currentFilter().editedToday
|
||||
? "bg-primary/10 text-primary border-primary/20"
|
||||
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<div class={cn("w-1.5 h-1.5 rounded-full", store.filter.editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
|
||||
<div class={cn("w-1.5 h-1.5 rounded-full", currentFilter().editedToday ? "bg-primary" : "bg-muted-foreground/30")} />
|
||||
Edited Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => currentSetFilter({ ownedByMe: !currentFilter().ownedByMe })}
|
||||
class={cn(
|
||||
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
|
||||
currentFilter().ownedByMe
|
||||
? "bg-primary/10 text-primary border-primary/20"
|
||||
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<User size={10} class={cn(currentFilter().ownedByMe ? "text-primary" : "text-muted-foreground/50")} />
|
||||
Mine
|
||||
</button>
|
||||
<button
|
||||
onClick={() => currentSetFilter({ starred: !currentFilter().starred })}
|
||||
class={cn(
|
||||
"flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[0.5625rem] font-black uppercase tracking-tight transition-all border",
|
||||
currentFilter().starred
|
||||
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
: "bg-muted/50 text-muted-foreground border-transparent hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Star size={10} class={cn(currentFilter().starred ? "fill-amber-500 text-amber-500" : "text-muted-foreground/50")} />
|
||||
Starred
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-primary"
|
||||
onClick={() => {
|
||||
loadAllHistory();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
title="Load all completed tasks"
|
||||
>
|
||||
Load All
|
||||
</Button>
|
||||
<Show when={mode() === 'tasks'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-primary"
|
||||
onClick={() => {
|
||||
loadAllHistory();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
title="Load all completed tasks"
|
||||
>
|
||||
Load All
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={hasActiveFilters()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground hover:text-destructive"
|
||||
onClick={clearFilter}
|
||||
onClick={currentClearFilter}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
@@ -110,9 +182,9 @@ export const FilterBar: Component = () => {
|
||||
if (el) setTimeout(() => el.focus(), 50);
|
||||
}}
|
||||
type="text"
|
||||
placeholder="Search tasks, descriptions, tags..."
|
||||
value={store.filter.query}
|
||||
onInput={(e) => setFilter({ query: e.currentTarget.value })}
|
||||
placeholder={mode() === 'tasks' ? "Search tasks, descriptions, tags..." : "Search notes, tags..."}
|
||||
value={currentFilter().query}
|
||||
onInput={(e) => currentSetFilter({ query: e.currentTarget.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -124,77 +196,233 @@ export const FilterBar: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Priority Range */}
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Priority Range</label>
|
||||
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
|
||||
<Select
|
||||
value={store.filter.priorityMin.toString()}
|
||||
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
|
||||
<span>Min: {store.filter.priorityMin}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
<div class="w-px h-4 bg-border/50" />
|
||||
<Select
|
||||
value={store.filter.priorityMax.toString()}
|
||||
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<span>Max: {store.filter.priorityMax}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
<Show when={mode() === 'tasks'}>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Priority Range */}
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Priority Range</label>
|
||||
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
|
||||
<Select
|
||||
value={store.filter.priorityMin.toString()}
|
||||
onChange={(val) => val && setFilter({ priorityMin: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<ArrowUpCircle size={14} class="text-muted-foreground/70" />
|
||||
<span>Min: {store.filter.priorityMin}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
<div class="w-px h-4 bg-border/50" />
|
||||
<Select
|
||||
value={store.filter.priorityMax.toString()}
|
||||
onChange={(val) => val && setFilter({ priorityMax: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<span>Max: {store.filter.priorityMax}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Urgency Range */}
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Urgency Range</label>
|
||||
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
|
||||
<Select
|
||||
value={store.filter.urgencyMin.toString()}
|
||||
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<Clock size={14} class="text-muted-foreground/70" />
|
||||
<span>Min: {store.filter.urgencyMin}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
<div class="w-px h-4 bg-border/50" />
|
||||
<Select
|
||||
value={store.filter.urgencyMax.toString()}
|
||||
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<span>Max: {store.filter.urgencyMax}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
{/* Urgency Range */}
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Urgency Range</label>
|
||||
<div class="flex items-center gap-2 bg-muted/20 p-1 rounded-xl border border-border/30">
|
||||
<Select
|
||||
value={store.filter.urgencyMin.toString()}
|
||||
onChange={(val) => val && setFilter({ urgencyMin: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<Clock size={14} class="text-muted-foreground/70" />
|
||||
<span>Min: {store.filter.urgencyMin}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
<div class="w-px h-4 bg-border/50" />
|
||||
<Select
|
||||
value={store.filter.urgencyMax.toString()}
|
||||
onChange={(val) => val && setFilter({ urgencyMax: parseInt(val) })}
|
||||
options={["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]}
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-9 flex-1 bg-transparent border-none shadow-none focus:ring-0 text-xs font-bold text-foreground">
|
||||
<div class="flex items-center gap-2 justify-center">
|
||||
<span>Max: {store.filter.urgencyMax}</span>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={mode() === 'notes'}>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Job Status</label>
|
||||
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
|
||||
<For each={normalizeFilterTags(currentFilter().jobStatus as any)}>
|
||||
{(status) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class={cn(
|
||||
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
|
||||
status.excluded
|
||||
? "bg-red-500/10 text-red-600 border-red-500/20 hover:bg-red-500/20"
|
||||
: "bg-blue-500/10 text-blue-600 border-blue-500/20 hover:bg-blue-500/20"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer h-full"
|
||||
onClick={() => {
|
||||
const current = normalizeFilterTags(currentFilter().jobStatus as any);
|
||||
const next = current.map(s =>
|
||||
s.name === status.name ? { ...s, excluded: !s.excluded } : s
|
||||
);
|
||||
currentSetFilter({ jobStatus: next as any });
|
||||
}}
|
||||
>
|
||||
<Show when={status.excluded} fallback={<Tag size={12} class="opacity-70" />}>
|
||||
<Minus size={12} class="opacity-100" />
|
||||
</Show>
|
||||
<span class={cn(status.excluded && "line-through opacity-70")}>{status.name}</span>
|
||||
</div>
|
||||
<button
|
||||
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const next = normalizeFilterTags(currentFilter().jobStatus as any).filter(s => s.name !== status.name);
|
||||
currentSetFilter({ jobStatus: next as any });
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
<Popover open={isJobStatusOpen()} onOpenChange={setIsJobStatusOpen}>
|
||||
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
|
||||
<span>+ Status</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[220px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search status..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No status found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<For each={JOB_STATUSES}>
|
||||
{(status) => (
|
||||
<CommandItem
|
||||
value={status}
|
||||
onSelect={() => {
|
||||
const current = normalizeFilterTags(currentFilter().jobStatus as any);
|
||||
if (!current.some(s => s.name === status)) {
|
||||
currentSetFilter({ jobStatus: [...current, { name: status, excluded: false }] as any });
|
||||
}
|
||||
setIsJobStatusOpen(false);
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</CommandItem>
|
||||
)}
|
||||
</For>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2.5">
|
||||
<label class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground ml-1">Job Division</label>
|
||||
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
|
||||
<For each={normalizeFilterTags(currentFilter().jobDivision as any)}>
|
||||
{(division) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class={cn(
|
||||
"h-7 px-2.5 rounded-lg text-xs gap-2 transition-all border shadow-sm",
|
||||
division.excluded
|
||||
? "bg-red-500/10 text-red-600 border-red-500/20 hover:bg-red-500/20"
|
||||
: "bg-emerald-500/10 text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/20"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer h-full"
|
||||
onClick={() => {
|
||||
const current = normalizeFilterTags(currentFilter().jobDivision as any);
|
||||
const next = current.map(d =>
|
||||
d.name === division.name ? { ...d, excluded: !d.excluded } : d
|
||||
);
|
||||
currentSetFilter({ jobDivision: next as any });
|
||||
}}
|
||||
>
|
||||
<Show when={division.excluded} fallback={<Tag size={12} class="opacity-70" />}>
|
||||
<Minus size={12} class="opacity-100" />
|
||||
</Show>
|
||||
<span class={cn(division.excluded && "line-through opacity-70")}>{division.name}</span>
|
||||
</div>
|
||||
<button
|
||||
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const next = normalizeFilterTags(currentFilter().jobDivision as any).filter(d => d.name !== division.name);
|
||||
currentSetFilter({ jobDivision: next as any });
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
<Popover open={isJobDivisionOpen()} onOpenChange={setIsJobDivisionOpen}>
|
||||
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
|
||||
<span>+ Division</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search division..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No division found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<For each={JOB_DIVISIONS}>
|
||||
{(division) => (
|
||||
<CommandItem
|
||||
value={division}
|
||||
onSelect={() => {
|
||||
const current = normalizeFilterTags(currentFilter().jobDivision as any);
|
||||
if (!current.some(d => d.name === division)) {
|
||||
currentSetFilter({ jobDivision: [...current, { name: division, excluded: false }] as any });
|
||||
}
|
||||
setIsJobDivisionOpen(false);
|
||||
}}
|
||||
>
|
||||
{division}
|
||||
</CommandItem>
|
||||
)}
|
||||
</For>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Tags Multi-select */}
|
||||
<div class="space-y-2.5">
|
||||
@@ -206,7 +434,7 @@ export const FilterBar: Component = () => {
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2 min-h-[44px] p-2 bg-muted/10 border border-dashed border-border/60 rounded-xl">
|
||||
<For each={store.filter.tags}>
|
||||
<For each={currentFilter().tags}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -229,10 +457,10 @@ export const FilterBar: Component = () => {
|
||||
<div
|
||||
class="flex items-center gap-2 cursor-pointer h-full"
|
||||
onClick={() => {
|
||||
const newTags = store.filter.tags.map(t =>
|
||||
const newTags = currentFilter().tags.map(t =>
|
||||
t.name === tag.name ? { ...t, excluded: !t.excluded } : t
|
||||
);
|
||||
setFilter({ tags: newTags });
|
||||
currentSetFilter({ tags: newTags });
|
||||
}}
|
||||
>
|
||||
<Show when={tag.excluded} fallback={<Tag size={12} class="opacity-70" />}>
|
||||
@@ -244,7 +472,7 @@ export const FilterBar: Component = () => {
|
||||
class="p-0.5 hover:bg-foreground/10 rounded transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFilter({ tags: store.filter.tags.filter(t => t.name !== tag.name) });
|
||||
currentSetFilter({ tags: currentFilter().tags.filter(t => t.name !== tag.name) });
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
@@ -252,22 +480,53 @@ export const FilterBar: Component = () => {
|
||||
</Badge>
|
||||
)}
|
||||
</For>
|
||||
<Select
|
||||
value=""
|
||||
onChange={(val) => {
|
||||
if (val && !store.filter.tags.some(t => t.name === val)) {
|
||||
setFilter({ tags: [...store.filter.tags, { name: val, excluded: false }] });
|
||||
}
|
||||
}}
|
||||
options={allTags()}
|
||||
placeholder="Add tag..."
|
||||
itemComponent={(props) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||
>
|
||||
<SelectTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
|
||||
<Popover open={isTagOpen()} onOpenChange={setIsTagOpen}>
|
||||
<PopoverTrigger class="h-7 px-3 bg-muted/40 border-none shadow-none focus:ring-0 text-[0.625rem] font-bold uppercase tracking-wider rounded-lg hover:bg-muted/60 transition-colors">
|
||||
<span>+ Tag</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search tags..."
|
||||
value={tagSearch()}
|
||||
onValueChange={setTagSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<Show when={allTags().filter(t => {
|
||||
if (t.startsWith('_')) return false;
|
||||
const search = tagSearch().toLowerCase();
|
||||
if (!search && (t.startsWith('@') || t.startsWith('#'))) return false;
|
||||
return t.toLowerCase().includes(search);
|
||||
}).length === 0}>
|
||||
<CommandEmpty>No tags found.</CommandEmpty>
|
||||
</Show>
|
||||
<CommandGroup>
|
||||
<For each={allTags().filter(t => {
|
||||
if (t.startsWith('_')) return false;
|
||||
const search = tagSearch().toLowerCase();
|
||||
if (!search && (t.startsWith('@') || t.startsWith('#'))) return false;
|
||||
return t.toLowerCase().includes(search);
|
||||
})}>
|
||||
{(tag) => (
|
||||
<CommandItem
|
||||
value={tag}
|
||||
onSelect={() => {
|
||||
if (!currentFilter().tags.some(t => t.name === tag)) {
|
||||
currentSetFilter({ tags: [...currentFilter().tags, { name: tag, excluded: false }] });
|
||||
}
|
||||
setIsTagOpen(false);
|
||||
setTagSearch("");
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</CommandItem>
|
||||
)}
|
||||
</For>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,7 +560,7 @@ export const FilterBar: Component = () => {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (newTemplateName().trim()) {
|
||||
saveFilterTemplate(newTemplateName().trim());
|
||||
currentSaveTemplate(newTemplateName().trim());
|
||||
setNewTemplateName("");
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -315,7 +574,7 @@ export const FilterBar: Component = () => {
|
||||
class="h-8 px-3"
|
||||
disabled={!newTemplateName().trim()}
|
||||
onClick={() => {
|
||||
saveFilterTemplate(newTemplateName().trim());
|
||||
currentSaveTemplate(newTemplateName().trim());
|
||||
setNewTemplateName("");
|
||||
setIsSaving(false);
|
||||
}}
|
||||
@@ -343,7 +602,7 @@ export const FilterBar: Component = () => {
|
||||
<div class="group flex items-center justify-between p-2 rounded-xl bg-muted/20 border border-border/30 hover:bg-muted/40 transition-all">
|
||||
<button
|
||||
class="flex-1 text-left px-1 min-w-0"
|
||||
onClick={() => applyFilterTemplate(template.id)}
|
||||
onClick={() => currentApplyTemplate(template.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-[0.6875rem] font-bold text-foreground truncate">{template.name}</p>
|
||||
@@ -354,7 +613,7 @@ export const FilterBar: Component = () => {
|
||||
<Show when={template.filter.tags.length > 0}>
|
||||
<div class="w-1 h-1 rounded-full bg-indigo-500" />
|
||||
</Show>
|
||||
<Show when={template.filter.priorityMin > 1 || template.filter.priorityMax < 10}>
|
||||
<Show when={mode() === 'tasks' && (template.filter.priorityMin > 1 || template.filter.priorityMax < 10)}>
|
||||
<div class="w-1 h-1 rounded-full bg-amber-500" />
|
||||
</Show>
|
||||
</div>
|
||||
@@ -364,7 +623,7 @@ export const FilterBar: Component = () => {
|
||||
class="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-destructive/10 hover:text-destructive rounded-lg transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFilterTemplate(template.id);
|
||||
currentRemoveTemplate(template.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { createSignal, createResource, Show, For, onMount } from "solid-js";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { store, activeNoteId } from "@/store";
|
||||
import { Search, File as FileIcon, Loader2, FolderOpen } from "lucide-solid";
|
||||
|
||||
interface JobFileSelectorProps {
|
||||
onSelectFile: (jobId: string, filename: string) => void;
|
||||
}
|
||||
|
||||
export const JobFileSelector = (props: JobFileSelectorProps) => {
|
||||
const [query, setQuery] = createSignal("");
|
||||
const [selectedJobId, setSelectedJobId] = createSignal<string | null>(null);
|
||||
|
||||
// Auto-detect job ID from active note tags (traversing up hierarchy)
|
||||
onMount(() => {
|
||||
const currentNoteId = activeNoteId();
|
||||
if (currentNoteId) {
|
||||
let currentId = currentNoteId;
|
||||
let currentNote = store.notes.find(n => n.id === currentId);
|
||||
let depth = 0;
|
||||
|
||||
// Traverse up "child-of-" tags to find the root or any ancestor with a job_id
|
||||
while (currentNote && depth < 10) {
|
||||
const jobIdTag = currentNote.tags?.find(t => t.startsWith("job_id:"));
|
||||
if (jobIdTag) {
|
||||
const id = jobIdTag.split(":")[1];
|
||||
if (id) {
|
||||
setSelectedJobId(id);
|
||||
return; // Found it!
|
||||
}
|
||||
}
|
||||
|
||||
const parentTag = currentNote.tags?.find(t => t.startsWith("child-of-"));
|
||||
if (!parentTag) break;
|
||||
|
||||
const parentId = parentTag.replace("child-of-", "");
|
||||
const parent = store.notes.find(n => n.id === parentId);
|
||||
if (!parent) break;
|
||||
|
||||
currentNote = parent;
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Query Jobs
|
||||
const [jobs] = createResource(query, async (q) => {
|
||||
if (!q.trim()) return [];
|
||||
try {
|
||||
const res = await pb.collection('Job_Info_Prod').getList(1, 10, {
|
||||
filter: `Job_Number ~ "${q}" || Job_Name ~ "${q}"`,
|
||||
sort: '-created',
|
||||
requestKey: null // Allow concurrent overlapping requests without auto-cancelling if needed, or leave it
|
||||
});
|
||||
return res.items;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch details for the selected job to list files
|
||||
const [selectedJob] = createResource(selectedJobId, async (id) => {
|
||||
if (!id) return null;
|
||||
try {
|
||||
const data = await pb.collection('Job_Info_Prod').getOne(id);
|
||||
console.log("Job File Selector Selected Job Data:", data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error("Job File Selector Error Fetching Job:", e);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const categories = ["plans", "EST_and_CO", "Addendums", "files"] as const;
|
||||
|
||||
return (
|
||||
<div class="w-full flex flex-col items-center justify-center p-8 gap-6 bg-muted/10 rounded-xl relative">
|
||||
<Show when={!selectedJobId()}>
|
||||
<div class="text-center w-full max-w-lg space-y-4 relative z-10">
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mx-auto mb-4">
|
||||
<FolderOpen size={24} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-semibold text-lg">Embed a Job File</h3>
|
||||
<p class="text-sm text-muted-foreground">Search for a Prism job to browse its files.</p>
|
||||
</div>
|
||||
|
||||
<div class="relative w-full">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Job Name or Number..."
|
||||
class="w-full pl-9 pr-4 py-2 bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all text-sm"
|
||||
value={query()}
|
||||
onInput={(e) => setQuery(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={jobs.loading}>
|
||||
<div class="flex items-center justify-center py-4 text-muted-foreground">
|
||||
<Loader2 class="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={jobs() && jobs()!.length > 0}>
|
||||
<div class="mt-4 border border-border bg-background rounded-lg shadow-sm overflow-hidden text-left flex flex-col">
|
||||
<For each={jobs()}>
|
||||
{(job) => (
|
||||
<button
|
||||
class="px-4 py-3 hover:bg-muted text-left flex flex-col gap-1 border-b border-border last:border-0 transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedJobId(job.id)}
|
||||
>
|
||||
<span class="font-medium text-sm text-foreground">{job.Job_Name || "Unnamed Job"}</span>
|
||||
<span class="text-xs text-muted-foreground">Job #{job.Job_Number || job.id}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedJobId()}>
|
||||
<Show when={selectedJob.loading}>
|
||||
<div class="flex flex-col items-center justify-center gap-4 py-12 relative z-10">
|
||||
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||
<span class="text-sm text-muted-foreground font-medium">Loading Job Files...</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedJob()}>
|
||||
{(jobRecord) => (
|
||||
<div class="w-full max-w-2xl bg-background rounded-xl border border-border shadow-sm overflow-hidden relative z-10">
|
||||
<div class="p-4 border-b border-border bg-muted/30 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="font-semibold">{jobRecord().Job_Name || "Unnamed Job"}</h3>
|
||||
<span class="text-xs text-muted-foreground">Job #{jobRecord().Job_Number || jobRecord().id}</span>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-blue-500 hover:text-blue-600 font-medium px-2 py-1 hover:bg-blue-50 rounded transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedJobId(null)}
|
||||
>
|
||||
Change Job
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex flex-col gap-6 max-h-[400px] overflow-y-auto">
|
||||
<For each={categories}>
|
||||
{(category) => {
|
||||
let files = jobRecord()[category];
|
||||
if (files === null || files === undefined || files === "") return null;
|
||||
if (!Array.isArray(files)) files = [files];
|
||||
files = files.filter(Boolean);
|
||||
|
||||
if (files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">{category.replace(/_/g, " ")}</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<For each={files as string[]}>
|
||||
{(filename) => (
|
||||
<button
|
||||
class="flex items-center gap-3 p-2 rounded-md border border-border hover:bg-muted hover:border-primary/50 transition-colors text-left cursor-pointer group"
|
||||
onClick={() => props.onSelectFile(jobRecord().id, filename)}
|
||||
>
|
||||
<div class="p-2 rounded bg-muted text-muted-foreground group-hover:text-primary transition-colors shrink-0">
|
||||
<FileIcon class="w-4 h-4" />
|
||||
</div>
|
||||
<span class="text-sm truncate font-medium text-foreground min-w-0" title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
{/* Empty state if no files at all */}
|
||||
<Show when={categories.every(c => {
|
||||
const val = jobRecord()[c];
|
||||
return !val || (Array.isArray(val) && val.length === 0) || (typeof val === 'string' && val.trim() === '');
|
||||
})}>
|
||||
<div class="text-center py-8 text-muted-foreground text-sm">
|
||||
No files found for this job.
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+186
-28
@@ -1,25 +1,170 @@
|
||||
import { type Component, createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { store } from "@/store";
|
||||
import { type Component, createMemo, createResource, For, Show } from "solid-js";
|
||||
import { store, setNoteFilter, getFavoriteTag } from "@/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { Plus, Lock, Search } from "lucide-solid";
|
||||
import { Plus, Lock, Star } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NOTES_COLLECTION } from "@/lib/constants";
|
||||
import { FilterBar } from "./FilterBar";
|
||||
|
||||
export const NotesSidebar: Component<{
|
||||
selectedNoteId: string | null;
|
||||
setSelectedNoteId: (id: string | null) => void;
|
||||
}> = (props) => {
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const [accessibleJobs] = createResource(currentUserId, async (userId) => {
|
||||
if (!userId) return new Set<string>();
|
||||
try {
|
||||
const items = await pb.collection('Job_Info_Prod').getFullList({
|
||||
filter: [
|
||||
`EstimatorUser = "${userId}"`,
|
||||
`OfficeRepUser = "${userId}"`,
|
||||
`ProjectManagerUser = "${userId}"`,
|
||||
`Estimator = "${userId}"`,
|
||||
`Office_Rep = "${userId}"`,
|
||||
`Project_Manager = "${userId}"`
|
||||
].join(" || "),
|
||||
fields: "id",
|
||||
requestKey: null
|
||||
});
|
||||
return new Set(items.map((r: any) => String(r.id)));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load job access list for notes filter:", e);
|
||||
return new Set<string>();
|
||||
}
|
||||
});
|
||||
const [jobFilterIds] = createResource(
|
||||
() => {
|
||||
const f = store.noteFilter;
|
||||
const normalize = (items: Array<string | { name: string; excluded: boolean }> | undefined) => {
|
||||
if (!items) return [] as { name: string; excluded: boolean }[];
|
||||
return items.map((i: any) => (typeof i === "string" ? { name: i, excluded: false } : { name: i.name, excluded: !!i.excluded }));
|
||||
};
|
||||
const status = normalize(f.jobStatus as any).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const division = normalize(f.jobDivision as any).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const key = JSON.stringify({ status, division });
|
||||
return key;
|
||||
},
|
||||
async (key) => {
|
||||
if (!key) return null;
|
||||
const parsed = JSON.parse(key) as { status: Array<{ name: string; excluded: boolean }>; division: Array<{ name: string; excluded: boolean }> };
|
||||
const status = parsed.status || [];
|
||||
const division = parsed.division || [];
|
||||
if (status.length === 0 && division.length === 0) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
const statusInclude = status.filter(s => !s.excluded).map(s => s.name);
|
||||
const statusExclude = status.filter(s => s.excluded).map(s => s.name);
|
||||
const divisionInclude = division.filter(d => !d.excluded).map(d => d.name);
|
||||
const divisionExclude = division.filter(d => d.excluded).map(d => d.name);
|
||||
|
||||
if (statusInclude.length > 0) {
|
||||
parts.push(`(${statusInclude.map(s => `Job_Status = "${s}"`).join(" || ")})`);
|
||||
}
|
||||
if (statusExclude.length > 0) {
|
||||
parts.push(`(${statusExclude.map(s => `Job_Status != "${s}"`).join(" && ")})`);
|
||||
}
|
||||
if (divisionInclude.length > 0) {
|
||||
parts.push(`(${divisionInclude.map(d => `Job_Division = "${d}"`).join(" || ")})`);
|
||||
}
|
||||
if (divisionExclude.length > 0) {
|
||||
parts.push(`(${divisionExclude.map(d => `Job_Division != "${d}"`).join(" && ")})`);
|
||||
}
|
||||
|
||||
const filter = parts.join(" && ");
|
||||
try {
|
||||
const items = await pb.collection('Job_Info_Prod').getFullList({
|
||||
filter,
|
||||
fields: "id",
|
||||
requestKey: null
|
||||
});
|
||||
return new Set(items.map((r: any) => String(r.id)));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load job filter list for notes:", e);
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const filteredNotes = createMemo(() => {
|
||||
let n = store.notes;
|
||||
if (searchQuery()) {
|
||||
const q = searchQuery().toLowerCase();
|
||||
n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q)));
|
||||
const filter = store.noteFilter;
|
||||
let n = store.notes.filter(note => {
|
||||
if (note.deletedAt) return false;
|
||||
// Hide child notes from the main list
|
||||
if (note.tags && note.tags.some(t => t.startsWith("child-of-"))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Query Filter
|
||||
if (filter.query) {
|
||||
const q = filter.query.toLowerCase();
|
||||
n = n.filter(note =>
|
||||
note.title.toLowerCase().includes(q) ||
|
||||
note.tags.some(t => t.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return n;
|
||||
|
||||
// Tags Filter
|
||||
if (filter.tags.length > 0) {
|
||||
const required = filter.tags.filter(t => !t.excluded).map(t => t.name);
|
||||
const excluded = filter.tags.filter(t => t.excluded).map(t => t.name);
|
||||
|
||||
if (required.length > 0) {
|
||||
n = n.filter(note => required.some(r => note.tags.includes(r)));
|
||||
}
|
||||
if (excluded.length > 0) {
|
||||
n = n.filter(note => !excluded.some(e => note.tags.includes(e)));
|
||||
}
|
||||
}
|
||||
|
||||
// Edited Today Filter
|
||||
if (filter.editedToday) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
n = n.filter(note => {
|
||||
const updated = new Date(note.updated);
|
||||
return updated >= today;
|
||||
});
|
||||
}
|
||||
|
||||
// Owned By Me Filter
|
||||
if (filter.ownedByMe) {
|
||||
const accessible = accessibleJobs();
|
||||
n = n.filter(note => {
|
||||
if (note.tags?.includes("prism_job")) {
|
||||
if (!accessible) return false;
|
||||
const jobIdTag = note.tags.find(t => t.startsWith("job_id:"));
|
||||
const jobId = jobIdTag?.split(":")[1]?.trim();
|
||||
if (!jobId) return false;
|
||||
return accessible.has(jobId);
|
||||
}
|
||||
return note.user === currentUserId;
|
||||
});
|
||||
}
|
||||
|
||||
// Job Status / Division Filter (notes only)
|
||||
if ((filter.jobStatus?.length || 0) > 0 || (filter.jobDivision?.length || 0) > 0) {
|
||||
const allowedJobIds = jobFilterIds();
|
||||
n = n.filter(note => {
|
||||
if (!note.tags?.includes("prism_job")) return false;
|
||||
if (!allowedJobIds) return false;
|
||||
const jobIdTag = note.tags.find(t => t.startsWith("job_id:"));
|
||||
const jobId = jobIdTag?.split(":")[1]?.trim();
|
||||
if (!jobId) return false;
|
||||
return allowedJobIds.has(jobId);
|
||||
});
|
||||
}
|
||||
|
||||
// Starred Filter
|
||||
if (filter.starred) {
|
||||
n = n.filter(note => note.tags?.includes(getFavoriteTag()));
|
||||
}
|
||||
|
||||
return n.sort((a, b) => {
|
||||
const titleA = a.title || "Untitled";
|
||||
const titleB = b.title || "Untitled";
|
||||
return titleB.localeCompare(titleA);
|
||||
});
|
||||
});
|
||||
|
||||
const handleCreateNote = async () => {
|
||||
@@ -40,21 +185,29 @@ export const NotesSidebar: Component<{
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full bg-card">
|
||||
{/* Header */}
|
||||
<div class="p-4 border-b border-border bg-card/50 backdrop-blur flex justify-between items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
class="w-full bg-muted/50 border border-border/50 rounded-lg pl-8 pr-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30"
|
||||
/>
|
||||
<div class="z-20">
|
||||
{/* Header */}
|
||||
<div class="p-4 border-b border-border bg-card/50 backdrop-blur flex justify-between items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search notes, tags..."
|
||||
value={store.noteFilter.query}
|
||||
onInput={(e) => setNoteFilter({ query: e.currentTarget.value })}
|
||||
class="w-full bg-muted/30 border border-border/50 rounded-xl px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/30"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<FilterBar
|
||||
mode="notes"
|
||||
class="relative top-0 right-0 z-0"
|
||||
triggerClass="h-9 w-9 p-0 bg-muted/40 hover:bg-muted/60 border-border/40"
|
||||
/>
|
||||
<Button onClick={handleCreateNote} size="sm" variant="default" class="h-9 w-9 p-0 shrink-0 shadow-sm rounded-xl">
|
||||
<Plus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCreateNote} size="sm" variant="default" class="h-8 w-8 p-0 shrink-0 shadow-sm">
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
@@ -71,23 +224,28 @@ export const NotesSidebar: Component<{
|
||||
)}
|
||||
>
|
||||
<div class="font-semibold text-sm truncate flex items-center justify-between">
|
||||
<span>{note.title || "Untitled"}</span>
|
||||
<div class="flex items-center gap-1.5 truncate">
|
||||
<Show when={note.tags?.includes(getFavoriteTag())}>
|
||||
<Star size={12} class="fill-amber-500 text-amber-500 shrink-0" />
|
||||
</Show>
|
||||
<span class="truncate">{note.title || "Untitled"}</span>
|
||||
</div>
|
||||
<Show when={note.isPrivate}>
|
||||
<Lock size={12} class="text-orange-500 shrink-0 ml-2" />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={note.tags && note.tags.length > 0}>
|
||||
<Show when={note.tags && note.tags.some(t => !t.endsWith("_favorite__"))}>
|
||||
<div class="flex gap-1 mt-2 flex-wrap">
|
||||
<For each={note.tags.slice(0, 3)}>
|
||||
<For each={note.tags.filter(t => !t.endsWith("_favorite__")).slice(0, 3)}>
|
||||
{(t) => (
|
||||
<span class="text-[0.6rem] px-1.5 py-0.5 bg-muted rounded-md text-muted-foreground whitespace-nowrap border border-border/50">
|
||||
{t}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={note.tags.length > 3}>
|
||||
<Show when={note.tags.filter(t => !t.endsWith("_favorite__")).length > 3}>
|
||||
<span class="text-[0.6rem] px-1.5 py-0.5 text-muted-foreground whitespace-nowrap shrink-0">
|
||||
+{note.tags.length - 3}
|
||||
+{note.tags.filter(t => !t.endsWith("_favorite__")).length - 3}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { type Component, createEffect, onCleanup } from 'solid-js';
|
||||
import { useRegisterSW } from 'virtual:pwa-register/solid';
|
||||
|
||||
// We export a function so other parts of the app can manually trigger an update
|
||||
let manualUpdateFn: (() => void) | undefined;
|
||||
|
||||
export const forceServiceWorkerUpdate = () => {
|
||||
if (manualUpdateFn) {
|
||||
manualUpdateFn();
|
||||
} else {
|
||||
console.warn("PWA Updater not initialized yet");
|
||||
}
|
||||
};
|
||||
|
||||
export const PwaUpdater: Component = () => {
|
||||
let registration: ServiceWorkerRegistration | undefined;
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegistered(r: ServiceWorkerRegistration | undefined) {
|
||||
console.log('SW Registered:', r);
|
||||
registration = r;
|
||||
|
||||
// Periodically check for updates (every 1 hour)
|
||||
if (r) {
|
||||
setInterval(() => {
|
||||
console.log('Periodic SW update check...');
|
||||
r.update();
|
||||
}, 60 * 60 * 1000);
|
||||
}
|
||||
},
|
||||
onRegisterError(error: Error | any) {
|
||||
console.error('SW registration error', error);
|
||||
},
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
// Expose function globally
|
||||
manualUpdateFn = () => {
|
||||
if (needRefresh()) {
|
||||
console.log('User manually forced an update. Reloading now...');
|
||||
updateServiceWorker(true);
|
||||
} else if (registration) {
|
||||
console.log('User manually asked to check for updates...');
|
||||
registration.update();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
// Check for updates when the app regains focus/visibility
|
||||
const handleVisibilityFocus = () => {
|
||||
if (document.visibilityState === 'visible' && registration) {
|
||||
console.log('App became visible, checking for SW updates...');
|
||||
registration.update();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityFocus);
|
||||
window.addEventListener('focus', handleVisibilityFocus);
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityFocus);
|
||||
window.removeEventListener('focus', handleVisibilityFocus);
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (needRefresh()) {
|
||||
console.log('New PWA version available. Monitoring for idle/hidden state to update...');
|
||||
|
||||
let idleTimeout: number | undefined;
|
||||
|
||||
const triggerUpdate = () => {
|
||||
console.log('Triggering PWA update...');
|
||||
updateServiceWorker(true);
|
||||
};
|
||||
|
||||
const resetIdle = () => {
|
||||
if (idleTimeout) window.clearTimeout(idleTimeout);
|
||||
// 10 minutes idle
|
||||
idleTimeout = window.setTimeout(triggerUpdate, 10 * 60 * 1000);
|
||||
};
|
||||
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
triggerUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
// Start idle timer
|
||||
resetIdle();
|
||||
|
||||
// Listen for user activity
|
||||
const events = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart'];
|
||||
events.forEach(e => window.addEventListener(e, resetIdle, { passive: true }));
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
|
||||
onCleanup(() => {
|
||||
if (idleTimeout) window.clearTimeout(idleTimeout);
|
||||
events.forEach(e => window.removeEventListener(e, resetIdle));
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Component, createSignal, createMemo, onCleanup, onMount } from "solid-js";
|
||||
import { store, currentTaskContext } from "@/store";
|
||||
import { type Component, createSignal, onCleanup, onMount } from "solid-js";
|
||||
import { store } from "@/store";
|
||||
import { Plus, X } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -7,37 +7,6 @@ import { QuickEntryForm } from "./QuickEntryForm";
|
||||
|
||||
export const QuickEntry: Component = () => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
// Autofill tags based on context when opening
|
||||
const initialTags = createMemo(() => {
|
||||
if (!isOpen()) return [];
|
||||
|
||||
const tags: string[] = [];
|
||||
const ctx = currentTaskContext();
|
||||
|
||||
if (store.isNotepadMode) {
|
||||
// Not in a specific context, but in Notes mode.
|
||||
// We use global window variable set by Layout to get the active note id
|
||||
const activeNoteId = (window as any)._activeNoteId;
|
||||
if (activeNoteId) {
|
||||
const note = store.notes.find(n => n.id === activeNoteId);
|
||||
if (note && note.title) {
|
||||
const noteTagName = note.title.trim();
|
||||
if (noteTagName) tags.push(noteTagName);
|
||||
}
|
||||
}
|
||||
} else if (typeof ctx === 'object') {
|
||||
if ('bucketId' in ctx) {
|
||||
// Bucket View: Autofill bucket name
|
||||
const bucketName = ctx.name;
|
||||
if (bucketName) tags.push(`@${bucketName}`);
|
||||
} else if ('userId' in ctx) {
|
||||
// Shared User View: Autofill user name (assuming name IS the tag for sharing)
|
||||
const userName = ctx.name;
|
||||
if (userName) tags.push(`@${userName}`);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
@@ -66,10 +35,10 @@ export const QuickEntry: Component = () => {
|
||||
</Button>
|
||||
|
||||
{isOpen() && (
|
||||
<div class="fixed inset-0 z-[100] flex items-end md:items-start justify-center md:pt-[15vh] px-0 md:px-4 pb-0 md:pb-4">
|
||||
<div class="fixed inset-0 z-[100] flex items-start justify-center md:pt-[15vh] px-0 md:px-4 pb-0 md:pb-4">
|
||||
<div class="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
|
||||
|
||||
<div class="relative w-full max-w-xl max-h-[100vh] md:max-h-[80vh] bg-card border border-border rounded-t-2xl md:rounded-2xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 md:zoom-in-95 duration-200 flex flex-col">
|
||||
<div class="relative w-full max-w-xl max-h-[100vh] md:max-h-[80vh] bg-card border border-border rounded-b-2xl md:rounded-2xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-4 md:slide-in-from-bottom-0 md:zoom-in-95 duration-200 flex flex-col">
|
||||
<div class="absolute top-3 right-4 z-[101]">
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -85,7 +54,6 @@ export const QuickEntry: Component = () => {
|
||||
<QuickEntryForm
|
||||
onSuccess={() => setIsOpen(false)}
|
||||
autoFocus={true}
|
||||
initialTags={initialTags()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Component, createSignal, createEffect, onMount, For, Show, Suspense, lazy } from "solid-js";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate } from "@/store";
|
||||
import { Search, Command, Clock, ArrowUpCircle, Copy, ChevronDown, Gauge } from "lucide-solid";
|
||||
import { addTask, calculateDateFromUrgency, calculateUrgencyFromDate, store, upsertTagDefinition, type TaskTemplate, currentTaskContext } from "@/store";
|
||||
import { Search, Command, Clock, ArrowUpCircle, Copy, ChevronDown, Gauge, RotateCcw } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TextField, TextFieldInput } from "@/components/ui/textfield";
|
||||
@@ -21,19 +21,32 @@ interface QuickEntryFormProps {
|
||||
initialTags?: string[];
|
||||
}
|
||||
|
||||
// Global state for QuickEntry persistence
|
||||
const [input, setInput] = createSignal("");
|
||||
const [priority, setPriority] = createSignal<string>("");
|
||||
const [urgency, setUrgency] = createSignal<string>("");
|
||||
const [tags, setTags] = createSignal<string[]>([]);
|
||||
const [description, setDescription] = createSignal("");
|
||||
const [size, setSize] = createSignal<string>("3");
|
||||
const [dateString, setDateString] = createSignal<string>("");
|
||||
|
||||
let lastParsedTags: string[] = [];
|
||||
|
||||
const resetQuickEntryState = () => {
|
||||
setInput("");
|
||||
setPriority("");
|
||||
setUrgency("");
|
||||
setTags([]);
|
||||
setDescription("");
|
||||
setSize("3");
|
||||
setDateString("");
|
||||
lastParsedTags = [];
|
||||
};
|
||||
|
||||
export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
// Diff tracking for reactive tags
|
||||
let lastParsedTags: string[] = [];
|
||||
|
||||
const [input, setInput] = createSignal(props.initialTitle || "");
|
||||
const [priority, setPriority] = createSignal<string>("");
|
||||
const [urgency, setUrgency] = createSignal<string>("");
|
||||
const [tags, setTags] = createSignal<string[]>(props.initialTags || []);
|
||||
const [description, setDescription] = createSignal("");
|
||||
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||
const [size, setSize] = createSignal<string>("3");
|
||||
const [dateString, setDateString] = createSignal<string>("");
|
||||
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
|
||||
@@ -41,6 +54,9 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
||||
if (props.autoFocus) {
|
||||
setTimeout(() => inputRef?.focus(), 100);
|
||||
}
|
||||
if (props.initialTitle && !input()) {
|
||||
setInput(props.initialTitle);
|
||||
}
|
||||
});
|
||||
|
||||
// Reactive sync from props (handles context changes while open)
|
||||
@@ -187,6 +203,33 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
||||
finalDueDate = new Date(dateStr);
|
||||
}
|
||||
|
||||
// Apply context tags at the time of creation
|
||||
const ctx = currentTaskContext();
|
||||
if (store.isNotepadMode) {
|
||||
const activeNoteId = (window as any)._activeNoteId;
|
||||
if (activeNoteId) {
|
||||
const note = store.notes.find(n => n.id === activeNoteId);
|
||||
if (note && note.title) {
|
||||
const noteTagName = note.title.trim();
|
||||
if (noteTagName && !finalTags.includes(noteTagName)) finalTags.push(noteTagName);
|
||||
}
|
||||
}
|
||||
} else if (typeof ctx === 'object') {
|
||||
if ('bucketId' in ctx) {
|
||||
const bucketName = ctx.name;
|
||||
if (bucketName) {
|
||||
const tag = `@${bucketName}`;
|
||||
if (!finalTags.includes(tag)) finalTags.push(tag);
|
||||
}
|
||||
} else if ('userId' in ctx) {
|
||||
const userName = ctx.name;
|
||||
if (userName) {
|
||||
const tag = `@${userName}`;
|
||||
if (!finalTags.includes(tag)) finalTags.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addTask(title.replace(/\s+/g, " ").trim(), {
|
||||
priority: finalPriority,
|
||||
dueDate: finalDueDate || undefined,
|
||||
@@ -195,14 +238,7 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
||||
size: parseInt(size())
|
||||
});
|
||||
|
||||
setInput("");
|
||||
setPriority("");
|
||||
setUrgency("");
|
||||
setTags([]);
|
||||
setDescription("");
|
||||
setSize("3");
|
||||
lastParsedTags = [];
|
||||
setDateString("");
|
||||
resetQuickEntryState();
|
||||
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.commands.setContent("");
|
||||
@@ -345,7 +381,7 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
||||
<div class="flex flex-col md:flex-row items-stretch md:items-center px-4 py-3 gap-4 bg-muted/10 border-b border-border">
|
||||
<div class="flex-1 flex flex-wrap gap-2 items-center">
|
||||
<TagPicker selectedTags={tags()} onTagsChange={setTags} />
|
||||
<For each={tags()}>
|
||||
<For each={tags().filter(t => !t.endsWith("_favorite__"))}>
|
||||
{(tag) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -420,6 +456,18 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
resetQuickEntryState();
|
||||
editorInstance()?.commands.setContent("");
|
||||
}}
|
||||
title="Restart Quick Entry"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</Button>
|
||||
<Show when={(store.templates || []).length > 0}>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import { type Component, createSignal, For, createEffect } from "solid-js";
|
||||
import {
|
||||
Heading1, Heading2, Heading3,
|
||||
List, ListTodo, Type,
|
||||
Code, Quote,
|
||||
Image as ImageIcon, Minus,
|
||||
Bold, Italic, Underline,
|
||||
Grid3X3, Highlighter,
|
||||
AlignLeft, AlignCenter, AlignRight,
|
||||
Trash2
|
||||
} from "lucide-solid";
|
||||
import { cn, convertImageToWebpFile } from "@/lib/utils";
|
||||
import { activeTaskId, uploadTaskAttachment } from "@/store";
|
||||
import { toast } from "solid-sonner";
|
||||
import { Type, Underline, Code, Quote, ImageIcon, Heading1, Heading2, Heading3, List, ListTodo, Film, FileText, AppWindow, Minus, Grid3X3, Highlighter, AlignLeft, AlignCenter, AlignRight, Trash2, Bold, Italic } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CommandItem {
|
||||
title: string;
|
||||
@@ -122,47 +111,34 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
||||
description: "Upload an image.",
|
||||
icon: ImageIcon,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
const taskId = activeTaskId();
|
||||
if (!taskId) {
|
||||
toast.error("Cannot upload image outside of a task.");
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const originalFile = input.files[0];
|
||||
toast.promise(
|
||||
(async () => {
|
||||
const webpFile = await convertImageToWebpFile(originalFile);
|
||||
return uploadTaskAttachment(taskId, webpFile);
|
||||
})(),
|
||||
{
|
||||
loading: "Processing & uploading image...",
|
||||
success: (url) => {
|
||||
// Insert image, then run enter to add a newline (paragraph) below it.
|
||||
// Without this, the image stays selected and the next keypress deletes it.
|
||||
editor.chain()
|
||||
.focus()
|
||||
.setImage({ src: url })
|
||||
.run();
|
||||
|
||||
// Manually force a new paragraph at the end so the user can keep typing
|
||||
editor.chain()
|
||||
.focus('end')
|
||||
.insertContent('<p></p>')
|
||||
.run();
|
||||
return "Image uploaded.";
|
||||
},
|
||||
error: "Failed to upload image."
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
editor.chain().focus().deleteRange(range).uploadImage().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Video",
|
||||
description: "Upload a video.",
|
||||
aliases: ["movie", "mp4", "mov"],
|
||||
icon: Film,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).uploadVideo().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "File",
|
||||
description: "Upload a document or file.",
|
||||
aliases: ["file", "pdf", "doc", "upload"],
|
||||
icon: FileText,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).uploadFile().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Job File",
|
||||
description: "Embed a job file from Prism.",
|
||||
aliases: ["job", "jobfile", "prism"],
|
||||
icon: AppWindow,
|
||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||
editor.chain().focus().deleteRange(range).insertFileViewer().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
};
|
||||
|
||||
const allTagNames = () => {
|
||||
const definedTags = store.tagDefinitions.map(d => d.name);
|
||||
const definedTags = store.tagDefinitions.filter(d => !d.name.endsWith("_favorite__")).map(d => d.name);
|
||||
const bucketTags = store.buckets.map(b => `@${b.name}`);
|
||||
const noteTags = store.notes.map(n => `#${n.title}`);
|
||||
return [...new Set([...definedTags, ...bucketTags, ...noteTags])].sort();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type Component, createMemo, For, Show } from "solid-js";
|
||||
import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now } from "@/store";
|
||||
import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now, getFavoriteTag } from "@/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Clock, ArrowUpCircle, Copy, Users2 } from "lucide-solid";
|
||||
import { Clock, ArrowUpCircle, Copy, Users2, Star } from "lucide-solid";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
@@ -35,10 +35,10 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
||||
// We are in a bucket view. Hide the tag that matches this bucket's name.
|
||||
// Buckets are now tagged with '@' + name.
|
||||
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
|
||||
return tags.filter(t => t.toLowerCase() !== bucketTagName);
|
||||
return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__"));
|
||||
}
|
||||
|
||||
return tags;
|
||||
return tags.filter(t => !t.endsWith("_favorite__"));
|
||||
});
|
||||
|
||||
const isDueWithin12Hours = createMemo(() => {
|
||||
@@ -218,9 +218,9 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-2 sm:ml-4 opacity-100 sm:opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-1">
|
||||
<button
|
||||
class="p-1.5 hover:bg-muted rounded-full transition-colors text-muted-foreground hover:text-primary"
|
||||
class="p-1.5 hover:bg-muted rounded-full transition-all text-muted-foreground hover:text-primary opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyTask(props.task.id);
|
||||
@@ -229,6 +229,25 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
||||
>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<button
|
||||
class={cn(
|
||||
"p-1.5 rounded-full transition-all",
|
||||
props.task.tags?.includes(getFavoriteTag())
|
||||
? "text-amber-500 bg-amber-500/10 hover:bg-amber-500/20 opacity-100"
|
||||
: "text-muted-foreground hover:text-amber-500 hover:bg-muted opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const favTag = getFavoriteTag();
|
||||
const currentTags = props.task.tags || [];
|
||||
const isFav = currentTags.includes(favTag);
|
||||
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
|
||||
updateTask(props.task.id, { tags: newTags });
|
||||
}}
|
||||
title="Toggle Favorite"
|
||||
>
|
||||
<Star size={16} class={cn(props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type Component, createEffect, createSignal, For, createMemo } from "solid-js";
|
||||
import { type Component, createEffect, createSignal, For, createMemo, onCleanup } from "solid-js";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext } from "@/store";
|
||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag } from "@/store";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings } from "lucide-solid";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, Image as ImageIcon, Film, FileText } from "lucide-solid";
|
||||
import { StatusCircle } from "./StatusCircle";
|
||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -110,9 +110,31 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Removed onCleanup to avoid lockup
|
||||
// Sync activeTaskId for slash commands and run cleanup on unmount/change
|
||||
createEffect(() => {
|
||||
const currentTaskId = props.task.id;
|
||||
|
||||
onCleanup(() => {
|
||||
// 1. Immediately flush any pending debounce updates for the outgoing task
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
if (pendingContent !== null) {
|
||||
updateTask(currentTaskId, { content: pendingContent });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clear pending state
|
||||
debounceTimer = undefined;
|
||||
pendingContent = null;
|
||||
|
||||
// 3. Trigger cleanup after a tiny delay so the updateTask above processes first
|
||||
if (currentTaskId) {
|
||||
setTimeout(() => {
|
||||
cleanupTaskAttachments(currentTaskId).catch(console.error);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
const updateTitle = (val: string) => {
|
||||
setTitle(val);
|
||||
updateTask(props.task.id, { title: val });
|
||||
@@ -179,10 +201,10 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
// We are in a bucket view. Hide the tag that matches this bucket's name.
|
||||
// Buckets are now tagged with '@' + name.
|
||||
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
|
||||
return tags.filter(t => t.toLowerCase() !== bucketTagName);
|
||||
return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__"));
|
||||
}
|
||||
|
||||
return tags;
|
||||
return tags.filter(t => !t.endsWith("_favorite__"));
|
||||
});
|
||||
|
||||
const statusOptions = createMemo(() => getStatusOptionsForTask(props.task.recurrence));
|
||||
@@ -339,6 +361,26 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Favorite */}
|
||||
<div class="flex items-center gap-1 group shrink-0">
|
||||
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline"></span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn("h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider transition-colors shrink-0", props.task.tags?.includes(getFavoriteTag()) ? "text-amber-500 hover:bg-amber-500/10" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground")}
|
||||
onClick={() => {
|
||||
const favTag = getFavoriteTag();
|
||||
const currentTags = props.task.tags || [];
|
||||
const isFav = currentTags.includes(favTag);
|
||||
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
|
||||
updateTags(newTags);
|
||||
}}
|
||||
>
|
||||
<Star size={14} class={cn("opacity-70 group-hover:opacity-100 transition-opacity shrink-0", props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
|
||||
<span class="hidden sm:inline sm:ml-1.5">{props.task.tags?.includes(getFavoriteTag()) ? "Starred" : "Star"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Commands Shortcut */}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
@@ -356,6 +398,60 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
<span class="hidden sm:inline">Commands</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Image Upload Shortcut */}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-1 sm:px-2 text-[0.625rem] 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().uploadImage().run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ImageIcon size={14} class="opacity-70" />
|
||||
<span class="hidden sm:inline">Image</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Video Upload Shortcut */}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-1 sm:px-2 text-[0.625rem] 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().uploadVideo().run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Film size={14} class="opacity-70" />
|
||||
<span class="hidden sm:inline">Video</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* File Upload Shortcut */}
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-1 sm:px-2 text-[0.625rem] 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().uploadFile().run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FileText size={14} class="opacity-70" />
|
||||
<span class="hidden sm:inline">File</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Row (Dates/Timestamps) */}
|
||||
@@ -616,7 +712,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Close button (Mobile only) */}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -4,11 +4,14 @@ import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import { CustomTaskItem } from "./CustomTaskItem";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { CustomImage as Image } from "@/lib/extensions/image";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import { X } from "lucide-solid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { store } from "@/store";
|
||||
import { store, activeTaskId, activeNoteId, uploadTaskAttachment, uploadNoteAttachment } from "@/store";
|
||||
import { convertImageToWebpFile } from "@/lib/utils";
|
||||
import { toast } from "solid-sonner";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
|
||||
import { SlashCommands } from "@/lib/slash-command";
|
||||
import { suggestion } from "@/lib/slash-renderer";
|
||||
@@ -26,6 +29,12 @@ import { Typography } from "@tiptap/extension-typography";
|
||||
import { Dropcursor } from "@tiptap/extension-dropcursor";
|
||||
import { Mention } from "@tiptap/extension-mention";
|
||||
import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion";
|
||||
import { FileViewer } from "@/lib/extensions/file-viewer";
|
||||
import { ImageUpload } from "@/lib/extensions/image-upload";
|
||||
import { Video } from "@/lib/extensions/video";
|
||||
import { VideoUpload } from "@/lib/extensions/video-upload";
|
||||
import { FileAttachment } from "@/lib/extensions/file-attachment";
|
||||
import { FileUpload } from "@/lib/extensions/file-upload";
|
||||
|
||||
interface TaskEditorProps {
|
||||
content?: string;
|
||||
@@ -51,6 +60,51 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
setZoomOffset({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const handleFileUploads = async (editor: Editor, files: File[]) => {
|
||||
const taskId = activeTaskId();
|
||||
const noteId = activeNoteId();
|
||||
|
||||
if (!taskId && !noteId) {
|
||||
toast.error("Cannot upload files outside of a task or note.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
toast.promise(
|
||||
(async () => {
|
||||
let fileToUpload = file;
|
||||
if (isImage) {
|
||||
fileToUpload = await convertImageToWebpFile(file);
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
return uploadTaskAttachment(taskId, fileToUpload);
|
||||
} else {
|
||||
return uploadNoteAttachment(noteId!, fileToUpload);
|
||||
}
|
||||
})(),
|
||||
{
|
||||
loading: `Uploading ${file.name}...`,
|
||||
success: ({ url, filename }) => {
|
||||
if (isImage) {
|
||||
// @ts-ignore
|
||||
editor.chain().focus().setImage({ src: url, filename }).run();
|
||||
} else if (isVideo) {
|
||||
editor.chain().focus().setVideo({ src: url, filename }).run();
|
||||
} else {
|
||||
editor.chain().focus().insertFileAttachment({ src: url, filename, filesize: file.size.toString() }).run();
|
||||
}
|
||||
return `${file.name} uploaded.`;
|
||||
},
|
||||
error: `Failed to upload ${file.name}.`
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const editor = createTiptapEditor(() => ({
|
||||
element: editorRef!,
|
||||
extensions: [
|
||||
@@ -159,6 +213,12 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
color: "var(--primary)",
|
||||
width: 2,
|
||||
}),
|
||||
FileViewer,
|
||||
ImageUpload,
|
||||
Video,
|
||||
VideoUpload,
|
||||
FileAttachment,
|
||||
FileUpload,
|
||||
],
|
||||
// Use untrack so the editor doesn't re-initialize when props.content changes
|
||||
content: untrack(() => props.content) || "",
|
||||
@@ -167,6 +227,27 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
props.onUpdate(editor.getHTML());
|
||||
},
|
||||
editorProps: {
|
||||
handlePaste: (_view, event) => {
|
||||
const items = Array.from(event.clipboardData?.items || []);
|
||||
const files = items
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileUploads(editor()!, files);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (_view, event, _slice, moved) => {
|
||||
if (!moved && event.dataTransfer?.files?.length) {
|
||||
event.preventDefault();
|
||||
handleFileUploads(editor()!, Array.from(event.dataTransfer.files));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
attributes: {
|
||||
class: cn(
|
||||
"prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[150px]",
|
||||
@@ -189,6 +270,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||
"[&_.tiptap-task-content>p]:min-h-[1.5rem] [&_.tiptap-task-content>p]:m-0 [&_.tiptap-task-content>p]:w-full",
|
||||
"[&_.tiptap-task-content>p:empty]:after:content-['\\200B'] [&_.tiptap-task-content>p:empty]:after:inline-block",
|
||||
"[&_.tiptap-task-content>p>br]:after:content-['\\200B'] [&_.tiptap-task-content>p>br]:after:inline-block", // if tiptap adds br
|
||||
"[&>.file-viewer-wrapper:first-child]:mt-0 [&>.file-viewer-wrapper:first-child]:border-t-0 [&>.file-viewer-wrapper:first-child]:border-x-0 [&>.file-viewer-wrapper:first-child]:rounded-none [&>.file-viewer-wrapper:first-child]:bg-transparent [&>.file-viewer-wrapper:first-child]:h-[80vh]",
|
||||
props.class
|
||||
),
|
||||
},
|
||||
|
||||
@@ -77,18 +77,6 @@
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-task-enter: task-enter 0.25s ease-out backwards;
|
||||
|
||||
@keyframes task-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -3,12 +3,14 @@ import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
import { Toaster } from './components/ui/sonner'
|
||||
import { PwaUpdater } from './components/PwaUpdater'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
render(() => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<PwaUpdater />
|
||||
<App />
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
|
||||
export interface FileAttachmentOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const FileAttachment = Node.create<FileAttachmentOptions>({
|
||||
name: 'fileAttachment',
|
||||
|
||||
group: 'block',
|
||||
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: 'file-attachment-node w-full max-w-sm rounded-lg border border-border bg-muted/30 hover:bg-muted/50 transition-colors my-4 overflow-hidden relative group cursor-pointer shadow-sm',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-src'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.src) return {};
|
||||
return { 'data-src': attributes.src };
|
||||
},
|
||||
},
|
||||
filename: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-filename'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.filename) return {};
|
||||
return { 'data-filename': attributes.filename };
|
||||
},
|
||||
},
|
||||
filesize: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-filesize'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.filesize) return {};
|
||||
return { 'data-filesize': attributes.filesize };
|
||||
},
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div[data-type="file-attachment"]',
|
||||
},
|
||||
{
|
||||
tag: 'a.file-attachment-link',
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const filename = HTMLAttributes.filename || '';
|
||||
const isPdf = filename.toLowerCase().endsWith('.pdf');
|
||||
|
||||
if (isPdf) {
|
||||
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-type': 'file-attachment',
|
||||
class: 'file-attachment-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[500px] bg-muted/20 group'
|
||||
}),
|
||||
['iframe', { src: HTMLAttributes.src, class: 'w-full h-full border-0 absolute inset-0' }]
|
||||
];
|
||||
}
|
||||
|
||||
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-type': 'file-attachment',
|
||||
}),
|
||||
['a', { href: HTMLAttributes.src, target: '_blank', rel: 'noopener noreferrer', class: 'file-attachment-link flex items-center p-3 gap-3 w-full h-full' },
|
||||
['div', { class: 'flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary' },
|
||||
// SVG for FileText
|
||||
['svg', { xmlns: 'http://www.w3.org/2000/svg', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' },
|
||||
['path', { d: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' }],
|
||||
['path', { d: 'M14 2v4a2 2 0 0 0 2 2h4' }],
|
||||
['path', { d: 'M10 9H8' }],
|
||||
['path', { d: 'M16 13H8' }],
|
||||
['path', { d: 'M16 17H8' }]
|
||||
]
|
||||
],
|
||||
['div', { class: 'flex flex-col overflow-hidden w-full text-left' },
|
||||
['span', { class: 'text-sm font-medium truncate w-full text-foreground' }, HTMLAttributes.filename || 'Attachment'],
|
||||
['span', { class: 'text-xs text-muted-foreground mt-0.5' }, 'View File']
|
||||
]
|
||||
]
|
||||
];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () => {
|
||||
const { selection } = this.editor.state;
|
||||
if (!selection.empty) {
|
||||
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
|
||||
if (selectedNode?.type.name === this.name) {
|
||||
return true; // Prevent default deletion
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
Delete: () => {
|
||||
const { selection } = this.editor.state;
|
||||
if (!selection.empty) {
|
||||
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
|
||||
if (selectedNode?.type.name === this.name) {
|
||||
return true; // Prevent default deletion
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, getPos, editor }) => {
|
||||
const filename = node.attrs.filename || '';
|
||||
const isPdf = filename.toLowerCase().endsWith('.pdf');
|
||||
const isTxt = filename.toLowerCase().endsWith('.txt');
|
||||
const isFramable = isPdf || isTxt;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-type', 'file-attachment');
|
||||
wrapper.setAttribute('data-src', node.attrs.src);
|
||||
wrapper.setAttribute('data-filename', node.attrs.filename);
|
||||
wrapper.setAttribute('data-filesize', node.attrs.filesize);
|
||||
|
||||
if (isFramable) {
|
||||
// Framed mode matching FileViewer
|
||||
wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group');
|
||||
wrapper.style.minHeight = '500px';
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = node.attrs.src;
|
||||
iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0');
|
||||
|
||||
wrapper.appendChild(iframe);
|
||||
} else {
|
||||
// Card mode
|
||||
wrapper.classList.add('file-attachment-node', 'w-full', 'max-w-sm', 'rounded-lg', 'border', 'border-border', 'bg-muted/30', 'hover:bg-muted/50', 'transition-colors', 'my-4', 'overflow-hidden', 'relative', 'group', 'cursor-pointer', 'shadow-sm');
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = node.attrs.src || '#';
|
||||
anchor.target = '_blank';
|
||||
anchor.rel = 'noopener noreferrer';
|
||||
anchor.classList.add('flex', 'items-center', 'p-3', 'gap-3', 'w-full', 'h-full', 'no-underline');
|
||||
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.classList.add('flex-shrink-0', 'flex', 'items-center', 'justify-center', 'w-10', 'h-10', 'rounded-md', 'bg-primary/10', 'text-primary');
|
||||
iconContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>';
|
||||
|
||||
const textContainer = document.createElement('div');
|
||||
textContainer.classList.add('flex', 'flex-col', 'overflow-hidden', 'w-full', 'text-left');
|
||||
|
||||
const filenameSpan = document.createElement('span');
|
||||
filenameSpan.classList.add('text-sm', 'font-medium', 'truncate', 'w-full', 'text-foreground');
|
||||
filenameSpan.innerText = node.attrs.filename || 'Attachment';
|
||||
|
||||
const subTextSpan = document.createElement('span');
|
||||
subTextSpan.classList.add('text-xs', 'text-muted-foreground', 'mt-0.5');
|
||||
if (node.attrs.filesize) {
|
||||
const mb = (parseInt(node.attrs.filesize) / (1024 * 1024)).toFixed(2);
|
||||
subTextSpan.innerText = `${mb} MB • View File`;
|
||||
} else {
|
||||
subTextSpan.innerText = `View File`;
|
||||
}
|
||||
|
||||
textContainer.appendChild(filenameSpan);
|
||||
textContainer.appendChild(subTextSpan);
|
||||
anchor.appendChild(iconContainer);
|
||||
anchor.appendChild(textContainer);
|
||||
wrapper.appendChild(anchor);
|
||||
}
|
||||
|
||||
// Delete button overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('absolute', 'top-1.5', 'right-1.5', 'hidden', 'group-hover:flex', 'bg-background/90', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||
deleteBtn.classList.add('p-1', 'cursor-pointer');
|
||||
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof getPos === 'function') {
|
||||
const pos = getPos();
|
||||
if (typeof pos === 'number') {
|
||||
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
overlay.appendChild(deleteBtn);
|
||||
wrapper.appendChild(overlay);
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
stopEvent: (e) => {
|
||||
// Stop event propagation if click is exactly on delete button, to prevent anchor following
|
||||
if (overlay.contains(e.target as unknown as globalThis.Node)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
ignoreMutation: () => true,
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
fileAttachment: {
|
||||
insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
|
||||
import { toast } from "solid-sonner";
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
fileUpload: {
|
||||
uploadFile: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const FileUpload = Extension.create({
|
||||
name: 'fileUpload',
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
uploadFile: () => ({ editor }) => {
|
||||
const taskId = activeTaskId();
|
||||
const noteId = activeNoteId();
|
||||
|
||||
if (!taskId && !noteId) {
|
||||
toast.error("Cannot upload a file outside of a task or note.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
// Accept any file type for generic attachments
|
||||
input.accept = "*/*";
|
||||
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const filesize = file.size.toString();
|
||||
|
||||
toast.promise(
|
||||
(async () => {
|
||||
if (taskId) {
|
||||
return uploadTaskAttachment(taskId, file);
|
||||
} else {
|
||||
return uploadNoteAttachment(noteId!, file);
|
||||
}
|
||||
})(),
|
||||
{
|
||||
loading: "Uploading file...",
|
||||
success: ({ url, filename: pbFilename }) => {
|
||||
// Insert file attchment UI card
|
||||
editor.chain()
|
||||
.focus()
|
||||
.insertFileAttachment({ src: url, filename: pbFilename, filesize })
|
||||
.run();
|
||||
|
||||
// Manually force a new paragraph at the end so the user can keep typing
|
||||
// Add paragraph after insertion
|
||||
editor.chain()
|
||||
.focus('end')
|
||||
.insertContent('<p></p>')
|
||||
.run();
|
||||
|
||||
return "File uploaded successfully.";
|
||||
},
|
||||
error: "Failed to upload file."
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { render } from 'solid-js/web';
|
||||
import { JobFileSelector } from '@/components/JobFileSelector';
|
||||
|
||||
export interface FileViewerOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
fileViewer: {
|
||||
insertFileViewer: (options?: { src?: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const FileViewer = Node.create<FileViewerOptions>({
|
||||
name: 'fileViewer',
|
||||
|
||||
group: 'block',
|
||||
|
||||
selectable: true,
|
||||
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: 'file-viewer-node group relative',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
jobId: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-job-id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.jobId) return {};
|
||||
return { 'data-job-id': attributes.jobId };
|
||||
},
|
||||
},
|
||||
collectionId: {
|
||||
default: 'Job_Info_Prod',
|
||||
parseHTML: element => element.getAttribute('data-collection-id') || 'Job_Info_Prod',
|
||||
renderHTML: attributes => {
|
||||
return { 'data-collection-id': attributes.collectionId || 'Job_Info_Prod' };
|
||||
},
|
||||
},
|
||||
filename: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-filename'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.filename) return {};
|
||||
return { 'data-filename': attributes.filename };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div[data-type="file-viewer-wrapper"]',
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const { jobId, collectionId, filename, ...rest } = HTMLAttributes;
|
||||
let src = '';
|
||||
if (jobId && filename) {
|
||||
src = `https://pocketbase.ccllc.pro/api/files/${collectionId}/${jobId}/${filename}`;
|
||||
}
|
||||
return ['div', mergeAttributes(this.options.HTMLAttributes, rest, { 'data-type': 'file-viewer-wrapper', class: 'file-viewer-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[500px] bg-muted/20' }),
|
||||
['iframe', { 'data-type': 'file-viewer', src: src, class: 'w-full h-full border-0 absolute inset-0' }]
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, getPos, editor }) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group');
|
||||
|
||||
const hasFile = node.attrs.jobId && node.attrs.filename;
|
||||
|
||||
if (hasFile) {
|
||||
// Viewer mode
|
||||
wrapper.style.minHeight = '500px';
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = `https://pocketbase.ccllc.pro/api/files/${node.attrs.collectionId}/${node.attrs.jobId}/${node.attrs.filename}`;
|
||||
iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0');
|
||||
|
||||
// Delete button overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||
deleteBtn.classList.add('p-1', 'cursor-pointer');
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
if (typeof getPos === 'function') {
|
||||
const pos = getPos();
|
||||
if (typeof pos === 'number') {
|
||||
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
overlay.appendChild(deleteBtn);
|
||||
wrapper.appendChild(iframe);
|
||||
wrapper.appendChild(overlay);
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
stopEvent: () => true,
|
||||
ignoreMutation: () => true,
|
||||
};
|
||||
} else {
|
||||
render(() => JobFileSelector({
|
||||
onSelectFile: (jobId: string, filename: string) => {
|
||||
if (typeof getPos === 'function') {
|
||||
editor.commands.command(({ tr }) => {
|
||||
const pos = getPos();
|
||||
if (typeof pos === 'number') {
|
||||
tr.setNodeMarkup(pos, undefined, { jobId, collectionId: 'Job_Info_Prod', filename });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}), wrapper);
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
stopEvent: () => true,
|
||||
ignoreMutation: () => true,
|
||||
};
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertFileViewer: (options?: { src?: string }) => ({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options || {},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
|
||||
import { convertImageToWebpFile } from "@/lib/utils";
|
||||
import { toast } from "solid-sonner";
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
imageUpload: {
|
||||
uploadImage: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const ImageUpload = Extension.create({
|
||||
name: 'imageUpload',
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
uploadImage: () => ({ editor }) => {
|
||||
const taskId = activeTaskId();
|
||||
const noteId = activeNoteId();
|
||||
|
||||
if (!taskId && !noteId) {
|
||||
toast.error("Cannot upload image outside of a task or note.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*,.heic,.heif";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const originalFile = input.files[0];
|
||||
toast.promise(
|
||||
(async () => {
|
||||
const webpFile = await convertImageToWebpFile(originalFile);
|
||||
if (taskId) {
|
||||
return uploadTaskAttachment(taskId, webpFile);
|
||||
} else {
|
||||
return uploadNoteAttachment(noteId!, webpFile);
|
||||
}
|
||||
})(),
|
||||
{
|
||||
loading: "Processing & uploading image...",
|
||||
success: ({ url, filename }) => {
|
||||
// Insert image
|
||||
editor.chain()
|
||||
.focus()
|
||||
// @ts-ignore
|
||||
.setImage({ src: url, filename })
|
||||
.run();
|
||||
|
||||
// Manually force a new paragraph at the end so the user can keep typing
|
||||
editor.chain()
|
||||
.focus('end')
|
||||
.insertContent('<p></p>')
|
||||
.run();
|
||||
|
||||
return "Image uploaded.";
|
||||
},
|
||||
error: "Failed to upload image."
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
|
||||
export const CustomImage = Image.extend({
|
||||
name: "image",
|
||||
|
||||
group: "block",
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
filename: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-filename'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.filename) return {};
|
||||
return { 'data-filename': attributes.filename };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setImage: (options: { src: string, alt?: string, title?: string, filename?: string }) => ({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, getPos, editor }) => {
|
||||
const wrapper = document.createElement('div');
|
||||
// Give it position: relative and flex/inline-block to wrap the image tightly
|
||||
wrapper.classList.add('image-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-fit', 'max-w-full');
|
||||
wrapper.style.display = 'block'; // Block level to not mess up flow
|
||||
wrapper.style.marginInline = 'auto'; // Center if desired, or let it flow
|
||||
|
||||
const img = document.createElement('img');
|
||||
Object.entries(node.attrs).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
if (key === 'filename') {
|
||||
img.setAttribute('data-filename', value);
|
||||
} else {
|
||||
img.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
img.classList.add('rounded-lg', 'border', 'border-border', 'max-w-full', 'h-auto', 'cursor-default');
|
||||
|
||||
// Delete button overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||
deleteBtn.classList.add('p-1', 'cursor-pointer');
|
||||
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof getPos === 'function') {
|
||||
const pos = getPos();
|
||||
if (typeof pos === 'number') {
|
||||
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
overlay.appendChild(deleteBtn);
|
||||
wrapper.appendChild(img);
|
||||
wrapper.appendChild(overlay);
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
stopEvent: () => true,
|
||||
ignoreMutation: () => true,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
// @ts-ignore
|
||||
image: {
|
||||
setImage: (options: { src: string, alt?: string, title?: string, filename?: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
|
||||
import { toast } from "solid-sonner";
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
videoUpload: {
|
||||
uploadVideo: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const VideoUpload = Extension.create({
|
||||
name: 'videoUpload',
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
uploadVideo: () => ({ editor }) => {
|
||||
const taskId = activeTaskId();
|
||||
const noteId = activeNoteId();
|
||||
|
||||
if (!taskId && !noteId) {
|
||||
toast.error("Cannot upload video outside of a task or note.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "video/*";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const originalFile = input.files[0];
|
||||
toast.promise(
|
||||
(async () => {
|
||||
// For video, we don't necessarily convert to webp. We just upload it directly.
|
||||
if (taskId) {
|
||||
return uploadTaskAttachment(taskId, originalFile);
|
||||
} else {
|
||||
return uploadNoteAttachment(noteId!, originalFile);
|
||||
}
|
||||
})(),
|
||||
{
|
||||
loading: "Uploading video...",
|
||||
success: ({ url, filename }) => {
|
||||
// Insert video
|
||||
editor.chain()
|
||||
.focus()
|
||||
.setVideo({ src: url, filename })
|
||||
.run();
|
||||
|
||||
// Manually force a new paragraph at the end so the user can keep typing
|
||||
editor.chain()
|
||||
.focus('end')
|
||||
.insertContent('<p></p>')
|
||||
.run();
|
||||
|
||||
return "Video uploaded.";
|
||||
},
|
||||
error: "Failed to upload video."
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
|
||||
export interface VideoOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const Video = Node.create<VideoOptions>({
|
||||
name: 'video',
|
||||
|
||||
group: 'block',
|
||||
|
||||
// Allows it to be selected and manipulated as a single block
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: 'w-full rounded-lg border border-border/50 shadow-sm overflow-hidden my-4',
|
||||
controls: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
filename: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-filename'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.filename) return {};
|
||||
return { 'data-filename': attributes.filename };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setVideo: (options: { src: string, filename?: string }) => ({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'video',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['video', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () => {
|
||||
const { selection } = this.editor.state;
|
||||
if (!selection.empty) {
|
||||
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
|
||||
if (selectedNode?.type.name === this.name) {
|
||||
return true; // Prevent default deletion
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
Delete: () => {
|
||||
const { selection } = this.editor.state;
|
||||
if (!selection.empty) {
|
||||
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
|
||||
if (selectedNode?.type.name === this.name) {
|
||||
return true; // Prevent default deletion
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, getPos, editor }) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-full', 'max-w-full');
|
||||
wrapper.style.display = 'block';
|
||||
|
||||
const video = document.createElement('video');
|
||||
Object.entries(node.attrs).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
if (key === 'filename') {
|
||||
video.setAttribute('data-filename', value);
|
||||
} else {
|
||||
video.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Apply default attributes if not present
|
||||
if (!video.hasAttribute('controls')) {
|
||||
video.setAttribute('controls', 'true');
|
||||
}
|
||||
video.classList.add('w-full', 'rounded-lg', 'border', 'border-border/50', 'shadow-sm', 'overflow-hidden');
|
||||
|
||||
// Delete button overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||
deleteBtn.classList.add('p-1', 'cursor-pointer');
|
||||
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (typeof getPos === 'function') {
|
||||
const pos = getPos();
|
||||
if (typeof pos === 'number') {
|
||||
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
overlay.appendChild(deleteBtn);
|
||||
wrapper.appendChild(video);
|
||||
wrapper.appendChild(overlay);
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
stopEvent: () => true, // Stops events from propagating to editor (makes the input usable)
|
||||
ignoreMutation: () => true,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
video: {
|
||||
setVideo: (options: { src: string, filename?: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -1,5 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import heic2any from "heic2any"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -38,7 +39,28 @@ export const compressImageBase64 = (base64Str: string, maxWidth = 800, quality =
|
||||
};
|
||||
|
||||
export const convertImageToWebpFile = async (file: File): Promise<File> => {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(async (resolve) => {
|
||||
let processableFile: File | Blob = file;
|
||||
|
||||
// Convert HEIC/HEIF to JPEG first so the browser can read it
|
||||
const isHeic = file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
|
||||
if (isHeic) {
|
||||
try {
|
||||
const converted = await heic2any({
|
||||
blob: file,
|
||||
toType: "image/jpeg",
|
||||
quality: 0.8 // high quality intermediate step
|
||||
});
|
||||
|
||||
// heic2any can return an array of blobs if it's an image sequence, just take the first
|
||||
processableFile = Array.isArray(converted) ? converted[0] : converted;
|
||||
} catch (err) {
|
||||
console.error("Failed to convert HEIC image:", err);
|
||||
// If conversion fails, just fall back to returning the original file
|
||||
return resolve(file);
|
||||
}
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
@@ -92,6 +114,6 @@ export const convertImageToWebpFile = async (file: File): Promise<File> => {
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.onerror = () => resolve(file); // Fallback
|
||||
reader.readAsDataURL(file);
|
||||
reader.readAsDataURL(processableFile);
|
||||
});
|
||||
};
|
||||
|
||||
+478
-13
@@ -4,6 +4,12 @@ import { pb } from "@/lib/pocketbase";
|
||||
import { toast } from "solid-sonner";
|
||||
import { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, NOTES_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants";
|
||||
|
||||
// Helper to get the current user's personalized favorite tag
|
||||
export const getFavoriteTag = () => {
|
||||
const userId = pb.authStore.model?.id;
|
||||
return userId ? `__${userId}_favorite__` : "__favorite__";
|
||||
};
|
||||
|
||||
const LIST_FIELDS = 'id,collectionId,collectionName,created,updated,title,startDate,dueDate,priority,status,completed,tags,user,size,recurrence,sharedWith,deletedAt';
|
||||
|
||||
const getStorageKey = () => {
|
||||
@@ -15,6 +21,7 @@ export const [now, setNow] = createSignal(Date.now());
|
||||
// Context: 'mine' = My Bucket (default), { userId: string } = Oversight View for that user, { bucketId: string, name: string } = Bucket View
|
||||
type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string };
|
||||
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
||||
export const [activeNoteId, setActiveNoteId] = createSignal<string | null>(null);
|
||||
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>('mine');
|
||||
|
||||
export type UrgencyLevel = number;
|
||||
@@ -146,6 +153,7 @@ export interface Note {
|
||||
tasks: string[]; // List of related task IDs
|
||||
created: string;
|
||||
updated: string;
|
||||
deletedAt?: number | null; // Timestamp of soft delete or null if restored
|
||||
}
|
||||
|
||||
export interface FilterTag {
|
||||
@@ -161,8 +169,76 @@ export interface Filter {
|
||||
urgencyMin: number;
|
||||
urgencyMax: number;
|
||||
editedToday: boolean;
|
||||
ownedByMe: boolean;
|
||||
starred: boolean;
|
||||
jobStatus: FilterTag[];
|
||||
jobDivision: FilterTag[];
|
||||
}
|
||||
|
||||
// Background sync for Context Switcher (polling fallback for realtime)
|
||||
let contextSyncInterval: number | undefined;
|
||||
|
||||
export const startContextPolling = () => {
|
||||
if (contextSyncInterval) {
|
||||
clearInterval(contextSyncInterval);
|
||||
contextSyncInterval = undefined;
|
||||
}
|
||||
|
||||
const ctx = currentTaskContext();
|
||||
if (typeof ctx === 'object' && 'userId' in ctx) {
|
||||
contextSyncInterval = window.setInterval(async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
// Fetch only recent updates (last 30s) to minimize payload
|
||||
const timeStr = new Date(Date.now() - 30000).toISOString();
|
||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `user = "${ctx.userId}" && updated >= "${timeStr}"`,
|
||||
sort: '-updated',
|
||||
fields: LIST_FIELDS,
|
||||
requestKey: 'context-poll'
|
||||
});
|
||||
|
||||
if (records.length === 0) return;
|
||||
|
||||
setStore("tasks", (currentTasks) => {
|
||||
const taskMap = new Map(currentTasks.map(t => [t.id, t]));
|
||||
let changed = false;
|
||||
|
||||
records.forEach((r: any) => {
|
||||
if (r.tags?.includes("__template__")) return;
|
||||
|
||||
const existing = taskMap.get(r.id);
|
||||
if (existing && existing.updated === r.updated) return;
|
||||
|
||||
const newTask = mapRecordToTask(r);
|
||||
if (existing && existing.content) {
|
||||
newTask.content = existing.content;
|
||||
}
|
||||
taskMap.set(r.id, newTask);
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (changed) return Array.from(taskMap.values());
|
||||
return currentTasks;
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (!err.isAbort) {
|
||||
console.warn("Context poll failed:", err);
|
||||
}
|
||||
}
|
||||
}, 5000); // 5 seconds
|
||||
}
|
||||
};
|
||||
|
||||
createRoot(() => {
|
||||
createEffect(() => {
|
||||
// Re-run whenever ctx changes
|
||||
currentTaskContext();
|
||||
startContextPolling();
|
||||
});
|
||||
});
|
||||
|
||||
export interface FilterTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -203,6 +279,7 @@ interface TaskStore {
|
||||
notes: Note[];
|
||||
isNotepadMode: boolean;
|
||||
quickloadTasks: string[];
|
||||
noteFilter: Filter;
|
||||
}
|
||||
|
||||
// Initial empty state
|
||||
@@ -221,7 +298,11 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10,
|
||||
editedToday: false
|
||||
editedToday: false,
|
||||
ownedByMe: false,
|
||||
starred: false,
|
||||
jobStatus: [],
|
||||
jobDivision: []
|
||||
},
|
||||
shareRules: [],
|
||||
filterTemplates: [],
|
||||
@@ -229,7 +310,20 @@ export const [store, setStore] = createStore<TaskStore>({
|
||||
subscribedBuckets: [],
|
||||
notes: [],
|
||||
isNotepadMode: false,
|
||||
quickloadTasks: []
|
||||
quickloadTasks: [],
|
||||
noteFilter: {
|
||||
query: "",
|
||||
tags: [],
|
||||
priorityMin: 1,
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10,
|
||||
editedToday: false,
|
||||
ownedByMe: false,
|
||||
starred: false,
|
||||
jobStatus: [],
|
||||
jobDivision: []
|
||||
}
|
||||
});
|
||||
|
||||
export const matchesFilter = (task: Task) => {
|
||||
@@ -327,6 +421,16 @@ export const matchesFilter = (task: Task) => {
|
||||
if (today !== updated) return false;
|
||||
}
|
||||
|
||||
// Owned By Me
|
||||
if (f.ownedByMe && task.ownerId !== currentUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Starred
|
||||
if (f.starred && !task.tags?.includes(getFavoriteTag())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -478,29 +582,112 @@ const mapRecordToTask = (r: any): Task => {
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<string> => {
|
||||
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<{ url: string, filename: string }> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('attachments', file);
|
||||
formData.append('attachments+', file);
|
||||
|
||||
const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData);
|
||||
|
||||
// Return the URL for the newly uploaded file. PocketBase appends new files to the array.
|
||||
// We need to find the specific filename that was just uploaded to get its URL.
|
||||
// Since we don't know the exact generated filename, we can return the URL of the last file in the array.
|
||||
if (record.attachments && record.attachments.length > 0) {
|
||||
const latestFilename = record.attachments[record.attachments.length - 1];
|
||||
return pb.files.getURL(record, latestFilename);
|
||||
return {
|
||||
url: pb.files.getURL(record, latestFilename),
|
||||
filename: latestFilename
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("File uploaded but no attachment returned");
|
||||
} catch (err: any) {
|
||||
console.error("Error uploading attachment:", err);
|
||||
console.error("Error uploading attachment. Validation details:", JSON.stringify(err.data, null, 2));
|
||||
toast.error("Failed to upload image.");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: string }> => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('attachments+', file);
|
||||
|
||||
const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData);
|
||||
|
||||
if (record.attachments && record.attachments.length > 0) {
|
||||
const latestFilename = record.attachments[record.attachments.length - 1];
|
||||
return {
|
||||
url: pb.files.getURL(record, latestFilename),
|
||||
filename: latestFilename
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("File uploaded but no attachment returned");
|
||||
} catch (err: any) {
|
||||
console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2));
|
||||
toast.error("Failed to upload image to note.");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanupNoteAttachments = async (noteId: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
const record = await pb.collection(NOTES_COLLECTION).getOne(noteId);
|
||||
if (!record.attachments || record.attachments.length === 0) return;
|
||||
|
||||
const content = record.content || "";
|
||||
const filesToDelete: string[] = [];
|
||||
|
||||
// Also check decoded content just in case of encoding mismatches
|
||||
const decodedContent = decodeURIComponent(content);
|
||||
|
||||
for (const filename of record.attachments) {
|
||||
// Check for literal filename OR the part of the URL that includes the filename
|
||||
if (!content.includes(filename) && !decodedContent.includes(filename)) {
|
||||
filesToDelete.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToDelete.length > 0) {
|
||||
await pb.collection(NOTES_COLLECTION).update(noteId, {
|
||||
"attachments-": filesToDelete
|
||||
});
|
||||
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for note ${noteId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[CLEANUP] Failed to clean up orphaned note attachments:", err);
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanupTaskAttachments = async (taskId: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
try {
|
||||
const record = await pb.collection(TASGRID_COLLECTION).getOne(taskId);
|
||||
if (!record.attachments || record.attachments.length === 0) return;
|
||||
|
||||
const content = record.content || "";
|
||||
const filesToDelete: string[] = [];
|
||||
|
||||
const decodedContent = decodeURIComponent(content);
|
||||
|
||||
for (const filename of record.attachments) {
|
||||
if (!content.includes(filename) && !decodedContent.includes(filename)) {
|
||||
filesToDelete.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToDelete.length > 0) {
|
||||
await pb.collection(TASGRID_COLLECTION).update(taskId, {
|
||||
"attachments-": filesToDelete
|
||||
});
|
||||
console.log(`[CLEANUP] Deleted ${filesToDelete.length} orphaned attachment(s) for task ${taskId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[CLEANUP] Failed to clean up orphaned task attachments:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const mapRecordToShareRule = (r: any): ShareRule => {
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -522,7 +709,8 @@ const mapRecordToNote = (r: any): Note => {
|
||||
user: r.user,
|
||||
tasks: r.tasks || [],
|
||||
created: r.created,
|
||||
updated: r.updated
|
||||
updated: r.updated,
|
||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -792,6 +980,128 @@ export const subscribeToRealtime = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const runLegacyTagMigration = async () => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
if (!currentUserId) return;
|
||||
|
||||
try {
|
||||
const userRec = await pb.collection('users').getOne(currentUserId, { requestKey: null });
|
||||
const prefs = userRec.Taskgrid_pref || {};
|
||||
|
||||
if (prefs.migratedLegacyTagsV1) {
|
||||
return; // Already migrated
|
||||
}
|
||||
|
||||
console.log("[MIGRATION] Starting legacy tag migration...");
|
||||
|
||||
// Fetch users and buckets
|
||||
const [allUsers, allBuckets] = await Promise.all([
|
||||
pb.collection('users').getFullList({
|
||||
filter: 'verified = true',
|
||||
fields: 'name,email',
|
||||
requestKey: null
|
||||
}),
|
||||
pb.collection(BUCKETS_COLLECTION).getFullList({
|
||||
fields: 'name',
|
||||
requestKey: null
|
||||
})
|
||||
]);
|
||||
|
||||
const validNames = new Set([
|
||||
...allBuckets.map(b => b.name.toLowerCase()),
|
||||
...allUsers.map(u => (u.name || u.email)?.toLowerCase()).filter(Boolean)
|
||||
]);
|
||||
|
||||
// Fetch all Tasks for this user
|
||||
const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
filter: `user = "${currentUserId}"`,
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
let tasksUpdated = 0;
|
||||
for (const t of tasks) {
|
||||
if (!t.tags || t.tags.length === 0) continue;
|
||||
let changed = false;
|
||||
const newTags = t.tags.map((tag: string) => {
|
||||
if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) {
|
||||
changed = true;
|
||||
return `@${tag}`;
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
await pb.collection(TASGRID_COLLECTION).update(t.id, { tags: newTags }, { requestKey: null });
|
||||
tasksUpdated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all Notes for this user
|
||||
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
|
||||
filter: `user = "${currentUserId}"`,
|
||||
requestKey: null
|
||||
});
|
||||
|
||||
let notesUpdated = 0;
|
||||
for (const n of notes) {
|
||||
if (!n.tags || n.tags.length === 0) continue;
|
||||
let changed = false;
|
||||
const newTags = n.tags.map((tag: string) => {
|
||||
if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) {
|
||||
changed = true;
|
||||
return `@${tag}`;
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
await pb.collection(NOTES_COLLECTION).update(n.id, { tags: newTags }, { requestKey: null });
|
||||
notesUpdated++;
|
||||
}
|
||||
}
|
||||
|
||||
prefs.migratedLegacyTagsV1 = true;
|
||||
await pb.collection('users').update(currentUserId, { Taskgrid_pref: prefs }, { requestKey: null });
|
||||
|
||||
if (pb.authStore.model) {
|
||||
pb.authStore.model.Taskgrid_pref = prefs;
|
||||
}
|
||||
|
||||
console.log(`[MIGRATION] Completed. Updated ${tasksUpdated} tasks and ${notesUpdated} notes.`);
|
||||
|
||||
// Update local state directly so it reflects immediately
|
||||
setStore("tasks", (tasks) => tasks.map(t => {
|
||||
let localChanged = false;
|
||||
const nt = t.tags?.map(tag => {
|
||||
if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) {
|
||||
localChanged = true;
|
||||
return `@${tag}`;
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
if (localChanged) return { ...t, tags: nt };
|
||||
return t;
|
||||
}));
|
||||
|
||||
setStore("notes", (items) => items.map(n => {
|
||||
let localChanged = false;
|
||||
const nt = n.tags?.map(tag => {
|
||||
if (!tag.startsWith("@") && validNames.has(tag.toLowerCase())) {
|
||||
localChanged = true;
|
||||
return `@${tag}`;
|
||||
}
|
||||
return tag;
|
||||
});
|
||||
if (localChanged) return { ...n, tags: nt };
|
||||
return n;
|
||||
}));
|
||||
|
||||
} catch (err) {
|
||||
console.error("[MIGRATION] Failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const initStore = async () => {
|
||||
if (!pb.authStore.isValid || store.isInitializing) return;
|
||||
setStore("isInitializing", true);
|
||||
@@ -869,7 +1179,18 @@ export const initStore = async () => {
|
||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||
prefId: userId,
|
||||
subscribedBuckets: user.subscribedBuckets || [],
|
||||
quickloadTasks: prefs.quickloadTasks || []
|
||||
quickloadTasks: prefs.quickloadTasks || [],
|
||||
noteFilter: prefs.noteFilter || {
|
||||
query: "",
|
||||
tags: [],
|
||||
priorityMin: 1,
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10,
|
||||
editedToday: false,
|
||||
ownedByMe: false,
|
||||
starred: false
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (prefErr) {
|
||||
@@ -1124,6 +1445,9 @@ export const initStore = async () => {
|
||||
// Fire off background sync
|
||||
await backgroundSync();
|
||||
|
||||
// Run one-time migration for legacy tags
|
||||
await runLegacyTagMigration();
|
||||
|
||||
// Separate Templates load (less critical)
|
||||
try {
|
||||
const templates = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||
@@ -1648,6 +1972,57 @@ export const removeTagDefinition = async (name: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const updateNote = async (id: string, updates: Partial<Note>) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
// Optimistic update
|
||||
const optimisticUpdates = {
|
||||
...updates,
|
||||
updated: new Date().toISOString()
|
||||
};
|
||||
setStore("notes", (n) => n.id === id, optimisticUpdates);
|
||||
|
||||
try {
|
||||
const pbUpdates: any = { ...updates };
|
||||
|
||||
if ("deletedAt" in updates) {
|
||||
if (updates.deletedAt) {
|
||||
pbUpdates.deletedAt = new Date(updates.deletedAt).toISOString();
|
||||
} else {
|
||||
pbUpdates.deletedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
await pb.collection(NOTES_COLLECTION).update(id, pbUpdates, { requestKey: null });
|
||||
} catch (err) {
|
||||
console.error("Note update failed", err);
|
||||
toast.error("Failed to update note.");
|
||||
}
|
||||
};
|
||||
|
||||
export const removeNote = (id: string) => {
|
||||
const nowTs = Date.now();
|
||||
updateNote(id, { deletedAt: nowTs });
|
||||
};
|
||||
|
||||
export const restoreNote = (id: string) => {
|
||||
updateNote(id, { deletedAt: null });
|
||||
};
|
||||
|
||||
export const deleteNotePermanently = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
|
||||
// Optimistic
|
||||
setStore("notes", (n) => n.filter((n) => n.id !== id));
|
||||
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).delete(id);
|
||||
} catch (err) {
|
||||
console.error("Note delete failed", err);
|
||||
toast.error("Failed to delete note.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const syncSystemTagsAndRules = async () => {
|
||||
@@ -2017,7 +2392,8 @@ export const syncPreferences = async () => {
|
||||
uWeight: store.uWeight,
|
||||
matrixScaleDays: store.matrixScaleDays,
|
||||
tagDefinitions: store.tagDefinitions,
|
||||
quickloadTasks: store.quickloadTasks
|
||||
quickloadTasks: store.quickloadTasks,
|
||||
noteFilter: store.noteFilter
|
||||
};
|
||||
|
||||
await pb.collection('users').update(userId, {
|
||||
@@ -2364,10 +2740,99 @@ export const clearFilter = () => {
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10,
|
||||
editedToday: false
|
||||
editedToday: false,
|
||||
ownedByMe: false,
|
||||
starred: false,
|
||||
jobStatus: [],
|
||||
jobDivision: []
|
||||
});
|
||||
};
|
||||
|
||||
export const setNoteFilter = (update: Partial<Filter>) => {
|
||||
setStore("noteFilter", (f) => ({ ...f, ...update }));
|
||||
syncPreferences();
|
||||
};
|
||||
|
||||
const NOTE_FILTER_TEMPLATES_COLLECTION = 'TasGrid_NoteFilterTemplates';
|
||||
|
||||
export const loadNoteFilterTemplates = async () => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
const records = await pb.collection(NOTE_FILTER_TEMPLATES_COLLECTION).getFullList({
|
||||
filter: `user = "${pb.authStore.model?.id}"`,
|
||||
requestKey: null
|
||||
});
|
||||
const templates: FilterTemplate[] = records.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
filter: r.filter as Filter
|
||||
}));
|
||||
setStore("filterTemplates", templates);
|
||||
} catch (err) {
|
||||
console.error("Failed to load note filter templates:", err);
|
||||
}
|
||||
};
|
||||
|
||||
export const saveNoteFilterTemplate = async (name: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
const record = await pb.collection(NOTE_FILTER_TEMPLATES_COLLECTION).create({
|
||||
user: pb.authStore.model?.id,
|
||||
name,
|
||||
filter: store.noteFilter
|
||||
}, { requestKey: null });
|
||||
|
||||
const newTemplate: FilterTemplate = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
filter: record.filter as Filter
|
||||
};
|
||||
|
||||
setStore("filterTemplates", prev => [...prev, newTemplate]);
|
||||
toast.success(`Note filter template "${name}" saved`);
|
||||
} catch (err) {
|
||||
console.error("Failed to save note filter template:", err);
|
||||
toast.error("Failed to save note filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const applyNoteFilterTemplate = (id: string) => {
|
||||
const template = store.filterTemplates.find(t => t.id === id);
|
||||
if (template) {
|
||||
setNoteFilter(template.filter);
|
||||
toast.success(`Applied note filter: ${template.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeNoteFilterTemplate = async (id: string) => {
|
||||
if (!pb.authStore.isValid) return;
|
||||
try {
|
||||
await pb.collection(NOTE_FILTER_TEMPLATES_COLLECTION).delete(id);
|
||||
setStore("filterTemplates", prev => prev.filter(t => t.id !== id));
|
||||
toast.success("Note filter template deleted");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete note filter template:", err);
|
||||
toast.error("Failed to delete note filter template.");
|
||||
}
|
||||
};
|
||||
|
||||
export const clearNoteFilter = () => {
|
||||
setStore("noteFilter", {
|
||||
query: "",
|
||||
tags: [],
|
||||
priorityMin: 1,
|
||||
priorityMax: 10,
|
||||
urgencyMin: 1,
|
||||
urgencyMax: 10,
|
||||
editedToday: false,
|
||||
ownedByMe: false,
|
||||
starred: false,
|
||||
jobStatus: [],
|
||||
jobDivision: []
|
||||
});
|
||||
syncPreferences();
|
||||
};
|
||||
|
||||
// Legacy cleanup - We don't need local storage persistence setup anymore
|
||||
export const setupPersistence = () => {
|
||||
// Moved logic to initStore which is called on auth.
|
||||
|
||||
@@ -28,10 +28,10 @@ export const CriticalView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ranked by highest urgency + priority.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<div class="w-full min-w-0">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,10 +33,10 @@ export const DigInView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Largest tasks first. Tackle big projects head-on.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<div class="w-full min-w-0">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+686
-204
@@ -1,14 +1,17 @@
|
||||
import { type Component, createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { store, renameTagDefinition } from "@/store";
|
||||
import { type Component, createSignal, createMemo, createEffect, onCleanup, For, Show } from "solid-js";
|
||||
import { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag } from "@/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { type Note } from "@/store";
|
||||
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight } from "lucide-solid";
|
||||
import { Plus, X, Lock, Unlock, Trash2, ChevronDown, Link as LinkIcon, Image as ImageIcon, MoreHorizontal, Type, FileText, Film, Search, Star, ChevronRight } from "lucide-solid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { DragDropProvider, DragDropSensors, SortableProvider, createSortable, closestCenter } from "@thisbeyond/solid-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TaskEditor } from "@/components/TaskEditor";
|
||||
import { NOTES_COLLECTION } from "@/lib/constants";
|
||||
import { TaskEditor } from "@/components/TaskEditor";
|
||||
import { TaskCard } from "@/components/TaskCard";
|
||||
import { NotesSidebar } from "@/components/NotesSidebar";
|
||||
import { toast } from "solid-sonner";
|
||||
|
||||
export const NotepadView: Component<{
|
||||
selectedNoteId: string | null;
|
||||
@@ -17,10 +20,44 @@ export const NotepadView: Component<{
|
||||
}> = (props) => {
|
||||
const [isLinking, setIsLinking] = createSignal(false);
|
||||
const [linkSearchQuery, setLinkSearchQuery] = createSignal("");
|
||||
const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(true);
|
||||
const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(false);
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
||||
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
|
||||
// Sync activeNoteId for slash commands and run cleanup on unmount/change
|
||||
createEffect(() => {
|
||||
// By reading the prop inside the effect block, SolidJS tracks it reactively.
|
||||
const currentId = props.selectedNoteId;
|
||||
setActiveNoteId(currentId);
|
||||
|
||||
// This will run when the effect re-runs (currentId changes) or the component unmounts
|
||||
onCleanup(() => {
|
||||
setActiveNoteId(null);
|
||||
|
||||
// 1. Immediately flush any pending debounce updates for the outgoing note
|
||||
if (contentUpdateTimeout) {
|
||||
clearTimeout(contentUpdateTimeout);
|
||||
if (pendingContentUpdate) {
|
||||
// Fire update manually to PocketBase & Store synchronously (fire-and-forget)
|
||||
updateNote(pendingContentUpdate.id, { content: pendingContentUpdate.html }).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clear pending state
|
||||
contentUpdateTimeout = undefined;
|
||||
pendingContentUpdate = null;
|
||||
|
||||
// 3. Trigger cleanup after a tiny delay so the updateNote above processes first
|
||||
if (currentId) {
|
||||
setTimeout(() => {
|
||||
cleanupNoteAttachments(currentId).catch(console.error);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Derived states
|
||||
const activeNote = createMemo(() => {
|
||||
const id = props.selectedNoteId?.trim();
|
||||
@@ -28,26 +65,19 @@ export const NotepadView: Component<{
|
||||
});
|
||||
|
||||
const handleUpdateNote = async (id: string, data: Partial<Note>) => {
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).update(id, data);
|
||||
} catch (e: any) {
|
||||
if (!e.isAbort) {
|
||||
console.error("Failed to update note", e);
|
||||
}
|
||||
}
|
||||
await updateNote(id, data);
|
||||
};
|
||||
|
||||
let contentUpdateTimeout: number | undefined;
|
||||
let pendingContentUpdate: { id: string, html: string } | null = null;
|
||||
|
||||
const handleUpdateContent = (id: string, html: string) => {
|
||||
clearTimeout(contentUpdateTimeout);
|
||||
pendingContentUpdate = { id, html };
|
||||
|
||||
contentUpdateTimeout = window.setTimeout(async () => {
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).update(id, { content: html });
|
||||
} catch (e: any) {
|
||||
if (!e.isAbort) {
|
||||
console.error("Failed to update note content", e);
|
||||
}
|
||||
}
|
||||
await updateNote(id, { content: html });
|
||||
pendingContentUpdate = null;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
@@ -59,15 +89,22 @@ export const NotepadView: Component<{
|
||||
// Use '#' prefix for note tags
|
||||
await renameTagDefinition(`#${oldTitle}`, `#${newTitle}`);
|
||||
};
|
||||
|
||||
const handleDeleteNote = async (id: string) => {
|
||||
try {
|
||||
await pb.collection(NOTES_COLLECTION).delete(id);
|
||||
if (props.selectedNoteId === id) {
|
||||
props.setSelectedNoteId(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete note", e);
|
||||
removeNote(id);
|
||||
if (props.selectedNoteId === id) {
|
||||
props.setSelectedNoteId(null);
|
||||
}
|
||||
|
||||
toast.success("Note moved to trash", {
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => {
|
||||
restoreNote(id);
|
||||
props.setSelectedNoteId(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleLinkTask = async (taskId: string) => {
|
||||
@@ -129,12 +166,136 @@ export const NotepadView: Component<{
|
||||
}).slice(0, 10);
|
||||
});
|
||||
|
||||
// --- Tabs Logic ---
|
||||
const rootParentNote = createMemo(() => {
|
||||
let current = activeNote();
|
||||
let count = 0;
|
||||
while (current && count < 10) {
|
||||
const parentTag = current.tags?.find(t => t.startsWith("child-of-"));
|
||||
if (!parentTag) break;
|
||||
const parentId = parentTag.replace("child-of-", "");
|
||||
const parent = store.notes.find(n => n.id === parentId);
|
||||
if (!parent) break;
|
||||
current = parent;
|
||||
count++;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
|
||||
const childTabs = createMemo(() => {
|
||||
const root = rootParentNote();
|
||||
if (!root) return [];
|
||||
const childTag = `child-of-${root.id}`;
|
||||
|
||||
const tabs = store.notes.filter(n => !n.deletedAt && n.tags?.includes(childTag));
|
||||
|
||||
tabs.sort((a, b) => {
|
||||
const getOrder = (tags: string[]) => {
|
||||
const orderTag = (tags || []).find(t => t.startsWith('taborder:'));
|
||||
return orderTag ? parseInt(orderTag.split(':')[1]) : 999;
|
||||
};
|
||||
return getOrder(a.tags) - getOrder(b.tags);
|
||||
});
|
||||
|
||||
return tabs;
|
||||
});
|
||||
|
||||
const handleCreateTab = async () => {
|
||||
const root = rootParentNote();
|
||||
if (!root || !currentUserId) return;
|
||||
|
||||
try {
|
||||
const result = await pb.collection(NOTES_COLLECTION).create({
|
||||
title: "New Tab",
|
||||
content: "",
|
||||
tags: [`child-of-${root.id}`],
|
||||
isPrivate: root.isPrivate, // inherit privacy
|
||||
user: currentUserId,
|
||||
});
|
||||
props.setSelectedNoteId(result.id);
|
||||
} catch (e) {
|
||||
console.error("Failed to create tab", e);
|
||||
toast.error("Failed to create tab");
|
||||
}
|
||||
};
|
||||
|
||||
const openQuickEntry = () => {
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { metaKey: true, key: 'k' }));
|
||||
};
|
||||
|
||||
const handleDragStart = () => {
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: any) => {
|
||||
setIsDragging(false);
|
||||
const { draggable, droppable } = event;
|
||||
if (draggable && droppable && draggable.id !== droppable.id) {
|
||||
const newTabs = [...childTabs()];
|
||||
const oldIndex = newTabs.findIndex(t => t.id === draggable.id);
|
||||
const newIndex = newTabs.findIndex(t => t.id === droppable.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const [item] = newTabs.splice(oldIndex, 1);
|
||||
newTabs.splice(newIndex, 0, item);
|
||||
|
||||
// Fire off updates
|
||||
newTabs.forEach((tab, index) => {
|
||||
const cleanedTags = (tab.tags || []).filter(t => !t.startsWith('taborder:'));
|
||||
cleanedTags.push(`taborder:${index}`);
|
||||
handleUpdateNote(tab.id, { tags: cleanedTags });
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sub-component for sortable tab
|
||||
const SortableDesktopTab: Component<{ tab: Note, isSelected: boolean, canEdit: boolean }> = (sProps) => {
|
||||
const sortable = createSortable(sProps.tab.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
{...(sortable.dragActivators as object)}
|
||||
style={sortable.transform ? `transform: translate3d(${sortable.transform.x}px, ${sortable.transform.y}px, 0); z-index: 50;` : undefined}
|
||||
class={cn(
|
||||
"py-2 px-3 flex flex-row items-center justify-start relative group rounded-l-xl shadow-sm text-left truncate flex-none cursor-pointer select-none",
|
||||
!isDragging() && "transition-all",
|
||||
sProps.isSelected
|
||||
? "bg-background border-y border-l border-border text-primary font-semibold w-full z-30"
|
||||
: cn(
|
||||
"bg-muted/40 border-y border-l border-border/40 text-muted-foreground z-0 w-10/12",
|
||||
!isDragging() && "hover:bg-muted hover:w-full hover:border-border/80"
|
||||
),
|
||||
sortable.isActiveDraggable && "opacity-50 ring-2 ring-primary"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (props.selectedNoteId !== sProps.tab.id) {
|
||||
props.setSelectedNoteId(sProps.tab.id);
|
||||
}
|
||||
}}
|
||||
title={sProps.tab.title || "Untitled Tab"}
|
||||
>
|
||||
<Show when={sProps.isSelected} fallback={
|
||||
<span class="text-xs truncate w-full">{sProps.tab.title || "Untitled Tab"}</span>
|
||||
}>
|
||||
<input
|
||||
class="text-xs bg-transparent border-none outline-none focus:ring-0 px-0 min-w-0 w-full truncate font-semibold"
|
||||
value={sProps.tab.title}
|
||||
placeholder="Untitled Tab"
|
||||
readOnly={!sProps.canEdit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => {
|
||||
const val = e.currentTarget.value.trim() || 'Untitled Tab';
|
||||
if (val !== sProps.tab.title) handleRenameNote(sProps.tab.id, sProps.tab.title, val);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col md:flex-row h-full bg-background md:bg-card md:border md:border-border md:rounded-xl md:shadow-sm overflow-hidden relative">
|
||||
<div class="flex flex-col md:flex-row h-full bg-background md:bg-transparent overflow-visible relative">
|
||||
{/* Mobile Notes List */}
|
||||
<Show when={!props.hideNavigation}>
|
||||
<div class={cn(
|
||||
@@ -147,8 +308,8 @@ export const NotepadView: Component<{
|
||||
|
||||
{/* Note Editor (Main Panel) */}
|
||||
<div class={cn(
|
||||
"flex-1 bg-card min-w-0 transition-all duration-200 ease-out h-full overflow-y-auto md:overflow-hidden absolute md:relative inset-0 z-20 md:z-0",
|
||||
!props.selectedNoteId ? "opacity-0 pointer-events-none md:pointer-events-auto md:opacity-100 translate-x-8 md:translate-x-0 md:flex items-center justify-center bg-muted/5" : "opacity-100 translate-x-0 flex flex-col md:flex-row"
|
||||
"flex-1 min-w-0 transition-all duration-200 ease-out h-full overflow-y-auto md:overflow-visible absolute md:relative inset-0 z-20 md:z-0",
|
||||
!props.selectedNoteId ? "opacity-0 pointer-events-none md:pointer-events-auto md:opacity-100 translate-x-8 md:translate-x-0 md:flex items-center justify-center bg-card md:bg-muted/5 md:border md:border-border md:rounded-xl md:shadow-sm overflow-hidden" : "opacity-100 translate-x-0 flex flex-col md:flex-row bg-transparent md:justify-center"
|
||||
)}>
|
||||
<Show when={activeNote()} fallback={
|
||||
<div class="text-center space-y-6 opacity-40 select-none flex flex-col items-center justify-center h-full">
|
||||
@@ -171,202 +332,523 @@ export const NotepadView: Component<{
|
||||
const isOwner = note().user === currentUserId;
|
||||
const canEdit = isOwner || !note().isPrivate;
|
||||
|
||||
const hasTopViewer = createMemo(() => {
|
||||
const c = note()?.content;
|
||||
if (!c) return false;
|
||||
const s = c.trim();
|
||||
return s.startsWith('<div data-type="file-viewer-wrapper"') ||
|
||||
(s.startsWith('<div data-type="file-attachment"') && s.includes('data-filename') && s.toLowerCase().includes('.pdf'));
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Editor Area */}
|
||||
<div class="flex-1 flex flex-col min-w-0 md:h-full overflow-visible md:overflow-y-auto relative p-4 md:p-6 space-y-4 md:space-y-6 scroll-smooth">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<input
|
||||
class="text-3xl sm:text-4xl font-black tracking-tight bg-transparent border-none outline-none focus:ring-0 flex-1 placeholder:text-muted-foreground/30 px-0 min-w-0"
|
||||
value={note().title}
|
||||
placeholder="Note Title"
|
||||
readOnly={!canEdit}
|
||||
onBlur={(e) => {
|
||||
const val = e.currentTarget.value.trim() || 'Untitled';
|
||||
if (val !== note().title) handleRenameNote(note().id, note().title, val);
|
||||
}}
|
||||
/>
|
||||
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||
<Show when={isOwner}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn("h-8 text-[0.625rem] font-bold uppercase tracking-widest", note().isPrivate ? "text-orange-500 hover:bg-orange-500/10" : "text-muted-foreground")}
|
||||
onClick={() => handleUpdateNote(note().id, { isPrivate: !note().isPrivate })}
|
||||
>
|
||||
<Show when={note().isPrivate} fallback={<><Unlock size={12} class="mr-1.5" /> Public</>}>
|
||||
<><Lock size={12} class="mr-1.5" /> Private</>
|
||||
</Show>
|
||||
</Button>
|
||||
</Show>
|
||||
{/* Desktop Style Tabs (Left Side) - Ribbon Bookmark Style */}
|
||||
<div class="hidden md:flex flex-col w-32 shrink-0 bg-transparent pt-12 items-end gap-1.5 z-30 transition-all relative border-0 -mr-[1px]">
|
||||
|
||||
<Show when={isOwner}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg"
|
||||
onClick={() => handleDeleteNote(note().id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<DragDropProvider onDragStart={handleDragStart} onDragEnd={handleDragEnd} collisionDetector={closestCenter}>
|
||||
<DragDropSensors />
|
||||
|
||||
</div>
|
||||
<Show when={rootParentNote()}>
|
||||
{(root) => (
|
||||
<div
|
||||
class={cn(
|
||||
"py-2 px-3 flex flex-row items-center justify-start relative group rounded-l-xl shadow-sm text-left truncate flex-none cursor-pointer",
|
||||
!isDragging() && "transition-all",
|
||||
props.selectedNoteId === root().id
|
||||
? "bg-background border-y border-l border-border text-primary font-semibold w-full z-30"
|
||||
: cn(
|
||||
"bg-muted/40 border-y border-l border-border/40 text-muted-foreground z-0 w-10/12",
|
||||
!isDragging() && "hover:bg-muted hover:w-full hover:border-border/80"
|
||||
)
|
||||
)}
|
||||
onClick={() => {
|
||||
if (props.selectedNoteId !== root().id) props.setSelectedNoteId(root().id);
|
||||
}}
|
||||
>
|
||||
<span class="text-xs truncate w-full">General</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Editor Instance */}
|
||||
<div class="flex-1 min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm mb-4">
|
||||
<TaskEditor
|
||||
content={note().content}
|
||||
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
||||
editable={canEdit}
|
||||
/>
|
||||
<SortableProvider ids={childTabs().map(t => t.id)}>
|
||||
<For each={childTabs()}>
|
||||
{(tab) => (
|
||||
<SortableDesktopTab
|
||||
tab={tab}
|
||||
isSelected={props.selectedNoteId === tab.id}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</DragDropProvider>
|
||||
|
||||
<div class="w-8 h-8 rounded-xl bg-transparent hover:bg-muted border border-dashed border-border flex items-center justify-center text-muted-foreground transition-colors mt-2 mr-3 cursor-pointer"
|
||||
onClick={() => handleCreateTab()}
|
||||
title="Add Tab"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linked Tasks Sidebar */}
|
||||
<div class={cn(
|
||||
"border-t md:border-t-0 md:border-l border-border bg-muted/5 flex flex-col shrink-0 transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isLinkedTasksOpen() ? "w-full md:w-80 lg:w-96 h-auto md:h-full" : "w-full md:w-12 h-12 md:h-full cursor-pointer hover:bg-muted/10"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isLinkedTasksOpen()) {
|
||||
setIsLinkedTasksOpen(true);
|
||||
}
|
||||
}}>
|
||||
<div class={cn("p-4 border-b border-border bg-card/50 flex flex-col gap-3", !isLinkedTasksOpen() && "items-center justify-center p-0 h-full md:py-4")}>
|
||||
<div
|
||||
class="flex items-center justify-between cursor-pointer group"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsLinkedTasksOpen(!isLinkedTasksOpen());
|
||||
}}
|
||||
>
|
||||
<div class={cn("flex items-center gap-2", !isLinkedTasksOpen() && "md:flex-col")}>
|
||||
<ChevronRight size={14} class={cn("text-muted-foreground transition-all duration-300 group-hover:text-foreground", !isLinkedTasksOpen() ? "rotate-90 md:rotate-0" : "rotate-90 md:rotate-180")} />
|
||||
<Show when={isLinkedTasksOpen()} fallback={
|
||||
<div class="hidden md:flex items-center justify-center -rotate-90 origin-center whitespace-nowrap mt-8 text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground/50">
|
||||
<Link size={10} class="mr-2 rotate-90" />
|
||||
Linked Tasks
|
||||
{/* Editor Area Container */}
|
||||
<div class="flex-1 md:flex-initial md:shrink w-full md:w-auto flex flex-col md:flex-row min-w-0 md:h-full md:bg-card md:border md:border-border md:rounded-xl md:shadow-[0_4px_12px_rgba(0,0,0,0.02)] overflow-visible md:overflow-hidden z-20 relative shadow-[-4px_0_12px_rgba(0,0,0,0.02)] transition-all duration-300">
|
||||
|
||||
{/* Editor Area */}
|
||||
<div class={cn(
|
||||
"flex-1 md:flex-initial md:shrink flex flex-col min-w-0 md:w-[960px] lg:w-[1120px] md:h-full overflow-visible md:overflow-y-auto relative scroll-smooth bg-background z-20 pt-0",
|
||||
hasTopViewer() ? "p-0 space-y-0" : "px-4 md:px-6 pb-4 md:pb-6 space-y-4 md:space-y-6"
|
||||
)}>
|
||||
|
||||
<div class={cn(
|
||||
"sticky top-0 z-30 bg-card/95 backdrop-blur-sm pb-2",
|
||||
hasTopViewer() ? "px-4 md:px-6 pt-4 md:pt-6 border-b border-border/50" : "space-y-4 pt-4 -mx-4 px-4 md:pt-6 md:-mx-6 md:px-6 md:pb-2"
|
||||
)}>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col min-w-0 flex-1 gap-1">
|
||||
<Show when={rootParentNote()}>
|
||||
{(root) => (
|
||||
<textarea
|
||||
class={cn(
|
||||
"font-black tracking-tight bg-transparent border-none outline-none focus:ring-0 flex-1 placeholder:text-muted-foreground/30 px-0 min-w-0 resize-none overflow-hidden h-auto text-3xl sm:text-4xl w-full text-foreground"
|
||||
)}
|
||||
value={root().title}
|
||||
placeholder="Note Title"
|
||||
readOnly={!canEdit}
|
||||
rows={1}
|
||||
onBlur={(e) => {
|
||||
const val = e.currentTarget.value.trim() || 'Untitled';
|
||||
if (val !== root().title) handleRenameNote(root().id, root().title, val);
|
||||
}}
|
||||
onInput={(e) => {
|
||||
e.currentTarget.style.height = 'auto';
|
||||
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
||||
}}
|
||||
ref={(el) => {
|
||||
createEffect(() => {
|
||||
root().title;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
{/* Editor Commands Row */}
|
||||
<div class="flex items-center gap-2 mt-1 px-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.chain().focus().insertContent("/").run();
|
||||
}}
|
||||
title="Text Commands"
|
||||
>
|
||||
<Type size={14} class="opacity-70" />
|
||||
<span>Commands</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.chain().focus().uploadImage().run();
|
||||
}}
|
||||
title="Upload Image"
|
||||
>
|
||||
<ImageIcon size={14} class="opacity-70" />
|
||||
<span>Image</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.chain().focus().uploadVideo().run();
|
||||
}}
|
||||
title="Upload Video"
|
||||
>
|
||||
<Film size={14} class="opacity-70" />
|
||||
<span>Video</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
|
||||
onClick={() => {
|
||||
const instance = editorInstance();
|
||||
if (instance) instance.chain().focus().uploadFile().run();
|
||||
}}
|
||||
title="Attach File"
|
||||
>
|
||||
<FileText size={14} class="opacity-70" />
|
||||
<span>File</span>
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<Link size={12} />
|
||||
Linked Tasks
|
||||
</h3>
|
||||
</Show>
|
||||
<Show when={!isLinkedTasksOpen()}>
|
||||
<div class="md:hidden flex items-center justify-center text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<Link size={12} class="mr-2" />
|
||||
Linked Tasks
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn(
|
||||
"h-8 w-8 p-0 transition-all rounded-xl border border-border/40 shadow-sm",
|
||||
note().tags?.includes(getFavoriteTag()) ? "bg-amber-500/10 text-amber-500 hover:bg-amber-500/20" : "text-muted-foreground hover:bg-muted"
|
||||
)}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
const favTag = getFavoriteTag();
|
||||
const currentTags = note().tags || [];
|
||||
const isFav = currentTags.includes(favTag);
|
||||
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
|
||||
await handleUpdateNote(note().id, { tags: newTags });
|
||||
}}
|
||||
title="Toggle Favorite"
|
||||
>
|
||||
<Star size={14} class={cn(note().tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
|
||||
</Button>
|
||||
<Show when={isOwner}>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="hidden md:flex h-8 w-8 p-0 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40 shadow-sm shrink-0"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="end">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn("w-full justify-start text-[0.625rem] font-bold uppercase tracking-widest gap-2", note().isPrivate ? "text-orange-500 hover:bg-orange-500/10" : "text-muted-foreground hover:bg-muted")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateNote(note().id, { isPrivate: !note().isPrivate });
|
||||
}}
|
||||
>
|
||||
<Show when={note().isPrivate} fallback={<><Unlock size={14} /> Public</>}>
|
||||
<><Lock size={14} /> Private</>
|
||||
</Show>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start text-[0.625rem] font-bold uppercase tracking-widest gap-2 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteNote(note().id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inside Tabs Section Removed: Sub-tabs are now displayed on the outside as hanging bubbles */}
|
||||
|
||||
{/* Editor Instance */}
|
||||
<div class={cn(
|
||||
"grow shrink-0 flex flex-col",
|
||||
hasTopViewer() ? "border-none rounded-none p-0 shadow-none mb-0 min-h-full" : "min-h-[300px] mb-4 p-4 border border-border/50 rounded-xl bg-background shadow-sm"
|
||||
)}>
|
||||
<TaskEditor
|
||||
content={note().content}
|
||||
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
||||
onEditorReady={setEditorInstance}
|
||||
editable={canEdit}
|
||||
class={hasTopViewer() ? "[&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:px-4 md:[&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:px-6 [&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:max-w-3xl [&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:mx-auto [&>.file-viewer-wrapper:first-child]:h-[80vh] [&>.file-viewer-wrapper:first-child]:min-h-0 [&>.file-attachment-wrapper:first-child]:h-[80vh] [&>.file-attachment-wrapper:first-child]:min-h-0 pb-20" : ""}
|
||||
/>
|
||||
</div>
|
||||
<Show when={!hasTopViewer()}>
|
||||
<div class="shrink-0 h-16 md:h-8" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Linked Tasks Sidebar */}
|
||||
<div class={cn(
|
||||
"border-t md:border-t-0 md:border-l border-border bg-muted/5 flex flex-col shrink-0 transition-all duration-300 ease-in-out overflow-hidden",
|
||||
isLinkedTasksOpen() ? "w-full md:w-80 lg:w-96 h-auto md:h-full" : "w-full md:w-12 h-12 md:h-full cursor-pointer hover:bg-muted/10"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isLinkedTasksOpen()) {
|
||||
setIsLinkedTasksOpen(true);
|
||||
}
|
||||
}}>
|
||||
<div class={cn("p-4 border-b border-border bg-card/50 flex flex-col gap-3", !isLinkedTasksOpen() && "items-center justify-center p-0 h-full md:py-4")}>
|
||||
<div
|
||||
class="flex items-center justify-between cursor-pointer group"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsLinkedTasksOpen(!isLinkedTasksOpen());
|
||||
}}
|
||||
>
|
||||
<div class={cn("flex items-center gap-2", !isLinkedTasksOpen() && "md:flex-col")}>
|
||||
<ChevronRight size={14} class={cn("text-muted-foreground transition-all duration-300 group-hover:text-foreground", !isLinkedTasksOpen() ? "-rotate-90 md:rotate-180" : "rotate-90 md:rotate-0")} />
|
||||
<Show when={isLinkedTasksOpen()} fallback={
|
||||
<div class="hidden md:flex items-center justify-center -rotate-90 origin-center whitespace-nowrap mt-8 text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground/50">
|
||||
<LinkIcon size={10} class="mr-2 rotate-90" />
|
||||
Linked Tasks
|
||||
</div>
|
||||
}>
|
||||
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
||||
<LinkIcon size={12} />
|
||||
Linked Tasks
|
||||
</h3>
|
||||
</Show>
|
||||
<Show when={!isLinkedTasksOpen()}>
|
||||
<div class="md:hidden flex items-center justify-center text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
<LinkIcon size={12} class="mr-2" />
|
||||
Linked Tasks
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={isLinkedTasksOpen()}>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsLinking(!isLinking());
|
||||
}}
|
||||
title="Link Existing Task"
|
||||
>
|
||||
<LinkIcon size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-primary hover:bg-primary/10 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openQuickEntry();
|
||||
}}
|
||||
title="Create Task via Quick Entry"
|
||||
>
|
||||
+ Task
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={isLinkedTasksOpen()}>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="h-7 w-7 rounded-lg text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsLinking(!isLinking());
|
||||
}}
|
||||
title="Link Existing Task"
|
||||
>
|
||||
<Link size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider text-primary hover:bg-primary/10 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openQuickEntry();
|
||||
}}
|
||||
title="Create Task via Quick Entry"
|
||||
>
|
||||
+ Task
|
||||
</Button>
|
||||
|
||||
<Show when={isLinkedTasksOpen() && isLinking()}>
|
||||
<div class="animate-in fade-in slide-in-from-top-2 space-y-2">
|
||||
<div class="relative">
|
||||
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks to link..."
|
||||
value={linkSearchQuery()}
|
||||
onInput={(e) => setLinkSearchQuery(e.currentTarget.value)}
|
||||
class="w-full bg-background border border-border/50 rounded-lg pl-8 pr-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<Show when={linkSearchQuery() && unlinkedTasks().length > 0}>
|
||||
<div class="bg-background border border-border rounded-lg shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
||||
<For each={unlinkedTasks()}>
|
||||
{(t) => (
|
||||
<button
|
||||
class="w-full text-left p-2 text-xs hover:bg-muted border-b border-border last:border-0 truncate"
|
||||
onClick={() => handleLinkTask(t.id)}
|
||||
>
|
||||
{t.title}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={isLinkedTasksOpen() && isLinking()}>
|
||||
<div class="animate-in fade-in slide-in-from-top-2 space-y-2">
|
||||
<div class="relative">
|
||||
<Search size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks to link..."
|
||||
value={linkSearchQuery()}
|
||||
onInput={(e) => setLinkSearchQuery(e.currentTarget.value)}
|
||||
class="w-full bg-background border border-border/50 rounded-lg pl-8 pr-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-primary/30 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<Show when={linkSearchQuery() && unlinkedTasks().length > 0}>
|
||||
<div class="bg-background border border-border rounded-lg shadow-xl overflow-hidden max-h-48 overflow-y-auto">
|
||||
<For each={unlinkedTasks()}>
|
||||
{(t) => (
|
||||
<button
|
||||
class="w-full text-left p-2 text-xs hover:bg-muted border-b border-border last:border-0 truncate"
|
||||
onClick={() => handleLinkTask(t.id)}
|
||||
>
|
||||
{t.title}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={isLinkedTasksOpen()}>
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<For each={linkedTasks()} fallback={
|
||||
<div class="text-center text-xs text-muted-foreground p-6 bg-muted/10 border border-dashed border-border/50 rounded-xl flex flex-col items-center gap-2">
|
||||
<LinkIcon size={24} class="opacity-20" />
|
||||
<p>No tasks linked yet.</p>
|
||||
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{note().title} to a task.</p>
|
||||
</div>
|
||||
</Show>
|
||||
}>
|
||||
{(task) => (
|
||||
<div class="relative group">
|
||||
<TaskCard task={task} />
|
||||
{/* If explicitly linked, allow unlinking. If linked by tag, they should remove the tag from the task, which we can't easily do here unless we implement it. */}
|
||||
<Show when={note().tasks && note().tasks.includes(task.id)}>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:scale-110"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnlinkTask(task.id);
|
||||
}}
|
||||
title="Unlink Task"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={isLinkedTasksOpen()}>
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<For each={linkedTasks()} fallback={
|
||||
<div class="text-center text-xs text-muted-foreground p-6 bg-muted/10 border border-dashed border-border/50 rounded-xl flex flex-col items-center gap-2">
|
||||
<Link size={24} class="opacity-20" />
|
||||
<p>No tasks linked yet.</p>
|
||||
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{note().title} to a task.</p>
|
||||
</div>
|
||||
}>
|
||||
{(task) => (
|
||||
<div class="relative group">
|
||||
<TaskCard task={task} />
|
||||
{/* If explicitly linked, allow unlinking. If linked by tag, they should remove the tag from the task, which we can't easily do here unless we implement it. */}
|
||||
<Show when={note().tasks && note().tasks.includes(task.id)}>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:scale-110"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUnlinkTask(task.id);
|
||||
}}
|
||||
title="Unlink Task"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!props.hideNavigation}>
|
||||
{/* Sticky Full-Width Mobile Close Footer */}
|
||||
<div class="md:hidden sticky bottom-0 z-[60] p-4 bg-background/95 backdrop-blur-sm border-t border-border flex justify-end shrink-0 w-full shadow-[0_-10px_40px_-5px_hsl(var(--background))]">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full h-11 rounded-xl text-sm font-bold shadow-sm"
|
||||
onClick={() => props.setSelectedNoteId(null)}
|
||||
>
|
||||
<X size={16} class="mr-2" />
|
||||
Close Note
|
||||
</Button>
|
||||
{/* Sticky Footer Actions (Match TaskDetail style) */}
|
||||
<div class="px-4 py-3 border-t border-border/50 md:hidden flex items-center justify-between shrink-0 bg-background/95 backdrop-blur-sm z-40 w-full sticky bottom-0">
|
||||
<div class="flex items-center gap-2 w-16 shrink-0">
|
||||
{/* "..." popover menu containing Public/Private toggle and Delete */}
|
||||
<Show when={isOwner}>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-9 w-9 p-0 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40 shrink-0"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2" align="start">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn("w-full justify-start text-[0.625rem] font-bold uppercase tracking-widest gap-2", note().isPrivate ? "text-orange-500 hover:bg-orange-500/10" : "text-muted-foreground hover:bg-muted")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateNote(note().id, { isPrivate: !note().isPrivate });
|
||||
}}
|
||||
>
|
||||
<Show when={note().isPrivate} fallback={<><Unlock size={14} /> Public</>}>
|
||||
<><Lock size={14} /> Private</>
|
||||
</Show>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start text-[0.625rem] font-bold uppercase tracking-widest gap-2 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteNote(note().id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Tab dropdown moved to middle */}
|
||||
<div class="flex-1 flex justify-center px-2 min-w-0">
|
||||
<Show when={rootParentNote()}>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-9 px-3 text-xs gap-1 max-w-[150px] bg-muted/30 hover:bg-muted/50 rounded-xl w-full"
|
||||
>
|
||||
<FileText size={14} class="shrink-0" />
|
||||
<span class="truncate">Tabs ({childTabs().length + (rootParentNote() ? 1 : 0)})</span>
|
||||
<ChevronDown size={14} class="opacity-50 shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-2 bg-popover text-popover-foreground rounded-xl shadow-lg border-border" align="center">
|
||||
<div class="flex flex-col gap-1 max-h-60 overflow-y-auto">
|
||||
<Show when={rootParentNote()}>
|
||||
{(root) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
class={cn(
|
||||
"w-full justify-start px-3 py-2 text-sm hover:bg-muted border-border/50 truncate flex items-center h-auto font-normal rounded-lg",
|
||||
props.selectedNoteId === root().id && "bg-muted font-medium text-primary border"
|
||||
)}
|
||||
onClick={() => props.setSelectedNoteId(root().id)}
|
||||
>
|
||||
<span class="truncate">General</span>
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
<For each={childTabs()}>
|
||||
{(tab) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
class={cn(
|
||||
"w-full justify-start px-3 py-2 text-sm hover:bg-muted border-border/50 truncate flex items-center h-auto font-normal rounded-lg group",
|
||||
props.selectedNoteId === tab.id && "bg-muted font-medium text-primary border"
|
||||
)}
|
||||
onClick={() => props.setSelectedNoteId(tab.id)}
|
||||
>
|
||||
<Show when={props.selectedNoteId === tab.id} fallback={
|
||||
<span class="truncate">{tab.title || "Untitled Tab"}</span>
|
||||
}>
|
||||
<input
|
||||
class="text-sm bg-transparent border-none outline-none focus:ring-0 px-0 min-w-0 w-full truncate font-semibold cursor-text"
|
||||
value={tab.title}
|
||||
placeholder="Untitled Tab"
|
||||
readOnly={!canEdit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onBlur={(e) => {
|
||||
const val = e.currentTarget.value.trim() || 'Untitled Tab';
|
||||
if (val !== tab.title) handleRenameNote(tab.id, tab.title, val);
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-3 py-2 text-sm hover:bg-primary/10 text-primary font-medium transition-colors gap-2 h-auto rounded-lg mt-1"
|
||||
onClick={handleCreateTab}
|
||||
>
|
||||
<Plus size={14} /> Add Tab
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Show>
|
||||
<Show when={childTabs().length === 0 && !rootParentNote()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-9 px-3 text-xs gap-1 bg-muted/30 hover:bg-muted/50 rounded-xl"
|
||||
onClick={() => handleCreateTab()}
|
||||
>
|
||||
<Plus size={14} /> Add Tab
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 w-16 justify-end shrink-0">
|
||||
<Show when={!props.hideNavigation}>
|
||||
{/* Close button (Mobile only) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="md:hidden h-9 px-3 gap-2 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40 shrink-0"
|
||||
onClick={() => props.setSelectedNoteId(null)}
|
||||
>
|
||||
<X size={16} />
|
||||
<span class="text-xs font-bold uppercase tracking-wider">Close</span>
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -28,10 +28,10 @@ export const PriorityView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<div class="w-full min-w-0">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -35,10 +35,10 @@ export const ProgressView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Tasks sorted by progress status, then by focus score.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<div class="w-full min-w-0">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+115
-45
@@ -1,6 +1,6 @@
|
||||
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
import { store, restoreTask, deleteTaskPermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
|
||||
import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid";
|
||||
import { TagPicker } from "@/components/TagPicker";
|
||||
@@ -77,7 +77,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-col gap-12">
|
||||
<div class="flex flex-col gap-12 mt-4">
|
||||
|
||||
|
||||
{/* Resources Category (Mostly for Mobile) */}
|
||||
@@ -826,51 +826,104 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
||||
</div>
|
||||
|
||||
<Show when={isTrashOpen()}>
|
||||
<div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<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 flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-2xl bg-muted/20 border border-border/40 group hover:bg-muted/40 transition-all gap-3 min-w-0">
|
||||
<div class="min-w-0 flex-1 px-1">
|
||||
<p class="font-semibold truncate text-sm sm:text-base">{task.title || "Untitled"}</p>
|
||||
<p class="text-[0.5rem] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
|
||||
<div class="space-y-6 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
{/* Tasks Trash */}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pl-1">Tasks</h4>
|
||||
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
|
||||
<div class="text-center py-4 text-muted-foreground text-[0.625rem] italic border border-dashed border-border/40 rounded-xl bg-muted/5 uppercase tracking-widest">
|
||||
No trashed tasks.
|
||||
</div>
|
||||
}>
|
||||
{(task) => {
|
||||
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return (
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-2xl bg-muted/20 border border-border/40 group hover:bg-muted/40 transition-all gap-3 min-w-0">
|
||||
<div class="min-w-0 flex-1 px-1">
|
||||
<p class="font-semibold truncate text-sm sm:text-base">{task.title || "Untitled"}</p>
|
||||
<p class="text-[0.5rem] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 border-t sm:border-t-0 pt-3 sm:pt-0 border-border/10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-9 px-4 text-[0.625rem] font-bold uppercase tracking-widest hover:bg-green-500/10 hover:text-green-600 bg-background/50 border border-border/50 transition-all rounded-xl shadow-sm"
|
||||
onClick={() => {
|
||||
restoreTask(task.id);
|
||||
toast.success("Task restored");
|
||||
}}
|
||||
>
|
||||
<Undo2 size={14} class="mr-2" />
|
||||
Recover
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
|
||||
onClick={() => {
|
||||
if (confirm("Permanently delete this task? This cannot be undone.")) {
|
||||
deleteTaskPermanently(task.id);
|
||||
toast.error("Task permanently deleted");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 border-t sm:border-t-0 pt-3 sm:pt-0 border-border/10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-9 px-4 text-[0.625rem] font-bold uppercase tracking-widest hover:bg-green-500/10 hover:text-green-600 bg-background/50 border border-border/50 transition-all rounded-xl shadow-sm"
|
||||
onClick={() => {
|
||||
restoreTask(task.id);
|
||||
toast.success("Task restored");
|
||||
}}
|
||||
>
|
||||
<Undo2 size={14} class="mr-2" />
|
||||
Recover
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
|
||||
onClick={() => {
|
||||
if (confirm("Permanently delete this task? This cannot be undone.")) {
|
||||
deleteTaskPermanently(task.id);
|
||||
toast.error("Task permanently deleted");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Notes Trash */}
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pl-1">Notes</h4>
|
||||
<For each={store.notes.filter(n => n.deletedAt)} fallback={
|
||||
<div class="text-center py-4 text-muted-foreground text-[0.625rem] italic border border-dashed border-border/40 rounded-xl bg-muted/5 uppercase tracking-widest">
|
||||
No trashed notes.
|
||||
</div>
|
||||
}>
|
||||
{(note) => {
|
||||
const daysLeft = Math.ceil(((note.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
return (
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between p-3.5 rounded-2xl bg-muted/20 border border-border/40 group hover:bg-muted/40 transition-all gap-3 min-w-0">
|
||||
<div class="min-w-0 flex-1 px-1">
|
||||
<p class="font-semibold truncate text-sm sm:text-base">{note.title || "Untitled"}</p>
|
||||
<p class="text-[0.5rem] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2 shrink-0 border-t sm:border-t-0 pt-3 sm:pt-0 border-border/10">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-9 px-4 text-[0.625rem] font-bold uppercase tracking-widest hover:bg-green-500/10 hover:text-green-600 bg-background/50 border border-border/50 transition-all rounded-xl shadow-sm"
|
||||
onClick={() => {
|
||||
restoreNote(note.id);
|
||||
toast.success("Note restored");
|
||||
}}
|
||||
>
|
||||
<Undo2 size={14} class="mr-2" />
|
||||
Recover
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
|
||||
onClick={() => {
|
||||
if (confirm("Permanently delete this note? This cannot be undone.")) {
|
||||
deleteNotePermanently(note.id);
|
||||
toast.error("Note permanently deleted");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
@@ -1033,6 +1086,23 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="pt-40 pb-20 flex flex-col items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-2 text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground opacity-30 hover:opacity-100 transition-opacity"
|
||||
onClick={async () => {
|
||||
const { forceServiceWorkerUpdate } = await import('@/components/PwaUpdater');
|
||||
forceServiceWorkerUpdate();
|
||||
toast.success("Checking for updates...");
|
||||
}}
|
||||
>
|
||||
<Upload class="rotate-180" size={14} />
|
||||
Check for Updates
|
||||
</Button>
|
||||
<p class="text-[0.5rem] font-mono text-muted-foreground/30 uppercase tracking-[0.2em]">Tasgrid v1.0.1</p> {/* Versioning System: v[breaking update].[user-facing update].[patch/backend updated] */}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,10 +33,10 @@ export const SnowballView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Smallest tasks first. Build momentum with quick wins.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<div class="w-full min-w-0">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,10 +32,10 @@ export const UrgencyView: Component = () => {
|
||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-3" ref={listRef}>
|
||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
||||
<For each={displayedTasks()}>
|
||||
{(task) => (
|
||||
<div class="animate-task-enter">
|
||||
<div class="w-full min-w-0">
|
||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+138
-65
@@ -113,6 +113,22 @@
|
||||
<h1>TasGrid Parent App Simulator</h1>
|
||||
|
||||
<div class="controls">
|
||||
<div id="env-settings" style="margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #eee;">
|
||||
<p style="font-size: 13px; color: #666; margin-top: 0;"><strong>Target Environment:</strong></p>
|
||||
<label style="margin-right: 15px; cursor: pointer;">
|
||||
<input type="radio" name="envToggle" value="https://tasgrid.ccllc.pro" checked onchange="updateEnv()">
|
||||
Production (tasgrid.ccllc.pro)
|
||||
</label>
|
||||
<label style="margin-right: 15px; cursor: pointer;">
|
||||
<input type="radio" name="envToggle" value="http://localhost:4000" onchange="updateEnv()">
|
||||
Localhost:4000
|
||||
</label>
|
||||
<label style="cursor: pointer;">
|
||||
<input type="radio" name="envToggle" value="http://localhost:4001" onchange="updateEnv()">
|
||||
Localhost:4001
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="login-section">
|
||||
<h3>Login to Parent Application</h3>
|
||||
<p>This simulates the parent application authenticating with the shared backend.</p>
|
||||
@@ -132,12 +148,23 @@
|
||||
<button onclick="loadSpecificNote()" style="padding: 8px 16px; cursor: pointer;">Load Note</button>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Test API Creation:</strong></p>
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||||
<input type="text" id="new-note-title" style="flex: 1; padding: 8px;" placeholder="New Note Title...">
|
||||
<button onclick="createNoteViaAPI()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #28a745; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
& Open Note</button>
|
||||
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Test Headless SDK (Centralized
|
||||
Logic):</strong></p>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; margin-bottom: 10px;">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="sdk-task-title" style="flex: 1; padding: 8px;"
|
||||
placeholder="New Task Title...">
|
||||
<button onclick="createTaskViaSDK()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #6f42c1; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
Task via SDK</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<input type="text" id="sdk-note-title" style="flex: 1; padding: 8px;"
|
||||
placeholder="New Note Title...">
|
||||
<button onclick="createNoteViaSDK()"
|
||||
style="padding: 8px 16px; cursor: pointer; background: #fd7e14; color: white; border: none; border-radius: 4px; font-weight: bold;">Create
|
||||
Note via SDK</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 13px; color: #666; margin-top: 20px;"><strong>Manual Auth:</strong></p>
|
||||
@@ -157,44 +184,46 @@
|
||||
<li><code>/embed/quick-add</code>: Renders a standalone task creation form.</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>2. Creating Notes Programmatically (API):</strong></p>
|
||||
<p>Before embedding, a sister app can create a Note via PocketBase API to retrieve a new
|
||||
<code>noteId</code>. By default, notes should have an array of <code>tags</code> and a
|
||||
<code>title</code>.
|
||||
<p><strong>2. Programmatic Creation (TasGrid SDK):</strong></p>
|
||||
<p>The recommended way to create entries is using the <code>tasgrid-sdk.js</code> module. This
|
||||
ensures all TasGrid-specific defaults and date logic (like urgency decay) are handled correctly.
|
||||
</p>
|
||||
<p><em>Using the PocketBase JS SDK:</em></p>
|
||||
|
||||
<p><em>Setup:</em></p>
|
||||
<pre
|
||||
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
|
||||
const record = await pb.collection('TasGrid_Notes').create({
|
||||
title: "Sister App Payload",
|
||||
content: "<p>Initial content here...</p>",
|
||||
tags: ["sister_app_export"],
|
||||
import { createTasgridTask, createTasgridNote } from 'https://tasgrid.ccllc.pro/tasgrid-sdk.js';</pre>
|
||||
|
||||
<div style="border-left: 4px solid #6f42c1; padding-left: 15px; margin: 20px 0;">
|
||||
<p><strong>A. Creating a TASK:</strong></p>
|
||||
<p style="font-size: 12px; color: #666; margin-top: -10px;">Use this for items that need status,
|
||||
priority, and automated due date calculation.</p>
|
||||
<pre
|
||||
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
|
||||
const task = await createTasgridTask(pb, {
|
||||
title: "Urgent Task",
|
||||
urgency: 10, // Automated: 10=Today, 5=Next Week, 1=Six Months
|
||||
priority: 8, // 1-10
|
||||
size: 3, // Default: 3 (1 hour). Range 0-10.
|
||||
tags: ["external"],
|
||||
owner: "USER_ID" // Optional
|
||||
});</pre>
|
||||
</div>
|
||||
|
||||
<div style="border-left: 4px solid #fd7e14; padding-left: 15px; margin: 20px 0;">
|
||||
<p><strong>B. Creating a NOTE:</strong></p>
|
||||
<p style="font-size: 12px; color: #666; margin-top: -10px;">Use this for long-form content or
|
||||
documentation without a deadline.</p>
|
||||
<pre
|
||||
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
|
||||
const note = await createTasgridNote(pb, {
|
||||
title: "Project Brief",
|
||||
content: "<p>HTML content here...</p>",
|
||||
tags: ["documentation"],
|
||||
isPrivate: false,
|
||||
user: pb.authStore.model.id, // Must be authenticated user ID
|
||||
tasks: [] // Optional array of linked task IDs
|
||||
});
|
||||
const newNoteId = record.id;
|
||||
// Then iframe to /embed/notes?noteId=${newNoteId}
|
||||
</pre>
|
||||
<p><em>Using standard REST API (fetch):</em></p>
|
||||
<pre
|
||||
style="background: #f8f9fa; padding: 15px; border-radius: 4px; border: 1px solid #e9ecef; overflow-x: auto; font-size: 12px;">
|
||||
const res = await fetch('https://pocketbase.ccllc.pro/api/collections/TasGrid_Notes/records', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'YOUR_PB_AUTH_TOKEN'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: "REST Custom Note",
|
||||
user: "YOUR_USER_ID",
|
||||
tags: [],
|
||||
tasks: [],
|
||||
isPrivate: false
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
const newNoteId = data.id;</pre>
|
||||
tasks: [] // Optional: Array of Task IDs to link
|
||||
});</pre>
|
||||
</div>
|
||||
|
||||
<p><strong>3. Authentication Protocol:</strong></p>
|
||||
<p>The parent application must send a <code>postMessage</code> to the iframe immediately after the
|
||||
@@ -232,7 +261,7 @@ function syncAuth(iframe) {
|
||||
<span>Notes Embed</span>
|
||||
<code style="font-size: 10px;">/embed/notes</code>
|
||||
</div>
|
||||
<iframe id="notes-iframe" src="http://localhost:4000/embed/notes" onload="syncAuthOnLoad(this)"></iframe>
|
||||
<iframe id="notes-iframe" onload="syncAuthOnLoad(this)"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="iframe-container">
|
||||
@@ -241,14 +270,36 @@ function syncAuth(iframe) {
|
||||
<span>Quick Add Embed</span>
|
||||
<code style="font-size: 10px;">/embed/quick-add</code>
|
||||
</div>
|
||||
<iframe id="quick-add-iframe" src="http://localhost:4000/embed/quick-add"
|
||||
onload="syncAuthOnLoad(this)"></iframe>
|
||||
<iframe id="quick-add-iframe" onload="syncAuthOnLoad(this)"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
||||
const statusEl = document.getElementById('status');
|
||||
let ENV_URL = 'https://tasgrid.ccllc.pro';
|
||||
|
||||
function updateEnv() {
|
||||
const selected = document.querySelector('input[name="envToggle"]:checked').value;
|
||||
ENV_URL = selected;
|
||||
|
||||
// Reload iframes with new base URL
|
||||
const notesIframe = document.getElementById('notes-iframe');
|
||||
const noteId = document.getElementById('note-id-input')?.value.trim();
|
||||
if (noteId) {
|
||||
notesIframe.src = `${ENV_URL}/embed/notes?noteId=${noteId}`;
|
||||
} else {
|
||||
notesIframe.src = `${ENV_URL}/embed/notes`;
|
||||
}
|
||||
|
||||
document.getElementById('quick-add-iframe').src = `${ENV_URL}/embed/quick-add`;
|
||||
console.log(`Switched target environment to: ${ENV_URL}`);
|
||||
}
|
||||
|
||||
// Initialize iframes on load
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
updateEnv();
|
||||
});
|
||||
|
||||
function syncAuthOnLoad(iframe) {
|
||||
console.log(`Iframe ${iframe.id} loaded, checking auth sync...`);
|
||||
@@ -262,46 +313,68 @@ function syncAuth(iframe) {
|
||||
const id = document.getElementById('note-id-input').value.trim();
|
||||
const iframe = document.getElementById('notes-iframe');
|
||||
if (id) {
|
||||
iframe.src = `http://localhost:4000/embed/notes?noteId=${id}`;
|
||||
iframe.src = `${ENV_URL}/embed/notes?noteId=${id}`;
|
||||
} else {
|
||||
iframe.src = `http://localhost:4000/embed/notes`;
|
||||
iframe.src = `${ENV_URL}/embed/notes`;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoteViaAPI() {
|
||||
const title = document.getElementById('new-note-title').value.trim() || 'API Generated Note';
|
||||
async function createTaskViaSDK() {
|
||||
const title = document.getElementById('sdk-task-title').value.trim() || 'SDK Generated Task';
|
||||
if (!pb.authStore.isValid) {
|
||||
alert("Please log in first before creating a note via API.");
|
||||
alert("Please log in first before creating a task via SDK.");
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = 'Creating note via API...';
|
||||
statusEl.textContent = 'Importing SDK and creating task...';
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
// Using PocketBase SDK to mimic a REST API call from sister app
|
||||
const record = await pb.collection('TasGrid_Notes').create({
|
||||
const { createTasgridTask } = await import(`${ENV_URL}/tasgrid-sdk.js`);
|
||||
|
||||
const record = await createTasgridTask(pb, {
|
||||
title: title,
|
||||
content: "<p>This note was created automatically via the API script!</p>",
|
||||
tags: ["api_generated"],
|
||||
isPrivate: false,
|
||||
user: pb.authStore.model.id,
|
||||
tasks: []
|
||||
priority: 8,
|
||||
urgency: 4,
|
||||
tags: ["sdk_test"],
|
||||
content: "This task was created by a sister app using the imported Headless SDK!"
|
||||
});
|
||||
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = `Note created successfully (ID: ${record.id})! Loading iframe...`;
|
||||
|
||||
// Automatically set the new ID in the deep link input
|
||||
document.getElementById('note-id-input').value = record.id;
|
||||
|
||||
// Automatically load it
|
||||
loadSpecificNote();
|
||||
|
||||
statusEl.textContent = `Task created successfully via SDK (ID: ${record.id})!`;
|
||||
} catch (err) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Failed to create note: ' + err.message;
|
||||
statusEl.textContent = 'Failed to create task via SDK: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoteViaSDK() {
|
||||
const title = document.getElementById('sdk-note-title').value.trim() || 'SDK Generated Note';
|
||||
if (!pb.authStore.isValid) {
|
||||
alert("Please log in first before creating a note via SDK.");
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = 'Importing SDK and creating note...';
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
try {
|
||||
const { createTasgridNote } = await import(`${ENV_URL}/tasgrid-sdk.js`);
|
||||
|
||||
const record = await createTasgridNote(pb, {
|
||||
title: title,
|
||||
content: "<p>This note was created headlessly via the SDK!</p>",
|
||||
tags: ["sdk_test"],
|
||||
isPrivate: false
|
||||
});
|
||||
|
||||
statusEl.className = 'status success';
|
||||
statusEl.textContent = `Note created successfully via SDK (ID: ${record.id})!`;
|
||||
} catch (err) {
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = 'Failed to create note via SDK: ' + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -10,7 +10,9 @@
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": [
|
||||
"vite/client"
|
||||
"vite/client",
|
||||
"vite-plugin-pwa/client",
|
||||
"vite-plugin-pwa/solid"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
|
||||
+5
-1
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
solid(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
registerType: 'prompt',
|
||||
includeAssets: ['icon.webp', 'vite.svg'],
|
||||
manifest: {
|
||||
name: 'Tasgrid',
|
||||
@@ -63,6 +63,10 @@ export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 4000,
|
||||
cors: { origin: '*' },
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
},
|
||||
allowedHosts: [
|
||||
'tasgrid.ccllc.pro'
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user