might work, basic writing page with linking. no saving.

This commit is contained in:
2026-06-28 10:43:47 -05:00
parent 914c9fbac8
commit c04738f9c4
12 changed files with 745 additions and 3 deletions
+24
View File
@@ -0,0 +1,24 @@
/* Custom component node pill styling inside the editor wrapper */
.homelab-app-reference-pill {
background-color: rgba(
16,
185,
129,
0.1
); /* Emerald tint matching your UI accents */
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 0.375rem;
padding: 0.1rem 0.4rem;
font-weight: 500;
font-size: 0.875rem;
display: inline-flex;
align-items: center;
user-select: none;
}
/* Visually highlight the whole card pill block if a user hits delete or selects it */
.ProseMirror-selectednode .homelab-app-reference-pill {
outline: 2px solid #10b981;
outline-offset: 1px;
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Highlight from "@tiptap/extension-highlight";
import TextAlign from "@tiptap/extension-text-align";
import { Markdown } from "@tiptap/markdown";
import { HomelabApp } from "./types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import Menubar from "./menu-bar";
interface TextEditorProps {
content: string;
onSave: (content: string, linkedAppIds: string[]) => void;
initialLinkedAppIds?: string[]; // Added prop
}
const getApps = async (): Promise<HomelabApp[]> => {
return (await invoke("get_app")) as HomelabApp[];
};
export default function TextEditor({
content,
onSave,
initialLinkedAppIds = [],
}: TextEditorProps) {
const [availableApps, setAvailableApps] = useState<HomelabApp[]>([]);
// Seed state instantly from the prop if it exists
const [linkedAppIds, setLinkedAppIds] =
useState<string[]>(initialLinkedAppIds);
useEffect(() => {
const fetchApps = async () => {
const apps = await getApps();
setAvailableApps(apps);
};
fetchApps();
}, []);
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: { HTMLAttributes: { class: "list-disc ml-3" } },
orderedList: { HTMLAttributes: { class: "list-decimal ml-3" } },
}),
Highlight.configure({ HTMLAttributes: { class: "bg-yellow-200" } }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Markdown,
],
immediatelyRender: false,
content: content || "<p>Start typing your documentation notes here...</p>",
editorProps: {
attributes: {
class: "prose max-w-none focus:outline-none min-h-[300px]",
},
},
});
const toggleAppLink = (appId: string) => {
setLinkedAppIds((prev) =>
prev.includes(appId)
? prev.filter((id) => id !== appId)
: [...prev, appId],
);
};
const handleSave = () => {
if (!editor) return;
const htmlOutput = editor.getHTML();
onSave(htmlOutput, linkedAppIds);
};
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex justify-between items-center pb-2 border-b border-zinc-100">
<span className="text-sm text-zinc-400 font-medium">Draft Session</span>
<button
onClick={handleSave}
className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white font-medium text-sm rounded-md transition-colors"
>
Save Changes
</button>
</div>
<Menubar editor={editor} />
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 items-start">
<div className="md:col-span-3 border border-zinc-200 rounded-lg p-4 bg-white shadow-sm min-h-100">
<EditorContent editor={editor} />
</div>
<div className="md:col-span-1 bg-zinc-50 border border-zinc-200 rounded-lg p-4 flex flex-col gap-3">
<div>
<h3 className="font-semibold text-zinc-700 text-sm">
Document Assets
</h3>
<p className="text-xs text-zinc-400 mt-0.5">
Click apps to bind/unbind them to this entire document.
</p>
</div>
<div className="flex flex-col gap-2 mt-2">
{availableApps.map((app) => {
const isLinked = linkedAppIds.includes(app.id);
return (
<button
key={app.id}
type="button"
onClick={() => toggleAppLink(app.id)}
className={`w-full text-left p-3 border rounded-md transition relative overflow-hidden ${
isLinked
? "border-emerald-500 bg-emerald-50/30"
: "bg-white border-zinc-200 hover:border-zinc-300"
}`}
>
{isLinked && (
<div className="absolute top-0 right-0 h-0 w-0 border-t-[16px] border-t-emerald-500 border-l-[16px] border-l-transparent" />
)}
<div
className={`font-medium text-xs ${isLinked ? "text-emerald-700" : "text-zinc-800"}`}
>
{app.name}
</div>
<div className="text-[10px] text-zinc-400 font-mono truncate mt-0.5">
{app.id}
</div>
{app.description && (
<div className="text-[11px] text-zinc-500 mt-1 line-clamp-1">
{app.description}
</div>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Highlight from "@tiptap/extension-highlight";
import TextAlign from "@tiptap/extension-text-align";
import { Markdown } from "@tiptap/markdown";
import { HomelabApp } from "@/types";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import Menubar from "./menu-bar"; // Added menubar import back
interface TextEditorProps {
content: string;
onSave: (content: string, linkedAppIds: string[]) => void;
initialLinkedAppIds?: string[];
}
const getApps = async (): Promise<HomelabApp[]> => {
return (await invoke("get_app")) as HomelabApp[];
};
export default function TextEditor({ content, onSave }: TextEditorProps) {
const [availableApps, setAvailableApps] = useState<HomelabApp[]>([]);
// Track document-wide linked apps here
const [linkedAppIds, setLinkedAppIds] = useState<string[]>([]);
useEffect(() => {
const fetchApps = async () => {
const apps = await getApps();
setAvailableApps(apps);
};
fetchApps();
}, []);
const editor = useEditor({
extensions: [
StarterKit.configure({
bulletList: { HTMLAttributes: { class: "list-disc ml-3" } },
orderedList: { HTMLAttributes: { class: "list-decimal ml-3" } },
}),
Highlight.configure({ HTMLAttributes: { class: "bg-yellow-200" } }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Markdown,
],
immediatelyRender: false,
content: content || "<p>Start typing your documentation notes here...</p>",
editorProps: {
attributes: {
class: "prose max-w-none focus:outline-none min-h-[300px]",
},
},
});
const toggleAppLink = (appId: string) => {
setLinkedAppIds(
(prev) =>
prev.includes(appId)
? prev.filter((id) => id !== appId) // Unlink if already added
: [...prev, appId], // Link if not added
);
};
const handleSave = () => {
if (!editor) return;
const htmlOutput = editor.getHTML();
// Pass both the layout structure and the entire linked app set
onSave(htmlOutput, linkedAppIds);
};
return (
<div className="flex flex-col gap-4 w-full">
{/* Top action bar */}
<div className="flex justify-between items-center pb-2 border-b border-zinc-100">
<span className="text-sm text-zinc-400 font-medium">Draft Session</span>
<button
onClick={handleSave}
className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white font-medium text-sm rounded-md transition-colors"
>
Save Changes
</button>
</div>
{/* Editor Controls */}
<Menubar editor={editor} />
{/* Main split-view workspace */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 items-start">
{/* Left Side: Text Canvas */}
<div className="md:col-span-3 border border-zinc-200 rounded-lg p-4 bg-white shadow-sm min-h-100">
<EditorContent editor={editor} />
</div>
{/* Right Side: Document-Wide Reference Panel */}
<div className="md:col-span-1 bg-zinc-50 border border-zinc-200 rounded-lg p-4 flex flex-col gap-3">
<div>
<h3 className="font-semibold text-zinc-700 text-sm">
Document Assets
</h3>
<p className="text-xs text-zinc-400 mt-0.5">
Click apps to bind/unbind them to this entire document.
</p>
</div>
<div className="flex flex-col gap-2 mt-2">
{availableApps.map((app) => {
const isLinked = linkedAppIds.includes(app.id);
return (
<button
key={app.id}
type="button"
onClick={() => toggleAppLink(app.id)}
className={`w-full text-left p-3 border rounded-md transition relative overflow-hidden ${
isLinked
? "border-emerald-500 bg-emerald-50/30"
: "bg-white border-zinc-200 hover:border-zinc-300"
}`}
>
{/* Subtle active border indicator */}
{isLinked && (
<div className="absolute top-0 right-0 h-0 w-0 border-t-[16px] border-t-emerald-500 border-l-[16px] border-l-transparent" />
)}
<div
className={`font-medium text-xs ${isLinked ? "text-emerald-700" : "text-zinc-800"}`}
>
{app.name}
</div>
<div className="text-[10px] text-zinc-400 font-mono truncate mt-0.5">
{app.id}
</div>
{app.description && (
<div className="text-[11px] text-zinc-500 mt-1 line-clamp-1">
{app.description}
</div>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { Editor, useEditorState } from "@tiptap/react";
import {
AlignCenter,
AlignLeft,
AlignRight,
Bold,
Heading1,
Heading2,
Heading3,
Highlighter,
Italic,
List,
ListOrdered,
} from "lucide-react";
import { Toggle } from "../ui/toggle";
export default function Menubar({ editor }: { editor: Editor | null }) {
if (!editor) {
return null;
}
const editorState = useEditorState({
editor,
selector: (ctx) => {
return {
// Text formatting
isBold: ctx.editor.isActive("bold") ?? false,
isItalic: ctx.editor.isActive("italic") ?? false,
isStrike: ctx.editor.isActive("strike") ?? false,
isHighlight: ctx.editor.isActive("highlight") ?? false,
// Text alignment
isAlignLeft: ctx.editor.isActive({ textAlign: "left" }) ?? false,
isAlignCenter: ctx.editor.isActive({ textAlign: "center" }) ?? false,
isAlignRight: ctx.editor.isActive({ textAlign: "right" }) ?? false,
isAlignJustify: ctx.editor.isActive({ textAlign: "justify" }) ?? false,
// Block types
isParagraph: ctx.editor.isActive("paragraph") ?? false,
isHeading1: ctx.editor.isActive("heading", { level: 1 }) ?? false,
isHeading2: ctx.editor.isActive("heading", { level: 2 }) ?? false,
isHeading3: ctx.editor.isActive("heading", { level: 3 }) ?? false,
};
},
});
const options: {
icon: React.ReactNode;
onClick: () => void;
pressed: boolean;
}[] = [
{
icon: <Heading1 className="size-4" />,
onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
pressed: editor.isActive("heading", { level: 1 }),
},
{
icon: <Heading2 className="size-4" />,
onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
pressed: editor.isActive("heading", { level: 2 }),
},
{
icon: <Heading3 className="size-4" />,
onClick: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
pressed: editor.isActive("heading", { level: 3 }),
},
{
icon: <Bold className="size-4" />,
onClick: () => editor.chain().focus().toggleBold().run(),
pressed: editor.isActive("bold"),
},
{
icon: <Italic className="size-4" />,
onClick: () => editor.chain().focus().toggleItalic().run(),
pressed: editor.isActive("italic"),
},
{
icon: <AlignLeft className="size-4" />,
onClick: () => editor.chain().focus().toggleTextAlign("left").run(),
pressed: editor.isActive({ textAlign: "left" }),
},
{
icon: <AlignCenter className="size-4" />,
onClick: () => editor.chain().focus().toggleTextAlign("center").run(),
pressed: editor.isActive({ textAlign: "center" }),
},
{
icon: <AlignRight className="size-4" />,
onClick: () => editor.chain().focus().toggleTextAlign("right").run(),
pressed: editor.isActive({ textAlign: "right" }),
},
{
icon: <List className="size-4" />,
onClick: () => editor.chain().focus().toggleBulletList().run(),
pressed: editor.isActive("bulletList"),
},
{
icon: <ListOrdered className="size-4" />,
onClick: () => editor.chain().focus().toggleOrderedList().run(),
pressed: editor.isActive("orderList"),
},
{
icon: <Highlighter className="size-4" />,
onClick: () => editor.chain().focus().toggleHighlight().run(),
pressed: editor.isActive("highlight"),
},
];
return (
<div className="border rounded-md p-1 mb-1 bg-slate-50 space-x-2 z-50">
{options.map((option, index) => (
<Toggle key={index} pressed={option.pressed} onClick={option.onClick}>
{option.icon}
</Toggle>
))}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-2xl text-sm font-medium whitespace-nowrap transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };