diff --git a/public/loading_data_from_Prism.md b/public/loading_data_from_Prism.md new file mode 100644 index 0000000..00673d0 --- /dev/null +++ b/public/loading_data_from_Prism.md @@ -0,0 +1,44 @@ +# Accessing Job Files from Sister App + +This plan outlines how a sister application can access job data and physical files managed by Prism. + +## Proposed Strategy + +### 1. Accessing Job Metadata (Database) +The source of truth for all job information (names, numbers, addresses, and file paths) is the PocketBase instance. + +**Recommendation: Direct PocketBase SDK Integration** +- The sister app should use the [PocketBase JavaScript/TypeScript SDK](https://github.com/pocketbase/js-sdk). +- Point the SDK to the same URL as Prism (e.g., `http://127.0.0.1:8090` or your production domain). +- **Authentication**: The sister app will need its own authentication or a way to share the `pb_auth` token. + +### 2. Accessing Physical Files (NAS/Local Folders) +Prism uses two types of file links: +- **`Job_Folder_Link`**: URLs (SharePoint, OneDrive, etc.). These can be opened directly as `` tags or via `window.open()`. +- **`Folder_Local`**: UNC paths (e.g., `\\server\share\folder`). + +**Recommendation: Shared Local RPC Tool** +- Prism currently uses a local helper running on `http://localhost:8080` to open folders in File Explorer. +- The sister app can use the exact same logic found in [nasOpener.ts](file:///c:/Users/TimothyCardoza/Documents/AI-Apps/Prism/PRISM/src/lib/nasOpener.ts) to communicate with this tool. +- This allows both apps to provide a seamless "Open Folder" experience on the same machine. + +### 3. Proposed API Enhancements (Optional) +If the sister app cannot talk to PocketBase directly (e.g., security restrictions), we can add a "Service Account" or "API Key" mechanism to Prism to proxy requests. + +## Proposed Changes + +### [Component Name] +No changes are strictly required to Prism's code unless you want a dedicated API for the sister app. + +#### [MODIFY] [nasOpener.ts](file:///c:/Users/TimothyCardoza/Documents/AI-Apps/Prism/PRISM/src/lib/nasOpener.ts) +- Potentially export the RPC URL as a shared constant if both apps are in the same monorepo or share a library. + +## Verification Plan + +### Automated Tests +- Test that the sister app can fetch job data from PocketBase using the same collection names (`Job_Info_Prod`). +- Verify that `http://localhost:8080/health` is reachable from the sister app's environment. + +### Manual Verification +- Attempt to open a job folder from the sister app and confirm it opens in the local system's File Explorer. +- Confirm that the sister app correctly resolves job IDs to their respective `Job_Number` or metadata. diff --git a/src/components/SlashMenu.tsx b/src/components/SlashMenu.tsx index 36f2fa1..afea61b 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 + Trash2, AppWindow } from "lucide-solid"; import { cn, convertImageToWebpFile } from "@/lib/utils"; import { activeTaskId, uploadTaskAttachment, activeNoteId, uploadNoteAttachment } from "@/store"; @@ -172,6 +172,15 @@ export const getSuggestionItems = ({ query }: { query: string }): CommandItem[] input.click(); }, }, + { + title: "Job File", + description: "Embed a job file from Prism.", + aliases: ["file", "embed", "job", "pdf"], + icon: AppWindow, + command: ({ editor, range }: { editor: any, range: any }) => { + editor.chain().focus().deleteRange(range).insertFileViewer().run(); + }, + }, { title: "Divider", description: "Insert a horizontal rule.", diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index bc2da06..2538e79 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -26,6 +26,7 @@ import { Typography } from "@tiptap/extension-typography"; 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"; interface TaskEditorProps { content?: string; @@ -159,6 +160,7 @@ export const TaskEditor: Component = (props) => { color: "var(--primary)", width: 2, }), + FileViewer, ], // Use untrack so the editor doesn't re-initialize when props.content changes content: untrack(() => props.content) || "", @@ -189,6 +191,7 @@ export const TaskEditor: Component = (props) => { "[&_.tiptap-task-content>p]:min-h-[1.5rem] [&_.tiptap-task-content>p]:m-0 [&_.tiptap-task-content>p]:w-full", "[&_.tiptap-task-content>p:empty]:after:content-['\\200B'] [&_.tiptap-task-content>p:empty]:after:inline-block", "[&_.tiptap-task-content>p>br]:after:content-['\\200B'] [&_.tiptap-task-content>p>br]:after:inline-block", // if tiptap adds br + "[&>.file-viewer-wrapper:first-child]:mt-0 [&>.file-viewer-wrapper:first-child]:border-t-0 [&>.file-viewer-wrapper:first-child]:border-x-0 [&>.file-viewer-wrapper:first-child]:rounded-none [&>.file-viewer-wrapper:first-child]:bg-transparent [&>.file-viewer-wrapper:first-child]:h-[80vh]", props.class ), }, diff --git a/src/lib/extensions/file-viewer.ts b/src/lib/extensions/file-viewer.ts new file mode 100644 index 0000000..1aed48d --- /dev/null +++ b/src/lib/extensions/file-viewer.ts @@ -0,0 +1,174 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +export interface FileViewerOptions { + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + fileViewer: { + insertFileViewer: (options?: { src?: string }) => ReturnType; + }; + } +} + +export const FileViewer = Node.create({ + name: 'fileViewer', + + group: 'block', + + selectable: true, + + draggable: true, + + addOptions() { + return { + HTMLAttributes: { + class: 'file-viewer-node', + }, + }; + }, + + addAttributes() { + return { + src: { + default: null, + parseHTML: element => { + const iframe = element.querySelector('iframe'); + if (iframe) { + return iframe.getAttribute('src'); + } + return element.getAttribute('src'); + }, + renderHTML: attributes => { + if (!attributes.src) { + return {}; + } + return { + src: attributes.src, + }; + } + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'div[data-type="file-viewer-wrapper"]', + } + ]; + }, + + renderHTML({ HTMLAttributes }) { + const { src, ...rest } = HTMLAttributes; + return ['div', mergeAttributes(this.options.HTMLAttributes, rest, { 'data-type': 'file-viewer-wrapper', class: 'file-viewer-wrapper relative w-full my-4 rounded-xl overflow-hidden border border-border flex min-h-[400px] bg-muted/20' }), + ['iframe', { 'data-type': 'file-viewer', src: src, class: 'w-full h-full border-0 absolute inset-0' }] + ]; + }, + + addNodeView() { + return ({ node, getPos, editor }) => { + const wrapper = document.createElement('div'); + wrapper.classList.add('file-viewer-wrapper', 'relative', 'w-full', 'my-4', 'rounded-xl', 'overflow-hidden', 'border', 'border-border', 'flex', 'bg-muted/10', 'group'); + + if (node.attrs.src) { + // Typical file viewer mode + wrapper.style.minHeight = '400px'; + + const iframe = document.createElement('iframe'); + iframe.src = node.attrs.src; + iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0'); + + // Add a small drag handle / delete button overlay that appears on hover + 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 = ''; + deleteBtn.classList.add('p-1', 'cursor-pointer'); + deleteBtn.onclick = (e) => { + e.preventDefault(); + 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(iframe); + wrapper.appendChild(overlay); + } else { + // Form mode + wrapper.classList.add('items-center', 'justify-center', 'flex-col', 'p-8', 'gap-4'); + + const icon = document.createElement('div'); + // AppWindow Lucide Icon + icon.innerHTML = ''; + + const title = document.createElement('div'); + title.textContent = 'Embed File or Website'; + title.classList.add('font-medium', 'text-sm', 'text-muted-foreground'); + + const form = document.createElement('form'); + form.classList.add('flex', 'w-full', 'max-w-md', 'gap-2', 'relative', 'z-10'); + + const input = document.createElement('input'); + input.type = 'url'; + input.placeholder = 'https://...'; + input.classList.add('flex-1', 'bg-background', 'border', 'border-border', 'rounded-lg', 'px-3', 'py-1.5', 'text-sm', 'outline-none', 'focus:ring-1', 'focus:ring-primary/50'); + + const button = document.createElement('button'); + button.type = 'submit'; + button.textContent = 'Embed'; + button.classList.add('bg-primary', 'text-primary-foreground', 'px-3', 'py-1.5', 'rounded-lg', 'text-sm', 'font-medium', 'hover:bg-primary/90', 'cursor-pointer'); + + form.onsubmit = (e) => { + e.preventDefault(); + const url = input.value.trim(); + if (url && typeof getPos === 'function') { + editor.commands.command(({ tr }) => { + const pos = getPos(); + if (typeof pos === 'number') { + tr.setNodeMarkup(pos, undefined, { src: url }); + return true; + } + return false; + }); + } + }; + + form.appendChild(input); + form.appendChild(button); + + wrapper.appendChild(icon); + wrapper.appendChild(title); + wrapper.appendChild(form); + + // Clicking wrapper focuses input + wrapper.onclick = () => { + input.focus(); + }; + } + + return { + dom: wrapper, + stopEvent: () => true, + ignoreMutation: () => true, + }; + }; + }, + + addCommands() { + return { + insertFileViewer: (options?: { src?: string }) => ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options || {}, + }); + }, + }; + }, +}); diff --git a/src/views/NotepadView.tsx b/src/views/NotepadView.tsx index ced99c6..879ade7 100644 --- a/src/views/NotepadView.tsx +++ b/src/views/NotepadView.tsx @@ -331,6 +331,13 @@ export const NotepadView: Component<{ const isOwner = note().user === currentUserId; const canEdit = isOwner || !note().isPrivate; + const hasTopViewer = createMemo(() => { + const c = note()?.content; + if (!c) return false; + const s = c.trim(); + return s.startsWith('
{/* Desktop Style Tabs (Left Side) - Ribbon Bookmark Style */} @@ -386,10 +393,15 @@ export const NotepadView: Component<{
{/* Editor Area */} -
+
- -
+
@@ -466,20 +478,25 @@ export const NotepadView: Component<{
-
{/* Inside Tabs Section Removed: Sub-tabs are now displayed on the outside as hanging bubbles */} {/* Editor Instance */} -
+
handleUpdateContent(note().id, html)} 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" : ""} />
-
+ +
+
{/* Linked Tasks Sidebar */}