Skip to content

Commit 7178d99

Browse files
authored
Merge pull request #116 from cptKNJO/ui
UI
2 parents 50e4c48 + 9f5a429 commit 7178d99

22 files changed

+1422
-74
lines changed

urbackupserver/www2/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@lingui/core": "^4.10.1",
2121
"@lingui/react": "^4.10.1",
2222
"@tanstack/react-query": "^5.56.2",
23+
"@tanstack/react-query-devtools": "^5.61.5",
2324
"@types/crypto-js": "^4.2.2",
2425
"crypto-js": "^4.2.0",
2526
"react": "^18.3.1",

urbackupserver/www2/pnpm-lock.yaml

+20
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

urbackupserver/www2/src/App.tsx

+34
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,18 @@ import {
1414
Spinner,
1515
Toaster,
1616
} from "@fluentui/react-components";
17+
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
1718
import { useStackStyles } from "./components/StackStyles";
1819
import UrBackupServer, { SessionNotFoundError } from "./api/urbackupserver";
1920
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2021
import { i18n } from "@lingui/core";
2122
import { I18nProvider } from "@lingui/react";
23+
import { BackupsPage } from "./pages/Backups";
24+
import { ClientBackupsTable } from "./features/backups/ClientBackupsTable";
25+
import { BackupsTable } from "./features/backups/BackupsTable";
26+
import { BackupContentTable } from "./features/backups/BackupContentTable";
27+
import BackupErrorPage from "./features/backups/BackupsError";
28+
import "./css/global.css";
2229

2330
const initialDark =
2431
window.matchMedia &&
@@ -28,6 +35,7 @@ const initialTheme = initialDark ? teamsDarkTheme : teamsLightTheme;
2835
export enum Pages {
2936
Status = "status",
3037
Activities = "activities",
38+
Backups = "backups",
3139
Login = "login",
3240
About = "about",
3341
}
@@ -110,6 +118,30 @@ export const router = createHashRouter([
110118
return null;
111119
},
112120
},
121+
{
122+
path: `/${Pages.Backups}`,
123+
element: <BackupsPage />,
124+
loader: async () => {
125+
state.pageAfterLogin = Pages.Backups;
126+
await jumpToLoginPageIfNeccessary();
127+
return null;
128+
},
129+
errorElement: <BackupErrorPage />,
130+
children: [
131+
{
132+
index: true,
133+
element: <BackupsTable />,
134+
},
135+
{
136+
path: ":clientId",
137+
element: <ClientBackupsTable />,
138+
},
139+
{
140+
path: ":clientId/:backupId",
141+
element: <BackupContentTable />,
142+
},
143+
],
144+
},
113145
]);
114146

