basic job file function
This commit is contained in:
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { render } from 'solid-js/web';
|
||||
import { JobFileSelector } from '@/components/JobFileSelector';
|
||||
|
||||
export interface FileViewerOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -24,31 +26,16 @@ export const FileViewer = Node.create<FileViewerOptions>({
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: 'file-viewer-node',
|
||||
class: 'file-viewer-node group relative',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
},
|
||||
jobId: { default: null },
|
||||
collectionId: { default: 'Job_Info_Prod' },
|
||||
filename: { default: null },
|
||||
};
|
||||
},
|
||||
|
||||
@@ -61,8 +48,12 @@ export const FileViewer = Node.create<FileViewerOptions>({
|
||||
},
|
||||
|
||||
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' }),
|
||||
const { jobId, collectionId, filename, ...rest } = HTMLAttributes;
|
||||
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' }]
|
||||
];
|
||||
},
|
||||
@@ -72,15 +63,17 @@ export const FileViewer = Node.create<FileViewerOptions>({
|
||||
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 hasFile = node.attrs.jobId && node.attrs.filename;
|
||||
|
||||
if (hasFile) {
|
||||
// Viewer mode
|
||||
wrapper.style.minHeight = '500px';
|
||||
|
||||
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');
|
||||
|
||||
// Add a small drag handle / delete button overlay that appears on hover
|
||||
// Delete button overlay
|
||||
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');
|
||||
|
||||
@@ -100,64 +93,34 @@ export const FileViewer = Node.create<FileViewerOptions>({
|
||||
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 = '<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>';
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
return {
|
||||
dom: wrapper,
|
||||
stopEvent: () => true,
|
||||
ignoreMutation: () => true,
|
||||
};
|
||||
} 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);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
stopEvent: () => true,
|
||||
ignoreMutation: () => true,
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user