Compare commits

...

7 Commits

Author SHA1 Message Date
tcardoza 55047b5d90 Copy and paste as well as dragging files added
CI / build (push) Has been skipped
CI / deploy (push) Failing after 34s
2026-03-12 18:09:45 -05:00
tcardoza cd5fb71899 file attachment fix 2026-03-12 17:42:59 -05:00
tcardoza df94685c96 cleanup (broken files) 2026-03-12 17:23:28 -05:00
tcardoza f53030294b file upload 2026-03-12 17:05:07 -05:00
tcardoza cf188896be HEIC support 2026-03-12 16:43:27 -05:00
tcardoza 1e5f80739c working video 2026-03-12 16:33:57 -05:00
tcardoza 75723ed133 Upload image button 2026-03-12 15:51:37 -05:00
14 changed files with 987 additions and 98 deletions
+3
View File
@@ -32,6 +32,7 @@
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"heic2any": "^0.0.4",
"lucide-solid": "^0.563.0",
"pm2": "^6.0.14",
"pocketbase": "^0.26.8",
@@ -825,6 +826,8 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
+1
View File
@@ -42,6 +42,7 @@
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"heic2any": "^0.0.4",
"lucide-solid": "^0.563.0",
"pm2": "^6.0.14",
"pocketbase": "^0.26.8",
+22 -62
View File
@@ -1,17 +1,6 @@
import { type Component, createSignal, For, createEffect } from "solid-js";
import {
Heading1, Heading2, Heading3,
List, ListTodo, Type,
Code, Quote,
Image as ImageIcon, Minus,
Bold, Italic, Underline,
Grid3X3, Highlighter,
AlignLeft, AlignCenter, AlignRight,
Trash2, AppWindow
} from "lucide-solid";
import { cn, convertImageToWebpFile } from "@/lib/utils";
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { toast } from "solid-sonner";
import { Search, Save, FolderOpen, Tag, Settings, Database, Server, RefreshCw, Cpu, ChevronRight, X, Calendar, Pen, Type, Underline, Strikethrough, Code, Quote, ImageIcon, Heading1, Heading2, Heading3, Link, CheckSquare, List, ListOrdered, Film, AppWindow, Minus, Grid3X3, AlignLeft, AlignCenter, AlignRight, FileText, Delete, Trash2, Highlighter, Bold, Italic, ListTodo } from "lucide-solid";
import { cn } from "@/lib/utils";
export interface CommandItem {
title: string;
@@ -122,60 +111,31 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
description: "Upload an image.",
icon: ImageIcon,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).run();
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload image outside of a task or note.");
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
toast.promise(
(async () => {
const webpFile = await convertImageToWebpFile(originalFile);
if (taskId) {
return uploadTaskAttachment(taskId, webpFile);
} else {
return uploadNoteAttachment(noteId!, webpFile);
}
})(),
{
loading: "Processing & uploading image...",
success: (url) => {
// Insert image, then run enter to add a newline (paragraph) below it.
// Without this, the image stays selected and the next keypress deletes it.
editor.chain()
.focus()
.setImage({ src: url })
.run();
// Manually force a new paragraph at the end so the user can keep typing
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "Image uploaded.";
},
error: "Failed to upload image."
}
);
}
};
input.click();
editor.chain().focus().deleteRange(range).uploadImage().run();
},
},
{
title: "Video",
description: "Upload a video.",
aliases: ["movie", "mp4", "mov"],
icon: Film,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).uploadVideo().run();
},
},
{
title: "File",
description: "Upload a document or file.",
aliases: ["file", "pdf", "doc", "upload"],
icon: FileText,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).uploadFile().run();
},
},
{
title: "Job File",
description: "Embed a job file from Prism.",
aliases: ["file", "embed", "job", "pdf"],
aliases: ["job", "jobfile", "prism"],
icon: AppWindow,
command: ({ editor, range }: { editor: any, range: any }) => {
editor.chain().focus().deleteRange(range).insertFileViewer().run();
+66 -2
View File
@@ -3,7 +3,7 @@ import { Sheet, SheetContent } from "@/components/ui/sheet";
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag } from "@/store";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star } from "lucide-solid";
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, Image as ImageIcon, Film, FileText } from "lucide-solid";
import { StatusCircle } from "./StatusCircle";
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
import { Button } from "./ui/button";
@@ -363,7 +363,7 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
{/* Favorite */}
<div class="flex items-center gap-1 group shrink-0">
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Favorite</span>
<span class="text-[0.625rem] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline"></span>
<Button
variant="ghost"
size="sm"
@@ -398,6 +398,42 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
<span class="hidden sm:inline">Commands</span>
</Button>
</div>
{/* Image Upload Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
class="h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
const instance = editorInstance();
if (instance) {
instance.chain().focus().uploadImage().run();
}
}}
>
<ImageIcon size={14} class="opacity-70" />
<span class="hidden sm:inline">Image</span>
</Button>
</div>
{/* Video Upload Shortcut */}
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
class="h-8 px-1 sm:px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
const instance = editorInstance();
if (instance) {
instance.chain().focus().uploadVideo().run();
}
}}
>
<Film size={14} class="opacity-70" />
<span class="hidden sm:inline">Video</span>
</Button>
</div>
</div>
{/* Metadata Row (Dates/Timestamps) */}
@@ -659,6 +695,34 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
</Popover>
{/* Close button (Mobile only) */}
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
onClick={() => {
const instance = editorInstance();
if (instance) {
instance.chain().focus().uploadVideo().run();
}
}}
title="Video"
>
<Film class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
onClick={() => {
const instance = editorInstance();
if (instance) {
instance.chain().focus().uploadFile().run();
}
}}
title="File"
>
<FileText class="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
+80 -2
View File
@@ -4,11 +4,14 @@ import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list";
import { CustomTaskItem } from "./CustomTaskItem";
import Image from "@tiptap/extension-image";
import { CustomImage as Image } from "@/lib/extensions/image";
import Underline from "@tiptap/extension-underline";
import { X } from "lucide-solid";
import { cn } from "@/lib/utils";
import { store } from "@/store";
import { store, activeTaskId, activeNoteId, uploadTaskAttachment, uploadNoteAttachment } from "@/store";
import { convertImageToWebpFile } from "@/lib/utils";
import { toast } from "solid-sonner";
import type { Editor } from "@tiptap/core";
import { SlashCommands } from "@/lib/slash-command";
import { suggestion } from "@/lib/slash-renderer";
@@ -27,6 +30,11 @@ import { Dropcursor } from "@tiptap/extension-dropcursor";
import { Mention } from "@tiptap/extension-mention";
import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion";
import { FileViewer } from "@/lib/extensions/file-viewer";
import { ImageUpload } from "@/lib/extensions/image-upload";
import { Video } from "@/lib/extensions/video";
import { VideoUpload } from "@/lib/extensions/video-upload";
import { FileAttachment } from "@/lib/extensions/file-attachment";
import { FileUpload } from "@/lib/extensions/file-upload";
interface TaskEditorProps {
content?: string;
@@ -52,6 +60,50 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
setZoomOffset({ x: 0, y: 0 });
};
const handleFileUploads = async (editor: Editor, files: File[]) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload files outside of a task or note.");
return;
}
for (const file of files) {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
toast.promise(
(async () => {
let fileToUpload = file;
if (isImage) {
fileToUpload = await convertImageToWebpFile(file);
}
if (taskId) {
return uploadTaskAttachment(taskId, fileToUpload);
} else {
return uploadNoteAttachment(noteId!, fileToUpload);
}
})(),
{
loading: `Uploading ${file.name}...`,
success: ({ url, filename }) => {
if (isImage) {
editor.chain().focus().setImage({ src: url, filename }).run();
} else if (isVideo) {
editor.chain().focus().setVideo({ src: url, filename }).run();
} else {
editor.chain().focus().insertFileAttachment({ src: url, filename, filesize: file.size.toString() }).run();
}
return `${file.name} uploaded.`;
},
error: `Failed to upload ${file.name}.`
}
);
}
};
const editor = createTiptapEditor(() => ({
element: editorRef!,
extensions: [
@@ -161,6 +213,11 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
width: 2,
}),
FileViewer,
ImageUpload,
Video,
VideoUpload,
FileAttachment,
FileUpload,
],
// Use untrack so the editor doesn't re-initialize when props.content changes
content: untrack(() => props.content) || "",
@@ -169,6 +226,27 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
props.onUpdate(editor.getHTML());
},
editorProps: {
handlePaste: (_view, event) => {
const items = Array.from(event.clipboardData?.items || []);
const files = items
.map(item => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
handleFileUploads(editor()!, files);
return true;
}
return false;
},
handleDrop: (_view, event, _slice, moved) => {
if (!moved && event.dataTransfer?.files?.length) {
event.preventDefault();
handleFileUploads(editor()!, Array.from(event.dataTransfer.files));
return true;
}
return false;
},
attributes: {
class: cn(
"prose prose-sm dark:prose-invert max-w-none focus:outline-none min-h-[150px]",
+237
View File
@@ -0,0 +1,237 @@
import { Node, mergeAttributes } from '@tiptap/core';
export interface FileAttachmentOptions {
HTMLAttributes: Record<string, any>;
}
export const FileAttachment = Node.create<FileAttachmentOptions>({
name: 'fileAttachment',
group: 'block',
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {
class: 'file-attachment-node w-full max-w-sm rounded-lg border border-border bg-muted/30 hover:bg-muted/50 transition-colors my-4 overflow-hidden relative group cursor-pointer shadow-sm',
},
};
},
addAttributes() {
return {
src: {
default: null,
parseHTML: element => element.getAttribute('data-src'),
renderHTML: attributes => {
if (!attributes.src) return {};
return { 'data-src': attributes.src };
},
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
filesize: {
default: null,
parseHTML: element => element.getAttribute('data-filesize'),
renderHTML: attributes => {
if (!attributes.filesize) return {};
return { 'data-filesize': attributes.filesize };
},
}
};
},
parseHTML() {
return [
{
tag: 'div[data-type="file-attachment"]',
},
{
tag: 'a.file-attachment-link',
}
];
},
renderHTML({ HTMLAttributes }) {
const filename = HTMLAttributes.filename || '';
const isPdf = filename.toLowerCase().endsWith('.pdf');
if (isPdf) {
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'file-attachment',
class: 'file-attachment-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[500px] bg-muted/20 group'
}),
['iframe', { src: HTMLAttributes.src, class: 'w-full h-full border-0 absolute inset-0' }]
];
}
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'file-attachment',
}),
['a', { href: HTMLAttributes.src, target: '_blank', rel: 'noopener noreferrer', class: 'file-attachment-link flex items-center p-3 gap-3 w-full h-full' },
['div', { class: 'flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-md bg-primary/10 text-primary' },
// SVG for FileText
['svg', { xmlns: 'http://www.w3.org/2000/svg', width: '20', height: '20', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' },
['path', { d: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' }],
['path', { d: 'M14 2v4a2 2 0 0 0 2 2h4' }],
['path', { d: 'M10 9H8' }],
['path', { d: 'M16 13H8' }],
['path', { d: 'M16 17H8' }]
]
],
['div', { class: 'flex flex-col overflow-hidden w-full text-left' },
['span', { class: 'text-sm font-medium truncate w-full text-foreground' }, HTMLAttributes.filename || 'Attachment'],
['span', { class: 'text-xs text-muted-foreground mt-0.5' }, 'View File']
]
]
];
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
Delete: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const filename = node.attrs.filename || '';
const isPdf = filename.toLowerCase().endsWith('.pdf');
const isTxt = filename.toLowerCase().endsWith('.txt');
const isFramable = isPdf || isTxt;
const wrapper = document.createElement('div');
wrapper.setAttribute('data-type', 'file-attachment');
wrapper.setAttribute('data-src', node.attrs.src);
wrapper.setAttribute('data-filename', node.attrs.filename);
wrapper.setAttribute('data-filesize', node.attrs.filesize);
if (isFramable) {
// Framed mode matching FileViewer
wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group');
wrapper.style.minHeight = '500px';
const iframe = document.createElement('iframe');
iframe.src = node.attrs.src;
iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0');
wrapper.appendChild(iframe);
} else {
// Card mode
wrapper.classList.add('file-attachment-node', 'w-full', 'max-w-sm', 'rounded-lg', 'border', 'border-border', 'bg-muted/30', 'hover:bg-muted/50', 'transition-colors', 'my-4', 'overflow-hidden', 'relative', 'group', 'cursor-pointer', 'shadow-sm');
const anchor = document.createElement('a');
anchor.href = node.attrs.src || '#';
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
anchor.classList.add('flex', 'items-center', 'p-3', 'gap-3', 'w-full', 'h-full', 'no-underline');
const iconContainer = document.createElement('div');
iconContainer.classList.add('flex-shrink-0', 'flex', 'items-center', 'justify-center', 'w-10', 'h-10', 'rounded-md', 'bg-primary/10', 'text-primary');
iconContainer.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>';
const textContainer = document.createElement('div');
textContainer.classList.add('flex', 'flex-col', 'overflow-hidden', 'w-full', 'text-left');
const filenameSpan = document.createElement('span');
filenameSpan.classList.add('text-sm', 'font-medium', 'truncate', 'w-full', 'text-foreground');
filenameSpan.innerText = node.attrs.filename || 'Attachment';
const subTextSpan = document.createElement('span');
subTextSpan.classList.add('text-xs', 'text-muted-foreground', 'mt-0.5');
if (node.attrs.filesize) {
const mb = (parseInt(node.attrs.filesize) / (1024 * 1024)).toFixed(2);
subTextSpan.innerText = `${mb} MB • View File`;
} else {
subTextSpan.innerText = `View File`;
}
textContainer.appendChild(filenameSpan);
textContainer.appendChild(subTextSpan);
anchor.appendChild(iconContainer);
anchor.appendChild(textContainer);
wrapper.appendChild(anchor);
}
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-1.5', 'right-1.5', 'hidden', 'group-hover:flex', 'bg-background/90', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: (e) => {
// Stop event propagation if click is exactly on delete button, to prevent anchor following
if (overlay.contains(e.target as unknown as globalThis.Node)) {
return true;
}
return false;
},
ignoreMutation: () => true,
};
};
},
addCommands() {
return {
insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fileAttachment: {
insertFileAttachment: (options: { src: string, filename: string, filesize?: string }) => ReturnType;
};
}
}
+73
View File
@@ -0,0 +1,73 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
fileUpload: {
uploadFile: () => ReturnType;
};
}
}
export const FileUpload = Extension.create({
name: 'fileUpload',
addCommands() {
return {
uploadFile: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload a file outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
// Accept any file type for generic attachments
input.accept = "*/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const filesize = file.size.toString();
toast.promise(
(async () => {
if (taskId) {
return uploadTaskAttachment(taskId, file);
} else {
return uploadNoteAttachment(noteId!, file);
}
})(),
{
loading: "Uploading file...",
success: ({ url, filename: pbFilename }) => {
// Insert file attchment UI card
editor.chain()
.focus()
.insertFileAttachment({ src: url, filename: pbFilename, filesize })
.run();
// Manually force a new paragraph at the end so the user can keep typing
// Add paragraph after insertion
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "File uploaded successfully.";
},
error: "Failed to upload file."
}
);
}
};
input.click();
return true;
},
};
},
});
+70
View File
@@ -0,0 +1,70 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { convertImageToWebpFile } from "@/lib/utils";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageUpload: {
uploadImage: () => ReturnType;
};
}
}
export const ImageUpload = Extension.create({
name: 'imageUpload',
addCommands() {
return {
uploadImage: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload image outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,.heic,.heif";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
toast.promise(
(async () => {
const webpFile = await convertImageToWebpFile(originalFile);
if (taskId) {
return uploadTaskAttachment(taskId, webpFile);
} else {
return uploadNoteAttachment(noteId!, webpFile);
}
})(),
{
loading: "Processing & uploading image...",
success: ({ url, filename }) => {
// Insert image
editor.chain()
.focus()
.setImage({ src: url, filename })
.run();
// Manually force a new paragraph at the end so the user can keep typing
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "Image uploaded.";
},
error: "Failed to upload image."
}
);
}
};
input.click();
return true;
},
};
},
});
+100
View File
@@ -0,0 +1,100 @@
import Image from "@tiptap/extension-image";
export const CustomImage = Image.extend({
name: "image",
group: "block",
selectable: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
src: {
default: null,
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
};
},
addCommands() {
return {
setImage: (options: { src: string, alt?: string, title?: string, filename?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
// Give it position: relative and flex/inline-block to wrap the image tightly
wrapper.classList.add('image-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-fit', 'max-w-full');
wrapper.style.display = 'block'; // Block level to not mess up flow
wrapper.style.marginInline = 'auto'; // Center if desired, or let it flow
const img = document.createElement('img');
Object.entries(node.attrs).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
if (key === 'filename') {
img.setAttribute('data-filename', value);
} else {
img.setAttribute(key, value);
}
}
});
img.classList.add('rounded-lg', 'border', 'border-border', 'max-w-full', 'h-auto', 'cursor-default');
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(img);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: () => true,
ignoreMutation: () => true,
};
};
},
});
import type { SetImageOptions } from "@tiptap/extension-image";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
image: {
// @ts-ignore
setImage: (options: SetImageOptions & { filename?: string }) => ReturnType;
};
}
}
// @ts-ignore
+69
View File
@@ -0,0 +1,69 @@
import { Extension } from '@tiptap/core';
import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store";
import { toast } from "solid-sonner";
declare module '@tiptap/core' {
interface Commands<ReturnType> {
videoUpload: {
uploadVideo: () => ReturnType;
};
}
}
export const VideoUpload = Extension.create({
name: 'videoUpload',
addCommands() {
return {
uploadVideo: () => ({ editor }) => {
const taskId = activeTaskId();
const noteId = activeNoteId();
if (!taskId && !noteId) {
toast.error("Cannot upload video outside of a task or note.");
return false;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "video/*";
input.onchange = async () => {
if (input.files?.length) {
const originalFile = input.files[0];
toast.promise(
(async () => {
// For video, we don't necessarily convert to webp. We just upload it directly.
if (taskId) {
return uploadTaskAttachment(taskId, originalFile);
} else {
return uploadNoteAttachment(noteId!, originalFile);
}
})(),
{
loading: "Uploading video...",
success: ({ url, filename }) => {
// Insert video
editor.chain()
.focus()
.setVideo({ src: url, filename })
.run();
// Manually force a new paragraph at the end so the user can keep typing
editor.chain()
.focus('end')
.insertContent('<p></p>')
.run();
return "Video uploaded.";
},
error: "Failed to upload video."
}
);
}
};
input.click();
return true;
},
};
},
});
+149
View File
@@ -0,0 +1,149 @@
import { Node, mergeAttributes } from '@tiptap/core';
export interface VideoOptions {
HTMLAttributes: Record<string, any>;
}
export const Video = Node.create<VideoOptions>({
name: 'video',
group: 'block',
// Allows it to be selected and manipulated as a single block
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {
class: 'w-full rounded-lg border border-border/50 shadow-sm overflow-hidden my-4',
controls: true,
},
};
},
addAttributes() {
return {
src: {
default: null,
},
filename: {
default: null,
parseHTML: element => element.getAttribute('data-filename'),
renderHTML: attributes => {
if (!attributes.filename) return {};
return { 'data-filename': attributes.filename };
},
},
};
},
addCommands() {
return {
setVideo: (options: { src: string, filename?: string }) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
parseHTML() {
return [
{
tag: 'video',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['video', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
},
addKeyboardShortcuts() {
return {
Backspace: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
Delete: () => {
const { selection } = this.editor.state;
if (!selection.empty) {
const selectedNode = this.editor.state.doc.nodeAt(selection.from);
if (selectedNode?.type.name === this.name) {
return true; // Prevent default deletion
}
}
return false;
},
};
},
addNodeView() {
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-full', 'max-w-full');
wrapper.style.display = 'block';
const video = document.createElement('video');
Object.entries(node.attrs).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
if (key === 'filename') {
video.setAttribute('data-filename', value);
} else {
video.setAttribute(key, value);
}
}
});
// Apply default attributes if not present
if (!video.hasAttribute('controls')) {
video.setAttribute('controls', 'true');
}
video.classList.add('w-full', 'rounded-lg', 'border', 'border-border/50', 'shadow-sm', 'overflow-hidden');
// Delete button overlay
const overlay = document.createElement('div');
overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10');
const deleteBtn = document.createElement('button');
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
deleteBtn.classList.add('p-1', 'cursor-pointer');
deleteBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
const pos = getPos();
if (typeof pos === 'number') {
editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize });
}
}
};
overlay.appendChild(deleteBtn);
wrapper.appendChild(video);
wrapper.appendChild(overlay);
return {
dom: wrapper,
stopEvent: () => true, // Stops events from propagating to editor (makes the input usable)
ignoreMutation: () => true,
};
};
},
});
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
setVideo: (options: { src: string, filename?: string }) => ReturnType;
};
}
}
+24 -2
View File
@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import heic2any from "heic2any"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -38,7 +39,28 @@ export const compressImageBase64 = (base64Str: string, maxWidth = 800, quality =
};
export const convertImageToWebpFile = async (file: File): Promise<File> => {
return new Promise((resolve) => {
return new Promise(async (resolve) => {
let processableFile: File | Blob = file;
// Convert HEIC/HEIF to JPEG first so the browser can read it
const isHeic = file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
if (isHeic) {
try {
const converted = await heic2any({
blob: file,
toType: "image/jpeg",
quality: 0.8 // high quality intermediate step
});
// heic2any can return an array of blobs if it's an image sequence, just take the first
processableFile = Array.isArray(converted) ? converted[0] : converted;
} catch (err) {
console.error("Failed to convert HEIC image:", err);
// If conversion fails, just fall back to returning the original file
return resolve(file);
}
}
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
@@ -92,6 +114,6 @@ export const convertImageToWebpFile = async (file: File): Promise<File> => {
img.src = e.target?.result as string;
};
reader.onerror = () => resolve(file); // Fallback
reader.readAsDataURL(file);
reader.readAsDataURL(processableFile);
});
};
+26 -21
View File
@@ -576,44 +576,47 @@ const mapRecordToTask = (r: any): Task => {
};
};
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<string> => {
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<{ url: string, filename: string }> => {
try {
const formData = new FormData();
formData.append('attachments', file);
formData.append('attachments+', file);
const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData);
// Return the URL for the newly uploaded file. PocketBase appends new files to the array.
// We need to find the specific filename that was just uploaded to get its URL.
// Since we don't know the exact generated filename, we can return the URL of the last file in the array.
if (record.attachments && record.attachments.length > 0) {
const latestFilename = record.attachments[record.attachments.length - 1];
return pb.files.getURL(record, latestFilename);
return {
url: pb.files.getURL(record, latestFilename),
filename: latestFilename
};
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading attachment:", err);
console.error("Error uploading attachment. Validation details:", JSON.stringify(err.data, null, 2));
toast.error("Failed to upload image.");
throw err;
}
};
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<string> => {
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<{ url: string, filename: string }> => {
try {
const formData = new FormData();
formData.append('attachments', file);
formData.append('attachments+', file);
const record = await pb.collection(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);
return {
url: pb.files.getURL(record, latestFilename),
filename: latestFilename
};
}
throw new Error("File uploaded but no attachment returned");
} catch (err: any) {
console.error("Error uploading note attachment:", err);
console.error("Error uploading note attachment. Validation details:", JSON.stringify(err.data, null, 2));
toast.error("Failed to upload image to note.");
throw err;
}
@@ -623,16 +626,18 @@ 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 content = record.content || "";
const filesToDelete: string[] = [];
// Also check decoded content just in case of encoding mismatches
const decodedContent = decodeURIComponent(content);
for (const filename of record.attachments) {
if (!content.includes(filename)) {
// Check for literal filename OR the part of the URL that includes the filename
if (!content.includes(filename) && !decodedContent.includes(filename)) {
filesToDelete.push(filename);
}
}
@@ -652,16 +657,16 @@ 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 content = record.content || "";
const filesToDelete: string[] = [];
const decodedContent = decodeURIComponent(content);
for (const filename of record.attachments) {
if (!content.includes(filename)) {
if (!content.includes(filename) && !decodedContent.includes(filename)) {
filesToDelete.push(filename);
}
}
+67 -9
View File
@@ -2,7 +2,7 @@ import { type Component, createSignal, createMemo, createEffect, onCleanup, For,
import { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag } from "@/store";
import { pb } from "@/lib/pocketbase";
import { type Note } from "@/store";
import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight, ChevronDown, FileText, Plus, Star, MoreHorizontal } from "lucide-solid";
import { Plus, X, Lock, Unlock, Trash2, ChevronDown, Link as LinkIcon, Image as ImageIcon, MoreHorizontal, Type, FileText, Film, Search, Star, ChevronRight } from "lucide-solid";
import { Button } from "@/components/ui/button";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { DragDropProvider, DragDropSensors, SortableProvider, createSortable, closestCenter } from "@thisbeyond/solid-dnd";
@@ -22,6 +22,7 @@ export const NotepadView: Component<{
const [linkSearchQuery, setLinkSearchQuery] = createSignal("");
const [isLinkedTasksOpen, setIsLinkedTasksOpen] = createSignal(false);
const [isDragging, setIsDragging] = createSignal(false);
const [editorInstance, setEditorInstance] = createSignal<any>(null);
const currentUserId = pb.authStore.model?.id;
@@ -335,7 +336,8 @@ export const NotepadView: Component<{
const c = note()?.content;
if (!c) return false;
const s = c.trim();
return s.startsWith('<div data-type="file-viewer-wrapper"');
return s.startsWith('<div data-type="file-viewer-wrapper"') ||
(s.startsWith('<div data-type="file-attachment"') && s.includes('data-filename') && s.toLowerCase().includes('.pdf'));
});
return (
@@ -432,9 +434,64 @@ export const NotepadView: Component<{
/>
)}
</Show>
{/* Editor Commands Row */}
<div class="flex items-center gap-2 mt-1 px-1">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
onClick={() => {
const instance = editorInstance();
if (instance) instance.chain().focus().insertContent("/").run();
}}
title="Text Commands"
>
<Type size={14} class="opacity-70" />
<span>Commands</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
onClick={() => {
const instance = editorInstance();
if (instance) instance.chain().focus().uploadImage().run();
}}
title="Upload Image"
>
<ImageIcon size={14} class="opacity-70" />
<span>Image</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
onClick={() => {
const instance = editorInstance();
if (instance) instance.chain().focus().uploadVideo().run();
}}
title="Upload Video"
>
<Film size={14} class="opacity-70" />
<span>Video</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-[0.625rem] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-all rounded-lg border border-border/40 shadow-sm"
onClick={() => {
const instance = editorInstance();
if (instance) instance.chain().focus().uploadFile().run();
}}
title="Attach File"
>
<FileText size={14} class="opacity-70" />
<span>File</span>
</Button>
</div>
</div>
<div class="flex items-center gap-2 shrink-0 pt-1">
{/* Mobile Tabs Dropdown Moved to Footer */}
<Button
variant="ghost"
@@ -490,8 +547,9 @@ export const NotepadView: Component<{
<TaskEditor
content={note().content}
onUpdate={(html) => handleUpdateContent(note().id, html)}
onEditorReady={setEditorInstance}
editable={canEdit}
class={hasTopViewer() ? "[&>*:not(.file-viewer-wrapper:first-child)]:px-4 md:[&>*:not(.file-viewer-wrapper:first-child)]:px-6 [&>*:not(.file-viewer-wrapper:first-child)]:max-w-3xl [&>*:not(.file-viewer-wrapper:first-child)]:mx-auto pb-20" : ""}
class={hasTopViewer() ? "[&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:px-4 md:[&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:px-6 [&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:max-w-3xl [&>*:not(.file-viewer-wrapper:first-child,.file-attachment-wrapper:first-child)]:mx-auto [&>.file-viewer-wrapper:first-child]:h-[80vh] [&>.file-viewer-wrapper:first-child]:min-h-0 [&>.file-attachment-wrapper:first-child]:h-[80vh] [&>.file-attachment-wrapper:first-child]:min-h-0 pb-20" : ""}
/>
</div>
<Show when={!hasTopViewer()}>
@@ -521,18 +579,18 @@ export const NotepadView: Component<{
<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" />
<LinkIcon size={10} class="mr-2 rotate-90" />
Linked Tasks
</div>
}>
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
<Link size={12} />
<LinkIcon size={12} />
Linked Tasks
</h3>
</Show>
<Show when={!isLinkedTasksOpen()}>
<div class="md:hidden flex items-center justify-center text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground">
<Link size={12} class="mr-2" />
<LinkIcon size={12} class="mr-2" />
Linked Tasks
</div>
</Show>
@@ -549,7 +607,7 @@ export const NotepadView: Component<{
}}
title="Link Existing Task"
>
<Link size={14} />
<LinkIcon size={14} />
</Button>
<Button
size="sm"
@@ -600,7 +658,7 @@ export const NotepadView: Component<{
<div class="flex-1 overflow-y-auto p-4 space-y-4">
<For each={linkedTasks()} fallback={
<div class="text-center text-xs text-muted-foreground p-6 bg-muted/10 border border-dashed border-border/50 rounded-xl flex flex-col items-center gap-2">
<Link size={24} class="opacity-20" />
<LinkIcon size={24} class="opacity-20" />
<p>No tasks linked yet.</p>
<p class="text-[0.6rem] opacity-70 max-w-[200px]">Link an existing task, create a new one, or add the tag #{note().title} to a task.</p>
</div>