working video
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
|||||||
Bold, Italic, Underline,
|
Bold, Italic, Underline,
|
||||||
Grid3X3, Highlighter,
|
Grid3X3, Highlighter,
|
||||||
AlignLeft, AlignCenter, AlignRight,
|
AlignLeft, AlignCenter, AlignRight,
|
||||||
Trash2, AppWindow
|
Trash2, AppWindow, Film
|
||||||
} from "lucide-solid";
|
} from "lucide-solid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -123,6 +123,15 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
|||||||
editor.chain().focus().deleteRange(range).uploadImage().run();
|
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",
|
title: "Job File",
|
||||||
description: "Embed a job file from Prism.",
|
description: "Embed a job file from Prism.",
|
||||||
|
|||||||
@@ -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 { 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 { 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, 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 { StatusCircle } from "./StatusCircle";
|
||||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -416,6 +416,24 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
<span class="hidden sm:inline">Image</span>
|
<span class="hidden sm:inline">Image</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Metadata Row (Dates/Timestamps) */}
|
{/* Metadata Row (Dates/Timestamps) */}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { Mention } from "@tiptap/extension-mention";
|
|||||||
import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion";
|
import { userSuggestion, noteSuggestion } from "@/lib/mention-suggestion";
|
||||||
import { FileViewer } from "@/lib/extensions/file-viewer";
|
import { FileViewer } from "@/lib/extensions/file-viewer";
|
||||||
import { ImageUpload } from "@/lib/extensions/image-upload";
|
import { ImageUpload } from "@/lib/extensions/image-upload";
|
||||||
|
import { Video } from "@/lib/extensions/video";
|
||||||
|
import { VideoUpload } from "@/lib/extensions/video-upload";
|
||||||
|
|
||||||
interface TaskEditorProps {
|
interface TaskEditorProps {
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -163,6 +165,8 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
}),
|
}),
|
||||||
FileViewer,
|
FileViewer,
|
||||||
ImageUpload,
|
ImageUpload,
|
||||||
|
Video,
|
||||||
|
VideoUpload,
|
||||||
],
|
],
|
||||||
// Use untrack so the editor doesn't re-initialize when props.content changes
|
// Use untrack so the editor doesn't re-initialize when props.content changes
|
||||||
content: untrack(() => props.content) || "",
|
content: untrack(() => props.content) || "",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
// 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('<p></p>')
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return "Video uploaded.";
|
||||||
|
},
|
||||||
|
error: "Failed to upload video."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
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<ReturnType> {
|
||||||
|
video: {
|
||||||
|
setVideo: (options: { src: string }) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -579,7 +579,7 @@ const mapRecordToTask = (r: any): Task => {
|
|||||||
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<string> => {
|
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('attachments', file);
|
formData.append('attachments+', file);
|
||||||
|
|
||||||
const record = await pb.collection(TASGRID_COLLECTION).update(taskId, formData);
|
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");
|
throw new Error("File uploaded but no attachment returned");
|
||||||
} catch (err: any) {
|
} 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.");
|
toast.error("Failed to upload image.");
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -602,7 +602,7 @@ export const uploadTaskAttachment = async (taskId: string, file: File): Promise<
|
|||||||
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<string> => {
|
export const uploadNoteAttachment = async (noteId: string, file: File): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('attachments', file);
|
formData.append('attachments+', file);
|
||||||
|
|
||||||
const record = await pb.collection(NOTES_COLLECTION).update(noteId, formData);
|
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");
|
throw new Error("File uploaded but no attachment returned");
|
||||||
} catch (err: any) {
|
} 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.");
|
toast.error("Failed to upload image to note.");
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { type Component, createSignal, createMemo, createEffect, onCleanup, For,
|
|||||||
import { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag } from "@/store";
|
import { store, renameTagDefinition, updateNote, removeNote, restoreNote, setActiveNoteId, cleanupNoteAttachments, getFavoriteTag } 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, 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 { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||||
import { DragDropProvider, DragDropSensors, SortableProvider, createSortable, closestCenter } from "@thisbeyond/solid-dnd";
|
import { DragDropProvider, DragDropSensors, SortableProvider, createSortable, closestCenter } from "@thisbeyond/solid-dnd";
|
||||||
@@ -462,6 +462,19 @@ export const NotepadView: Component<{
|
|||||||
<ImageIcon size={14} class="opacity-70" />
|
<ImageIcon size={14} class="opacity-70" />
|
||||||
<span>Image</span>
|
<span>Image</span>
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0 pt-1">
|
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user