file upload
This commit is contained in:
@@ -1,14 +1,5 @@
|
|||||||
import { type Component, createSignal, For, createEffect } from "solid-js";
|
import { type Component, createSignal, For, createEffect } from "solid-js";
|
||||||
import {
|
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";
|
||||||
Heading1, Heading2, Heading3,
|
|
||||||
List, ListTodo, Type,
|
|
||||||
Code, Quote,
|
|
||||||
Image as ImageIcon, Minus,
|
|
||||||
Bold, Italic, Underline,
|
|
||||||
Grid3X3, Highlighter,
|
|
||||||
AlignLeft, AlignCenter, AlignRight,
|
|
||||||
Trash2, AppWindow, Film
|
|
||||||
} from "lucide-solid";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface CommandItem {
|
export interface CommandItem {
|
||||||
@@ -132,10 +123,19 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[]
|
|||||||
editor.chain().focus().deleteRange(range).uploadVideo().run();
|
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",
|
title: "Job File",
|
||||||
description: "Embed a job file from Prism.",
|
description: "Embed a job file from Prism.",
|
||||||
aliases: ["file", "embed", "job", "pdf"],
|
aliases: ["job", "jobfile", "prism"],
|
||||||
icon: AppWindow,
|
icon: AppWindow,
|
||||||
command: ({ editor, range }: { editor: any, range: any }) => {
|
command: ({ editor, range }: { editor: any, range: any }) => {
|
||||||
editor.chain().focus().deleteRange(range).insertFileViewer().run();
|
editor.chain().focus().deleteRange(range).insertFileViewer().run();
|
||||||
|
|||||||
@@ -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, Film } 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 { StatusCircle } from "./StatusCircle";
|
||||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -695,6 +695,34 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Close button (Mobile only) */}
|
{/* 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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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 { Video } from "@/lib/extensions/video";
|
||||||
import { VideoUpload } from "@/lib/extensions/video-upload";
|
import { VideoUpload } from "@/lib/extensions/video-upload";
|
||||||
|
import { FileAttachment } from "@/lib/extensions/file-attachment";
|
||||||
|
import { FileUpload } from "@/lib/extensions/file-upload";
|
||||||
|
|
||||||
interface TaskEditorProps {
|
interface TaskEditorProps {
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -167,6 +169,8 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
ImageUpload,
|
ImageUpload,
|
||||||
Video,
|
Video,
|
||||||
VideoUpload,
|
VideoUpload,
|
||||||
|
FileAttachment,
|
||||||
|
FileUpload,
|
||||||
],
|
],
|
||||||
// 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,221 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
filename: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
filesize: {
|
||||||
|
default: null,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
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, {
|
||||||
|
'data-type': 'file-attachment',
|
||||||
|
'data-src': HTMLAttributes.src,
|
||||||
|
'data-filename': HTMLAttributes.filename,
|
||||||
|
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, { 'data-type': 'file-attachment', 'data-src': HTMLAttributes.src, 'data-filename': HTMLAttributes.filename }),
|
||||||
|
['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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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 filename = file.name;
|
||||||
|
const filesize = file.size.toString();
|
||||||
|
|
||||||
|
toast.promise(
|
||||||
|
(async () => {
|
||||||
|
if (taskId) {
|
||||||
|
return uploadTaskAttachment(taskId, file);
|
||||||
|
} else {
|
||||||
|
return uploadNoteAttachment(noteId!, file);
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
{
|
||||||
|
loading: "Uploading file...",
|
||||||
|
success: (url) => {
|
||||||
|
// Insert file attchment UI card
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.insertFileAttachment({ src: url, filename, 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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, Film } 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 { 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";
|
||||||
@@ -336,7 +336,8 @@ export const NotepadView: Component<{
|
|||||||
const c = note()?.content;
|
const c = note()?.content;
|
||||||
if (!c) return false;
|
if (!c) return false;
|
||||||
const s = c.trim();
|
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 (
|
return (
|
||||||
@@ -475,6 +476,19 @@ export const NotepadView: Component<{
|
|||||||
<Film size={14} class="opacity-70" />
|
<Film size={14} class="opacity-70" />
|
||||||
<span>Video</span>
|
<span>Video</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().uploadFile().run();
|
||||||
|
}}
|
||||||
|
title="Attach File"
|
||||||
|
>
|
||||||
|
<FileText size={14} class="opacity-70" />
|
||||||
|
<span>File</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">
|
||||||
@@ -535,7 +549,7 @@ export const NotepadView: Component<{
|
|||||||
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
onUpdate={(html) => handleUpdateContent(note().id, html)}
|
||||||
onEditorReady={setEditorInstance}
|
onEditorReady={setEditorInstance}
|
||||||
editable={canEdit}
|
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>
|
</div>
|
||||||
<Show when={!hasTopViewer()}>
|
<Show when={!hasTopViewer()}>
|
||||||
@@ -565,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")} />
|
<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={
|
<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" />
|
<LinkIcon size={10} class="mr-2 rotate-90" />
|
||||||
Linked Tasks
|
Linked Tasks
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<h3 class="text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-2">
|
<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
|
Linked Tasks
|
||||||
</h3>
|
</h3>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!isLinkedTasksOpen()}>
|
<Show when={!isLinkedTasksOpen()}>
|
||||||
<div class="md:hidden flex items-center justify-center text-[0.625rem] font-bold uppercase tracking-widest text-muted-foreground">
|
<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
|
Linked Tasks
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -593,7 +607,7 @@ export const NotepadView: Component<{
|
|||||||
}}
|
}}
|
||||||
title="Link Existing Task"
|
title="Link Existing Task"
|
||||||
>
|
>
|
||||||
<Link size={14} />
|
<LinkIcon size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -644,7 +658,7 @@ export const NotepadView: Component<{
|
|||||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
<For each={linkedTasks()} fallback={
|
<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">
|
<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>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>
|
<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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user