mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Let sub-project groups be collapsed on the Projects page (issue #2991)
A project with sub-projects drew every one of them expanded, at every level, with nothing to shut. Over a three-level hierarchy and a couple of hundred archives that makes the page one long scroll. Each group's caption is now a chevron that folds that group, and a Collapse pill next to the status filter tabs sets the default for the page and is remembered across reloads. A shut group takes one grid cell rather than a full-width row, so folding a deep tree actually gets the page back. The count on the caption is of the cards nested there, not the parent card's badge: the API counts sub-projects across every status on purpose, so under the Active filter the badge can legitimately say 2 where one card unfolds. A count that disagrees with what unfolds is worse than no count at all.
This commit is contained in:
@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [1.2.6b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **Sub-projects on the Projects page can be folded away (#2991)** — A project with sub-projects drew every one of them expanded underneath it, at every level, with nothing to shut. That is fine for two projects and unusable for a three-level hierarchy over a couple of hundred archives, where the page becomes one long scroll before the first thing you were looking for. Each group's "Sub-projects of X" caption is now a chevron that folds that group, and a **Collapse** pill next to the status filter tabs sets the default for the whole page and is remembered across reloads. The caption stays visible when a group is shut — it is the way back in, and it carries a count of what is behind it. That count is of the cards actually nested there, not the card's own sub-project badge: the API counts sub-projects across every status on purpose, so under the default Active filter the badge can legitimately say 2 where only one card will unfold. A group folded by hand deviates from the default until the pill is pressed, which resets those deviations rather than leaving a group defying the switch that was just flipped. Nothing changes for a page with no nesting, where the pill is not shown at all, and the default with no stored preference is still fully expanded.
|
||||
- **A spool can carry a different filament preset on each printer model, and its K profiles are picked per hotend** — A slicer preset is bound to a printer model: `Bambu PLA Basic @BBL X1C` is not the same preset as `@BBL H2C`. A spool stored exactly one, which was right until the same spool was used on a second model — the AMS slot on the other machine was then configured with a preset that machine has no profile for. The spool form's PA Profile tab is now a **Printers** tab holding both halves of the answer: a model list on the left, and on the right that model's filament presets and the K profiles for each of its hotends. Presets are keyed on the printer *model*, because `@BBL X1C` is the same preset on every X1C you own and asking once per machine would mean picking the identical value twice; K profiles stay keyed on the individual printer, extruder and nozzle diameter, because a K value is measured on one physical hotend and two machines of the same model legitimately differ. Both halves cover **every nozzle size — 0.2, 0.4, 0.6 and 0.8 — not only the size currently fitted**, because a spool is configured once and nozzles get swapped: presets get a row per size (the preset is written to an AMS slot, a slot feeds exactly one nozzle, and Bambu names its presets per size anyway), and K profiles are laid out as a grid with size down the side and hotend across the top, a dash marking a size the printer has no calibration for. Anything left alone inherits the spool's own preset and keeps inheriting it when that changes later, so only what actually differs needs an override, and a spool nobody has configured behaves exactly as it did before. Each model is offered only the presets that name it — using the same matcher the Configure AMS Slot modal filters with, now shared between them — while presets whose name identifies no model, which is most user-authored and OrcaSlicer ones, stay available everywhere, and a preset already saved is never hidden from the control that shows it. Every preset carries an origin badge — Bambu Cloud, Orca Cloud, Local or Built-in — in the same wording and colours the Configure AMS Slot modal has used since #1623, because the same filament exists in several of those sources and which one is picked decides what actually reaches the printer. **Auto-match** fills every size of every model with the variant of the spool's preset that names it, preferring the variant for that exact size; a model with no such variant is left inherited rather than given an approximate one. The model list stays one row per model however large the fleet is, and carries a count of hotends still without a K profile so an unfinished spool is visible without opening anything. One limit worth knowing: a per-model override can be one of your own cloud presets, whose id the slicer refuses in a slot's filament field, so such an override configures the slot but is not used as the calibration link — the spool's own preset is used there instead.
|
||||
- **K profiles distinguish High Flow from Standard nozzles** — A printer files each calibration under a nozzle id of the form `HH00-0.4` (high flow) or `HS00-0.4` (standard) and can hold both for one diameter — a maintainer's H2D carries 102 high-flow entries and 6 standard — because the same filament reads a different K through each. The picker labels every profile with the flow it was measured on, saving records it, and a stored profile is no longer applied when the fitted nozzle disagrees; the picker marks such a profile rather than letting it look configured while quietly doing nothing. Two spellings have to agree for that: a calibration entry says `HH00-0.4` while the fitted nozzle reports `HH01`, so the comparison is two characters, not four. An unknown flow on either side matches anything, which is what it must do — every K profile stored before this has none, and an X1C declares none on any profile at all (measured: all eight come back with an empty nozzle id) even though the machine really does take either nozzle, so treating silence as "Standard" and filtering on it would have dropped every X1C profile the moment a high-flow nozzle was fitted.
|
||||
- **Every path that configures an AMS slot respects a spool's per-model preset and per-hotend K profile** — The manual assign in either inventory mode, the RFID auto-assign, the Spoolman tag link, the re-fire when a slot goes from empty to loaded, the re-apply after a calibration-table refresh, and the re-selection when a Filament Track Switch moves an AMS to the other nozzle. The Configure AMS Slot dialog also opens on the spool's own configured values, falling back to the slot's last manual configuration and then the tray's RFID data, rather than ignoring what the spool was configured with on the one screen that looks like it exists for it. A printer card in expanded view now lists every fitted nozzle size rather than the first entry alone, which on a machine with two different sizes named one hotend and implied it was the whole printer.
|
||||
|
||||
@@ -705,5 +705,194 @@ describe('ProjectsPage', () => {
|
||||
// Flattening every descendant onto the root would lose the depth.
|
||||
expect(wingGroup!.textContent).not.toContain('Airframe');
|
||||
});
|
||||
|
||||
describe('folding sub-project groups away (#2991)', () => {
|
||||
// localStorage is globally mocked in setup.ts, so each test programs the
|
||||
// preference it wants explicitly.
|
||||
const getItemMock = localStorage.getItem as ReturnType<typeof vi.fn>;
|
||||
const setItemMock = localStorage.setItem as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
getItemMock.mockReset();
|
||||
setItemMock.mockReset();
|
||||
});
|
||||
|
||||
// The mock is module-global: an implementation left behind here would
|
||||
// silently collapse the groups of every later test.
|
||||
afterEach(() => {
|
||||
getItemMock.mockReset();
|
||||
setItemMock.mockReset();
|
||||
});
|
||||
|
||||
const serveAirframe = () =>
|
||||
server.use(
|
||||
http.get('/api/v1/projects/', () =>
|
||||
HttpResponse.json([
|
||||
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
|
||||
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
it('leaves groups open when no preference has been stored', async () => {
|
||||
getItemMock.mockReturnValue(null);
|
||||
serveAirframe();
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
|
||||
});
|
||||
// The page behaved this way before the toggle existed, and an upgrade
|
||||
// that hides half of somebody's projects is not a fix.
|
||||
expect(screen.getByText('Wing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens with the groups shut when that is what was stored', async () => {
|
||||
getItemMock.mockImplementation((key: string) =>
|
||||
key === 'projects-collapse-subprojects' ? 'true' : null,
|
||||
);
|
||||
serveAirframe();
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
|
||||
});
|
||||
// The caption stays — it is the way back in, and it says what is behind
|
||||
// it. Only the cards go.
|
||||
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shuts one group from its own caption, leaving the others alone', async () => {
|
||||
getItemMock.mockReturnValue(null);
|
||||
server.use(
|
||||
http.get('/api/v1/projects/', () =>
|
||||
HttpResponse.json([
|
||||
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
|
||||
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
|
||||
listItem({ id: 3, name: 'Fuselage', child_count: 1 }),
|
||||
listItem({ id: 4, name: 'Bulkhead', parent_id: 3 }),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Sub-projects of Airframe/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Bulkhead')).toBeInTheDocument();
|
||||
// A per-group chevron is a passing choice, not a new default for the
|
||||
// whole page.
|
||||
expect(setItemMock).not.toHaveBeenCalledWith('projects-collapse-subprojects', 'true');
|
||||
});
|
||||
|
||||
it('remembers the default the pill sets', async () => {
|
||||
getItemMock.mockReturnValue(null);
|
||||
const user = userEvent.setup();
|
||||
serveAirframe();
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(setItemMock).toHaveBeenCalledWith('projects-collapse-subprojects', 'true');
|
||||
});
|
||||
|
||||
it('drops per-group choices when the default is flipped against them', async () => {
|
||||
getItemMock.mockReturnValue(null);
|
||||
const user = userEvent.setup();
|
||||
serveAirframe();
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Shut this one by hand, then ask for everything shut, then everything
|
||||
// open again. The hand-made choice was made against the old default,
|
||||
// and honouring it now would leave a group defying the pill the user
|
||||
// just pressed.
|
||||
await user.click(screen.getByRole('button', { name: /Sub-projects of Airframe/ }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Wing')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('counts what the chevron actually unfolds, not the card badge', async () => {
|
||||
// The API counts sub-projects across every status on purpose, so the
|
||||
// card badge says 2 while the Active filter leaves one child to show.
|
||||
// A caption repeating the badge would promise a card that is not there.
|
||||
getItemMock.mockImplementation((key: string) =>
|
||||
key === 'projects-collapse-subprojects' ? 'true' : null,
|
||||
);
|
||||
server.use(
|
||||
http.get('/api/v1/projects/', ({ request }) => {
|
||||
const status = new URL(request.url).searchParams.get('status');
|
||||
const all = [
|
||||
listItem({ id: 1, name: 'Airframe', child_count: 2 }),
|
||||
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
|
||||
listItem({ id: 3, name: 'Tailplane', parent_id: 1, status: 'archived' }),
|
||||
];
|
||||
return HttpResponse.json(status ? all.filter((p) => p.status === status) : all);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
const caption = await screen.findByRole('button', { name: /Sub-projects of Airframe/ });
|
||||
expect(caption.textContent).toContain('1');
|
||||
expect(caption.textContent).not.toContain('2');
|
||||
});
|
||||
|
||||
it('gives a shut group one grid cell instead of a whole row', async () => {
|
||||
// Folding a tree that still spends a full-width row per parent halves
|
||||
// the scrolling at best; the point is to get the page back.
|
||||
getItemMock.mockImplementation((key: string) =>
|
||||
key === 'projects-collapse-subprojects' ? 'true' : null,
|
||||
);
|
||||
serveAirframe();
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
const caption = await screen.findByRole('button', { name: /Sub-projects of Airframe/ });
|
||||
expect(caption.closest('.col-span-full')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers no pill on a page where nothing is nested', async () => {
|
||||
getItemMock.mockReturnValue(null);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Functional Parts')).toBeInTheDocument();
|
||||
});
|
||||
// A control that cannot change anything is just one more thing to read.
|
||||
expect(screen.queryByRole('button', { name: 'Collapse' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4039,6 +4039,8 @@ export default {
|
||||
partOf: 'Teil von {{name}}',
|
||||
subProjectCount: '{{count}} Unterprojekte',
|
||||
subProjectsOf: 'Unterprojekte von {{name}}',
|
||||
collapseSubProjects: 'Unterprojekte einklappen',
|
||||
expandSubProjects: 'Unterprojekte ausklappen',
|
||||
title: 'Projekte',
|
||||
subtitle: 'Organisieren und verfolgen Sie Ihre 3D-Druckprojekte',
|
||||
newProject: 'Neues Projekt',
|
||||
|
||||
@@ -4069,6 +4069,8 @@ export default {
|
||||
partOf: 'Part of {{name}}',
|
||||
subProjectCount: '{{count}} sub-projects',
|
||||
subProjectsOf: 'Sub-projects of {{name}}',
|
||||
collapseSubProjects: 'Collapse sub-projects',
|
||||
expandSubProjects: 'Expand sub-projects',
|
||||
title: 'Projects',
|
||||
subtitle: 'Organize and track your 3D printing projects',
|
||||
newProject: 'New Project',
|
||||
|
||||
@@ -4041,6 +4041,8 @@ export default {
|
||||
partOf: 'Parte de {{name}}',
|
||||
subProjectCount: '{{count}} subproyectos',
|
||||
subProjectsOf: 'Subproyectos de {{name}}',
|
||||
collapseSubProjects: 'Contraer subproyectos',
|
||||
expandSubProjects: 'Expandir subproyectos',
|
||||
title: 'Proyectos',
|
||||
subtitle: 'Organice y haga el seguimiento de sus proyectos de impresión 3D',
|
||||
newProject: 'Nuevo proyecto',
|
||||
|
||||
@@ -4028,6 +4028,8 @@ export default {
|
||||
partOf: 'Fait partie de {{name}}',
|
||||
subProjectCount: '{{count}} sous-projets',
|
||||
subProjectsOf: 'Sous-projets de {{name}}',
|
||||
collapseSubProjects: 'Réduire les sous-projets',
|
||||
expandSubProjects: 'Développer les sous-projets',
|
||||
title: 'Projets',
|
||||
subtitle: 'Suivez vos projets d\'impression 3D',
|
||||
newProject: 'Nouveau Projet',
|
||||
|
||||
@@ -4027,6 +4027,8 @@ export default {
|
||||
partOf: 'Parte di {{name}}',
|
||||
subProjectCount: '{{count}} sotto-progetti',
|
||||
subProjectsOf: 'Sotto-progetti di {{name}}',
|
||||
collapseSubProjects: 'Comprimi sotto-progetti',
|
||||
expandSubProjects: 'Espandi sotto-progetti',
|
||||
title: 'Progetti',
|
||||
subtitle: 'Organizza e traccia i tuoi progetti di stampa 3D',
|
||||
newProject: 'Nuovo progetto',
|
||||
|
||||
@@ -4039,6 +4039,8 @@ export default {
|
||||
partOf: '{{name}} の一部',
|
||||
subProjectCount: 'サブプロジェクト {{count}} 件',
|
||||
subProjectsOf: '{{name}} のサブプロジェクト',
|
||||
collapseSubProjects: 'サブプロジェクトを折りたたむ',
|
||||
expandSubProjects: 'サブプロジェクトを展開',
|
||||
title: 'プロジェクト',
|
||||
subtitle: '印刷プロジェクトを管理',
|
||||
newProject: '新規プロジェクト',
|
||||
|
||||
@@ -3846,6 +3846,8 @@ export default {
|
||||
partOf: '{{name}}의 일부',
|
||||
subProjectCount: '하위 프로젝트 {{count}}개',
|
||||
subProjectsOf: '{{name}}의 하위 프로젝트',
|
||||
collapseSubProjects: '하위 프로젝트 접기',
|
||||
expandSubProjects: '하위 프로젝트 펼치기',
|
||||
title: '프로젝트',
|
||||
subtitle: '3D 인쇄 프로젝트 정리 및 추적',
|
||||
newProject: '새 프로젝트',
|
||||
|
||||
@@ -4069,6 +4069,8 @@ export default {
|
||||
partOf: 'Onderdeel van {{name}}',
|
||||
subProjectCount: '{{count}} subprojecten',
|
||||
subProjectsOf: 'Subprojecten van {{name}}',
|
||||
collapseSubProjects: 'Subprojecten inklappen',
|
||||
expandSubProjects: 'Subprojecten uitklappen',
|
||||
title: 'Projecten',
|
||||
subtitle: 'Organiseer en volg je 3D-printprojecten',
|
||||
newProject: 'Nieuw project',
|
||||
|
||||
@@ -4027,6 +4027,8 @@ export default {
|
||||
partOf: 'Parte de {{name}}',
|
||||
subProjectCount: '{{count}} sub-projetos',
|
||||
subProjectsOf: 'Sub-projetos de {{name}}',
|
||||
collapseSubProjects: 'Recolher sub-projetos',
|
||||
expandSubProjects: 'Expandir sub-projetos',
|
||||
title: 'Projetos',
|
||||
subtitle: 'Organize e acompanhe seus projetos de impressão 3D',
|
||||
newProject: 'Novo Projeto',
|
||||
|
||||
@@ -3838,6 +3838,8 @@ export default {
|
||||
partOf: "Часть проекта {{name}}",
|
||||
subProjectCount: "Подпроектов: {{count}}",
|
||||
subProjectsOf: "Подпроекты проекта {{name}}",
|
||||
collapseSubProjects: "Свернуть подпроекты",
|
||||
expandSubProjects: "Развернуть подпроекты",
|
||||
title: "Проекты",
|
||||
subtitle: "Организация и отслеживание проектов 3D-печати",
|
||||
newProject: "Новый проект",
|
||||
|
||||
@@ -4034,6 +4034,8 @@ export default {
|
||||
partOf: '{{name}} projesinin parçası',
|
||||
subProjectCount: '{{count}} alt proje',
|
||||
subProjectsOf: '{{name}} alt projeleri',
|
||||
collapseSubProjects: 'Alt projeleri daralt',
|
||||
expandSubProjects: 'Alt projeleri genişlet',
|
||||
title: 'Projeler',
|
||||
subtitle: '3D baskı projelerinizi organize edin ve takip edin',
|
||||
newProject: 'Yeni Proje',
|
||||
|
||||
@@ -4067,6 +4067,8 @@ export default {
|
||||
partOf: "Частина проєкту {{name}}",
|
||||
subProjectCount: "Підпроєктів: {{count}}",
|
||||
subProjectsOf: "Підпроєкти проєкту {{name}}",
|
||||
collapseSubProjects: "Згорнути підпроєкти",
|
||||
expandSubProjects: "Розгорнути підпроєкти",
|
||||
title: "Проєкти",
|
||||
subtitle: "Організовуйте та відстежуйте свої проєкти 3D-друку",
|
||||
newProject: "Новий проєкт",
|
||||
|
||||
@@ -4027,6 +4027,8 @@ export default {
|
||||
partOf: '属于 {{name}}',
|
||||
subProjectCount: '{{count}} 个子项目',
|
||||
subProjectsOf: '{{name}} 的子项目',
|
||||
collapseSubProjects: '折叠子项目',
|
||||
expandSubProjects: '展开子项目',
|
||||
title: '项目',
|
||||
subtitle: '组织和跟踪您的 3D 打印项目',
|
||||
newProject: '新建项目',
|
||||
|
||||
@@ -4027,6 +4027,8 @@ export default {
|
||||
partOf: '屬於 {{name}}',
|
||||
subProjectCount: '{{count}} 個子專案',
|
||||
subProjectsOf: '{{name}} 的子專案',
|
||||
collapseSubProjects: '摺疊子專案',
|
||||
expandSubProjects: '展開子專案',
|
||||
title: '專案',
|
||||
subtitle: '組織和追蹤您的 3D 列印專案',
|
||||
newProject: '新增專案',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MoreVertical,
|
||||
Download,
|
||||
@@ -930,6 +931,26 @@ export function ProjectsPage() {
|
||||
const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
|
||||
const [statusFilter, setStatusFilter] = useState<string>('active');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
// Sub-project groups fold away (#2991). Every sub-project at every depth was
|
||||
// drawn expanded with no way to shut one, so a three-level hierarchy over a
|
||||
// couple of hundred archives turned this page into a very long scroll. The
|
||||
// pill next to the filter tabs sets the default and is remembered; the
|
||||
// chevron on a group deviates from it for that one parent.
|
||||
const [collapseSubProjects, setCollapseSubProjects] = useState(
|
||||
() => localStorage.getItem('projects-collapse-subprojects') === 'true',
|
||||
);
|
||||
const [subProjectOverrides, setSubProjectOverrides] = useState<Record<number, boolean>>({});
|
||||
|
||||
const toggleCollapseDefault = () => {
|
||||
const next = !collapseSubProjects;
|
||||
setCollapseSubProjects(next);
|
||||
// Every deviation was made against the old default, so keeping them would
|
||||
// leave groups sitting open right after the user asked for everything
|
||||
// shut. Same bargain the file manager's folder tree makes, where flipping
|
||||
// its Collapse toggle remounts the tree and drops each folder's own state.
|
||||
setSubProjectOverrides({});
|
||||
localStorage.setItem('projects-collapse-subprojects', String(next));
|
||||
};
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
@@ -1014,20 +1035,49 @@ export function ProjectsPage() {
|
||||
const children = (childrenByParent.get(project.id) || []).filter((c) => !descended.has(c.id));
|
||||
if (children.length === 0) return <div key={project.id}>{card}</div>;
|
||||
|
||||
const expanded = subProjectOverrides[project.id] ?? !collapseSubProjects;
|
||||
|
||||
return (
|
||||
<div key={project.id} className="col-span-full space-y-4">
|
||||
// A shut group is a card like any other, so it takes one grid cell
|
||||
// instead of a whole row of its own — folding a deep tree that still
|
||||
// spent a full-width row per parent would only halve the scrolling.
|
||||
<div key={project.id} className={expanded ? 'col-span-full space-y-4' : 'space-y-4'}>
|
||||
{card}
|
||||
<div
|
||||
className="ml-4 md:ml-8 pl-4 md:pl-6 border-l-2 rounded-l space-y-4"
|
||||
style={{ borderColor: project.color || '#6b7280' }}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide text-bambu-gray flex items-center gap-1.5">
|
||||
<FolderTree className="w-3.5 h-3.5" />
|
||||
{t('projects.subProjectsOf', { name: project.name })}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{children.map((child) => renderProjectTree(child, depth + 1, descended))}
|
||||
</div>
|
||||
{/* The caption is the toggle rather than the card above it: clicking
|
||||
a card opens the project, and taking that over would surprise
|
||||
every user who has no sub-projects to fold. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubProjectOverrides((prev) => ({ ...prev, [project.id]: !expanded }))}
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? t('projects.collapseSubProjects') : t('projects.expandSubProjects')}
|
||||
className="text-xs uppercase tracking-wide text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5 max-w-full min-w-0"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 flex-shrink-0" />}
|
||||
<FolderTree className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{/* Truncated because a shut group lives in one grid column, where
|
||||
a long project name would otherwise run out of the card. */}
|
||||
<span className="truncate">{t('projects.subProjectsOf', { name: project.name })}</span>
|
||||
{/* Counted from what is actually nested here, not from the card's
|
||||
own badge: the API counts sub-projects across every status on
|
||||
purpose, so that badge can say 2 where only one card is behind
|
||||
this chevron. A count that disagrees with what unfolds is
|
||||
worse than no count. */}
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-bambu-dark normal-case tracking-normal flex-shrink-0">
|
||||
{children.length}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{children.map((child) => renderProjectTree(child, depth + 1, descended))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1242,33 +1292,53 @@ export function ProjectsPage() {
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
|
||||
{[
|
||||
{ key: 'active', label: t('projects.statusActive'), icon: Clock },
|
||||
{ key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
|
||||
{ key: 'archived', label: t('projects.statusArchived'), icon: Archive },
|
||||
{ key: 'all', label: t('common.all'), icon: FolderKanban },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
|
||||
{[
|
||||
{ key: 'active', label: t('projects.statusActive'), icon: Clock },
|
||||
{ key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
|
||||
{ key: 'archived', label: t('projects.statusArchived'), icon: Archive },
|
||||
{ key: 'all', label: t('common.all'), icon: FolderKanban },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setStatusFilter(key)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
|
||||
statusFilter === key
|
||||
? 'bg-bambu-card text-white shadow-sm'
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{label}</span>
|
||||
{projectCounts[key] > 0 && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
|
||||
statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}>
|
||||
{projectCounts[key]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Only where something can actually be folded -- with no nesting under
|
||||
the current filter this would be a control that does nothing. */}
|
||||
{childrenByParent.size > 0 && (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setStatusFilter(key)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm rounded-lg transition-all ${
|
||||
statusFilter === key
|
||||
? 'bg-bambu-card text-white shadow-sm'
|
||||
: 'text-bambu-gray hover:text-white'
|
||||
type="button"
|
||||
onClick={toggleCollapseDefault}
|
||||
aria-pressed={collapseSubProjects}
|
||||
title={collapseSubProjects ? t('projects.expandSubProjects') : t('projects.collapseSubProjects')}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg transition-colors ${
|
||||
collapseSubProjects
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'text-bambu-gray hover:text-white hover:bg-bambu-dark'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{label}</span>
|
||||
{projectCounts[key] > 0 && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
|
||||
statusFilter === key ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}>
|
||||
{projectCounts[key]}
|
||||
</span>
|
||||
)}
|
||||
<FolderTree className="w-4 h-4" />
|
||||
{t('common.collapse')}
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-YOSBC2S2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CexGkv-b.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cm_Xn_Pf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DR-aOvsI.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user