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
3 changes: 3 additions & 0 deletions apps/web/components/cms/cms-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@/components/cms/post-dialogs";
import { RightPanel } from "@/components/cms/right-panel";
import { PublicationsDashboard } from "@/components/cms/publications-dashboard";
import { ResearchSection } from "@/components/cms/research-section";
import { WorkspaceHeader } from "@/components/cms/workspace-header";
import { FeedbackSection } from "@/components/cms/feedback-section";
import { MobileWorkspaceFooter } from "@/components/cms/mobile-workspace-footer";
Expand Down Expand Up @@ -682,6 +683,8 @@ export function CmsWorkspace() {
isSyncing={isSyncing}
onSync={syncPublications}
/>
) : activeView === "research" ? (
<ResearchSection />
) : activeView === "feedback" ? (
<FeedbackSection account={activeAccount} onReconnect={logOut} />
) : (
Expand Down
17 changes: 9 additions & 8 deletions apps/web/components/cms/mobile-workspace-footer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { BookOpenIcon, LibraryIcon, MessageSquareTextIcon, RocketIcon, SaveIcon } from "lucide-react";
import { BookOpenIcon, LibraryIcon, MessageSquareTextIcon, RocketIcon, SaveIcon, TelescopeIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { DraftSaveState } from "@/lib/draft-editor";
Expand Down Expand Up @@ -49,30 +49,31 @@ export function MobileWorkspaceFooter({
}

const views = [
{ value: "posts", label: "Posts", icon: BookOpenIcon },
{ value: "publications", label: "Publications", icon: LibraryIcon },
{ value: "feedback", label: "Feedback", icon: MessageSquareTextIcon },
{ value: "posts", label: "Posts", mobileLabel: "Posts", icon: BookOpenIcon },
{ value: "publications", label: "Publications", mobileLabel: "Sites", icon: LibraryIcon },
{ value: "research", label: "Research", mobileLabel: "Research", icon: TelescopeIcon },
{ value: "feedback", label: "Feedback", mobileLabel: "Feedback", icon: MessageSquareTextIcon },
] as const;

return (
<nav
aria-label="Mobile workspace"
className="z-20 grid shrink-0 grid-cols-3 border-t bg-background/95 px-2 pt-1 pb-[calc(0.25rem+env(safe-area-inset-bottom))] backdrop-blur xl:hidden"
className="z-20 grid shrink-0 grid-cols-4 gap-0.5 border-t bg-background/95 px-1 pt-1 pb-[calc(0.25rem+env(safe-area-inset-bottom))] backdrop-blur xl:hidden sm:px-2"
>
{views.map(({ value, label, icon: Icon }) => (
{views.map(({ value, label, mobileLabel, icon: Icon }) => (
<button
key={value}
type="button"
aria-label={`Open ${label}`}
aria-current={activeView === value ? "page" : undefined}
onClick={() => onViewChange(value)}
className={cn(
"flex min-h-12 flex-col items-center justify-center gap-0.5 rounded-md px-2 text-[11px] font-medium transition-colors",
"flex min-h-12 min-w-0 flex-col items-center justify-center gap-0.5 rounded-md px-0 text-[9px] leading-none font-medium transition-colors sm:px-2 sm:text-[11px]",
activeView === value ? "text-foreground" : "text-muted-foreground",
)}
>
<Icon className={cn("size-5", activeView === value && "text-primary")} aria-hidden />
{label}
<span className="max-w-full truncate">{mobileLabel}</span>
</button>
))}
</nav>
Expand Down
251 changes: 251 additions & 0 deletions apps/web/components/cms/research-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
"use client";

import * as React from "react";
import { format, parseISO } from "date-fns";
import {
ArrowUpRightIcon,
BookMarkedIcon,
HighlighterIcon,
LibraryBigIcon,
LoaderCircleIcon,
RefreshCwIcon,
} from "lucide-react";
import {
loadResearch,
type MarginResearchAnnotation,
type ResearchResponse,
type SembleResearchCard,
type SembleResearchCollection,
} from "@/lib/research-api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import { Empty, EmptyDescription, EmptyTitle } from "@/components/ui/empty";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

export function ResearchSection() {
const [research, setResearch] = React.useState<ResearchResponse | null>(null);
const [error, setError] = React.useState("");
const [isLoading, setIsLoading] = React.useState(true);
const [refreshVersion, setRefreshVersion] = React.useState(0);

const refresh = React.useCallback(() => {
setIsLoading(true);
setError("");
setRefreshVersion((version) => version + 1);
}, []);

React.useEffect(() => {
const controller = new AbortController();
loadResearch(controller.signal)
.then(setResearch)
.catch((loadError: unknown) => {
if (loadError instanceof DOMException && loadError.name === "AbortError") return;
setError(loadError instanceof Error ? loadError.message : "Could not load research.");
})
.finally(() => {
if (!controller.signal.aborted) setIsLoading(false);
});
return () => controller.abort();
}, [refreshVersion]);

return (
<section className="min-h-0 flex-1 overflow-auto bg-muted/20">
<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:py-10">
<div className="flex flex-col gap-4 border-b pb-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-muted-foreground text-xs font-medium uppercase tracking-[0.16em]">Your AT Protocol library</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Research</h1>
<p className="text-muted-foreground mt-1 max-w-2xl text-sm leading-6">
Browse your Semble collections and Margin annotations when you are looking for something worth developing.
</p>
</div>
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading}>
<RefreshCwIcon data-icon="inline-start" className={isLoading ? "animate-spin" : undefined} />
{isLoading ? "Refreshing…" : "Refresh"}
</Button>
</div>

{isLoading && !research ? (
<div className="text-muted-foreground flex min-h-64 items-center justify-center gap-2 text-sm">
<LoaderCircleIcon className="size-4 animate-spin" aria-hidden /> Loading your research…
</div>
) : error ? (
<Empty className="mt-8 min-h-64">
<EmptyTitle>Research is unavailable</EmptyTitle>
<EmptyDescription>{error}</EmptyDescription>
<Button variant="outline" size="sm" onClick={refresh}>Try again</Button>
</Empty>
) : research ? (
<Tabs defaultValue="semble" className="mt-6 gap-5">
<TabsList aria-label="Research sources">
<TabsTrigger value="semble">
<LibraryBigIcon className="mr-2 size-4" aria-hidden />
Semble <span className="text-muted-foreground ml-1.5">{research.semble.collections.length}</span>
</TabsTrigger>
<TabsTrigger value="margin">
<HighlighterIcon className="mr-2 size-4" aria-hidden />
Margin <span className="text-muted-foreground ml-1.5">{research.margin.annotations.length}</span>
</TabsTrigger>
</TabsList>
<TabsContent value="semble">
<SourceNotice message={research.semble.error} />
<SembleCollections collections={research.semble.collections} />
</TabsContent>
<TabsContent value="margin">
<SourceNotice message={research.margin.error} />
<MarginAnnotations annotations={research.margin.annotations} />
</TabsContent>
</Tabs>
) : null}
</div>
</section>
);
}

function SourceNotice({ message }: { message?: string }) {
return message ? (
<div role="status" className="mb-4 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
{message}
</div>
) : null;
}

function SembleCollections({ collections }: { collections: SembleResearchCollection[] }) {
if (!collections.length) {
return (
<Empty className="min-h-64">
<EmptyTitle>No Semble collections found</EmptyTitle>
<EmptyDescription>Collections saved by this linked account will appear here.</EmptyDescription>
</Empty>
);
}

return (
<div className="grid gap-5">
{collections.map((collection) => (
<Card key={collection.uri}>
<CardHeader className="gap-2 border-b">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-base font-semibold leading-none">{collection.name}</h2>
{collection.description ? <CardDescription className="mt-1 leading-5">{collection.description}</CardDescription> : null}
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{collection.accessType.toLowerCase()}</Badge>
<Badge variant="secondary">{collection.cards.length} {collection.cards.length === 1 ? "item" : "items"}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="divide-y p-0">
{collection.cards.length ? collection.cards.map((card) => (
<SembleCard key={card.uri} card={card} />
)) : (
<p className="text-muted-foreground p-4 text-sm">This collection is empty.</p>
)}
</CardContent>
</Card>
))}
</div>
);
}

function SembleCard({ card }: { card: SembleResearchCard }) {
const title = card.title || card.note || card.url || "Untitled saved item";
return (
<article className="grid gap-3 p-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start sm:gap-6">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold leading-5">{title}</h3>
{card.siteName ? <Badge variant="outline" className="font-normal">{card.siteName}</Badge> : null}
</div>
{card.author ? <p className="text-muted-foreground mt-1 text-xs">By {card.author}</p> : null}
{card.note && card.note !== title ? <p className="mt-2 text-sm leading-6">{card.note}</p> : null}
{card.description ? <p className="text-muted-foreground mt-2 line-clamp-3 text-sm leading-6">{card.description}</p> : null}
{card.createdAt ? <ResearchDate value={card.createdAt} /> : null}
</div>
{safeHTTPURL(card.url) ? (
<Button variant="ghost" size="sm" asChild>
<a href={card.url} target="_blank" rel="noreferrer">
Open source <ArrowUpRightIcon data-icon="inline-end" />
</a>
</Button>
) : null}
</article>
);
}

function MarginAnnotations({ annotations }: { annotations: MarginResearchAnnotation[] }) {
if (!annotations.length) {
return (
<Empty className="min-h-64">
<EmptyTitle>No Margin annotations found</EmptyTitle>
<EmptyDescription>Notes, highlights, and bookmarks from this linked account will appear here.</EmptyDescription>
</Empty>
);
}

return (
<div className="grid gap-4 md:grid-cols-2">
{annotations.map((annotation) => (
<Card key={annotation.uri} className="min-w-0">
<CardHeader className="gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Badge variant="outline" className="capitalize">
<BookMarkedIcon className="mr-1 size-3" aria-hidden /> {annotation.motivation}
</Badge>
<ResearchDate value={annotation.modifiedAt ?? annotation.createdAt} />
</div>
<h2 className="text-base font-semibold leading-6">{annotation.title || sourceHost(annotation.source)}</h2>
</CardHeader>
<CardContent className="space-y-3">
{annotation.quote ? (
<blockquote className="border-l-2 border-primary/40 pl-3 text-sm leading-6 italic">“{annotation.quote}”</blockquote>
) : null}
{annotation.body ? <p className="text-sm leading-6 whitespace-pre-wrap">{annotation.body}</p> : null}
{annotation.tags.length ? (
<div className="flex flex-wrap gap-1.5">
{annotation.tags.map((tag) => <Badge key={tag} variant="secondary">{tag}</Badge>)}
</div>
) : null}
{safeHTTPURL(annotation.source) ? (
<Button variant="ghost" size="sm" className="-ml-3" asChild>
<a href={annotation.source} target="_blank" rel="noreferrer">
Open source <ArrowUpRightIcon data-icon="inline-end" />
</a>
</Button>
) : null}
</CardContent>
</Card>
))}
</div>
);
}

function ResearchDate({ value }: { value: string }) {
let label: string;
try {
label = format(parseISO(value), "MMM d, yyyy");
} catch {
return null;
}
return <time dateTime={value} className="text-muted-foreground mt-2 block text-xs">{label}</time>;
}

function safeHTTPURL(value?: string) {
if (!value) return false;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}

function sourceHost(value: string) {
try {
return new URL(value).hostname.replace(/^www\./, "");
} catch {
return "Untitled annotation";
}
}
10 changes: 9 additions & 1 deletion apps/web/components/cms/workspace-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
RocketIcon,
Settings2Icon,
SunIcon,
TelescopeIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -183,6 +184,7 @@ function WorkspaceNavigation({
const views = [
{ value: "posts", label: "Posts", icon: BookOpenIcon },
{ value: "publications", label: "Publications", icon: LibraryIcon },
{ value: "research", label: "Research", icon: TelescopeIcon },
{ value: "feedback", label: "Feedback", icon: MessageSquareTextIcon },
] as const;

Expand All @@ -208,7 +210,13 @@ function WorkspaceNavigation({
}

function mobileViewTitle(view: WorkspaceView) {
return view === "posts" ? "Posts" : view === "publications" ? "Publications" : "Feedback";
return view === "posts"
? "Posts"
: view === "publications"
? "Publications"
: view === "research"
? "Research"
: "Feedback";
}

function MobileAccountSheet({
Expand Down
50 changes: 50 additions & 0 deletions apps/web/lib/research-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { apiFetch } from "@/lib/api";

export type SembleResearchCard = {
uri: string;
url?: string;
title?: string;
description?: string;
siteName?: string;
author?: string;
note?: string;
createdAt?: string;
};

export type SembleResearchCollection = {
uri: string;
name: string;
description?: string;
accessType: string;
createdAt?: string;
updatedAt?: string;
cards: SembleResearchCard[];
};

export type MarginResearchAnnotation = {
uri: string;
motivation: string;
source: string;
title?: string;
body?: string;
quote?: string;
tags: string[];
color?: string;
createdAt: string;
modifiedAt?: string;
};

export type ResearchResponse = {
semble: {
collections: SembleResearchCollection[];
error?: string;
};
margin: {
annotations: MarginResearchAnnotation[];
error?: string;
};
};

export function loadResearch(signal?: AbortSignal) {
return apiFetch<ResearchResponse>("/api/research", { signal });
}
Loading