Compare commits
3 Commits
bc9060e00d
...
092e139f6e
| Author | SHA1 | Date | |
|---|---|---|---|
| 092e139f6e | |||
| 1c8e8b1d54 | |||
| 8dfd0c8274 |
@@ -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,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 `<a>` 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.
|
||||
@@ -0,0 +1,198 @@
|
||||
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 (traversing up hierarchy)
|
||||
onMount(() => {
|
||||
const currentNoteId = activeNoteId();
|
||||
if (currentNoteId) {
|
||||
let currentId = currentNoteId;
|
||||
let currentNote = store.notes.find(n => n.id === currentId);
|
||||
let depth = 0;
|
||||
|
||||
// Traverse up "child-of-" tags to find the root or any ancestor with a job_id
|
||||
while (currentNote && depth < 10) {
|
||||
const jobIdTag = currentNote.tags?.find(t => t.startsWith("job_id:"));
|
||||
if (jobIdTag) {
|
||||
const id = jobIdTag.split(":")[1];
|
||||
if (id) {
|
||||
setSelectedJobId(id);
|
||||
return; // Found it!
|
||||
}
|
||||
}
|
||||
|
||||
const parentTag = currentNote.tags?.find(t => t.startsWith("child-of-"));
|
||||
if (!parentTag) break;
|
||||
|
||||
const parentId = parentTag.replace("child-of-", "");
|
||||
const parent = store.notes.find(n => n.id === parentId);
|
||||
if (!parent) break;
|
||||
|
||||
currentNote = parent;
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<TaskEditorProps> = (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<TaskEditorProps> = (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
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { render } from 'solid-js/web';
|
||||
import { JobFileSelector } from '@/components/JobFileSelector';
|
||||
|
||||
export interface FileViewerOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
fileViewer: {
|
||||
insertFileViewer: (options?: { src?: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const FileViewer = Node.create<FileViewerOptions>({
|
||||
name: 'fileViewer',
|
||||
|
||||
group: 'block',
|
||||
|
||||
selectable: true,
|
||||
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: 'file-viewer-node group relative',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
jobId: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-job-id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.jobId) return {};
|
||||
return { 'data-job-id': attributes.jobId };
|
||||
},
|
||||
},
|
||||
collectionId: {
|
||||
default: 'Job_Info_Prod',
|
||||
parseHTML: element => element.getAttribute('data-collection-id') || 'Job_Info_Prod',
|
||||
renderHTML: attributes => {
|
||||
return { 'data-collection-id': attributes.collectionId || 'Job_Info_Prod' };
|
||||
},
|
||||
},
|
||||
filename: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-filename'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.filename) return {};
|
||||
return { 'data-filename': attributes.filename };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'div[data-type="file-viewer-wrapper"]',
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
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' }]
|
||||
];
|
||||
},
|
||||
|
||||
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');
|
||||
|
||||
const hasFile = node.attrs.jobId && node.attrs.filename;
|
||||
|
||||
if (hasFile) {
|
||||
// Viewer mode
|
||||
wrapper.style.minHeight = '500px';
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
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');
|
||||
|
||||
// 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');
|
||||
|
||||
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();
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
stopEvent: () => true,
|
||||
ignoreMutation: () => true,
|
||||
};
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertFileViewer: (options?: { src?: string }) => ({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: options || {},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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('<div data-type="file-viewer-wrapper"');
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Style Tabs (Left Side) - Ribbon Bookmark Style */}
|
||||
@@ -386,10 +393,15 @@ export const NotepadView: Component<{
|
||||
<div class="flex-1 md:flex-initial md:shrink w-full md:w-auto flex flex-col md:flex-row min-w-0 md:h-full md:bg-card md:border md:border-border md:rounded-xl md:shadow-[0_4px_12px_rgba(0,0,0,0.02)] overflow-visible md:overflow-hidden z-20 relative shadow-[-4px_0_12px_rgba(0,0,0,0.02)] transition-all duration-300">
|
||||
|
||||
{/* Editor Area */}
|
||||
<div class="flex-1 md:flex-initial md:shrink flex flex-col min-w-0 md:w-[960px] lg:w-[1120px] md:h-full overflow-visible md:overflow-y-auto relative px-4 md:px-6 pb-4 md:pb-6 space-y-4 md:space-y-6 scroll-smooth pt-0 bg-background z-20">
|
||||
<div class={cn(
|
||||
"flex-1 md:flex-initial md:shrink flex flex-col min-w-0 md:w-[960px] lg:w-[1120px] md:h-full overflow-visible md:overflow-y-auto relative scroll-smooth bg-background z-20 pt-0",
|
||||
hasTopViewer() ? "p-0 space-y-0" : "px-4 md:px-6 pb-4 md:pb-6 space-y-4 md:space-y-6"
|
||||
)}>
|
||||
|
||||
|
||||
<div class="space-y-4 sticky top-0 z-30 bg-card/95 backdrop-blur-sm pt-4 pb-2 -mx-4 px-4 md:pt-6 md:-mx-6 md:px-6 md:pb-2">
|
||||
<div class={cn(
|
||||
"sticky top-0 z-30 bg-card/95 backdrop-blur-sm pb-2",
|
||||
hasTopViewer() ? "px-4 md:px-6 pt-4 md:pt-6 border-b border-border/50" : "space-y-4 pt-4 -mx-4 px-4 md:pt-6 md:-mx-6 md:px-6 md:pb-2"
|
||||
)}>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col min-w-0 flex-1 gap-1">
|
||||
<Show when={rootParentNote()}>
|
||||
@@ -466,20 +478,25 @@ export const NotepadView: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Inside Tabs Section Removed: Sub-tabs are now displayed on the outside as hanging bubbles */}
|
||||
|
||||
{/* Editor Instance */}
|
||||
<div class="grow shrink-0 flex flex-col min-h-[300px] border border-border/50 rounded-xl p-4 bg-background shadow-sm mb-4">
|
||||
<div class={cn(
|
||||
"grow shrink-0 flex flex-col",
|
||||
hasTopViewer() ? "border-none rounded-none p-0 shadow-none mb-0 min-h-full" : "min-h-[300px] mb-4 p-4 border border-border/50 rounded-xl bg-background shadow-sm"
|
||||
)}>
|
||||
<TaskEditor
|
||||
content={note().content}
|
||||
onUpdate={(html) => 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" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div class="shrink-0 h-16 md:h-8" />
|
||||
<Show when={!hasTopViewer()}>
|
||||
<div class="shrink-0 h-16 md:h-8" />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Linked Tasks Sidebar */}
|
||||
|
||||
Reference in New Issue
Block a user