Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 115 additions & 187 deletions src/components/modals/CreateWebsiteDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,14 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";

interface CreateWebsiteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
folderId?: string;
onNoteCreated?: (noteId: string) => void;
onCreate: (url: string, name: string, favicon?: string) => void;
}

/**
Expand All @@ -35,225 +32,156 @@ function isValidUrl(str: string): boolean {
}
}

/**
* Get the favicon URL for a given website URL using Google's favicon service
* Note: We use sz=64 to detect default globe icons (they stay 16x16 while real favicons scale)
*/
function getFaviconUrl(websiteUrl: string): string | undefined {
try {
const url = new URL(websiteUrl.trim());
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`;
} catch {
return undefined;
}
}

/**
* Extract a display name from a URL (domain without www)
*/
function getDisplayName(websiteUrl: string): string {
try {
const url = new URL(websiteUrl.trim());
return url.hostname.replace(/^www\./, "");
} catch {
return "Website";
}
}

export function CreateWebsiteDialog({
open,
onOpenChange,
workspaceId,
folderId,
onNoteCreated,
onCreate,
}: CreateWebsiteDialogProps) {
const [urlsText, setUrlsText] = useState("");
const [isCreating, setIsCreating] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [isValid, setIsValid] = useState(false);
const [isLoadingTitle, setIsLoadingTitle] = useState(false);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);

// Parse and validate URLs from textarea
const parseUrls = useCallback(() => {
const lines = urlsText.split("\n").map((line) => line.trim()).filter(Boolean);
const validUrls: string[] = [];
const invalidLines: string[] = [];
// Validate URL as user types
useEffect(() => {
setIsValid(isValidUrl(url));
}, [url]);

for (const line of lines) {
if (isValidUrl(line)) {
validUrls.push(line);
} else {
invalidLines.push(line);
}
// Auto-fill name from domain when a valid URL is entered
useEffect(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}

return { validUrls, invalidLines };
}, [urlsText]);

const { validUrls, invalidLines } = parseUrls();
const hasValidUrls = validUrls.length > 0;

const handleSubmit = useCallback(async () => {
if (!hasValidUrls || isCreating) return;

if (invalidLines.length > 0) {
toast.warning(`Skipping ${invalidLines.length} invalid URL(s)`);
if (isValid && url.trim() && !name.trim()) {
debounceTimerRef.current = setTimeout(() => {
setName(getDisplayName(url));
}, 300);
}

// Close dialog immediately for non-blocking UX
onOpenChange(false);
setUrlsText("");

// Show loading toast
const toastId = toast.loading("Creating note from websites...", {
description: `Processing ${validUrls.length} URL${validUrls.length > 1 ? 's' : ''}`,
});

setIsCreating(true);

try {
const response = await fetch("/api/notes/create-from-urls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
urls: validUrls,
workspaceId,
folderId,
}),
});

if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Request failed with status ${response.status}`);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [url, isValid]);

@cubic-dev-ai cubic-dev-ai Bot Mar 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Include name in the auto-fill effect dependencies so a pending timeout cannot overwrite a manually entered card name.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/modals/CreateWebsiteDialog.tsx, line 93:

<comment>Include `name` in the auto-fill effect dependencies so a pending timeout cannot overwrite a manually entered card name.</comment>

<file context>
@@ -35,225 +32,156 @@ function isValidUrl(str: string): boolean {
+                clearTimeout(debounceTimerRef.current);
             }
+        };
+    }, [url, isValid]);
 
-            const result = await response.json();
</file context>
Suggested change
}, [url, isValid]);
}, [url, isValid, name]);
Fix with Cubic


const result = await response.json();

if (result.success && result.itemId) {
if (result.warning) {
toast.warning("Note created with issues", {
id: toastId,
description: result.warning,
});
} else {
toast.success("Website note created!", {
id: toastId,
description: "Your note is ready",
});
}
onNoteCreated?.(result.itemId);
} else {
throw new Error(result.message || "Failed to create note");
}
} catch (error) {
console.error("Error creating website note:", error);
toast.error("Failed to create note from websites", {
id: toastId,
description: error instanceof Error ? error.message : undefined,
});
} finally {
setIsCreating(false);
const handleSubmit = useCallback(() => {
if (!isValid || !url.trim()) {
toast.error("Please enter a valid website URL");
return;
}
}, [validUrls, hasValidUrls, isCreating, invalidLines.length, workspaceId, folderId, onNoteCreated, onOpenChange]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// Cmd/Ctrl + Enter to submit
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && hasValidUrls && !isCreating) {
e.preventDefault();
handleSubmit();
} else if (e.key === "Escape") {
onOpenChange(false);
}
},
[hasValidUrls, isCreating, handleSubmit, onOpenChange]
);
const cardName = name.trim() || getDisplayName(url);
const favicon = getFaviconUrl(url);
onCreate(url.trim(), cardName, favicon);

// Reset form
setUrl("");
setName("");
onOpenChange(false);
}, [url, name, isValid, onCreate, onOpenChange]);

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && isValid) {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
onOpenChange(false);
}
}, [isValid, handleSubmit, onOpenChange]);

// Reset form when dialog opens
useEffect(() => {
if (open) {
setUrlsText("");
setIsCreating(false);
// Focus textarea after dialog opens
setTimeout(() => textareaRef.current?.focus(), 100);
setUrl("");
setName("");
setIsValid(false);
setIsLoadingTitle(false);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
}
}, [open]);

