From 95b7d107d10f1b976861e7ae39fa58f0f9beae58 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:24:17 +0300 Subject: [PATCH 1/2] feat: rework the desktop shell, settings, loading and error states --- app/frontend/src/api.ts | 15 +- app/frontend/src/components/CrashBoundary.tsx | 27 ++ app/frontend/src/components/EventEditor.tsx | 3 +- app/frontend/src/components/FormDialog.tsx | 43 +++ app/frontend/src/components/LoadError.tsx | 22 ++ .../src/components/ResendVerification.tsx | 8 +- app/frontend/src/components/Shell.tsx | 265 +++++++------- .../src/components/SubscriptionEditor.tsx | 3 +- app/frontend/src/components/TaskEditor.tsx | 3 +- app/frontend/src/components/TidyPreview.tsx | 3 +- app/frontend/src/dashboard/Dashboard.tsx | 158 +++++---- app/frontend/src/dashboard/widgets.tsx | 16 +- app/frontend/src/pages/AdminPage.tsx | 12 +- app/frontend/src/pages/AgendaPage.tsx | 29 +- app/frontend/src/pages/EventPage.tsx | 62 ++-- app/frontend/src/pages/FolderScan.tsx | 2 - app/frontend/src/pages/HomePage.tsx | 32 +- app/frontend/src/pages/InboxPage.tsx | 63 ++-- app/frontend/src/pages/InvitationsPage.tsx | 16 +- app/frontend/src/pages/InvitePage.tsx | 4 +- app/frontend/src/pages/LegalPages.tsx | 3 +- app/frontend/src/pages/LoginPage.tsx | 17 +- app/frontend/src/pages/NotFoundPage.tsx | 22 +- app/frontend/src/pages/PasswordPages.tsx | 23 +- app/frontend/src/pages/RecurringPage.tsx | 16 +- app/frontend/src/pages/RegisterPage.tsx | 17 +- app/frontend/src/pages/RsvpPages.tsx | 9 +- app/frontend/src/pages/SavePage.tsx | 4 +- app/frontend/src/pages/TidyPage.tsx | 25 +- app/frontend/src/pages/TodayPage.tsx | 12 +- app/frontend/src/pages/TrashPage.tsx | 15 +- app/frontend/src/pages/VerifyPage.tsx | 14 +- .../src/pages/settings/DataSettings.tsx | 139 ++++---- .../src/pages/settings/ProfileSettings.tsx | 131 +++---- .../src/pages/settings/SecurityLog.tsx | 12 +- .../src/pages/settings/SecuritySettings.tsx | 326 +++++++++--------- .../src/pages/settings/SessionsSettings.tsx | 32 +- .../src/pages/settings/SettingsLayout.tsx | 52 +-- .../src/pages/settings/TokensSettings.tsx | 158 +++++---- .../src/pages/spaces/SpaceActivity.tsx | 7 +- .../src/pages/spaces/SpaceInvitations.tsx | 7 +- app/frontend/src/pages/spaces/SpaceItems.tsx | 17 +- app/frontend/src/pages/spaces/SpaceLayout.tsx | 19 +- .../src/pages/spaces/SpaceMembers.tsx | 5 +- .../src/pages/spaces/SpaceSubscriptions.tsx | 14 +- app/frontend/src/pages/spaces/SpaceTags.tsx | 8 +- app/frontend/src/pages/spaces/SpaceTasks.tsx | 13 +- app/frontend/src/pages/spaces/SpacesPage.tsx | 23 +- app/frontend/src/pages/spaces/TidyRules.tsx | 10 +- app/frontend/src/useAction.tsx | 19 +- app/frontend/src/useResource.ts | 3 +- app/frontend/test/api.spec.tsx | 6 + app/frontend/test/dashboard.spec.tsx | 57 ++- app/frontend/test/settings.spec.tsx | 95 +++++ app/frontend/test/skeletons.spec.tsx | 4 +- app/frontend/test/tasks.spec.tsx | 25 ++ app/frontend/test/tokens.spec.tsx | 6 +- 57 files changed, 1206 insertions(+), 945 deletions(-) create mode 100644 app/frontend/src/components/CrashBoundary.tsx create mode 100644 app/frontend/src/components/FormDialog.tsx create mode 100644 app/frontend/src/components/LoadError.tsx diff --git a/app/frontend/src/api.ts b/app/frontend/src/api.ts index 108ae12..1230e90 100644 --- a/app/frontend/src/api.ts +++ b/app/frontend/src/api.ts @@ -37,6 +37,16 @@ function reportOutage() { const gatewayStatuses = [500, 502, 503, 504]; +const statusMessages: Record = { + 400: 'Something in that request was not accepted. Check it and try again.', + 401: 'Your session has ended. Sign in again.', + 403: 'You don’t have permission to do that.', + 404: 'That no longer exists, or you can’t see it.', + 409: 'Someone changed this at the same time. Reload and try again.', + 413: 'That is too large.', + 429: 'Too many attempts. Wait a minute, then try again.', +}; + export const readBlob = (response: Response) => response.blob(); async function readJson(response: Response) { @@ -77,7 +87,10 @@ export async function apiRequest( const message = Array.isArray(body?.message) ? body.message.join(', ') : body?.message; - throw new ApiError(message ?? `Something went wrong (error ${response.status}). Try again.`, response.status); + throw new ApiError( + message ?? statusMessages[response.status] ?? `Something went wrong (error ${response.status}). Try again.`, + response.status, + ); } return (await read(response)) as T; } diff --git a/app/frontend/src/components/CrashBoundary.tsx b/app/frontend/src/components/CrashBoundary.tsx new file mode 100644 index 0000000..bbbdb9d --- /dev/null +++ b/app/frontend/src/components/CrashBoundary.tsx @@ -0,0 +1,27 @@ +import { Component, type ReactNode } from 'react'; +import { ErrorState } from '@project-graphite/ui'; + +export class CrashBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + render() { + if (!this.state.failed) return this.props.children; + return ( + + Go home + + } + onRetry={() => window.location.reload()} + title="This page hit a problem" + > + Reloading usually fixes it. If it keeps happening, the page may be broken for now. + + ); + } +} diff --git a/app/frontend/src/components/EventEditor.tsx b/app/frontend/src/components/EventEditor.tsx index ab367c7..9761c33 100644 --- a/app/frontend/src/components/EventEditor.tsx +++ b/app/frontend/src/components/EventEditor.tsx @@ -27,7 +27,7 @@ export function EventEditor({ const custom = event?.repeatRule && !repeatPresets.some(([value]) => value === event.repeatRule); return ( - +
- {saving.status}
+ +
+ +
+ ); +} diff --git a/app/frontend/src/components/LoadError.tsx b/app/frontend/src/components/LoadError.tsx new file mode 100644 index 0000000..826f33e --- /dev/null +++ b/app/frontend/src/components/LoadError.tsx @@ -0,0 +1,22 @@ +import { ErrorState, Icon } from '@project-graphite/ui'; + +export function LoadError({ compact = false, error, onRetry }: { compact?: boolean; error: string; onRetry: () => void }) { + if (compact) { + return ( +
+ + + + {error} + +
+ ); + } + return ( + + {error} + + ); +} diff --git a/app/frontend/src/components/ResendVerification.tsx b/app/frontend/src/components/ResendVerification.tsx index 36a341b..b98db67 100644 --- a/app/frontend/src/components/ResendVerification.tsx +++ b/app/frontend/src/components/ResendVerification.tsx @@ -1,14 +1,13 @@ import { useState } from 'react'; -import { errorMessage } from '@project-graphite/ui'; +import { errorMessage, useSnackbar } from '@project-graphite/ui'; import { apiRequest } from '../api'; export function ResendVerification({ email }: { email: string }) { const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle'); - const [error, setError] = useState(''); + const show = useSnackbar(); async function resend() { setState('sending'); - setError(''); try { await apiRequest('/auth/resend-verification', { method: 'POST', @@ -16,7 +15,7 @@ export function ResendVerification({ email }: { email: string }) { }); setState('sent'); } catch (reason) { - setError(errorMessage(reason, 'Could not send the link')); + show({ message: errorMessage(reason, 'Could not send the link'), tone: 'error' }); setState('idle'); } } @@ -38,7 +37,6 @@ export function ResendVerification({ email }: { email: string }) {

)} - {error &&

{error}

} ); } diff --git a/app/frontend/src/components/Shell.tsx b/app/frontend/src/components/Shell.tsx index e0e6a74..d230a42 100644 --- a/app/frontend/src/components/Shell.tsx +++ b/app/frontend/src/components/Shell.tsx @@ -1,8 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; -import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'; +import { Link, Outlet, useLocation, useNavigate } from 'react-router'; import { AppShell, - errorMessage, Icon, OutageGate, Sidebar, @@ -15,29 +14,44 @@ import { onOutage } from '../api'; import { useAuth } from '../auth'; import { InboxContext } from '../inbox'; import { SpacesContext, type Space } from '../spaces'; -import { useResource } from '../useResource'; +import { useAction } from '../useAction'; +import { useResource, type Resource } from '../useResource'; import { AccountMenu } from './AccountMenu'; +import { CrashBoundary } from './CrashBoundary'; import { SearchPalette } from './SearchPalette'; import { UpdatePrompt } from './UpdatePrompt'; import { StrataMark } from './StrataMark'; -const navClass = ({ isActive }: { isActive: boolean }) => - `nav-link whitespace-nowrap no-underline ${isActive ? 'text-ink' : 'text-muted hover:text-ink'}`; +const sessionHintKey = 'strata-signed-in'; + +function hadSession() { + try { + return localStorage.getItem(sessionHintKey) === '1'; + } catch { + return false; + } +} + +function rememberSession(signedIn: boolean) { + try { + if (signedIn) localStorage.setItem(sessionHintKey, '1'); + else localStorage.removeItem(sessionHintKey); + } catch { + return; + } +} function SiteFooter() { return (
-
- Your life, in layers. - -
+
); } @@ -50,139 +64,148 @@ function ScrollToTop() { return null; } -export function Shell() { +function ShellFrame({ inbox, spaces }: { inbox: Resource<{ unread: number }>; spaces: Resource }) { const auth = useAuth(); const navigate = useNavigate(); const { pathname } = useLocation(); - const [error, setError] = useState(''); - const spaces = useResource('/spaces', true); - const inbox = useResource<{ unread: number }>('/me/inbox/summary', true); + const signingOut = useAction(); const { reload: checkInbox } = inbox; const unread = inbox.data?.unread ?? 0; const [searching, setSearching] = useState(false); + const [expectSession] = useState(hadSession); const signedIn = Boolean(auth.user); const openSearch = useCallback(() => signedIn && setSearching(true), [signedIn]); useCommandShortcut(openSearch); + useEffect(() => { + if (auth.ready) rememberSession(signedIn); + }, [auth.ready, signedIn]); + useEffect(() => { if (!auth.user) return; const timer = setInterval(checkInbox, 60_000); return () => clearInterval(timer); }, [auth.user, checkInbox]); - async function signOut() { - setError(''); - try { - await auth.logout(); - navigate('/'); - } catch (reason) { - setError(errorMessage(reason, 'Could not sign out')); - } - } - const tabs: ShellTab[] = [ { href: '/', label: 'Home', icon: 'home', active: pathname === '/' }, ...(auth.user ? [ - { href: '/today', label: 'Today', icon: 'compass' as const, active: pathname === '/today' }, - { href: '/agenda', label: 'Agenda', icon: 'bell' as const, active: pathname === '/agenda' || pathname.startsWith('/events/') }, - { href: '/spaces', label: 'Spaces', icon: 'library' as const, active: pathname.startsWith('/spaces') }, - { href: '/settings', label: 'Settings', icon: 'user' as const, active: pathname.startsWith('/settings') }, + { href: '/today', label: 'Today', icon: 'check' as const, active: pathname === '/today' }, + { href: '/agenda', label: 'Agenda', icon: 'calendar' as const, active: pathname === '/agenda' || pathname.startsWith('/events/') }, + { href: '/spaces', label: 'Spaces', icon: 'folder' as const, active: pathname.startsWith('/spaces') }, + { href: '/settings', label: 'Settings', icon: 'settings' as const, active: pathname.startsWith('/settings') }, ] : [{ href: '/login', label: 'Sign in', icon: 'user' as const, active: pathname === '/login', loading: !auth.ready }]), ]; + const showSidebar = auth.user || (!auth.ready && expectSession); + + return ( + <> + + + ) : auth.user ? ( + <> + + + + {unread > 0 && {unread > 9 ? '9+' : unread}} + + + void signingOut.run(async () => { + await auth.logout(); + navigate('/'); + return ''; + }, 'Could not sign out') + } + /> + + ) : ( + <> + + Sign in + + + Create account + + + ) + } + brand={{ href: '/', mark: , name: 'Strata' }} + footer={} + nav={[]} + sidebar={ + showSidebar && ( + ({ + active: pathname === `/spaces/${space.id}` || pathname.startsWith(`/spaces/${space.id}/`), + href: `/spaces/${space.id}`, + icon: ( + + ), + label: space.name, + })), + { active: pathname === '/spaces', href: '/spaces', icon: 'folder', label: 'All spaces' }, + ], + }, + { + items: [ + { active: pathname === '/inbox', badge: unread || undefined, href: '/inbox', icon: 'bell', label: 'Inbox' }, + { active: pathname === '/invitations', href: '/invitations', icon: 'mail', label: 'Invitations' }, + { active: pathname === '/trash', href: '/trash', icon: 'trash', label: 'Trash' }, + { active: pathname.startsWith('/settings'), href: '/settings', icon: 'settings', label: 'Settings' }, + ], + }, + ]} + /> + ) + } + tabs={tabs} + > + + + + + + + {searching && setSearching(false)} />} + + + ); +} + +export function Shell() { + const spaces = useResource('/spaces', true); + const inbox = useResource<{ unread: number }>('/me/inbox/summary', true); return ( - - - ) : auth.user ? ( - <> - - - - {unread > 0 && {unread > 9 ? '9+' : unread}} - - void signOut()} /> - - ) : ( - <> - `${navClass(state)} mono-sm px-2`} to="/login"> - sign in - - - Create account - - - ) - } - alert={ - error && ( -

- {error} -

- ) - } - brand={{ href: '/', mark: , name: 'Strata' }} - footer={} - nav={[]} - sidebar={ - auth.user && ( - ({ - active: pathname === `/spaces/${space.id}` || pathname.startsWith(`/spaces/${space.id}/`), - href: `/spaces/${space.id}`, - label: space.name, - })), - { active: pathname === '/spaces', href: '/spaces', icon: 'library', label: 'All spaces' }, - ], - }, - { - items: [ - { active: pathname === '/inbox', badge: unread || undefined, href: '/inbox', icon: 'bell', label: 'Inbox' }, - { active: pathname === '/invitations', href: '/invitations', label: 'Invitations' }, - { active: pathname === '/trash', href: '/trash', label: 'Trash' }, - { active: pathname.startsWith('/settings'), href: '/settings', icon: 'user', label: 'Settings' }, - ], - }, - ]} - /> - ) - } - tabs={tabs} - > - - - -
- {searching && setSearching(false)} />} - +
diff --git a/app/frontend/src/components/SubscriptionEditor.tsx b/app/frontend/src/components/SubscriptionEditor.tsx index 8c1ae0a..4581ab7 100644 --- a/app/frontend/src/components/SubscriptionEditor.tsx +++ b/app/frontend/src/components/SubscriptionEditor.tsx @@ -27,7 +27,7 @@ export function SubscriptionEditor({ const today = new Date().toISOString().slice(0, 10); return ( - +
- {saving.status}
+ + - )} -
- + ) + } + title={current.name} + /> {dashboards.data.length > 1 && (
)} -
{saving.status}
{editing && (
+ {adding && ( + setAdding(false)} + onSubmit={(target) => { + if (!addForm.check(target, { name: [required('Name the dashboard.'), atMost(40, 'Use at most 40 characters.')] })) return; + const values = new FormData(target); + const template = String(values.get('template')); + void saving + .run(async () => { + const created = await auth.request('/me/dashboards', { + method: 'POST', + body: JSON.stringify({ name: String(values.get('name')).trim(), ...(template ? { template } : {}) }), + }); + dashboards.mutate((all) => [...all, created]); + navigate(`/?d=${created.id}`); + return ''; + }, 'Could not add the dashboard') + .then((added) => added && setAdding(false)); + }} + submitLabel="Add dashboard" + title="New dashboard" + > + +
+ Start from + {templates.map(([value, label, contents]) => ( + + ))} +
+
+ )} {deleting && ( setDeleting(false)} onConfirm={async () => { await auth.request(`/me/dashboards/${current.id}`, { method: 'DELETE' }); diff --git a/app/frontend/src/dashboard/widgets.tsx b/app/frontend/src/dashboard/widgets.tsx index aec095c..5643b9f 100644 --- a/app/frontend/src/dashboard/widgets.tsx +++ b/app/frontend/src/dashboard/widgets.tsx @@ -11,6 +11,7 @@ import { money, shortDate, type Subscription } from '../subscriptions'; import { TaskRow, type Task, type TaskList } from '../tasks'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; +import { LoadError } from '../components/LoadError'; export type WidgetType = 'clock' | 'today' | 'tasks' | 'shortcuts' | 'recurring' | 'inbox' | 'agenda' | 'countdown' | 'focus' | 'tidy'; export type WidgetSize = 'small' | 'medium' | 'wide' | 'full'; @@ -93,7 +94,7 @@ function TaskWidget({ path, empty }: { path: string | null; empty: ReactNode }) const tasks = useResource>(path, true); const action = useAction(); if (!path) return {empty}; - if (tasks.error) return

{tasks.error}

; + if (tasks.error) return ; if (!tasks.data) return ; const list = Array.isArray(tasks.data) ? tasks.data : tasks.data.results; if (list.length === 0) return Nothing due. Enjoy it.; @@ -116,7 +117,6 @@ function TaskWidget({ path, empty }: { path: string | null; empty: ReactNode }) /> ))} - {action.status} ); } @@ -250,7 +250,7 @@ function Recurring() { '/subscriptions/summary', true, ); - if (summary.error) return

