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 [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [intakeResult, setIntakeResult] = useState<IntakeSubmitResult | null>(null);
|
||||
const saveDraft = useNotesStore((state: NotesState) => state.saveDraft);
|
||||
const workspaces = useWorkspaceStore((state: WorkspaceState) => state.workspaces);
|
||||
const activeWorkspaceId = useWorkspaceStore((state: WorkspaceState) => state.activeWorkspaceId);
|
||||
@ -56,16 +57,28 @@ export default function CaptureScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlExtract = async () => {
|
||||
if (!activeWorkspaceId || !urlInput.trim()) return;
|
||||
const waitForJob = useIntakeStore((state: IntakeState) => state.waitForJob);
|
||||
|
||||
const handleUrlIntake = async () => {
|
||||
if (!urlInput.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setIntakeResult(null);
|
||||
try {
|
||||
const result = await extractFromUrl(urlInput.trim(), activeWorkspaceId);
|
||||
setTitle(result.title);
|
||||
setBody(result.content);
|
||||
const result = await submitIntake(urlInput.trim(), activeWorkspaceId ?? undefined);
|
||||
setIntakeResult(result);
|
||||
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) {
|
||||
setError(e instanceof Error ? e.message : 'URL extraction failed');
|
||||
setError(e instanceof Error ? e.message : 'Intake failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -161,14 +174,21 @@ export default function CaptureScreen() {
|
||||
|
||||
{mode === 'url' && (
|
||||
<>
|
||||
<TextInput value={urlInput} onChangeText={setUrlInput} 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]}>
|
||||
<Text style={styles.buttonText}>{busy ? 'Extracting...' : 'Extract & Summarize'}</Text>
|
||||
</Pressable>
|
||||
<TextInput value={urlInput} onChangeText={(t) => { setUrlInput(t); setIntakeResult(null); }} placeholder="https://example.com/article" placeholderTextColor={colors.textTertiary} style={styles.input} autoCapitalize="none" keyboardType="url" />
|
||||
{intakeResult ? (
|
||||
<View style={styles.card}>
|
||||
<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 ? (
|
||||
<>
|
||||
<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}
|
||||
</>
|
||||
|
||||
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 { listSavedViews, type SavedView } from "@/lib/saved-views-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { IntakeUrlBar } from "@/components/IntakeUrlBar";
|
||||
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||
|
||||
function hrefForSavedView(view: SavedView): string {
|
||||
@ -150,6 +151,11 @@ function DashboardContent() {
|
||||
</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)" }}>
|
||||
{summaryCards.map((card) => (
|
||||
<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