const handlePaste = useCallback((e: React.ClipboardEvent) => {
// Prevent default paste to handle it manually
e.preventDefault();

// Get pasted text
let text = e.clipboardData.getData("text");

// 1. Pre-process to separate mashed URLs (e.g. "http://a.comhttp://b.com")
// This adds a space before any http protocol that isn't at the very start
text = text.replace(/(?<!^)(https?:\/\/)/g, " $1");

// 2. Split by whitespace or commas
// Filter out empty strings
const urls = text.split(/[\s,]+/).filter(Boolean);

if (urls.length === 0) return;

// 3. Prepare content to insert
let formattedText = urls.join("\n");

// 4. Handle insertion context
const textarea = e.currentTarget as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;

const currentValue = urlsText;

// If we are appending to a line that has content, prepend a newline
// We look at the character before the cursor
if (start > 0 && currentValue[start - 1] !== "\n") {
formattedText = "\n" + formattedText;
}

const newValue =
currentValue.substring(0, start) +
formattedText +
currentValue.substring(end);

setUrlsText(newValue);

// Update cursor position (async to let render happen, though React state update might contest this)
// With standard React controlled inputs, we usually just set state.
// We can create a synthetic change event or just let the effect handle it,
// but explicit state setting is cleanest here.

// Fix cursor position after update
setTimeout(() => {
if (textareaRef.current) {
const newCursorPos = start + formattedText.length;
textareaRef.current.selectionStart = textareaRef.current.selectionEnd = newCursorPos;
}
}, 0);
}, [urlsText]);

return (
<Dialog open={open} onOpenChange={isCreating ? undefined : onOpenChange}>
<DialogContent onKeyDown={handleKeyDown} className="sm:max-w-md max-h-[85vh] flex flex-col">
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle>Create Note from Websites</DialogTitle>
<DialogTitle>Embed Website</DialogTitle>
<DialogDescription>
Paste one or more website URLs (one per line). A note will be created with content synthesized from these pages.
Enter a website URL to embed it in your workspace.
</DialogDescription>
</DialogHeader>

<div className="space-y-4 py-4 overflow-y-auto flex-1 min-h-0">
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="website-urls">Website URLs</Label>
<Textarea
ref={textareaRef}
id="website-urls"
placeholder="https://example.com/page-1&#10;https://example.com/page-2"
value={urlsText}
onChange={(e) => setUrlsText(e.target.value)}
onPaste={handlePaste}
rows={5}
disabled={isCreating}
className="resize-none max-h-[40vh] overflow-y-auto"
<Label htmlFor="website-url">Website URL</Label>
<Input
id="website-url"
type="url"
placeholder="https://example.com"
value={url}
onChange={(e) => setUrl(e.target.value)}
autoFocus
/>
{urlsText && (
<p className="text-xs text-muted-foreground">
{validUrls.length} valid URL{validUrls.length !== 1 ? "s" : ""}
{invalidLines.length > 0 && (
<span className="text-yellow-500"> • {invalidLines.length} invalid</span>
)}
{!isValid && url && (
<p className="text-sm text-red-500">
Please enter a valid URL (http:// or https://)
</p>
)}
</div>

<div className="space-y-2">
<Label htmlFor="website-name">Card Name</Label>
<Input
id="website-name"
type="text"
placeholder={isValid ? getDisplayName(url) : "Website"}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the domain name
</p>
</div>
</div>

<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!hasValidUrls || isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
"Create Note"
)}
<Button
onClick={handleSubmit}
disabled={!isValid}
>
Add Website
</Button>
</DialogFooter>
</DialogContent>
Expand Down
22 changes: 22 additions & 0 deletions src/components/workspace-canvas/CardRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,28 @@ export function CardRenderer(props: {
return <AudioCardContent item={item} />;
}

if (item.type === "website") {
// Website cards are rendered via WebsitePanelContent in the panel;
// this fallback is for generic/modal context
const websiteData = item.data as import("@/lib/workspace-state/types").WebsiteData;
let hostname = '';
try { hostname = new URL(websiteData.url).hostname.replace(/^www\./, ''); } catch {}
return (
<div className="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 p-8">
{websiteData.favicon ? (
<img src={websiteData.favicon} alt="" className="size-16 rounded-lg" />
) : (
<div className="size-16 rounded-lg bg-muted flex items-center justify-center">
<span className="text-2xl font-bold text-muted-foreground">{hostname.charAt(0).toUpperCase()}</span>
Comment on lines +115 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid rendering an empty website placeholder.

If new URL() throws here, hostname stays empty and the fallback avatar renders no glyph. In modal/generic contexts that looks broken instead of degraded.

🔧 Suggested fix
-            <span className="text-2xl font-bold text-muted-foreground">{hostname.charAt(0).toUpperCase()}</span>
+            <span className="text-2xl font-bold text-muted-foreground">
+              {(hostname || item.name || "W").charAt(0).toUpperCase()}
+            </span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/workspace-canvas/CardRenderer.tsx` around lines 115 - 123, The
fallback avatar can render an empty glyph if new URL(websiteData.url) throws and
hostname remains '', so update the try/catch in CardRenderer (where hostname is
derived from new URL(websiteData.url)) to ensure hostname always has a safe
fallback (for example set hostname = (websiteData.title || websiteData.url ||
'?').toString() and then extract the first char via
hostname.charAt(0).toUpperCase()); ensure the catch block assigns that fallback
rather than leaving hostname empty so the avatar span always displays a visible
glyph.

</div>
)}
{hostname && (
<span className="text-sm text-muted-foreground">{hostname}</span>
)}
</div>
);
}

return (
<div className="mt-4 p-4 rounded-lg bg-muted/30 text-center">
<p className="text-sm text-muted-foreground">
Expand Down
Loading
Loading