{summary.error}

; + if (summary.error) return ; if (!summary.data) return ; if (summary.data.totals.length === 0) { return ( @@ -280,7 +280,7 @@ function Recurring() { function Inbox() { const notifications = useResource>('/me/inbox?page=1', true); - if (notifications.error) return

{notifications.error}

; + if (notifications.error) return ; if (!notifications.data) return ; const unread = notifications.data.results.filter((notification) => !notification.read).slice(0, 5); if (unread.length === 0) return You are all caught up.; @@ -301,7 +301,7 @@ function Inbox() { function Agenda() { const today = dayKey(new Date()); const agenda = useResource(`/agenda?from=${today}&to=${addDays(today, 7)}`, true); - if (agenda.error) return

{agenda.error}

; + if (agenda.error) return ; if (!agenda.data) return ; if (agenda.data.length === 0) return Nothing in the next seven days.; return ( @@ -385,7 +385,7 @@ function Focus({ settings }: WidgetProps) {

{Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, '0')}

-

{phase === 'focus' ? 'focus' : 'break: step away for a moment'}

+

{phase === 'focus' ? 'Focus' : 'Break'}

diff --git a/app/frontend/src/pages/AgendaPage.tsx b/app/frontend/src/pages/AgendaPage.tsx index 4f7b9f6..9fd0023 100644 --- a/app/frontend/src/pages/AgendaPage.tsx +++ b/app/frontend/src/pages/AgendaPage.tsx @@ -1,10 +1,11 @@ import { useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; -import { ListSkeleton, Tabs } from '@project-graphite/ui'; +import { Icon, ListSkeleton, PageHeader, Tabs } from '@project-graphite/ui'; import { addDays, dayKey, entryDay, entryLink, entryTime, kindLabels, type AgendaEntry } from '../agenda'; import { EventEditor } from '../components/EventEditor'; import { useSpaces } from '../spaces'; import { useResource } from '../useResource'; +import { LoadError } from '../components/LoadError'; type View = 'list' | 'week' | 'month'; @@ -60,15 +61,15 @@ export function AgendaPage() { return (
-
-
-

agenda

-

Agenda

-
- -
+ space.role !== 'viewer')} onClick={() => setCreating(true)} type="button"> + + New event + + } + title="Agenda" + /> ({ active: view === option, @@ -95,7 +96,7 @@ export function AgendaPage() {
{agenda.error ? ( -

{agenda.error}

+ ) : !agenda.data ? ( ) : view === 'list' ? ( @@ -107,8 +108,8 @@ export function AgendaPage() { .filter((day) => byDay.has(day)) .map((day) => (
-

- {day === today ? 'today · ' : ''} +

+ {day === today ? 'Today · ' : ''} {new Date(`${day}T00:00:00`).toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}

@@ -124,7 +125,7 @@ export function AgendaPage() {
{view === 'month' && allDays.slice(0, 7).map((day) => ( -

+

{new Date(`${day}T00:00:00`).toLocaleDateString(undefined, { weekday: 'short' })}

))} diff --git a/app/frontend/src/pages/EventPage.tsx b/app/frontend/src/pages/EventPage.tsx index d5e8102..b110e60 100644 --- a/app/frontend/src/pages/EventPage.tsx +++ b/app/frontend/src/pages/EventPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useParams } from 'react-router'; -import { EmptyState, PageSkeleton, TextField } from '@project-graphite/ui'; +import { EmptyState, Icon, PageHeader, PageSkeleton, TextField } from '@project-graphite/ui'; import { eventWhen, type EventDetails, type Guest } from '../agenda'; import { useAuth } from '../auth'; import { EventEditor } from '../components/EventEditor'; @@ -8,6 +8,7 @@ import { useSpaces } from '../spaces'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; import { emailAddress, required, useFormErrors } from '../validation'; +import { LoadError } from '../components/LoadError'; const responseLabels: Record = { yes: 'coming', no: 'not coming', maybe: 'maybe', pending: 'no answer yet' }; @@ -42,7 +43,7 @@ export function EventPage() { ); } - if (event.error) return

{event.error}

; + if (event.error) return ; if (!event.data) return ; const details = event.data; const space = spaces.data?.find((candidate) => candidate.id === details.spaceId); @@ -50,33 +51,35 @@ export function EventPage() { return (
-
-
-

event{space ? ` · ${space.name}` : ''}

-

{details.title}

-

{eventWhen(details)}

- {details.location && ( -

- {details.location} ·{' '} - - map - -

- )} - {details.meetingUrl && ( -

- - Join the meeting - -

- )} -
- {editable && ( - + setEditing(true)} type="button"> + + Edit + + ) + } + eyebrow={space?.name} + title={details.title} + > +

{eventWhen(details)}

+ {details.location && ( +

+ {details.location} ·{' '} + + Map + +

+ )} + {details.meetingUrl && ( +

+ + Join the meeting + +

)} -
+ {details.description &&

{details.description}

}
@@ -114,7 +117,6 @@ export function EventPage() { ))} )} - {removing.status}
{editable && ( @@ -146,7 +148,6 @@ export function EventPage() {
- {inviting.status} {personalLink && } - {scanning.status}
{report && ( @@ -223,7 +222,6 @@ export function FolderScan() { busyLabel="Deleting…" confirmLabel={`Delete ${chosen.length} file${chosen.length === 1 ? '' : 's'}`} errorFallback="Could not delete the files" - eyebrow="delete files" onClose={() => setConfirming(false)} onConfirm={async () => { if ((await root.requestPermission({ mode: 'readwrite' })) !== 'granted') { diff --git a/app/frontend/src/pages/HomePage.tsx b/app/frontend/src/pages/HomePage.tsx index 8cabdfe..c2246f6 100644 --- a/app/frontend/src/pages/HomePage.tsx +++ b/app/frontend/src/pages/HomePage.tsx @@ -1,14 +1,15 @@ import { Link } from 'react-router'; -import { PageSkeleton } from '@project-graphite/ui'; +import { Icon, PageSkeleton, type IconName } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { DashboardHome } from '../dashboard/Dashboard'; -const areas = [ - ['Home', 'A dashboard of widgets you arrange yourself: weather, agenda, tasks, feeds and shortcuts.'], - ['Agenda', 'Your Google Calendar beside your own events, renewals and deadlines.'], - ['Notes', 'Fast capture that grows into pages, databases and live whiteboards.'], - ['Recurring', 'Every subscription and bill, what it costs and when it renews.'], - ['Tidy', 'Find and clear the clutter across your files and subscriptions.'], +const areas: [IconName, string, string][] = [ + ['home', 'Home', 'A dashboard of widgets you arrange.'], + ['calendar', 'Agenda', 'Events, RSVPs, due tasks and renewals together.'], + ['check', 'Tasks', 'Lists, repeats, reminders and a Today view.'], + ['repeat', 'Recurring', 'What each subscription costs and when it renews.'], + ['sparkles', 'Tidy', 'Duplicate files and subscriptions you no longer use.'], + ['folder', 'Spaces', 'Share any of it with the people you choose.'], ]; export function HomePage() { @@ -21,24 +22,23 @@ export function HomePage() { } return (
-

a personal workspace

-

Your life, in layers.

-

- Strata brings your dashboard, agenda, notes, whiteboards, subscriptions and files into one - calm, private place. Free, open source and built by Project Graphite. -

+

Tasks, events, subscriptions and files in one place.

+

Free and open source, from Project Graphite.

- Create your workspace + Create an account Sign in
    - {areas.map(([name, description]) => ( + {areas.map(([icon, name, description]) => (
  • -

    {name}

    +

    + + {name} +

    {description}

  • ))} diff --git a/app/frontend/src/pages/InboxPage.tsx b/app/frontend/src/pages/InboxPage.tsx index 2109d3d..faf4f43 100644 --- a/app/frontend/src/pages/InboxPage.tsx +++ b/app/frontend/src/pages/InboxPage.tsx @@ -1,10 +1,11 @@ import { Link, useSearchParams } from 'react-router'; -import { EmptyState, ListSkeleton, Pagination, timeAgo } from '@project-graphite/ui'; +import { EmptyState, ListSkeleton, PageHeader, Pagination, timeAgo } from '@project-graphite/ui'; import type { Page } from '../api'; import { useAuth } from '../auth'; import { useInbox, type InboxNotification } from '../inbox'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; +import { LoadError } from '../components/LoadError'; export function InboxPage() { const auth = useAuth(); @@ -28,43 +29,37 @@ export function InboxPage() { return (
    -
    -
    -

    inbox

    -

    Inbox

    -
    - {(inbox.data?.unread ?? 0) > 0 && ( - - )} -
    - {action.status} + 0 && ( + + ) + } + title="Inbox" + /> {notifications.error ? ( -

    {notifications.error}

    + ) : !notifications.data ? ( ) : notifications.data.results.length === 0 ? ( - -

    - Invitations and changes to your spaces appear here. -

    -
    + ) : ( <>
      diff --git a/app/frontend/src/pages/InvitationsPage.tsx b/app/frontend/src/pages/InvitationsPage.tsx index b769460..d2e6ed2 100644 --- a/app/frontend/src/pages/InvitationsPage.tsx +++ b/app/frontend/src/pages/InvitationsPage.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; -import { ListSkeleton, TextAreaField, TextField, timeAgo } from '@project-graphite/ui'; +import { ListSkeleton, PageHeader, TextAreaField, TextField, timeAgo } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { noteChecks, type ReceivedInvitation, type SentInvitation } from '../invitations'; import { addJoinedSpace, SpaceDot, useSpaces } from '../spaces'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; import { emailAddress, useFormErrors } from '../validation'; +import { LoadError } from '../components/LoadError'; const statusLabels: Record = { pending: 'waiting', @@ -35,15 +36,12 @@ export function InvitationsPage() { return (
      -
      -

      invitations

      -

      Invitations

      -
      +

      For you

      {received.error ? ( -

      {received.error}

      + ) : !received.data ? ( ) : received.data.length === 0 ? ( @@ -84,13 +82,12 @@ export function InvitationsPage() { ))}
    )} -
    {answering.status}

    Invite someone to Strata

    - Each invite works once, for 14 days. Leave the email empty to get a link you send yourself. + Each invite works once, for 14 days. Leave the email empty to get a link to send yourself.

    - {inviting.status} {link && (
    {link} @@ -139,7 +135,7 @@ export function InvitationsPage() {

    Sent

    {sent.error ? ( -

    {sent.error}

    + ) : !sent.data ? ( ) : sent.data.length === 0 ? ( diff --git a/app/frontend/src/pages/InvitePage.tsx b/app/frontend/src/pages/InvitePage.tsx index ca581f4..a9dbf41 100644 --- a/app/frontend/src/pages/InvitePage.tsx +++ b/app/frontend/src/pages/InvitePage.tsx @@ -28,8 +28,7 @@ export function InvitePage() { return (
    -

    invitation

    -

    {invitedLine(invite.data)}

    +

    {invitedLine(invite.data)}

    {!auth.user ? ( <>

    @@ -52,7 +51,6 @@ export function InvitePage() {

    ) : (
    - {joining.status} @@ -72,6 +69,7 @@ function TwoStepForm({ challenge, onRestart }: { challenge: string; onRestart: ( export function LoginPage() { const auth = useAuth(); const form = useFormErrors(); + const show = useSnackbar(); const [error, setError] = useState(''); const [email, setEmail] = useState(''); const [busy, setBusy] = useState(false); @@ -100,6 +98,7 @@ export function LoginPage() { } } catch (reason) { setError(errorMessage(reason, 'Sign in failed')); + show({ message: errorMessage(reason, 'Sign in failed'), tone: 'error' }); setBusy(false); } } @@ -110,12 +109,10 @@ export function LoginPage() { return (
    -

    Welcome back

    -

    Sign in

    +

    Sign in

    - {error &&

    {error}

    } diff --git a/app/frontend/src/pages/NotFoundPage.tsx b/app/frontend/src/pages/NotFoundPage.tsx index 868c899..c24e6d4 100644 --- a/app/frontend/src/pages/NotFoundPage.tsx +++ b/app/frontend/src/pages/NotFoundPage.tsx @@ -1,18 +1,18 @@ import { Link } from 'react-router'; +import { Icon } from '@project-graphite/ui'; export function NotFoundPage() { return ( -
    -

    404

    -

    Page not found

    -

    - There is nothing at this address. The link may be wrong, or the page may have moved. -

    -
    - - Go home - -
    +
    + + + +

    404

    +

    Page not found

    +

    The link may be wrong, or the page may have moved.

    + + Go home +
    ); } diff --git a/app/frontend/src/pages/PasswordPages.tsx b/app/frontend/src/pages/PasswordPages.tsx index f00e6c5..756e66b 100644 --- a/app/frontend/src/pages/PasswordPages.tsx +++ b/app/frontend/src/pages/PasswordPages.tsx @@ -1,18 +1,17 @@ import { useState, type FormEvent } from 'react'; import { Link, useSearchParams } from 'react-router'; -import { errorMessage, TextField } from '@project-graphite/ui'; +import { errorMessage, TextField, useSnackbar } from '@project-graphite/ui'; import { apiRequest } from '../api'; import { emailAddress, password, required, useFormErrors } from '../validation'; export function ForgotPasswordPage() { const form = useFormErrors(); const [sentTo, setSentTo] = useState(''); - const [error, setError] = useState(''); + const show = useSnackbar(); const [busy, setBusy] = useState(false); async function submit(event: FormEvent) { event.preventDefault(); - setError(''); if (!form.check(event.currentTarget, { email: [required('Enter your email address.'), emailAddress] })) { return; } @@ -25,7 +24,7 @@ export function ForgotPasswordPage() { }); setSentTo(email); } catch (reason) { - setError(errorMessage(reason, 'Could not send the reset link')); + show({ message: errorMessage(reason, 'Could not send the reset link'), tone: 'error' }); } finally { setBusy(false); } @@ -33,8 +32,7 @@ export function ForgotPasswordPage() { return (
    -

    Account help

    -

    Reset your password

    +

    Reset your password

    {sentTo ? (

    If an account uses {sentTo}, a reset link is on its way. It works for one hour. @@ -42,7 +40,6 @@ export function ForgotPasswordPage() { ) : ( - {error &&

    {error}

    } @@ -59,6 +56,7 @@ export function ResetPasswordPage() { const [parameters] = useSearchParams(); const form = useFormErrors(); const [done, setDone] = useState(false); + const show = useSnackbar(); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); @@ -78,6 +76,7 @@ export function ResetPasswordPage() { setDone(true); } catch (reason) { setError(errorMessage(reason, 'Could not reset the password')); + show({ message: errorMessage(reason, 'Could not reset the password'), tone: 'error' }); } finally { setBusy(false); } @@ -86,8 +85,7 @@ export function ResetPasswordPage() { if (done) { return (
    -

    Password changed

    -

    Sign in with your new password.

    +

    Sign in with your new password.

    Every device that was signed in has been signed out.

    Sign in
    @@ -96,8 +94,7 @@ export function ResetPasswordPage() { return (
    -

    Account help

    -

    Choose a new password

    +

    Choose a new password

    {error && ( -

    - {error} Request a new link +

    + Request a new link

    )}
)} - {action.status} )}
diff --git a/app/frontend/src/pages/RegisterPage.tsx b/app/frontend/src/pages/RegisterPage.tsx index 00209a6..1ad8c95 100644 --- a/app/frontend/src/pages/RegisterPage.tsx +++ b/app/frontend/src/pages/RegisterPage.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react'; import { Link, useSearchParams } from 'react-router'; -import { errorMessage, FormPanelSkeleton, TextField } from '@project-graphite/ui'; +import { errorMessage, FormPanelSkeleton, TextField, useSnackbar } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { ResendVerification } from '../components/ResendVerification'; import { invitedLine, type InviteDetails } from '../invitations'; @@ -10,7 +10,7 @@ import { atLeast, atMost, emailAddress, password, required, useFormErrors } from export function RegisterPage() { const auth = useAuth(); const form = useFormErrors(); - const [error, setError] = useState(''); + const show = useSnackbar(); const [createdEmail, setCreatedEmail] = useState(''); const [busy, setBusy] = useState(false); const [params] = useSearchParams(); @@ -20,7 +20,6 @@ export function RegisterPage() { async function submit(event: FormEvent) { event.preventDefault(); - setError(''); if ( !form.check(event.currentTarget, { displayName: [required('Enter a display name.'), atMost(80, 'Use at most 80 characters.')], @@ -52,7 +51,7 @@ export function RegisterPage() { }); setCreatedEmail(email); } catch (reason) { - setError(errorMessage(reason, 'Registration failed')); + show({ message: errorMessage(reason, 'Registration failed'), tone: 'error' }); } finally { setBusy(false); } @@ -61,8 +60,7 @@ export function RegisterPage() { if (createdEmail) { return (
-

Almost there

-

Check your inbox

+

Check your inbox

We sent an email to {createdEmail}. Open the verification link in it within 24 hours to finish setting up your account. If that address already has an account, the email explains @@ -80,8 +78,7 @@ export function RegisterPage() { if (!invite.data && site.data?.inviteOnly) { return (

-

Invite only

-

Sign-up is closed for now

+

Sign-up is closed for now

{invite.error || 'Strata is taking new accounts by invitation only at the moment.'} Ask someone who uses Strata to invite you, then open the link in their invite. @@ -95,8 +92,7 @@ export function RegisterPage() { return (

-

Start your workspace

-

Create your account

+

Create your account

{invite.data &&

{invitedLine(invite.data)}

} {invite.error &&

{invite.error}

} @@ -124,7 +120,6 @@ export function RegisterPage() { type="password" {...form.field('password')} /> - {error &&

{error}

} diff --git a/app/frontend/src/pages/RsvpPages.tsx b/app/frontend/src/pages/RsvpPages.tsx index 6033797..c0880a5 100644 --- a/app/frontend/src/pages/RsvpPages.tsx +++ b/app/frontend/src/pages/RsvpPages.tsx @@ -10,8 +10,7 @@ import { required, useFormErrors } from '../validation'; function EventSummary({ event }: { event: PublicEvent }) { return ( <> -

invitation

-

{event.title}

+

{event.title}

{eventWhen(event)}

{event.location &&

{event.location}

} {event.meetingUrl && ( @@ -83,7 +82,6 @@ export function RsvpPage() {

Hi {guest.name}.

- {answering.status} @@ -112,8 +110,8 @@ export function SharePage() { if (!shared.data.event) { return (
-

shared {shared.data.item.kind}

-

{shared.data.item.title || 'Untitled'}

+

Shared {shared.data.item.kind}

+

{shared.data.item.title || 'Untitled'}

); } @@ -143,7 +141,6 @@ export function SharePage() { - {answering.status} diff --git a/app/frontend/src/pages/SavePage.tsx b/app/frontend/src/pages/SavePage.tsx index 33bd1f7..2659525 100644 --- a/app/frontend/src/pages/SavePage.tsx +++ b/app/frontend/src/pages/SavePage.tsx @@ -76,8 +76,7 @@ export function SavePage() { return (
-

save to strata

-

Save what you shared

+

Save what you shared

)} - {saving.status}
)} - {undoing.status} {empty ? ( - -

- No duplicate, large or forgotten uploads, and no subscriptions that look unused or overlap. -

-
+ ) : ( saving > 0 && (
@@ -242,7 +238,7 @@ function TidyHistory() { const history = useResource('/tidy/history', true); const undoing = useAction(); - if (history.error) return

{history.error}

; + if (history.error) return ; if (!history.data) return ; return ( @@ -250,7 +246,7 @@ function TidyHistory() { {history.data.length === 0 ? (

- Changes made here and by tagging rules are listed for 90 days, so you can undo them. + Changes stay here for 90 days, so you can undo them.

) : ( @@ -287,7 +283,6 @@ function TidyHistory() { ))} )} - {undoing.status}
); } @@ -298,13 +293,7 @@ export function TidyPage() { return (
-
-

tidy

-

Tidy

-

- Clutter across the spaces you can edit. Nothing changes until you check it and confirm, and every change can be undone. -

-
+ -
-

today

-

Today

-

What is due today or overdue, across every space, for you or for no one in particular.

-
+ {tasks.error ? ( -

{tasks.error}

+ ) : !tasks.data ? ( ) : tasks.data.length === 0 ? ( @@ -51,7 +48,6 @@ export function TodayPage() { ))} )} - {action.status}
); } diff --git a/app/frontend/src/pages/TrashPage.tsx b/app/frontend/src/pages/TrashPage.tsx index 2eb3a35..7b15f99 100644 --- a/app/frontend/src/pages/TrashPage.tsx +++ b/app/frontend/src/pages/TrashPage.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { useSearchParams } from 'react-router'; -import { ConfirmDialog, EmptyState, ListSkeleton, Pagination, timeAgo } from '@project-graphite/ui'; +import { ConfirmDialog, EmptyState, ListSkeleton, PageHeader, Pagination, timeAgo } from '@project-graphite/ui'; import type { Page } from '../api'; import { useAuth } from '../auth'; import { useSpaces, type Item } from '../spaces'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; import { ItemList } from './spaces/ItemList'; +import { LoadError } from '../components/LoadError'; export function TrashPage() { const auth = useAuth(); @@ -20,15 +21,9 @@ export function TrashPage() { return (
-
-

trash

-

Trash

-

- What you move to the trash in spaces you can edit. Restore it, or delete it for good. -

-
+ Items here are deleted for good after 30 days. {trash.error ? ( -

{trash.error}

+ ) : !trash.data ? ( ) : trash.data.results.length === 0 ? ( @@ -63,14 +58,12 @@ export function TrashPage() { `/trash?page=${next}`} totalPages={trash.data.totalPages} /> )} - {action.status} {deleting && ( setDeleting(undefined)} onConfirm={async () => { await auth.request(`/items/${deleting.id}`, { method: 'DELETE' }); diff --git a/app/frontend/src/pages/VerifyPage.tsx b/app/frontend/src/pages/VerifyPage.tsx index 36a83d2..fc52fa6 100644 --- a/app/frontend/src/pages/VerifyPage.tsx +++ b/app/frontend/src/pages/VerifyPage.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react'; import { Link, useSearchParams } from 'react-router'; -import { errorMessage, TextField } from '@project-graphite/ui'; +import { errorMessage, TextField, useSnackbar } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { required, useFormErrors } from '../validation'; @@ -8,13 +8,12 @@ export function VerifyPage() { const auth = useAuth(); const [parameters] = useSearchParams(); const form = useFormErrors(); - const [error, setError] = useState(''); + const show = useSnackbar(); const [verified, setVerified] = useState(false); const [busy, setBusy] = useState(false); async function submit(event: FormEvent) { event.preventDefault(); - setError(''); if ( !form.check(event.currentTarget, { token: [required('Paste the token from the email, or open the link it contains.')], @@ -28,7 +27,7 @@ export function VerifyPage() { await auth.verify(String(values.get('token')).trim()); setVerified(true); } catch (reason) { - setError(errorMessage(reason, 'Verification failed')); + show({ message: errorMessage(reason, 'Verification failed'), tone: 'error' }); } finally { setBusy(false); } @@ -37,8 +36,7 @@ export function VerifyPage() { if (verified) { return (
-

Email verified

-

Your email address is confirmed.

+

Your email address is confirmed.

{auth.user ? ( Back to settings ) : ( @@ -50,8 +48,7 @@ export function VerifyPage() { return (
-

One last step

-

Verify your email

+

Verify your email

- {error &&

{error}

} diff --git a/app/frontend/src/pages/settings/DataSettings.tsx b/app/frontend/src/pages/settings/DataSettings.tsx index 3964771..542f84e 100644 --- a/app/frontend/src/pages/settings/DataSettings.tsx +++ b/app/frontend/src/pages/settings/DataSettings.tsx @@ -1,12 +1,14 @@ import { useState } from 'react'; import { useNavigate } from 'react-router'; -import { Dialog, FormSkeleton } from '@project-graphite/ui'; +import { FormSkeleton } from '@project-graphite/ui'; import { useAuth } from '../../auth'; +import { FormDialog } from '../../components/FormDialog'; +import { LoadError } from '../../components/LoadError'; import { ProofFields, proofFrom } from '../../components/ProofFields'; import { useAction } from '../../useAction'; import { useResource } from '../../useResource'; import { required, useFormErrors } from '../../validation'; -import { SettingsSection, type TwoStepStatus } from './SettingsLayout'; +import { SettingsRow, SettingsRows, SettingsSection, type TwoStepStatus } from './SettingsLayout'; export function DataSettings() { const auth = useAuth(); @@ -17,82 +19,81 @@ export function DataSettings() { const removalForm = useFormErrors(); const [removing, setRemoving] = useState(false); - if (twoStep.error) return

{twoStep.error}

; + if (twoStep.error) return ; if (!twoStep.data) return ; const enabled = twoStep.data.enabled; return ( -
- -
- {exporting.status} -