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
1 change: 1 addition & 0 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@wordpress/react-i18n": "^4.41.0",
"@wordpress/theme": "0.10.0",
"@wordpress/ui": "0.10.0",
"clsx": "^2.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
Expand Down
60 changes: 60 additions & 0 deletions apps/ui/src/components/gravatar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import styles from './style.module.css';

const DEFAULT_LIGHT = 'https://s0.wp.com/i/studio-app/profile-icon.png';
const DEFAULT_DARK = 'https://s0.wp.com/i/studio-app/profile-icon-black.png';

async function sha256Hex( input: string ): Promise< string > {
const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode( input ) );
return Array.from( new Uint8Array( digest ) )
.map( ( b ) => b.toString( 16 ).padStart( 2, '0' ) )
.join( '' );
}

function useGravatarUrl( email: string | undefined, isDark: boolean ): string | undefined {
const [ url, setUrl ] = useState< string | undefined >( undefined );

useEffect( () => {
if ( ! email ) {
setUrl( undefined );
return;
}
let cancelled = false;
const fallback = isDark ? DEFAULT_DARK : DEFAULT_LIGHT;
void sha256Hex( email.trim().toLowerCase() ).then( ( hash ) => {
if ( cancelled ) {
return;
}
setUrl( `https://www.gravatar.com/avatar/${ hash }?d=${ encodeURIComponent( fallback ) }` );
} );
return () => {
cancelled = true;
};
}, [ email, isDark ] );

return url;
}

type GravatarProps = {
email: string;
isDark: boolean;
};

export function Gravatar( { email, isDark }: GravatarProps ) {
const url = useGravatarUrl( email, isDark );
const [ errored, setErrored ] = useState( false );

if ( ! url || errored ) {
return <span aria-hidden="true" className={ styles.root } />;
}

return (
<img
aria-hidden="true"
className={ styles.root }
src={ url }
alt=""
onError={ () => setErrored( true ) }
/>
);
}
9 changes: 9 additions & 0 deletions apps/ui/src/components/gravatar/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.root {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
background-color: var(--wpds-color-bg-surface-neutral);
}
200 changes: 132 additions & 68 deletions apps/ui/src/components/project-list/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Link } from '@tanstack/react-router';
import { Link, useParams } from '@tanstack/react-router';
import { __ } from '@wordpress/i18n';
import { chevronDown, chevronRight, plus } from '@wordpress/icons';
import { Button, Collapsible, IconButton } from '@wordpress/ui';
import { moreHorizontal, plus } from '@wordpress/icons';
import { IconButton } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useMemo, useState } from 'react';
import { SidebarButton } from '@/components/sidebar-button';
import { useSessions } from '@/data/queries/use-sessions';
import { useSites } from '@/data/queries/use-sites';
import { formatRelativeTime } from '@/lib/format-relative-time';
import styles from './style.module.css';
import type { AiSessionSummary, SiteDetails } from '@/data/core';

Expand All @@ -14,9 +17,22 @@ type ProjectGroup = {
key: string;
site?: SiteDetails;
label: string;
subtitle?: string;
sessions: AiSessionSummary[];
};

