basic job file function

This commit is contained in:
2026-03-09 09:44:12 -05:00
parent 8dfd0c8274
commit 1c8e8b1d54
3 changed files with 240 additions and 79 deletions
+15
View File
@@ -0,0 +1,15 @@
import PocketBase from 'pocketbase';
const pb = new PocketBase('https://pocketbase.ccllc.pro');
async function check() {
try {
await pb.admins.authWithPassword("timothy@ccllc.pro", 'pass');
const res = await pb.collection('Job_Info_Prod').getList(1, 1);
console.log("Got item:", res.items[0]);
} catch (e) {
console.error("Auth / Fetch Error", e);
}
}
check();
+183
View File
@@ -0,0 +1,183 @@
import { createSignal, createResource, Show, For, onMount } from "solid-js";
import { pb } from "@/lib/pocketbase";
import { store, activeNoteId } from "@/store";
import { Search, File as FileIcon, Loader2, FolderOpen } from "lucide-solid";
interface JobFileSelectorProps {
onSelectFile: (jobId: string, filename: string) => void;
}
export const JobFileSelector = (props: JobFileSelectorProps) => {
const [query, setQuery] = createSignal("");
const [selectedJobId, setSelectedJobId] = createSignal<string | null>(null);
// Auto-detect job ID from active note tags
onMount(() => {
const currentNoteId = activeNoteId();
if (currentNoteId) {
const currentNote = store.notes.find(n => n.id === currentNoteId);
if (currentNote) {
const jobIdTag = currentNote.tags.find(t => t.startsWith("job_id:"));
if (jobIdTag) {
const id = jobIdTag.split(":")[1];
if (id) {
setSelectedJobId(id);
}
}
}
}
});
// Query Jobs
const [jobs] = createResource(query, async (q) => {
if (!q.trim()) return [];
try {
const res = await pb.collection('Job_Info_Prod').getList(1, 10, {
filter: `Job_Number ~ "${q}" || Job_Name ~ "${q}"`,
sort: '-created',
requestKey: null // Allow concurrent overlapping requests without auto-cancelling if needed, or leave it
});
return res.items;
} catch (e) {
return [];
}
});
// Fetch details for the selected job to list files
const [selectedJob] = createResource(selectedJobId, async (id) => {
if (!id) return null;
try {
const data = await pb.collection('Job_Info_Prod').getOne(id);
console.log("Job File Selector Selected Job Data:", data);
return data;
} catch (e) {
console.error("Job File Selector Error Fetching Job:", e);
return null;
}
});
const categories = ["plans", "EST_and_CO", "Addendums", "files"] as const;
return (
<div class="w-full flex flex-col items-center justify-center p-8 gap-6 bg-muted/10 rounded-xl relative">
<Show when={!selectedJobId()}>
<div class="text-center w-full max-w-lg space-y-4 relative z-10">
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 text-primary mx-auto mb-4">
<FolderOpen size={24} />
</div>
<div class="space-y-1">
<h3 class="font-semibold text-lg">Embed a Job File</h3>
<p class="text-sm text-muted-foreground">Search for a Prism job to browse its files.</p>
</div>
<div class="relative w-full">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<input
type="text"
placeholder="Search by Job Name or Number..."
class="w-full pl-9 pr-4 py-2 bg-background border border-border rounded-lg outline-none focus:ring-2 focus:ring-primary/50 transition-all text-sm"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
/>
</div>
<Show when={jobs.loading}>
<div class="flex items-center justify-center py-4 text-muted-foreground">
<Loader2 class="w-5 h-5 animate-spin" />
</div>
</Show>
<Show when={jobs() && jobs()!.length > 0}>
<div class="mt-4 border border-border bg-background rounded-lg shadow-sm overflow-hidden text-left flex flex-col">
<For each={jobs()}>
{(job) => (
<button
class="px-4 py-3 hover:bg-muted text-left flex flex-col gap-1 border-b border-border last:border-0 transition-colors cursor-pointer"
onClick={() => setSelectedJobId(job.id)}
>
<span class="font-medium text-sm text-foreground">{job.Job_Name || "Unnamed Job"}</span>
<span class="text-xs text-muted-foreground">Job #{job.Job_Number || job.id}</span>
</button>
)}
</For>
</div>
</Show>
</div>
</Show>
<Show when={selectedJobId()}>
<Show when={selectedJob.loading}>
<div class="flex flex-col items-center justify-center gap-4 py-12 relative z-10">
<Loader2 class="w-8 h-8 animate-spin text-primary" />
<span class="text-sm text-muted-foreground font-medium">Loading Job Files...</span>
</div>
</Show>
<Show when={selectedJob()}>
{(jobRecord) => (
<div class="w-full max-w-2xl bg-background rounded-xl border border-border shadow-sm overflow-hidden relative z-10">
<div class="p-4 border-b border-border bg-muted/30 flex items-center justify-between">
<div class="flex flex-col">
<h3 class="font-semibold">{jobRecord().Job_Name || "Unnamed Job"}</h3>
<span class="text-xs text-muted-foreground">Job #{jobRecord().Job_Number || jobRecord().id}</span>
</div>
<button
class="text-xs text-blue-500 hover:text-blue-600 font-medium px-2 py-1 hover:bg-blue-50 rounded transition-colors cursor-pointer"
onClick={() => setSelectedJobId(null)}
>
Change Job
</button>
</div>
<div class="p-4 flex flex-col gap-6 max-h-[400px] overflow-y-auto">
<For each={categories}>
{(category) => {
let files = jobRecord()[category];
if (files === null || files === undefined || files === "") return null;
if (!Array.isArray(files)) files = [files];
files = files.filter(Boolean);
if (files.length === 0) return null;
return (
<div class="space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">{category.replace(/_/g, " ")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<For each={files as string[]}>
{(filename) => (
<button
class="flex items-center gap-3 p-2 rounded-md border border-border hover:bg-muted hover:border-primary/50 transition-colors text-left cursor-pointer group"
onClick={() => props.onSelectFile(jobRecord().id, filename)}
>
<div class="p-2 rounded bg-muted text-muted-foreground group-hover:text-primary transition-colors shrink-0">
<FileIcon class="w-4 h-4" />
</div>
<span class="text-sm truncate font-medium text-foreground min-w-0" title={filename}>
{filename}
</span>
</button>
)}
</For>
</div>
</div>
);
}}
</For>
{/* Empty state if no files at all */}
<Show when={categories.every(c => {
const val = jobRecord()[c];
return !val || (Array.isArray(val) && val.length === 0) || (typeof val === 'string' && val.trim() === '');
})}>
<div class="text-center py-8 text-muted-foreground text-sm">
No files found for this job.
</div>
</Show>
</div>
</div>
)}
</Show>
</Show>
</div>
);
};
+42 -79
View File
@@ -1,4 +1,6 @@
import { Node, mergeAttributes } from '@tiptap/core'; import { Node, mergeAttributes } from '@tiptap/core';
import { render } from 'solid-js/web';
import { JobFileSelector } from '@/components/JobFileSelector';
export interface FileViewerOptions { export interface FileViewerOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@@ -24,31 +26,16 @@ export const FileViewer = Node.create<FileViewerOptions>({
addOptions() { addOptions() {
return { return {
HTMLAttributes: { HTMLAttributes: {
class: 'file-viewer-node', class: 'file-viewer-node group relative',
}, },
}; };
}, },
addAttributes() { addAttributes() {
return { return {
src: { jobId: { default: null },
default: null, collectionId: { default: 'Job_Info_Prod' },
parseHTML: element => { filename: { default: null },
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,
};
}
},
}; };
}, },
@@ -61,8 +48,12 @@ export const FileViewer = Node.create<FileViewerOptions>({
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
const { src, ...rest } = HTMLAttributes; const { jobId, collectionId, filename, ...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' }), let src = '';
if (jobId && filename) {
src = `https://pocketbase.ccllc.pro/api/files/${collectionId}/${jobId}/${filename}`;
}
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-[500px] bg-muted/20' }),
['iframe', { 'data-type': 'file-viewer', src: src, class: 'w-full h-full border-0 absolute inset-0' }] ['iframe', { 'data-type': 'file-viewer', src: src, class: 'w-full h-full border-0 absolute inset-0' }]
]; ];
}, },
@@ -72,15 +63,17 @@ export const FileViewer = Node.create<FileViewerOptions>({
const wrapper = document.createElement('div'); 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'); 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) { const hasFile = node.attrs.jobId && node.attrs.filename;
// Typical file viewer mode
wrapper.style.minHeight = '400px'; if (hasFile) {
// Viewer mode
wrapper.style.minHeight = '500px';
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.src = node.attrs.src; iframe.src = `https://pocketbase.ccllc.pro/api/files/${node.attrs.collectionId}/${node.attrs.jobId}/${node.attrs.filename}`;
iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0'); iframe.classList.add('w-full', 'h-full', 'border-0', 'absolute', 'inset-0');
// Add a small drag handle / delete button overlay that appears on hover // Delete button overlay
const overlay = document.createElement('div'); 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'); 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');
@@ -100,64 +93,34 @@ export const FileViewer = Node.create<FileViewerOptions>({
overlay.appendChild(deleteBtn); overlay.appendChild(deleteBtn);
wrapper.appendChild(iframe); wrapper.appendChild(iframe);
wrapper.appendChild(overlay); wrapper.appendChild(overlay);
} else {
// Form mode
wrapper.classList.add('items-center', 'justify-center', 'flex-col', 'p-8', 'gap-4');
const icon = document.createElement('div'); return {
// AppWindow Lucide Icon dom: wrapper,
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground/50"><rect x="2" y="4" width="20" height="16" rx="2"></rect><path d="M10 4v4"></path><path d="M2 8h20"></path><path d="M6 4v4"></path></svg>'; stopEvent: () => true,
ignoreMutation: () => true,
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;
});
}
}; };
} else {
render(() => JobFileSelector({
onSelectFile: (jobId: string, filename: string) => {
if (typeof getPos === 'function') {
editor.commands.command(({ tr }) => {
const pos = getPos();
if (typeof pos === 'number') {
tr.setNodeMarkup(pos, undefined, { jobId, collectionId: 'Job_Info_Prod', filename });
return true;
}
return false;
});
}
}
}), wrapper);
form.appendChild(input); return {
form.appendChild(button); dom: wrapper,
stopEvent: () => true,
wrapper.appendChild(icon); ignoreMutation: () => true,
wrapper.appendChild(title);
wrapper.appendChild(form);
// Clicking wrapper focuses input
wrapper.onclick = () => {
input.focus();
}; };
} }
return {
dom: wrapper,
stopEvent: () => true,
ignoreMutation: () => true,
};
}; };
}, },