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
18 changes: 18 additions & 0 deletions app/frontend/src/components/SearchPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { CommandPalette, isAbortError, type CommandItem } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { itemHref, useSpaces, type Tag } from '../spaces';
Expand Down Expand Up @@ -27,6 +28,8 @@ const pages = [
export function SearchPalette({ onClose }: { onClose: () => void }) {
const { request, user } = useAuth();
const spaces = useSpaces();
const navigate = useNavigate();
const personal = spaces.data?.find((space) => space.kind === 'personal');
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults>();
const text = query.trim();
Expand All @@ -53,7 +56,22 @@ export function SearchPalette({ onClose }: { onClose: () => void }) {

const matches = (label: string) => label.toLowerCase().includes(text.toLowerCase());
const spaceName = (spaceId: string) => spaces.data?.find((space) => space.id === spaceId)?.name;
const actions: CommandItem[] = personal
? [
{
group: 'Create',
id: 'new-page',
label: 'New page',
onSelect: () =>
void request<{ id: string }>(`/spaces/${personal.id}/notes`, { method: 'POST', body: JSON.stringify({}) }).then((created) =>
navigate(`/notes/${created.id}`),
),
},
{ group: 'Create', href: `/spaces/${personal.id}/tasks`, id: 'new-task', label: 'New task' },
]
: [];
const items: CommandItem[] = [
...actions.filter((action) => !text || matches(action.label)),
...(results?.items ?? []).map((item) => ({
group: 'Items',
hint: `${item.kind} · ${item.spaceName}${item.archived ? ' · archived' : ''}`,
Expand Down
61 changes: 61 additions & 0 deletions app/frontend/src/dashboard/capture-widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { useAuth } from '../auth';
import { useSpaces } from '../spaces';
import { useAction } from '../useAction';
import { Empty } from './widget-parts';

export function Capture() {
const auth = useAuth();
const spaces = useSpaces();
const saving = useAction();
const [text, setText] = useState('');
const [spaceId, setSpaceId] = useState('');
const editable = (spaces.data ?? []).filter((space) => space.role !== 'viewer');
const target = spaceId || editable[0]?.id;

if (spaces.data && editable.length === 0) return <Empty>You can only view your spaces, so there is nowhere to save.</Empty>;

function save(kind: 'note' | 'task') {
const content = text.trim();
if (!content || !target) return;
void saving.run(async () => {
if (kind === 'note') {
await auth.request(`/spaces/${target}/notes`, { method: 'POST', body: JSON.stringify({ text: content }) });
} else {
await auth.request(`/spaces/${target}/tasks`, { method: 'POST', body: JSON.stringify({ title: content.split(/\r?\n/)[0]!.slice(0, 200) }) });
}
setText('');
return kind === 'note' ? 'Saved as a page.' : 'Saved as a task.';
}, 'Could not save that');
}

return (
<div className="grid gap-2">
<textarea
aria-label="Quick note"
maxLength={10_000}
onChange={(event) => setText(event.currentTarget.value)}
placeholder="Write it down…"
rows={3}
value={text}
/>
<div className="flex flex-wrap items-center gap-2">
{editable.length > 1 && (
<select aria-label="Save in" className="text-sm" onChange={(event) => setSpaceId(event.currentTarget.value)} value={target}>
{editable.map((space) => (
<option key={space.id} value={space.id}>
{space.name}
</option>
))}
</select>
)}
<button className="secondary-button px-3 py-1.5 text-sm" disabled={!text.trim() || saving.busy} onClick={() => save('note')} type="button">
Save as page
</button>
<button className="secondary-button px-3 py-1.5 text-sm" disabled={!text.trim() || saving.busy} onClick={() => save('task')} type="button">
Save as task
</button>
</div>
</div>
);
}
4 changes: 3 additions & 1 deletion app/frontend/src/dashboard/widgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { TaskRow, type Task, type TaskList } from '../tasks';
import { useAction } from '../useAction';
import { useResource } from '../useResource';
import { LoadError } from '../components/LoadError';
import { Capture } from './capture-widget';
import { News, NewsSettings, Weather, WeatherSettings } from './outside-widgets';
import { Empty, type SettingsProps, type WidgetProps } from './widget-parts';

export type WidgetType = 'clock' | 'today' | 'tasks' | 'shortcuts' | 'recurring' | 'inbox' | 'agenda' | 'countdown' | 'focus' | 'tidy' | 'weather' | 'news';
export type WidgetType = 'clock' | 'today' | 'tasks' | 'shortcuts' | 'recurring' | 'inbox' | 'agenda' | 'countdown' | 'focus' | 'tidy' | 'weather' | 'news' | 'capture';
export type WidgetSize = 'small' | 'medium' | 'wide' | 'full';

export interface Widget {
Expand Down Expand Up @@ -479,4 +480,5 @@ export const widgetKinds: Record<
tidy: { title: 'Tidy status', size: 'small', settings: {}, View: Tidy },
weather: { title: 'Weather', size: 'small', settings: { unit: 'celsius' }, View: Weather, Settings: WeatherSettings },
news: { title: 'News', size: 'medium', settings: { feeds: [], count: 5 }, View: News, Settings: NewsSettings },
capture: { title: 'Quick note', size: 'medium', settings: {}, View: Capture },
};
8 changes: 8 additions & 0 deletions app/frontend/src/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface Note {
position: number;
icon: string | null;
pinnedAt: string | null;
template: boolean;
createdAt: string;
updatedAt: string;
}
Expand Down Expand Up @@ -42,4 +43,11 @@ export function realtimeUrl() {
return `${window.location.origin.replace(/^http/, 'ws')}/api/v1/realtime`;
}

export const builtInTemplates = [
['meeting', 'Meeting notes'],
['journal', 'Journal'],
['project', 'Project brief'],
['recipe', 'Recipe'],
] as const;

export const noteTitle = (note: { title: string }) => note.title || 'Untitled';
25 changes: 22 additions & 3 deletions app/frontend/src/pages/NotePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function NotePage() {
const backlinks = (links.data?.backlinks ?? []).filter((link) => link.kind === 'mention');
const renaming = useAction();
const adding = useAction();
const marking = useAction();
const [history, setHistory] = useState(false);

if (note.status === 404) {
Expand Down Expand Up @@ -66,9 +67,27 @@ export function NotePage() {
</Fragment>
))}
</nav>
<button className="text-button text-sm" onClick={() => setHistory(true)} type="button">
History
</button>
<div className="flex flex-wrap items-center gap-4">
{details.editable && (
<button
className="text-button text-sm"
disabled={marking.busy}
onClick={() =>
void marking.run(async () => {
const saved = await auth.request<Note>(`/notes/${details.id}`, { method: 'PATCH', body: JSON.stringify({ template: !details.template }) });
note.mutate((current) => ({ ...current, ...saved }));
return saved.template ? 'New pages in this space can start from this one.' : 'This page is no longer a template.';
}, 'Could not change the page')
}
type="button"
>
{details.template ? 'Stop using as a template' : 'Use as a template'}
</button>
)}
<button className="text-button text-sm" onClick={() => setHistory(true)} type="button">
History
</button>
</div>
</div>
<input
aria-label="Page title"
Expand Down
89 changes: 58 additions & 31 deletions app/frontend/src/pages/spaces/SpaceNotes.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import { Link, useNavigate } from 'react-router';
import { EmptyState, Icon, ListSkeleton } from '@project-graphite/ui';
import { EmptyState, Icon, ListSkeleton, Menu } from '@project-graphite/ui';
import { useAuth } from '../../auth';
import { LoadError } from '../../components/LoadError';
import { flattenTree, noteTitle, type Note } from '../../notes';
import { builtInTemplates, flattenTree, noteTitle, type Note } from '../../notes';
import { useAction } from '../../useAction';
import { useResource } from '../../useResource';
import { useSpace } from './SpaceLayout';

function PageRow({ depth = 0, note }: { depth?: number; note: Note }) {
return (
<li>
<Link
className="flex items-center gap-2 py-3 pr-4 text-ink no-underline hover:bg-line-soft"
style={{ paddingLeft: `${1 + depth * 1.25}rem` }}
to={`/notes/${note.id}`}
>
<span aria-hidden="true" className="w-5 shrink-0 text-center">
{note.icon ?? '·'}
</span>
<span className="min-w-0 flex-1 truncate">{noteTitle(note)}</span>
{note.pinnedAt && <span className="text-xs text-faint">Pinned</span>}
</Link>
</li>
);
}

export function SpaceNotes() {
const auth = useAuth();
const space = useSpace();
Expand All @@ -17,48 +35,57 @@ export function SpaceNotes() {

if (notes.error) return <LoadError error={notes.error} onRetry={notes.reload} />;
if (!notes.data) return <ListSkeleton label="Loading the pages in this space" rows={4} />;
const branches = flattenTree(notes.data);
const pages = notes.data.filter((note) => !note.template);
const templates = notes.data.filter((note) => note.template);
const branches = flattenTree(pages);

function create(body: Record<string, string>) {
void creating.run(async () => {
const created = await auth.request<Note>(`/spaces/${space.id}/notes`, { method: 'POST', body: JSON.stringify(body) });
navigate(`/notes/${created.id}`);
return '';
}, 'Could not create the page');
}

return (
<div className="fade-in grid max-w-3xl gap-6">
{editable && (
<button
className="primary-button w-fit px-3 py-2 text-sm"
disabled={creating.busy}
onClick={() =>
void creating.run(async () => {
const created = await auth.request<Note>(`/spaces/${space.id}/notes`, { method: 'POST', body: JSON.stringify({}) });
navigate(`/notes/${created.id}`);
return '';
}, 'Could not create the page')
}
type="button"
>
<Icon name="plus" size={16} />
New page
</button>
<div className="flex flex-wrap items-center gap-2">
<button className="primary-button px-3 py-2 text-sm" disabled={creating.busy} onClick={() => create({})} type="button">
<Icon name="plus" size={16} />
New page
</button>
<Menu
items={[
...builtInTemplates.map(([template, label]) => ({ label, onSelect: () => create({ template, title: label }) })),
...templates.map((note, index) => ({ label: noteTitle(note), onSelect: () => create({ fromNoteId: note.id, title: note.title }), separated: index === 0 })),
]}
label="Templates"
trigger="From a template"
triggerClassName="secondary-button px-3 py-2 text-sm"
triggerLabel="Start a page from a template"
/>
</div>
)}
{branches.length === 0 ? (
<EmptyState title="No pages yet" />
) : (
<ul className="panel-rows m-0 grid list-none p-0">
{branches.map(({ note, depth }) => (
<li key={note.id}>
<Link
className="flex items-center gap-2 py-3 pr-4 text-ink no-underline hover:bg-line-soft"
style={{ paddingLeft: `${1 + depth * 1.25}rem` }}
to={`/notes/${note.id}`}
>
<span aria-hidden="true" className="w-5 shrink-0 text-center">
{note.icon ?? '·'}
</span>
<span className="min-w-0 flex-1 truncate">{noteTitle(note)}</span>
{note.pinnedAt && <span className="text-xs text-faint">Pinned</span>}
</Link>
</li>
<PageRow depth={depth} key={note.id} note={note} />
))}
</ul>
)}
{templates.length > 0 && (
<section className="grid gap-2">
<h2 className="m-0 text-sm font-medium text-muted">Templates in this space</h2>
<ul className="panel-rows m-0 grid list-none p-0">
{templates.map((note) => (
<PageRow key={note.id} note={note} />
))}
</ul>
</section>
)}
</div>
);
}
55 changes: 55 additions & 0 deletions app/frontend/test/dashboard.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,4 +275,59 @@ describe('Home dashboard', () => {
expect(news.querySelector('a')?.getAttribute('rel')).toBe('noopener noreferrer');
expect(news.textContent).toContain('1 of 3 feeds didn’t load.');
});

it('saves a quick note as a page or as a task in the chosen space', async () => {
const quick = { ...home, layout: { widgets: [{ id: 'jot', type: 'capture', size: 'medium', settings: {} }] } };
const spaces = [
{ id: 'mine', name: 'Mine', color: 'teal', kind: 'personal', role: 'owner', createdAt: '2026-10-01T00:00:00Z' },
{ id: 'flat', name: 'Flat', color: 'blue', kind: 'shared', role: 'editor', createdAt: '2026-10-01T00:00:00Z' },
{ id: 'club', name: 'Club', color: 'red', kind: 'shared', role: 'viewer', createdAt: '2026-10-01T00:00:00Z' },
];
const fetchMock = vi.fn((input: string, init?: RequestInit) => {
const path = input.replace('/api/v1', '');
if (path === '/auth/refresh') return Promise.resolve(json({ accessToken: 'token', user }));
if (path === '/spaces') return Promise.resolve(json(spaces));
if (path === '/me/inbox/summary') return Promise.resolve(json({ unread: 0 }));
if (path === '/me/dashboards') return Promise.resolve(json([quick]));
if (init?.method === 'POST') return Promise.resolve(json({ id: 'new' }, 201));
return Promise.resolve(new Response(null, { status: 404 }));
});
vi.stubGlobal('fetch', fetchMock);
await act(async () =>
root.render(
<MemoryRouter initialEntries={['/']}>
<AuthProvider>
<App />
</AuthProvider>
</MemoryRouter>,
),
);
await act(async () => {});

const widget = container.querySelector('article[aria-label="Quick note"]')!;
const note = widget.querySelector<HTMLTextAreaElement>('textarea')!;
const space = widget.querySelector<HTMLSelectElement>('select')!;
expect([...space.options].map((option) => option.textContent)).toEqual(['Mine', 'Flat']);
const type = async (text: string) =>
act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(note, text);
note.dispatchEvent(new Event('input', { bubbles: true }));
});

await type('Call the landlord\nabout the boiler');
await act(async () => button('Save as page').click());
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/spaces/mine/notes',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ text: 'Call the landlord\nabout the boiler' }) }),
);
expect(note.value).toBe('');

await act(async () => {
space.value = 'flat';
space.dispatchEvent(new Event('change', { bubbles: true }));
});
await type('Buy bin bags\nthe big ones');
await act(async () => button('Save as task').click());
expect(fetchMock).toHaveBeenCalledWith('/api/v1/spaces/flat/tasks', expect.objectContaining({ method: 'POST', body: JSON.stringify({ title: 'Buy bin bags' }) }));
});
});
Loading
Loading