function stripProtocol( url: string ): string {
return url.replace( /^https?:\/\//, '' ).replace( /\/$/, '' );
}

function deriveSubtitle( site: SiteDetails ): string | undefined {
if ( site.url ) {
return stripProtocol( site.url );
}
const basename = site.path.split( /[\\/]/ ).filter( Boolean ).pop();
return basename;
}

function groupSessionsByOwner(
sites: SiteDetails[] | undefined,
sessions: AiSessionSummary[] | undefined
Expand All @@ -26,9 +42,6 @@ function groupSessionsByOwner(
const unassigned: AiSessionSummary[] = [];

for ( const session of sessions ?? [] ) {
// A session is "assigned" only if its owner path still matches a known site.
// Orphans (site deleted/renamed, empty path, or never selected one) fall through
// to the Unassigned bucket so they stay reachable.
if ( ! session.ownerSitePath || ! knownSitePaths.has( session.ownerSitePath ) ) {
unassigned.push( session );
continue;
Expand All @@ -46,13 +59,12 @@ function groupSessionsByOwner(
key: site.id,
site,
label: site.name,
subtitle: deriveSubtitle( site ),
sessions: sessionsByPath.get( site.path ) ?? [],
} ) );

// Sort site-groups by the newest session's updatedAt so the most recently
// used project lands at the top (and gets expanded on launch). Sessions are
// already sorted newest-first, so sessions[0] is each project's MRU. Projects
// with no sessions drop to the bottom.
// used project lands at the top. Projects with no sessions drop to the bottom.
groups.sort( ( a, b ) => {
const aTimestamp = a.sessions[ 0 ]?.updatedAt;
const bTimestamp = b.sessions[ 0 ]?.updatedAt;
Expand Down Expand Up @@ -84,81 +96,134 @@ function SessionItem( { session }: { session: AiSessionSummary } ) {

return (
<li className={ styles.sessionItem }>
<Link
to="/sessions/$sessionId"
params={ { sessionId: session.id } }
<SidebarButton
className={ styles.sessionLink }
activeProps={ { className: `${ styles.sessionLink } ${ styles.sessionLinkActive }` } }
render={
<Link
to="/sessions/$sessionId"
params={ { sessionId: session.id } }
activeProps={ {
className: clsx( styles.sessionLink, styles.sessionLinkActive ),
} }
/>
}
>
{ label }
</Link>
<span className={ styles.sessionLabel }>{ label }</span>
<span className={ styles.sessionTime }>{ formatRelativeTime( session.updatedAt ) }</span>
</SidebarButton>
</li>
);
}

function ProjectSection( {
group,
defaultOpen,
isUnassigned,
isActive,
isOpen,
onToggle,
}: {
group: ProjectGroup;
defaultOpen: boolean;
isUnassigned: boolean;
isActive: boolean;
isOpen: boolean;
onToggle: () => void;
} ) {
const [ open, setOpen ] = useState( defaultOpen );

return (
<Collapsible.Root
open={ open }
onOpenChange={ setOpen }
className={ `${ styles.project } ${ isUnassigned ? styles.unassigned : '' }` }
<section
className={ clsx(
styles.project,
isUnassigned && styles.unassigned,
isActive && styles.projectActive
) }
>
<div className={ styles.projectRow }>
<Collapsible.Trigger
render={
<Button
<header className={ styles.projectHeader }>
<div className={ styles.projectText }>
<SidebarButton
className={ styles.projectToggle }
onClick={ onToggle }
aria-expanded={ isOpen }
>
<span className={ styles.projectName }>{ group.label }</span>
</SidebarButton>
{ group.subtitle ? (
<span className={ styles.projectSubtitle }>{ group.subtitle }</span>
) : null }
</div>
{ group.site ? (
<div className={ styles.projectActions }>
<IconButton
variant="minimal"
tone="neutral"
size="small"
className={ styles.projectTrigger }
>
<Button.Icon icon={ open ? chevronDown : chevronRight } size={ 16 } />

<span className={ styles.projectName }>{ group.label }</span>
</Button>
}
/>
{ group.site ? (
<IconButton
variant="minimal"
tone="neutral"
size="small"
icon={ plus }
label={ __( 'New session' ) }
className={ styles.projectAction }
/>
icon={ moreHorizontal }
label={ __( 'Project actions' ) }
className={ styles.projectAction }
/>
<IconButton
variant="minimal"
tone="neutral"
size="small"
icon={ plus }
label={ __( 'New session' ) }
className={ styles.projectAction }
/>
</div>
) : null }
</div>
<Collapsible.Panel>
{ group.sessions.length === 0 ? (
<p className={ styles.empty }>{ __( 'No sessions' ) }</p>
) : (
<ul className={ styles.sessionList }>
{ group.sessions.map( ( session ) => (
<SessionItem key={ session.id } session={ session } />
) ) }
</ul>
) }
</Collapsible.Panel>
</Collapsible.Root>
</header>
{ isOpen && group.sessions.length > 0 ? (
<ul className={ styles.sessionList }>
{ group.sessions.map( ( session ) => (
<SessionItem key={ session.id } session={ session } />
) ) }
</ul>
) : null }
</section>
);
}

function findActiveProjectKey(
groups: ProjectGroup[],
activeSessionId: string | undefined
): string | undefined {
if ( ! activeSessionId ) {
return undefined;
}
for ( const group of groups ) {
if ( group.sessions.some( ( session ) => session.id === activeSessionId ) ) {
return group.key;
}
}
return undefined;
}

export function ProjectList() {
const { data: sites, isLoading: sitesLoading } = useSites();
const { data: sessions, isLoading: sessionsLoading } = useSessions();
const params = useParams( { strict: false } ) as { sessionId?: string };
const activeSessionId = params.sessionId;

const groups = useMemo( () => groupSessionsByOwner( sites, sessions ), [ sites, sessions ] );
const activeProjectKey = useMemo(
() => findActiveProjectKey( groups, activeSessionId ),
[ groups, activeSessionId ]
);

// Expansion is derived: by default the active project (or, if none, the
// MRU project — first in the list) is open. Manual toggles are stored as
// overrides so the user's explicit choice wins until they toggle again.
const mruKey = groups[ 0 ]?.key;
const [ overrides, setOverrides ] = useState< Record< string, boolean > >( {} );

const isOpen = ( key: string ): boolean => {
if ( key in overrides ) {
return overrides[ key ];
}
return key === activeProjectKey || ( ! activeProjectKey && key === mruKey );
};

const toggleProject = ( key: string ) => {
setOverrides( ( prev ) => ( { ...prev, [ key ]: ! isOpen( key ) } ) );
};

return (
<div className={ styles.root }>
Expand All @@ -168,17 +233,16 @@ export function ProjectList() {
<p className={ styles.empty }>{ __( 'No projects yet' ) }</p>
) : (
<div className={ styles.projects }>
{ groups.map( ( group, index ) => {
const isUnassigned = group.key === UNASSIGNED_KEY;
return (
<ProjectSection
key={ group.key }
group={ group }
isUnassigned={ isUnassigned }
defaultOpen={ ! isUnassigned && index === 0 && group.sessions.length > 0 }
/>
);
} ) }
{ groups.map( ( group ) => (
<ProjectSection
key={ group.key }
group={ group }
isUnassigned={ group.key === UNASSIGNED_KEY }
isActive={ group.key === activeProjectKey }
isOpen={ isOpen( group.key ) }
onToggle={ () => toggleProject( group.key ) }
/>
) ) }
</div>
) }
</div>
Expand Down
Loading
Loading