feat(web): add intake URL bar, share dialog, intake API client — dashboard + enhanced sharing UI
This commit is contained in:
parent
599d68e116
commit
e5f287c7ea
@ -27,6 +27,7 @@ export default function CaptureScreen() {
|
|||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [intakeResult, setIntakeResult] = useState<IntakeSubmitResult | null>(null);
|
||||||
const saveDraft = useNotesStore((state: NotesState) => state.saveDraft);
|
const saveDraft = useNotesStore((state: NotesState) => state.saveDraft);
|
||||||
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
|
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
|
||||||
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
|
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
|
||||||
@ -56,16 +57,28 @@ export default function CaptureScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUrlExtract = async () => {
|
const waitForJob = useIntakeStore((state: IntakeState) => state.waitForJob);
|
||||||
if (!activeWorkspaceId || !urlInput.trim()) return;
|
|
||||||
|
const handleUrlIntake = async () => {
|
||||||
|
if (!urlInput.trim()) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setIntakeResult(null);
|
||||||
try {
|
try {
|
||||||
const result = await extractFromUrl(urlInput.trim(), activeWorkspaceId);
|
const result = await submitIntake(urlInput.trim(), activeWorkspaceId ?? undefined);
|
||||||
setTitle(result.title);
|
setIntakeResult(result);
|
||||||
setBody(result.content);
|
waitForJob(result.jobId, (job) => {
|
||||||
|
if (job.status === 'complete') {
|
||||||
|
setTitle(`Processed: ${new URL(urlInput.trim()).hostname}`);
|
||||||
|
setBody(`Note created from ${urlInput.trim()}. Open the note to view content.`);
|
||||||
|
setIntakeResult(null);
|
||||||
|
} else {
|
||||||
|
setError(job.error ?? 'Processing failed');
|
||||||
|
setIntakeResult(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'URL extraction failed');
|
setError(e instanceof Error ? e.message : 'Intake failed');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@ -161,14 +174,21 @@ export default function CaptureScreen() {
|
|||||||
|
|
||||||
{mode === 'url' && (
|
{mode === 'url' && (
|
||||||
<>
|
<>
|
||||||
<TextInput value={urlInput} onChangeText={setUrlInput} placeholder="https://example.com/article" placeholderTextColor={colors.textTertiary} style={styles.input} autoCapitalize="none" keyboardType="url" />
|
<TextInput value={urlInput} onChangeText={(t) => { setUrlInput(t); setIntakeResult(null); }} placeholder="https://example.com/article" placeholderTextColor={colors.textTertiary} style={styles.input} autoCapitalize="none" keyboardType="url" />
|
||||||
<Pressable accessibilityLabel="Extract content from URL" onPress={handleUrlExtract} disabled={busy || !urlInput.trim()} style={[styles.button, busy ? styles.buttonDisabled : null]}>
|
{intakeResult ? (
|
||||||
<Text style={styles.buttonText}>{busy ? 'Extracting...' : 'Extract & Summarize'}</Text>
|
<View style={styles.card}>
|
||||||
</Pressable>
|
<Text style={styles.cardTitle}>Processing as {intakeResult.contentType}…</Text>
|
||||||
|
<Text style={styles.cardBody}>Job: {intakeResult.jobId}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Pressable accessibilityLabel="Process URL with AI" onPress={() => void handleUrlIntake()} disabled={busy || !urlInput.trim()} style={[styles.button, busy ? styles.buttonDisabled : null]}>
|
||||||
|
<Text style={styles.buttonText}>{busy ? 'Submitting...' : 'Process with AI'}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
{body ? (
|
{body ? (
|
||||||
<>
|
<>
|
||||||
<TextInput value={title} onChangeText={setTitle} placeholder="Title" placeholderTextColor={colors.textTertiary} style={styles.input} />
|
<TextInput value={title} onChangeText={setTitle} placeholder="Title" placeholderTextColor={colors.textTertiary} style={styles.input} />
|
||||||
<TextInput value={body} onChangeText={setBody} placeholder="Extracted content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
|
<TextInput value={body} onChangeText={setBody} placeholder="Processed content" placeholderTextColor={colors.textTertiary} style={[styles.input, styles.bodyInput]} multiline textAlignVertical="top" />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
34
mobile/src/lib/share-intent.ts
Normal file
34
mobile/src/lib/share-intent.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Share intent handler stub.
|
||||||
|
*
|
||||||
|
* When expo-share-intent is installed and the app is prebuilt,
|
||||||
|
* this module will receive URLs shared from other apps and route
|
||||||
|
* them to the intake confirmation screen.
|
||||||
|
*
|
||||||
|
* Currently a no-op stub — no native dependency required.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
export type ShareIntentData = {
|
||||||
|
text?: string;
|
||||||
|
webUrl?: string;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a received share intent.
|
||||||
|
* Extracts the URL (if any) and navigates to the intake screen.
|
||||||
|
*/
|
||||||
|
export function handleShareIntent(data: ShareIntentData): void {
|
||||||
|
const url = data.webUrl ?? extractUrl(data.text);
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
router.push({ pathname: '/intake', params: { url } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUrl(text?: string): string | null {
|
||||||
|
if (!text) return null;
|
||||||
|
const match = text.match(/https?:\/\/[^\s]+/);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { listNoteSummaries, listWorkspaceSummaries, seedOnboardingWorkspace } fr
|
|||||||
import { listApprovalQueue } from "@/lib/review-client";
|
import { listApprovalQueue } from "@/lib/review-client";
|
||||||
import { listSavedViews, type SavedView } from "@/lib/saved-views-client";
|
import { listSavedViews, type SavedView } from "@/lib/saved-views-client";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { IntakeUrlBar } from "@/components/IntakeUrlBar";
|
||||||
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||||
|
|
||||||
function hrefForSavedView(view: SavedView): string {
|
function hrefForSavedView(view: SavedView): string {
|
||||||
@ -150,6 +151,11 @@ function DashboardContent() {
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<IntakeUrlBar
|
||||||
|
workspaceId={workspaces[0]?.id}
|
||||||
|
onIntakeSubmitted={(noteId) => router.push(`/notes/${noteId}`)}
|
||||||
|
/>
|
||||||
|
|
||||||
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: "var(--nl-space-4)" }}>
|
<section style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: "var(--nl-space-4)" }}>
|
||||||
{summaryCards.map((card) => (
|
{summaryCards.map((card) => (
|
||||||
<Link key={card.id} href={card.href} className="surface-card" style={{ padding: "var(--nl-space-5)" }}>
|
<Link key={card.id} href={card.href} className="surface-card" style={{ padding: "var(--nl-space-5)" }}>
|
||||||
|
|||||||
134
web/src/components/IntakeUrlBar.tsx
Normal file
134
web/src/components/IntakeUrlBar.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { submitIntake, type IntakeContentType } from "@/lib/intake-client";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
const CONTENT_TYPE_LABELS: Record<IntakeContentType, string> = {
|
||||||
|
youtube: "YouTube",
|
||||||
|
article: "Article",
|
||||||
|
pdf: "PDF",
|
||||||
|
tweet: "Tweet",
|
||||||
|
reddit: "Reddit",
|
||||||
|
github: "GitHub",
|
||||||
|
generic: "Link",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTENT_TYPE_COLORS: Record<IntakeContentType, string> = {
|
||||||
|
youtube: "var(--nl-red)",
|
||||||
|
article: "var(--nl-primary)",
|
||||||
|
pdf: "var(--nl-orange)",
|
||||||
|
tweet: "var(--nl-blue)",
|
||||||
|
reddit: "var(--nl-orange)",
|
||||||
|
github: "var(--nl-text-primary)",
|
||||||
|
generic: "var(--nl-text-secondary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function classifyUrlClient(url: string): IntakeContentType {
|
||||||
|
if (/youtube\.com\/watch|youtu\.be\/|youtube\.com\/shorts/i.test(url)) return "youtube";
|
||||||
|
if (/(?:twitter|x)\.com\/.*\/status/i.test(url)) return "tweet";
|
||||||
|
if (/github\.com\/[^/]+\/[^/]+/i.test(url)) return "github";
|
||||||
|
if (/reddit\.com\/r\//i.test(url)) return "reddit";
|
||||||
|
if (/\.pdf(\?.*)?$/i.test(url)) return "pdf";
|
||||||
|
return "article";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUrl(s: string): boolean {
|
||||||
|
try {
|
||||||
|
new URL(s);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntakeUrlBarProps {
|
||||||
|
workspaceId?: string;
|
||||||
|
onIntakeSubmitted?: (noteId: string, jobId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntakeUrlBar({ workspaceId, onIntakeSubmitted }: IntakeUrlBarProps) {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const detectedType = url && isValidUrl(url) ? classifyUrlClient(url) : null;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!url.trim() || !isValidUrl(url)) {
|
||||||
|
toast.error("Please enter a valid URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await submitIntake(url.trim(), workspaceId);
|
||||||
|
toast.success(`Processing ${CONTENT_TYPE_LABELS[result.contentType]} — note will be ready soon`);
|
||||||
|
setUrl("");
|
||||||
|
onIntakeSubmitted?.(result.noteId, result.jobId);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to submit URL");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="surface-card"
|
||||||
|
style={{
|
||||||
|
padding: "var(--nl-space-4)",
|
||||||
|
display: "flex",
|
||||||
|
gap: "var(--nl-space-3)",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, position: "relative" }}>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") void handleSubmit(); }}
|
||||||
|
placeholder="Paste a URL to auto-process (YouTube, article, tweet, PDF, GitHub…)"
|
||||||
|
aria-label="URL to process"
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "var(--nl-space-3) var(--nl-space-4)",
|
||||||
|
paddingRight: detectedType ? "5rem" : "var(--nl-space-4)",
|
||||||
|
border: "1px solid var(--nl-border)",
|
||||||
|
borderRadius: "var(--nl-radius-md)",
|
||||||
|
background: "var(--nl-surface)",
|
||||||
|
color: "var(--nl-text-primary)",
|
||||||
|
fontSize: "var(--nl-fs-base)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{detectedType && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: "var(--nl-space-3)",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "var(--nl-radius-sm)",
|
||||||
|
fontSize: "var(--nl-fs-xs)",
|
||||||
|
fontWeight: 600,
|
||||||
|
background: CONTENT_TYPE_COLORS[detectedType],
|
||||||
|
color: "white",
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CONTENT_TYPE_LABELS[detectedType]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={loading || !url.trim() || !isValidUrl(url)}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
aria-label="Process URL"
|
||||||
|
>
|
||||||
|
{loading ? "Processing…" : "Process"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
web/src/components/ShareDialog.tsx
Normal file
233
web/src/components/ShareDialog.tsx
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import { createNoteShare } from "@/lib/notes-client";
|
||||||
|
import { exportNoteText, shareNoteWithUser } from "@/lib/intake-client";
|
||||||
|
import { getWebAppOrigin } from "@/lib/product-config";
|
||||||
|
|
||||||
|
interface ShareDialogProps {
|
||||||
|
noteId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
noteTitle: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareDialog({ noteId, workspaceId, noteTitle, onClose }: ShareDialogProps) {
|
||||||
|
const [tab, setTab] = useState<"link" | "user" | "text" | "native">("link");
|
||||||
|
const [userId, setUserId] = useState("");
|
||||||
|
const [permission, setPermission] = useState<"view" | "comment" | "edit">("view");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleCopyLink() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { shareToken } = await createNoteShare(noteId, workspaceId);
|
||||||
|
const url = `${getWebAppOrigin()}/share/${shareToken}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast.success("Share link copied");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to create share link");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleShareWithUser() {
|
||||||
|
if (!userId.trim()) {
|
||||||
|
toast.error("Please enter a user ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await shareNoteWithUser(noteId, workspaceId, userId.trim(), permission);
|
||||||
|
toast.success(`Shared with ${userId.trim()}`);
|
||||||
|
setUserId("");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to share");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyText() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const exported = await exportNoteText(noteId, workspaceId);
|
||||||
|
await navigator.clipboard.writeText(`${exported.title}\n\n${exported.plaintext}`);
|
||||||
|
toast.success("Note text copied — paste in email, WhatsApp, etc.");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Failed to export");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNativeShare() {
|
||||||
|
if (!navigator.share) {
|
||||||
|
toast.error("Web Share API not supported in this browser");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const exported = await exportNoteText(noteId, workspaceId);
|
||||||
|
await navigator.share({
|
||||||
|
title: noteTitle,
|
||||||
|
text: exported.plaintext.slice(0, 500),
|
||||||
|
url: `${getWebAppOrigin()}/notes/${noteId}`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error)?.name !== "AbortError") {
|
||||||
|
toast.error("Share failed");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: "link" as const, label: "Copy Link" },
|
||||||
|
{ key: "user" as const, label: "Share with User" },
|
||||||
|
{ key: "text" as const, label: "Copy as Text" },
|
||||||
|
{ key: "native" as const, label: "Share Sheet" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Share note"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="surface-card"
|
||||||
|
style={{
|
||||||
|
width: "min(480px, 90vw)",
|
||||||
|
padding: "var(--nl-space-6)",
|
||||||
|
display: "grid",
|
||||||
|
gap: "var(--nl-space-4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: "var(--nl-fs-xl)" }}>Share Note</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close share dialog"
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--nl-text-secondary)",
|
||||||
|
fontSize: "var(--nl-fs-xl)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={tab === t.key ? "btn btn-primary" : "btn btn-secondary"}
|
||||||
|
style={{ fontSize: "var(--nl-fs-sm)" }}
|
||||||
|
aria-label={t.label}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "link" && (
|
||||||
|
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||||
|
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
|
||||||
|
Generate a public read-only link anyone can view.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void handleCopyLink()} aria-label="Copy share link">
|
||||||
|
{loading ? "Generating…" : "Copy Share Link"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "user" && (
|
||||||
|
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||||
|
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
|
||||||
|
Share directly with a NoteLett user by their ID.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
placeholder="User ID"
|
||||||
|
aria-label="User ID to share with"
|
||||||
|
style={{
|
||||||
|
padding: "var(--nl-space-3)",
|
||||||
|
border: "1px solid var(--nl-border)",
|
||||||
|
borderRadius: "var(--nl-radius-md)",
|
||||||
|
background: "var(--nl-surface)",
|
||||||
|
color: "var(--nl-text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={permission}
|
||||||
|
onChange={(e) => setPermission(e.target.value as "view" | "comment" | "edit")}
|
||||||
|
aria-label="Permission level"
|
||||||
|
style={{
|
||||||
|
padding: "var(--nl-space-3)",
|
||||||
|
border: "1px solid var(--nl-border)",
|
||||||
|
borderRadius: "var(--nl-radius-md)",
|
||||||
|
background: "var(--nl-surface)",
|
||||||
|
color: "var(--nl-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="view">View only</option>
|
||||||
|
<option value="comment">Can comment</option>
|
||||||
|
<option value="edit">Can edit</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void handleShareWithUser()} aria-label="Share with user">
|
||||||
|
{loading ? "Sharing…" : "Share"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "text" && (
|
||||||
|
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||||
|
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
|
||||||
|
Copy the note content as plain text — paste into email, WhatsApp, Messages, etc.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void handleCopyText()} aria-label="Copy note text">
|
||||||
|
{loading ? "Copying…" : "Copy Note Text"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "native" && (
|
||||||
|
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||||
|
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
|
||||||
|
Open your device's native share sheet (AirDrop, Messages, email, etc.)
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void handleNativeShare()} aria-label="Open share sheet">
|
||||||
|
{loading ? "Opening…" : "Open Share Sheet"}
|
||||||
|
</button>
|
||||||
|
{typeof navigator !== "undefined" && !navigator.share && (
|
||||||
|
<p style={{ margin: 0, color: "var(--nl-orange)", fontSize: "var(--nl-fs-sm)" }}>
|
||||||
|
Web Share API not supported in this browser.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
web/src/lib/intake-client.ts
Normal file
178
web/src/lib/intake-client.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createNotesApiClient } from "@/lib/api-helpers";
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type IntakeContentType =
|
||||||
|
| "youtube"
|
||||||
|
| "article"
|
||||||
|
| "pdf"
|
||||||
|
| "tweet"
|
||||||
|
| "reddit"
|
||||||
|
| "github"
|
||||||
|
| "generic";
|
||||||
|
|
||||||
|
export type IntakeJobStatus =
|
||||||
|
| "queued"
|
||||||
|
| "extracting"
|
||||||
|
| "processing"
|
||||||
|
| "complete"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export interface IntakeSubmitResult {
|
||||||
|
jobId: string;
|
||||||
|
noteId: string;
|
||||||
|
contentType: IntakeContentType;
|
||||||
|
ruleMatched: string | null;
|
||||||
|
templateSlug: string;
|
||||||
|
status: "queued";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntakeJob {
|
||||||
|
id: string;
|
||||||
|
noteId: string;
|
||||||
|
url: string;
|
||||||
|
contentType: IntakeContentType;
|
||||||
|
templateSlug: string;
|
||||||
|
status: IntakeJobStatus;
|
||||||
|
error?: string;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntakeRule {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
urlPattern: string;
|
||||||
|
contentType: IntakeContentType;
|
||||||
|
templateId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Calls ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function submitIntake(
|
||||||
|
url: string,
|
||||||
|
workspaceId?: string,
|
||||||
|
templateOverride?: string,
|
||||||
|
): Promise<IntakeSubmitResult> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
const payload: Record<string, string> = { url };
|
||||||
|
if (workspaceId) payload.workspaceId = workspaceId;
|
||||||
|
if (templateOverride) payload.templateOverride = templateOverride;
|
||||||
|
return api.fetch("/intake", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIntakeJobs(options?: {
|
||||||
|
status?: IntakeJobStatus;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<{ items: IntakeJob[]; total: number }> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.status) params.set("status", options.status);
|
||||||
|
if (options?.limit) params.set("limit", String(options.limit));
|
||||||
|
const qs = params.toString();
|
||||||
|
return api.fetch(`/intake/jobs${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIntakeJob(id: string): Promise<IntakeJob> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch(`/intake/jobs/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIntakeRules(): Promise<{
|
||||||
|
items: IntakeRule[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch("/intake-rules");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createIntakeRule(
|
||||||
|
rule: Omit<IntakeRule, "id" | "userId">,
|
||||||
|
): Promise<IntakeRule> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch("/intake-rules", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(rule),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIntakeRule(id: string): Promise<void> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
await api.fetch(`/intake-rules/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sharing helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function shareNoteWithUser(
|
||||||
|
noteId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
sharedWithUserId: string,
|
||||||
|
permission: "view" | "comment" | "edit" = "view",
|
||||||
|
): Promise<unknown> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch(`/notes/${encodeURIComponent(noteId)}/share-with-user`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ workspaceId, sharedWithUserId, permission }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCollaborators(
|
||||||
|
noteId: string,
|
||||||
|
): Promise<{ items: unknown[]; total: number }> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch(`/notes/${encodeURIComponent(noteId)}/collaborators`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSharedWithMe(): Promise<{
|
||||||
|
items: unknown[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch("/shared-with-me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCollaborator(
|
||||||
|
noteId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
await api.fetch(`/notes/${encodeURIComponent(noteId)}/collaborators/${encodeURIComponent(userId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportNoteText(
|
||||||
|
noteId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<{
|
||||||
|
title: string;
|
||||||
|
markdown: string;
|
||||||
|
plaintext: string;
|
||||||
|
html: string;
|
||||||
|
}> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch(`/notes/${encodeURIComponent(noteId)}/export-text`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ workspaceId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNoteDeepLinks(
|
||||||
|
noteId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<{ web: string; mobile: string; public: string | null }> {
|
||||||
|
const api = createNotesApiClient();
|
||||||
|
return api.fetch(
|
||||||
|
`/notes/${encodeURIComponent(noteId)}/deep-link?workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user