Compare commits
1 Commits
46d8dd9a4c
..
Dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 82a75411f2 |
@@ -1,3 +1,7 @@
|
|||||||
|
> **Branch notice:** This README belongs to the `Dev` branch and describes the **dev environment**. For live/production information, use the `main` branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
This project uses **Bun** as the package manager and runtime. Ensure you have Bun and PM2 installed globally before proceeding.
|
This project uses **Bun** as the package manager and runtime. Ensure you have Bun and PM2 installed globally before proceeding.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const NotesSidebar: Component<{
|
|||||||
const currentUserId = pb.authStore.model?.id;
|
const currentUserId = pb.authStore.model?.id;
|
||||||
|
|
||||||
const filteredNotes = createMemo(() => {
|
const filteredNotes = createMemo(() => {
|
||||||
let n = store.notes.filter(note => !note.deletedAt);
|
let n = store.notes;
|
||||||
if (searchQuery()) {
|
if (searchQuery()) {
|
||||||
const q = searchQuery().toLowerCase();
|
const q = searchQuery().toLowerCase();
|
||||||
n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q)));
|
n = n.filter(note => note.title.toLowerCase().includes(q) || note.tags.some(t => t.toLowerCase().includes(q)));
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Trash2
|
Trash2
|
||||||
} from "lucide-solid";
|
} from "lucide-solid";
|
||||||
import { cn, convertImageToWebpFile } from "@/lib/utils";
|
import { cn, convertImageToWebpFile } from "@/lib/utils";
|
||||||
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
|
import { activeTaskId, uploadTaskAttachment } from "@/store";
|
||||||
import { toast } from "solid-sonner";
|
import { toast } from "solid-sonner";
|
||||||
|
|
||||||
export interface CommandItem {
|
export interface CommandItem {
|
||||||
@@ -123,12 +123,9 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
|||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
|
||||||
const taskId = activeTaskId();
|
const taskId = activeTaskId();
|
||||||
const noteId = activeNoteId();
|
if (!taskId) {
|
||||||
|
toast.error("Cannot upload image outside of a task.");
|
||||||
if (!taskId && !noteId) {
|
|
||||||
toast.error("Cannot upload image outside of a task or note.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,11 +138,7 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
|||||||
toast.promise(
|
toast.promise(
|
||||||
(async () => {
|
(async () => {
|
||||||
const webpFile = await convertImageToWebpFile(originalFile);
|
const webpFile = await convertImageToWebpFile(originalFile);
|
||||||
if (taskId) {
|
return uploadTaskAttachment(taskId, webpFile);
|
||||||
return uploadTaskAttachment(taskId, webpFile);
|
|
||||||
} else {
|
|
||||||
return uploadNoteAttachment(noteId!, webpFile);
|
|
||||||
}
|
|
||||||
})(),
|
})(),
|
||||||
{
|
{
|
||||||
loading: "Processing & uploading image...",
|
loading: "Processing & uploading image...",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type Component, createEffect, createSignal, For, createMemo, onCleanup } from "solid-js";
|
import { type Component, createEffect, createSignal, For, createMemo } from "solid-js";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments } from "@/store";
|
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext } from "@/store";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
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 } from "lucide-solid";
|
||||||
@@ -110,31 +110,9 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync activeTaskId for slash commands and run cleanup on unmount/change
|
// Removed onCleanup to avoid lockup
|
||||||
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) => {
|
const updateTitle = (val: string) => {
|
||||||
setTitle(val);
|
setTitle(val);
|
||||||
updateTask(props.task.id, { title: val });
|
updateTask(props.task.id, { title: val });
|
||||||
|
|||||||
@@ -77,6 +77,18 @@
|
|||||||
transform: translateX(3px);
|
transform: translateX(3px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
--animate-task-enter: task-enter 0.25s ease-out backwards;
|
||||||
|
|
||||||
|
@keyframes task-enter {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import './index.css'
|
|||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { ThemeProvider } from './components/ThemeProvider'
|
import { ThemeProvider } from './components/ThemeProvider'
|
||||||
import { Toaster } from './components/ui/sonner'
|
import { Toaster } from './components/ui/sonner'
|
||||||
import { PwaUpdater } from './components/PwaUpdater'
|
|
||||||
|
|
||||||
const root = document.getElementById('root')
|
const root = document.getElementById('root')
|
||||||
|
|
||||||
render(() => {
|
render(() => {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<PwaUpdater />
|
|
||||||
<App />
|
<App />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
+1
-321
@@ -15,7 +15,6 @@ 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
|
// 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 };
|
type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string };
|
||||||
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
||||||
export const [activeNoteId, setActiveNoteId] = createSignal<string | null>(null);
|
|
||||||
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>('mine');
|
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>('mine');
|
||||||
|
|
||||||
export type UrgencyLevel = number;
|
export type UrgencyLevel = number;
|
||||||
@@ -147,7 +146,6 @@ export interface Note {
|
|||||||
tasks: string[]; // List of related task IDs
|
tasks: string[]; // List of related task IDs
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
deletedAt?: number | null; // Timestamp of soft delete or null if restored
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterTag {
|
export interface FilterTag {
|
||||||
@@ -165,70 +163,6 @@ export interface Filter {
|
|||||||
editedToday: boolean;
|
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 {
|
export interface FilterTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -567,84 +501,6 @@ 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 => {
|
const mapRecordToShareRule = (r: any): ShareRule => {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -666,8 +522,7 @@ const mapRecordToNote = (r: any): Note => {
|
|||||||
user: r.user,
|
user: r.user,
|
||||||
tasks: r.tasks || [],
|
tasks: r.tasks || [],
|
||||||
created: r.created,
|
created: r.created,
|
||||||
updated: r.updated,
|
updated: r.updated
|
||||||
deletedAt: r.deletedAt ? new Date(r.deletedAt).getTime() : undefined
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -937,127 +792,6 @@ 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 () => {
|
export const initStore = async () => {
|
||||||
if (!pb.authStore.isValid || store.isInitializing) return;
|
if (!pb.authStore.isValid || store.isInitializing) return;
|
||||||
setStore("isInitializing", true);
|
setStore("isInitializing", true);
|
||||||
@@ -1390,9 +1124,6 @@ export const initStore = async () => {
|
|||||||
// Fire off background sync
|
// Fire off background sync
|
||||||
await backgroundSync();
|
await backgroundSync();
|
||||||
|
|
||||||
// Run one-time migration for legacy tags
|
|
||||||
await runLegacyTagMigration();
|
|
||||||
|
|
||||||
// Separate Templates load (less critical)
|
// Separate Templates load (less critical)
|
||||||
try {
|
try {
|
||||||
const templates = await pb.collection(TASGRID_COLLECTION).getFullList({
|
const templates = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
@@ -1917,57 +1648,6 @@ 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 () => {
|
export const syncSystemTagsAndRules = async () => {
|
||||||
|
|||||||
@@ -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>
|
<p class="text-muted-foreground mt-1 text-lg">Your tasks ranked by highest urgency + priority.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={displayedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => (
|
{(task) => (
|
||||||
<div class="w-full min-w-0">
|
<div class="animate-task-enter">
|
||||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
</div>
|
</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>
|
<p class="text-muted-foreground mt-1 text-lg">Largest tasks first. Tackle big projects head-on.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={displayedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => (
|
{(task) => (
|
||||||
<div class="w-full min-w-0">
|
<div class="animate-task-enter">
|
||||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+50
-108
@@ -1,14 +1,14 @@
|
|||||||
import { type Component, createSignal, createMemo, createEffect, onCleanup, For, Show } from "solid-js";
|
import { type Component, createSignal, createMemo, For, Show } from "solid-js";
|
||||||
import { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments } from "@/store";
|
import { store, renameTagDefinition } from "@/store";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { type Note } from "@/store";
|
import { type Note } from "@/store";
|
||||||
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight } from "lucide-solid";
|
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight } from "lucide-solid";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { TaskEditor } from "@/components/TaskEditor";
|
import { TaskEditor } from "@/components/TaskEditor";
|
||||||
|
import { NOTES_COLLECTION } from "@/lib/constants";
|
||||||
import { TaskCard } from "@/components/TaskCard";
|
import { TaskCard } from "@/components/TaskCard";
|
||||||
import { NotesSidebar } from "@/components/NotesSidebar";
|
import { NotesSidebar } from "@/components/NotesSidebar";
|
||||||
import { toast } from "solid-sonner";
|
|
||||||
|
|
||||||
export const NotepadView: Component<{
|
export const NotepadView: Component<{
|
||||||
selectedNoteId: string | null;
|
selectedNoteId: string | null;
|
||||||
@@ -21,38 +21,6 @@ export const NotepadView: Component<{
|
|||||||
|
|
||||||
const currentUserId = pb.authStore.model?.id;
|
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
|
// Derived states
|
||||||
const activeNote = createMemo(() => {
|
const activeNote = createMemo(() => {
|
||||||
const id = props.selectedNoteId?.trim();
|
const id = props.selectedNoteId?.trim();
|
||||||
@@ -60,19 +28,26 @@ export const NotepadView: Component<{
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleUpdateNote = async (id: string, data: Partial<Note>) => {
|
const handleUpdateNote = async (id: string, data: Partial<Note>) => {
|
||||||
await updateNote(id, data);
|
try {
|
||||||
|
await pb.collection(NOTES_COLLECTION).update(id, data);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!e.isAbort) {
|
||||||
|
console.error("Failed to update note", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let contentUpdateTimeout: number | undefined;
|
let contentUpdateTimeout: number | undefined;
|
||||||
let pendingContentUpdate: { id: string, html: string } | null = null;
|
|
||||||
|
|
||||||
const handleUpdateContent = (id: string, html: string) => {
|
const handleUpdateContent = (id: string, html: string) => {
|
||||||
clearTimeout(contentUpdateTimeout);
|
clearTimeout(contentUpdateTimeout);
|
||||||
pendingContentUpdate = { id, html };
|
|
||||||
|
|
||||||
contentUpdateTimeout = window.setTimeout(async () => {
|
contentUpdateTimeout = window.setTimeout(async () => {
|
||||||
await updateNote(id, { content: html });
|
try {
|
||||||
pendingContentUpdate = null;
|
await pb.collection(NOTES_COLLECTION).update(id, { content: html });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!e.isAbort) {
|
||||||
|
console.error("Failed to update note content", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,22 +59,15 @@ export const NotepadView: Component<{
|
|||||||
// Use '#' prefix for note tags
|
// Use '#' prefix for note tags
|
||||||
await renameTagDefinition(`#${oldTitle}`, `#${newTitle}`);
|
await renameTagDefinition(`#${oldTitle}`, `#${newTitle}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteNote = async (id: string) => {
|
const handleDeleteNote = async (id: string) => {
|
||||||
removeNote(id);
|
try {
|
||||||
if (props.selectedNoteId === id) {
|
await pb.collection(NOTES_COLLECTION).delete(id);
|
||||||
props.setSelectedNoteId(null);
|
if (props.selectedNoteId === id) {
|
||||||
}
|
props.setSelectedNoteId(null);
|
||||||
|
|
||||||
toast.success("Note moved to trash", {
|
|
||||||
action: {
|
|
||||||
label: "Undo",
|
|
||||||
onClick: () => {
|
|
||||||
restoreNote(id);
|
|
||||||
props.setSelectedNoteId(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
|
console.error("Failed to delete note", e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLinkTask = async (taskId: string) => {
|
const handleLinkTask = async (taskId: string) => {
|
||||||
@@ -206,42 +174,21 @@ export const NotepadView: Component<{
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Editor Area */}
|
{/* Editor Area */}
|
||||||
<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="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 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="space-y-4">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<textarea
|
<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 resize-none overflow-hidden h-auto"
|
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}
|
value={note().title}
|
||||||
placeholder="Note Title"
|
placeholder="Note Title"
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
rows={1}
|
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const val = e.currentTarget.value.trim() || 'Untitled';
|
const val = e.currentTarget.value.trim() || 'Untitled';
|
||||||
if (val !== note().title) handleRenameNote(note().id, note().title, val);
|
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">
|
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||||
<Show when={isOwner}>
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -253,20 +200,30 @@ export const NotepadView: Component<{
|
|||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor Instance */}
|
{/* Editor Instance */}
|
||||||
<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">
|
<div class="flex-1 min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm mb-4">
|
||||||
<TaskEditor
|
<TaskEditor
|
||||||
content={note().content}
|
content={note().content}
|
||||||
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
||||||
editable={canEdit}
|
editable={canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 h-16 md:h-8" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Linked Tasks Sidebar */}
|
{/* Linked Tasks Sidebar */}
|
||||||
@@ -288,7 +245,7 @@ export const NotepadView: Component<{
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class={cn("flex items-center gap-2", !isLinkedTasksOpen() && "md:flex-col")}>
|
<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")} />
|
<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={
|
<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">
|
<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" />
|
<Link size={10} class="mr-2 rotate-90" />
|
||||||
@@ -397,34 +354,19 @@ export const NotepadView: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
{/* Sticky Footer Actions (Match TaskDetail style) */}
|
<Show when={!props.hideNavigation}>
|
||||||
<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">
|
{/* Sticky Full-Width Mobile Close Footer */}
|
||||||
{/* Delete button (persistent for owner) */}
|
<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))]">
|
||||||
<Show when={isOwner}>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
size="sm"
|
class="w-full h-11 rounded-xl text-sm font-bold shadow-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)}
|
onClick={() => props.setSelectedNoteId(null)}
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} class="mr-2" />
|
||||||
<span class="text-xs font-bold uppercase tracking-wider">Close</span>
|
Close Note
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ export const PriorityView: Component = () => {
|
|||||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
|
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by priority.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={displayedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => (
|
{(task) => (
|
||||||
<div class="w-full min-w-0">
|
<div class="animate-task-enter">
|
||||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
</div>
|
</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>
|
<p class="text-muted-foreground mt-1 text-lg">Tasks sorted by progress status, then by focus score.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={displayedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => (
|
{(task) => (
|
||||||
<div class="w-full min-w-0">
|
<div class="animate-task-enter">
|
||||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+45
-115
@@ -1,6 +1,6 @@
|
|||||||
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
|
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
|
||||||
import { ThemeToggle } from "../components/ThemeToggle";
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
|
import { store, restoreTask, deleteTaskPermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
|
||||||
import { useTheme } from "@/components/ThemeProvider";
|
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 { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid";
|
||||||
import { TagPicker } from "@/components/TagPicker";
|
import { TagPicker } from "@/components/TagPicker";
|
||||||
@@ -77,7 +77,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="flex flex-col gap-12 mt-4">
|
<div class="flex flex-col gap-12">
|
||||||
|
|
||||||
|
|
||||||
{/* Resources Category (Mostly for Mobile) */}
|
{/* Resources Category (Mostly for Mobile) */}
|
||||||
@@ -826,104 +826,51 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isTrashOpen()}>
|
<Show when={isTrashOpen()}>
|
||||||
<div class="space-y-6 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
<div class="space-y-2 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
{/* Tasks Trash */}
|
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
|
||||||
<div class="space-y-2">
|
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
||||||
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pl-1">Tasks</h4>
|
Trash is empty.
|
||||||
<For each={store.tasks.filter(t => t.deletedAt)} fallback={
|
</div>
|
||||||
<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.
|
{(task) => {
|
||||||
</div>
|
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
}>
|
return (
|
||||||
{(task) => {
|
<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">
|
||||||
const daysLeft = Math.ceil(((task.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
<div class="min-w-0 flex-1 px-1">
|
||||||
return (
|
<p class="font-semibold truncate text-sm sm:text-base">{task.title || "Untitled"}</p>
|
||||||
<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">
|
<p class="text-[0.5rem] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
|
||||||
<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>
|
||||||
);
|
<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
|
||||||
</For>
|
variant="secondary"
|
||||||
</div>
|
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"
|
||||||
{/* Notes Trash */}
|
onClick={() => {
|
||||||
<div class="space-y-2">
|
restoreTask(task.id);
|
||||||
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pl-1">Notes</h4>
|
toast.success("Task restored");
|
||||||
<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.
|
<Undo2 size={14} class="mr-2" />
|
||||||
</div>
|
Recover
|
||||||
}>
|
</Button>
|
||||||
{(note) => {
|
<Button
|
||||||
const daysLeft = Math.ceil(((note.deletedAt || 0) + (7 * 24 * 60 * 60 * 1000) - Date.now()) / (1000 * 60 * 60 * 24));
|
variant="ghost"
|
||||||
return (
|
size="icon"
|
||||||
<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">
|
class="h-9 w-9 hover:bg-red-500/10 hover:text-red-600 rounded-xl border border-border/50 bg-background/30"
|
||||||
<div class="min-w-0 flex-1 px-1">
|
onClick={() => {
|
||||||
<p class="font-semibold truncate text-sm sm:text-base">{note.title || "Untitled"}</p>
|
if (confirm("Permanently delete this task? This cannot be undone.")) {
|
||||||
<p class="text-[0.5rem] text-muted-foreground opacity-70">Expires in {Math.max(0, daysLeft)} days</p>
|
deleteTaskPermanently(task.id);
|
||||||
</div>
|
toast.error("Task permanently deleted");
|
||||||
<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"
|
<Trash2 size={14} />
|
||||||
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"
|
</Button>
|
||||||
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>
|
||||||
);
|
</div>
|
||||||
}}
|
);
|
||||||
</For>
|
}}
|
||||||
</div>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</section>
|
</section>
|
||||||
@@ -1086,23 +1033,6 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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.0</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</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>
|
<p class="text-muted-foreground mt-1 text-lg">Smallest tasks first. Build momentum with quick wins.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={displayedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => (
|
{(task) => (
|
||||||
<div class="w-full min-w-0">
|
<div class="animate-task-enter">
|
||||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ export const UrgencyView: Component = () => {
|
|||||||
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
|
<p class="text-muted-foreground mt-1 text-lg">Your tasks ordered by deadline.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 w-full" ref={listRef}>
|
<div class="grid gap-3" ref={listRef}>
|
||||||
<For each={displayedTasks()}>
|
<For each={displayedTasks()}>
|
||||||
{(task) => (
|
{(task) => (
|
||||||
<div class="w-full min-w-0">
|
<div class="animate-task-enter">
|
||||||
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
<TaskCard task={task} isShaking={shakingTaskIds().includes(task.id)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+1
-3
@@ -10,9 +10,7 @@
|
|||||||
"DOM.Iterable"
|
"DOM.Iterable"
|
||||||
],
|
],
|
||||||
"types": [
|
"types": [
|
||||||
"vite/client",
|
"vite/client"
|
||||||
"vite-plugin-pwa/client",
|
|
||||||
"vite-plugin-pwa/solid"
|
|
||||||
],
|
],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
solid(),
|
solid(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'prompt',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['icon.webp', 'vite.svg'],
|
includeAssets: ['icon.webp', 'vite.svg'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Tasgrid',
|
name: 'Tasgrid',
|
||||||
|
|||||||
Reference in New Issue
Block a user