Compare commits

...

15 Commits

Author SHA1 Message Date
tcardoza 9705a12660 Merge branch 'main' of https://gitea.ccllc.pro/Cardoza_Construction/TasGrid
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m3s
2026-03-05 16:23:40 -06:00
tcardoza cf22eda1e9 version test 2026-03-05 16:23:24 -06:00
tcardoza 75dde1b0d5 Added task and note crteation SDKs 2026-03-05 16:19:27 -06:00
eewing 46d8dd9a4c Refactor CI workflow to remove development deployment steps and restrict main branch triggers
CI / build (push) Has been skipped
CI / deploy (push) Successful in 57s
2026-03-05 08:46:15 -06:00
tcardoza 6a963a32fd Added custom PWA reloading - 10 minutes of idle or when moved to the background or when loading initially or manually with the new button in settings
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m19s
CI / deploy_dev (push) Has been skipped
2026-03-04 19:29:18 -06:00
tcardoza 4ebd0b5f61 Realtime updates when viewing workspaces
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m14s
CI / deploy_dev (push) Has been skipped
2026-03-04 19:08:50 -06:00
tcardoza 6a76f695fe Created share tag migration function
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m16s
CI / deploy_dev (push) Has been skipped
2026-03-04 18:47:18 -06:00
tcardoza 0e85603699 added attachment cleanup in tasks
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m25s
CI / deploy_dev (push) Has been skipped
2026-03-04 18:25:37 -06:00
tcardoza 4c096f18d7 Images adding in notes and cleanup of orphaned images from notes 2026-03-04 18:20:27 -06:00
tcardoza 9cbe7dcb12 Notes layout improvements and soft delete 2026-03-04 17:44:24 -06:00
tcardoza 391f52af23 Merge branch 'main' of https://gitea.ccllc.pro/Cardoza_Construction/TasGrid
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 1m0s
CI / deploy_dev (push) Has been skipped
2026-03-04 14:07:40 -06:00
tcardoza ac119c288b Notes view layout fixes 2026-03-04 14:07:33 -06:00
tcardoza 0bc9c74797 Stable task transitions and animations 2026-03-04 13:25:38 -06:00
tcardoza 50ccbbc564 View layout fixes from task sorting animations 2026-03-04 12:42:38 -06:00
eewing a5ecee084e Enhanced Gitea CI pipeline by adding a new deployment job for the Dev branch and updating the existing production deployment configuration.
CI / build (push) Has been skipped
CI / deploy_prod (push) Successful in 56s
CI / deploy_dev (push) Has been skipped
2026-03-04 10:49:04 -06:00
20 changed files with 966 additions and 196 deletions
+1 -1
View File
@@ -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)"'
+114
View File
@@ -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;
}
};
+1 -1
View File
@@ -14,7 +14,7 @@ export const NotesSidebar: Component<{
const currentUserId = pb.authStore.model?.id;
const filteredNotes = createMemo(() => {
let n = store.notes;
let n = store.notes.filter(note => !note.deletedAt);
if (searchQuery()) {
const q = searchQuery().toLowerCase();
n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q)));
+110
View File
@@ -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;
};
+11 -4
View File
@@ -10,7 +10,7 @@ import {
Trash2
} from "lucide-solid";
import { cn, convertImageToWebpFile } from "@/lib/utils";
import { activeTaskId, uploadTaskAttachment } from "@/store";
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { toast } from "solid-sonner";
export interface CommandItem {
@@ -123,9 +123,12 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
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.");
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload image outside of a task or note.");
return;
}
@@ -138,7 +141,11 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
toast.promise(
(async () => {
const webpFile = await convertImageToWebpFile(originalFile);
return uploadTaskAttachment(taskId, webpFile);
if (taskId) {
return uploadTaskAttachment(taskId, webpFile);
} else {
return uploadNoteAttachment(noteId!, webpFile);
}
})(),
{
loading: "Processing & uploading image...",
+25 -3
View File
@@ -1,6 +1,6 @@
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 } 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";
@@ -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 });
-12
View File
@@ -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 {
+2
View File
@@ -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>
+321 -1
View File
@@ -15,6 +15,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 +147,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 {
@@ -163,6 +165,70 @@ export interface Filter {
editedToday: boolean;
}
// 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;
@@ -501,6 +567,84 @@ export const uploadTaskAttachment = async (taskId: string, file: File): Promise<
}
};
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<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 pb.files.getURL(record, latestFilename);
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading note attachment:", err);
toast.error("Failed to upload image to note.");
throw err;
}
};
export const cleanupNoteAttachments = async (noteId: string) => {
if (!pb.authStore.isValid) return;
try {
const note = store.notes.find(n => n.id === noteId);
if (!note) return;
const record = await pb.collection(NOTES_COLLECTION).getOne(noteId);
if (!record.attachments || record.attachments.length === 0) return;
const content = note.content || "";
const filesToDelete: string[] = [];
for (const filename of record.attachments) {
if (!content.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 task = store.tasks.find(t => t.id === taskId);
if (!task) return;
const record = await pb.collection(TASGRID_COLLECTION).getOne(taskId);
if (!record.attachments || record.attachments.length === 0) return;
const content = task.content || "";
const filesToDelete: string[] = [];
for (const filename of record.attachments) {
if (!content.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 +666,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 +937,127 @@ 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);
@@ -1124,6 +1390,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 +1917,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 () => {
+2 -2
View File
@@ -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>
)}
+2 -2
View File
@@ -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>
)}
+108 -50
View File
@@ -1,14 +1,14 @@
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 } from "@/store";
import { pb } from "@/lib/pocketbase";
import { type Note } from "@/store";
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { TaskEditor } from "@/components/TaskEditor";
import { NOTES_COLLECTION } from "@/lib/constants";
import { TaskCard } from "@/components/TaskCard";
import { NotesSidebar } from "@/components/NotesSidebar";
import { toast } from "solid-sonner";
export const NotepadView: Component<{
selectedNoteId: string | null;
@@ -21,6 +21,38 @@ export const NotepadView: Component<{
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 +60,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 +84,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) => {
@@ -174,21 +206,42 @@ export const NotepadView: Component<{
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-1 flex flex-col min-w-0 md:h-full overflow-visible md:overflow-y-auto relative px-4 md:px-6 pb-4 md:pb-6 space-y-4 md:space-y-6 scroll-smooth pt-0">
<div class="space-y-4 sticky top-0 z-30 bg-card/95 backdrop-blur-sm pt-4 pb-2 -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">
<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"
<textarea
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 resize-none overflow-hidden h-auto"
value={note().title}
placeholder="Note Title"
readOnly={!canEdit}
rows={1}
onBlur={(e) => {
const val = e.currentTarget.value.trim() || 'Untitled';
if (val !== note().title) handleRenameNote(note().id, note().title, val);
}}
onInput={(e) => {
e.currentTarget.style.height = 'auto';
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
}}
ref={(el) => {
createEffect(() => {
note().title;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
});
}}
/>
<div class="flex items-center gap-2 shrink-0 pt-1">
<Show when={isOwner}>
<Button
variant="ghost"
size="sm"
class="hidden md:flex h-8 w-8 p-0 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all rounded-xl border border-border/40 shadow-sm"
onClick={() => handleDeleteNote(note().id)}
title="Move to Trash"
>
<Trash2 size={14} />
</Button>
<Button
variant="ghost"
size="sm"
@@ -200,30 +253,20 @@ export const NotepadView: Component<{
</Show>
</Button>
</Show>
<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>
</div>
{/* Editor Instance */}
<div class="flex-1 min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm mb-4">
<div class="grow shrink-0 flex flex-col 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}
/>
</div>
<div class="shrink-0 h-16 md:h-8" />
</div>
{/* Linked Tasks Sidebar */}
@@ -245,7 +288,7 @@ export const NotepadView: Component<{
}}
>
<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")} />
<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">
<Link size={10} class="mr-2 rotate-90" />
@@ -354,19 +397,34 @@ export const NotepadView: Component<{
</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))]">
{/* Sticky Footer Actions (Match TaskDetail style) */}
<div class="px-6 py-4 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">
{/* Delete button (persistent for owner) */}
<Show when={isOwner}>
<Button
variant="secondary"
class="w-full h-11 rounded-xl text-sm font-bold shadow-sm"
variant="ghost"
size="sm"
class="h-9 px-3 gap-2 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all rounded-xl border border-border/40"
onClick={() => handleDeleteNote(note().id)}
>
<Trash2 size={16} />
<span class="text-xs font-bold uppercase tracking-wider">Delete</span>
</Button>
</Show>
<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"
onClick={() => props.setSelectedNoteId(null)}
>
<X size={16} class="mr-2" />
Close Note
<X size={16} />
<span class="text-xs font-bold uppercase tracking-wider">Close</span>
</Button>
</div>
</Show>
</Show>
</div>
</>
);
}}
+2 -2
View File
@@ -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>
)}
+2 -2
View File
@@ -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
View File
@@ -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>
);
};
+2 -2
View File
@@ -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>
)}
+2 -2
View File
@@ -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
View File
@@ -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: "&lt;p&gt;Initial content here...&lt;/p&gt;",
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: "&lt;p&gt;HTML content here...&lt;/p&gt;",
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
View File
@@ -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
View File
@@ -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'
]