diff --git a/src/components/SlashMenu.tsx b/src/components/SlashMenu.tsx index 8358dd8..ce66197 100644 --- a/src/components/SlashMenu.tsx +++ b/src/components/SlashMenu.tsx @@ -7,7 +7,7 @@ import { Bold, Italic, Underline, Grid3X3, Highlighter, AlignLeft, AlignCenter, AlignRight, - Trash2, AppWindow + Trash2, AppWindow, Film } from "lucide-solid"; import { cn } from "@/lib/utils"; @@ -123,6 +123,15 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] 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: "Job File", description: "Embed a job file from Prism.", diff --git a/src/components/TaskDetail.tsx b/src/components/TaskDetail.tsx index 53d299d..cf6c411 100644 --- a/src/components/TaskDetail.tsx +++ b/src/components/TaskDetail.tsx @@ -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, Image as ImageIcon } from "lucide-solid"; +import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, Image as ImageIcon, Film } from "lucide-solid"; import { StatusCircle } from "./StatusCircle"; import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store"; import { Button } from "./ui/button"; @@ -416,6 +416,24 @@ export const TaskDetail: Component = (props) => { + + {/* Video Upload Shortcut */} +
+ +
{/* Metadata Row (Dates/Timestamps) */} diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index 5ab8812..2f59e23 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -28,6 +28,8 @@ 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"; interface TaskEditorProps { content?: string; @@ -163,6 +165,8 @@ export const TaskEditor: Component = (props) => { }), FileViewer, ImageUpload, + Video, + VideoUpload, ], // Use untrack so the editor doesn't re-initialize when props.content changes content: untrack(() => props.content) || "", diff --git a/src/lib/extensions/video-upload.ts b/src/lib/extensions/video-upload.ts new file mode 100644 index 0000000..ab2e28a --- /dev/null +++ b/src/lib/extensions/video-upload.ts @@ -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 { + 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) => { + // Insert video + editor.chain() + .focus() + .setVideo({ src: url }) + .run(); + + // Manually force a new paragraph at the end so the user can keep typing + editor.chain() + .focus('end') + .insertContent('

') + .run(); + + return "Video uploaded."; + }, + error: "Failed to upload video." + } + ); + } + }; + input.click(); + return true; + }, + }; + }, +}); diff --git a/src/lib/extensions/video.ts b/src/lib/extensions/video.ts new file mode 100644 index 0000000..3e2f73c --- /dev/null +++ b/src/lib/extensions/video.ts @@ -0,0 +1,63 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +export interface VideoOptions { + HTMLAttributes: Record; +} + +export const Video = Node.create({ + 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, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'video', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['video', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; + }, + + addCommands() { + return { + setVideo: (options: { src: string }) => ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }); + }, + }; + }, +}); + +declare module '@tiptap/core' { + interface Commands { + video: { + setVideo: (options: { src: string }) => ReturnType; + }; + } +} diff --git a/src/store/index.ts b/src/store/index.ts index e4c1c3f..926e348 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -579,7 +579,7 @@ const mapRecordToTask = (r: any): Task => { export const uploadTaskAttachment = async (taskId: string, file: File): Promise => { try { const formData = new FormData(); - formData.append('attachments', file); + formData.append('attachments+', file); const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData); @@ -593,7 +593,7 @@ export const uploadTaskAttachment = async (taskId: string, file: File): Promise< 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; } @@ -602,7 +602,7 @@ export const uploadTaskAttachment = async (taskId: string, file: File): Promise< export const uploadNoteAttachment = async (noteId: string, file: File): Promise => { try { const formData = new FormData(); - formData.append('attachments', file); + formData.append('attachments+', file); const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData); @@ -613,7 +613,7 @@ export const uploadNoteAttachment = async (noteId: string, file: File): Promise< 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; } diff --git a/src/views/NotepadView.tsx b/src/views/NotepadView.tsx index 68a1e5b..e85e1af 100644 --- a/src/views/NotepadView.tsx +++ b/src/views/NotepadView.tsx @@ -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, Type, Image as ImageIcon } from "lucide-solid"; +import { Trash2, Lock, Unlock, Search, Link, X, ChevronRight, ChevronDown, FileText, Plus, Star, MoreHorizontal, Type, Image as ImageIcon, Film } 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"; @@ -462,6 +462,19 @@ export const NotepadView: Component<{ Image +