115147
function getSessionFromLocalStorage(): string {
@@ -191,6 +223,8 @@ const App: React.FunctionComponent = () => {
191223
</div>
192224
</div>
193225
<Toaster toasterId="toaster" />
226+
{/* Following only bundled in development mode */}
227+
<ReactQueryDevtools initialIsOpen={false} />
194228
</QueryClientProvider>
195229
</I18nProvider>
196230
</React.StrictMode>

urbackupserver/www2/src/api/urbackupserver.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ interface BackupsErr {
155155
err: string | "access_denied" | undefined;
156156
}
157157

158-
interface BackupsClient {
158+
export interface BackupsClient {
159159
id: number; // client id
160160
lastbackup: number; // unix timestamp
161161
name: string; // name of client
@@ -165,7 +165,7 @@ interface BackupsClients {
165165
clients: BackupsClient[];
166166
}
167167

168-
interface Backup {
168+
export interface Backup {
169169
id: number; // Backup id
170170
size_bytes: number; // Size of backup in bytes
171171
incremental: number; // !=0 if this is a incremental backup
@@ -188,7 +188,7 @@ interface Backups {
188188
clientid: number; // Id of the client
189189
}
190190

191-
interface File {
191+
export interface File {
192192
name: string; // Name of the file
193193
dir: boolean; // If true this is a directory
194194
mod: number; // Unix timestamp of last modification
@@ -580,7 +580,7 @@ class UrBackupServer {
580580
clientid: number,
581581
backupid: number,
582582
path: string,
583-
mount: boolean,
583+
mount?: boolean,
584584
) => {
585585
const resp = await this.fetchData(
586586
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* Adapted from https://react.fluentui.dev/?path=/docs/components-breadcrumb--docs#breadcrumb-with-tooltip
3+
*/
4+
5+
import React, { Fragment } from "react";
6+
import {
7+
Breadcrumb as FUIBreadcrumb,
8+
BreadcrumbItem,
9+
BreadcrumbButton,
10+
BreadcrumbDivider,
11+
partitionBreadcrumbItems,
12+
truncateBreadcrumbLongName,
13+
isTruncatableBreadcrumbContent,
14+
Tooltip,
15+
useIsOverflowItemVisible,
16+
Menu,
17+
MenuTrigger,
18+
useOverflowMenu,
19+
MenuPopover,
20+
MenuList,
21+
Button,
22+
MenuItemLink,
23+
tokens,
24+
} from "@fluentui/react-components";
25+
import {
26+
MoreHorizontalRegular,
27+
MoreHorizontalFilled,
28+
bundleIcon,
29+
} from "@fluentui/react-icons";
30+
import type { PartitionBreadcrumbItems } from "@fluentui/react-components";
31+
32+
export type BreadcrumbItem = {
33+
key: string | number;
34+
text: string;
35+
itemProps: {
36+
href: string;
37+
};
38+
};
39+
40+
const MAX_TEXT_LENGTH = 30;
41+
const MAX_DISPLAYED_ITEMS = 6;
42+
43+
export const BASE_HREF = `${window.location.origin}/#` as const;
44+
45+
const MoreHorizontal = bundleIcon(MoreHorizontalFilled, MoreHorizontalRegular);
46+
47+
const styles = {
48+
breadcrumbText: {
49+
fontWeight: tokens.fontWeightRegular,
50+
margin: 0,
51+
},
52+
breadcrumbTextLast: {
53+
fontWeight: tokens.fontWeightBold,
54+
margin: 0,
55+
},
56+
tooltip: {
57+
whiteSpace: "nowrap",
58+
overflow: "hidden",
59+
textOverflow: "ellipsis",
60+
},
61+
};
62+
63+
function renderItem(
64+
entry: BreadcrumbItem,
65+
isLastItem: boolean,
66+
wrapper?: React.ElementType<{ children: React.ReactNode }>,
67+
) {
68+
const ButtonWrapper = wrapper ?? "span";
69+
70+
return (
71+
<Fragment key={`item-${entry.key}`}>
72+
{isTruncatableBreadcrumbContent(entry.text, MAX_TEXT_LENGTH) ? (
73+
<BreadcrumbItem>
74+
<Tooltip withArrow content={entry.text} relationship="label">
75+
<BreadcrumbButton current={isLastItem} href={entry.itemProps?.href}>
76+
<ButtonWrapper
77+
style={
78+
isLastItem ? styles.breadcrumbTextLast : styles.breadcrumbText
79+
}
80+
>
81+
{truncateBreadcrumbLongName(entry.text)}
82+
</ButtonWrapper>
83+
</BreadcrumbButton>
84+
</Tooltip>
85+
</BreadcrumbItem>
86+
) : (
87+
<BreadcrumbItem>
88+
<BreadcrumbButton current={isLastItem} href={entry.itemProps?.href}>
89+
<ButtonWrapper
90+
style={
91+
isLastItem ? styles.breadcrumbTextLast : styles.breadcrumbText
92+
}
93+
>
94+
{entry.text}
95+
</ButtonWrapper>
96+
</BreadcrumbButton>
97+
</BreadcrumbItem>
98+
)}
99+
100+
{!isLastItem && <BreadcrumbDivider />}
101+
</Fragment>
102+
);
103+
}
104+
105+
function BreadcrumbMenuItem({ item }: { item: BreadcrumbItem }) {
106+
const isVisible = useIsOverflowItemVisible(item.key.toString());
107+
108+
if (isVisible) {
109+
return null;
110+
}
111+
112+
return <MenuItemLink href={item.itemProps?.href}>{item.text}</MenuItemLink>;
113+
}
114+
115+
function MenuWithTooltip({
116+
overflowItems,
117+
startDisplayedItems,
118+
endDisplayedItems,
119+
}: PartitionBreadcrumbItems<BreadcrumbItem>) {
120+
const { ref, isOverflowing, overflowCount } =
121+
useOverflowMenu<HTMLButtonElement>();
122+
123+
if (!isOverflowing && overflowItems && overflowItems.length === 0) {
124+
return null;
125+
}
126+
127+
const overflowItemsCount = overflowItems
128+
? overflowItems.length + overflowCount
129+
: overflowCount;
130+
131+
const tooltipContent = {
132+
children:
133+
overflowItemsCount > 3
134+
? `${overflowItemsCount} items`
135+
: getTooltipContent(overflowItems),
136+
style: styles.tooltip,
137+
};
138+
139+
return (
140+
<Menu hasIcons>
141+
<MenuTrigger disableButtonEnhancement>
142+
<Tooltip withArrow content={tooltipContent} relationship="label">
143+
<Button
144+
id="menu"
145+
appearance="subtle"
146+
ref={ref}
147+
icon={<MoreHorizontal />}
148+
aria-label={`${overflowItemsCount} more items`}
149+
role="button"
150+
/>
151+
</Tooltip>
152+
</MenuTrigger>
153+
<MenuPopover>
154+
<MenuList>
155+
{isOverflowing &&
156+
startDisplayedItems.map((item: BreadcrumbItem) => (
157+
<BreadcrumbMenuItem item={item} key={item.key} />
158+
))}
159+
{overflowItems &&
160+
overflowItems.map((item: BreadcrumbItem) => (
161+
<BreadcrumbMenuItem item={item} key={item.key} />
162+
))}
163+
{isOverflowing &&
164+
endDisplayedItems &&
165+
endDisplayedItems.map((item: BreadcrumbItem) => (
166+
<BreadcrumbMenuItem item={item} key={item.key} />
167+
))}
168+
</MenuList>
169+
</MenuPopover>
170+
</Menu>
171+
);
172+
}
173+
174+
const getTooltipContent = (
175+
breadcrumbItems: readonly BreadcrumbItem[] | undefined,
176+
) => {
177+
if (!breadcrumbItems) {
178+
return "";
179+
}
180+
return breadcrumbItems.reduce(
181+
(acc, initialValue, _idx, arr) => {
182+
return (
183+
<>
184+
{acc}
185+
{arr[0].text !== initialValue.text && " > "}
186+
{initialValue.text}
187+
</>
188+
);
189+
},
190+
<Fragment />,
191+
);
192+
};
193+
194+
export function Breadcrumbs({
195+
items,
196+
wrapper,
197+
}: {
198+
items: BreadcrumbItem[];
199+
wrapper?: React.ElementType<{ children: React.ReactNode }>;
200+
}) {
201+
const {
202+
startDisplayedItems,
203+
overflowItems,
204+
endDisplayedItems,
205+
}: PartitionBreadcrumbItems<BreadcrumbItem> = partitionBreadcrumbItems({
206+
items,
207+
maxDisplayedItems: MAX_DISPLAYED_ITEMS,
208+
});
209+
210+
return (
211+
<FUIBreadcrumb aria-label="breadcrumb">
212+
{startDisplayedItems.map((item) => renderItem(item, false, wrapper))}
213+
214+
{overflowItems && (
215+
<>
216+
<MenuWithTooltip
217+
overflowItems={overflowItems}
218+
startDisplayedItems={startDisplayedItems}
219+
endDisplayedItems={endDisplayedItems}
220+
/>
221+
<BreadcrumbDivider />
222+
</>
223+
)}
224+
225+
{endDisplayedItems &&
226+
endDisplayedItems.map((item, index, array) =>
227+
renderItem(item, index === array.length - 1, wrapper),
228+
)}
229+
</FUIBreadcrumb>
230+
);
231+
}

urbackupserver/www2/src/components/NavSidebar.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const NavSidebar = () => {
1919
<TabList selectedValue={snap.activePage} vertical onTabSelect={onTabSelect}>
2020
<Tab value={Pages.Status}>Status</Tab>
2121
<Tab value={Pages.Activities}>Activities</Tab>
22+
<Tab value={Pages.Backups}>Backups</Tab>
2223
</TabList>
2324
);
2425
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function TableWrapper({ children }: { children: React.ReactNode }) {
2+
return <section className="flow table-wrapper">{children}</section>;
3+
}

0 commit comments

Comments
 (0)