feat: add project channel workbench shell
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte';
|
||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
||||
|
||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
||||
@@ -12,10 +13,7 @@
|
||||
</script>
|
||||
|
||||
{#if currentUser}
|
||||
<main class="workbench-placeholder" aria-label="Project workbench">
|
||||
<h1>SenlinAI Workbench</h1>
|
||||
<p>Signed in as {currentUser.account}</p>
|
||||
</main>
|
||||
<ProjectWorkbench {currentUser} />
|
||||
{:else}
|
||||
<ServerLogin {apiBase} onLogin={handleLogin} />
|
||||
{/if}
|
||||
|
||||
52
apps/web/src/features/workbench/ProjectChannelSidebar.svelte
Normal file
52
apps/web/src/features/workbench/ProjectChannelSidebar.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types';
|
||||
|
||||
export let project: WorkbenchProject;
|
||||
export let channels: WorkbenchChannel[];
|
||||
export let selectedChannelID: string;
|
||||
export let tags: string[];
|
||||
export let recentSessions: AISessionItem[];
|
||||
export let onSelectChannel: (channelID: string) => void;
|
||||
</script>
|
||||
|
||||
<aside class="channel-sidebar" aria-label="Project channels">
|
||||
<header>
|
||||
<div>
|
||||
<p>Current project</p>
|
||||
<h2>{project.name}</h2>
|
||||
</div>
|
||||
<button aria-label="Project settings">⚙</button>
|
||||
</header>
|
||||
|
||||
<section class="tag-row" aria-label="Project tags">
|
||||
{#each tags as tag}
|
||||
<button type="button">#{tag}</button>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="channel-group">
|
||||
{#each channels as channel}
|
||||
<button
|
||||
class:active={channel.id === selectedChannelID}
|
||||
aria-pressed={channel.id === selectedChannelID}
|
||||
aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
|
||||
on:click={() => onSelectChannel(channel.id)}
|
||||
>
|
||||
<span>{channel.title}</span>
|
||||
{#if channel.count !== undefined}
|
||||
<small>{channel.count}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="recent-sessions" aria-label="Recent sessions">
|
||||
<h3>Recent sessions</h3>
|
||||
{#each recentSessions as session}
|
||||
<button type="button">
|
||||
<span>{session.title}</span>
|
||||
<small>{session.updatedAt}</small>
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
</aside>
|
||||
26
apps/web/src/features/workbench/ProjectRail.svelte
Normal file
26
apps/web/src/features/workbench/ProjectRail.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { WorkbenchProject } from './types';
|
||||
|
||||
export let projects: WorkbenchProject[];
|
||||
export let selectedProjectID: number;
|
||||
export let onSelectProject: (projectID: number) => void;
|
||||
</script>
|
||||
|
||||
<nav class="project-rail" aria-label="Project list">
|
||||
<button class="rail-logo" aria-label="Dashboard">SA</button>
|
||||
{#each projects as project}
|
||||
<button
|
||||
class:active={project.id === selectedProjectID}
|
||||
aria-pressed={project.id === selectedProjectID}
|
||||
aria-label={project.name}
|
||||
title={project.name}
|
||||
on:click={() => onSelectProject(project.id)}
|
||||
>
|
||||
<span>{project.initials}</span>
|
||||
{#if project.unreadCount}
|
||||
<small>{project.unreadCount}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="add-project" aria-label="Create project">+</button>
|
||||
</nav>
|
||||
41
apps/web/src/features/workbench/ProjectWorkbench.svelte
Normal file
41
apps/web/src/features/workbench/ProjectWorkbench.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
||||
import ProjectRail from './ProjectRail.svelte';
|
||||
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
||||
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
||||
|
||||
export let currentUser: { account: string };
|
||||
|
||||
let selectedProjectID = workbenchProjects[0].id;
|
||||
$: workspace = getProjectWorkspace(selectedProjectID);
|
||||
$: selectedChannelID = workspace.channels[0].id;
|
||||
|
||||
function selectProject(projectID: number) {
|
||||
selectedProjectID = projectID;
|
||||
}
|
||||
|
||||
function selectChannel(channelID: string) {
|
||||
selectedChannelID = channelID;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="workbench-shell">
|
||||
<WorkspaceTopbar account={currentUser.account} />
|
||||
<div class="workbench-body">
|
||||
<ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
|
||||
<ProjectChannelSidebar
|
||||
project={workspace.project}
|
||||
channels={workspace.channels}
|
||||
{selectedChannelID}
|
||||
tags={workspace.tags}
|
||||
recentSessions={workspace.recentSessions}
|
||||
onSelectChannel={selectChannel}
|
||||
/>
|
||||
<section class="channel-stage" aria-label="Channel content">
|
||||
<h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1>
|
||||
</section>
|
||||
<aside class="object-inspector" aria-label="Object inspector">
|
||||
<p>Select an item to inspect discussion, properties, and actions.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
26
apps/web/src/features/workbench/ProjectWorkbench.test.ts
Normal file
26
apps/web/src/features/workbench/ProjectWorkbench.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import ProjectWorkbench from './ProjectWorkbench.svelte';
|
||||
|
||||
describe('ProjectWorkbench', () => {
|
||||
it('renders project rail, channel sidebar, topbar, and inspector', () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
expect(screen.getByLabelText('Project list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Global search')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes channels when switching projects', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
16
apps/web/src/features/workbench/WorkspaceTopbar.svelte
Normal file
16
apps/web/src/features/workbench/WorkspaceTopbar.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
export let account: string;
|
||||
</script>
|
||||
|
||||
<header class="workspace-topbar">
|
||||
<div class="brand-mark">SenlinAI</div>
|
||||
<label class="search-box">
|
||||
<span>Search</span>
|
||||
<input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" />
|
||||
</label>
|
||||
<div class="topbar-actions">
|
||||
<button aria-label="Back">←</button>
|
||||
<button aria-label="Forward">→</button>
|
||||
<button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button>
|
||||
</div>
|
||||
</header>
|
||||
Reference in New Issue
Block a user