Refine designs

This commit is contained in:
Tom Moor
2026-08-02 22:42:45 -04:00
parent 4d9763947e
commit c8d7b28e27
7 changed files with 167 additions and 154 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ import {
newTemplatePath,
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
import { ExportDialog } from "~/components/Export/ExportDialog";
import { isMobile } from "@shared/utils/browser";
import history from "~/utils/history";
import lazyWithRetry from "~/utils/lazyWithRetry";
+1 -1
View File
@@ -53,7 +53,7 @@ import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInT
import ConfirmationDialog from "~/components/ConfirmationDialog";
import { DialogTitle } from "~/components/DialogTitle";
import DocumentCopy from "~/components/DocumentExplorer/DocumentCopy";
import { DocumentDownload } from "~/components/DocumentDownload";
import { DocumentDownload } from "~/components/Export/DocumentDownload";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { ImportDocumentDialog } from "~/components/ImportDocumentDialog";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -4,11 +4,12 @@ import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { ExportContentType, NotificationEventType } from "@shared/types";
import type Document from "~/models/Document";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import { FileFormatSelector } from "~/components/Export/FileFormatSelector";
import type { FileFormat } from "~/components/Export/FileFormatSelector";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import Text from "~/components/Text";
import env from "~/env";
@@ -35,8 +36,8 @@ export const DocumentDownload = observer(({ document, onSubmit }: Props) => {
const [includeChildDocuments, setIncludeChildDocuments] =
useState<boolean>(hasChildDocuments);
const items = useMemo(() => {
const radioItems = [
const formats = useMemo(() => {
const items: FileFormat<ExportContentType>[] = [
{
title: "Markdown",
extension: ".md",
@@ -58,7 +59,7 @@ export const DocumentDownload = observer(({ document, onSubmit }: Props) => {
];
if (env.PDF_EXPORT_ENABLED) {
radioItems.push({
items.push({
title: "PDF",
extension: ".pdf",
value: ExportContentType.Pdf,
@@ -66,20 +67,13 @@ export const DocumentDownload = observer(({ document, onSubmit }: Props) => {
});
}
return radioItems;
return items;
}, []);
// The last chosen format may no longer be available, fallback to the first.
const contentType = items.some((item) => item.value === lastContentType)
const contentType = formats.some((format) => format.value === lastContentType)
? lastContentType
: items[0].value;
const handleContentTypeChange = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setContentType(ev.target.value as ExportContentType);
},
[setContentType]
);
: formats[0].value;
const handleIncludeChildDocumentsChange = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
@@ -121,28 +115,11 @@ export const DocumentDownload = observer(({ document, onSubmit }: Props) => {
return (
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Download")}>
<Flex gap={8} column>
{items.map((item) => (
<Format key={item.value}>
<HiddenInput
type="radio"
name="format"
value={item.value}
checked={contentType === item.value}
onChange={handleContentTypeChange}
/>
<FormatIcon>{item.icon}</FormatIcon>
<Flex align="center" gap={6}>
<Text size="small" weight="xbold">
{item.title}
</Text>
<Text size="small" type="secondary">
{item.extension}
</Text>
</Flex>
</Format>
))}
</Flex>
<FileFormatSelector
formats={formats}
value={contentType}
onChange={setContentType}
/>
{hasChildDocuments && (
<>
<hr style={{ margin: "16px 0 " }} />
@@ -183,49 +160,6 @@ const Option = styled.label`
}
`;
const HiddenInput = styled.input`
position: absolute;
opacity: 0;
pointer-events: none;
`;
const FormatIcon = styled.span`
display: flex;
flex-shrink: 0;
color: ${s("textSecondary")};
transition: color 100ms ease-in-out;
`;
const Format = styled.label`
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: var(--pointer);
background: ${s("backgroundSecondary")};
box-shadow: inset 0 0 0 2px transparent;
transition: box-shadow 100ms ease-in-out;
&: ${hover} {
background: ${s("backgroundTertiary")};
}
&:has(input:checked) {
box-shadow: inset 0 0 0 2px ${s("accent")};
${FormatIcon} {
color: ${s("text")};
}
}
&:has(input:focus-visible) {
outline: 2px solid ${s("accent")};
outline-offset: 2px;
}
`;
const StyledInput = styled.input`
position: relative;
top: 1.5px;
@@ -1,14 +1,18 @@
import { observer } from "mobx-react";
import { ArchiveIcon, CodeIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { FileOperationFormat, NotificationEventType } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import { FileFormatSelector } from "~/components/Export/FileFormatSelector";
import type { FileFormat } from "~/components/Export/FileFormatSelector";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import OutlineIcon from "~/components/Icons/OutlineIcon";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -17,7 +21,7 @@ type Props = {
onSubmit: () => void;
};
function ExportDialog({ collection, onSubmit }: Props) {
export const ExportDialog = observer(({ collection, onSubmit }: Props) => {
const [format, setFormat] = React.useState<FileOperationFormat>(
FileOperationFormat.MarkdownZip
);
@@ -27,14 +31,6 @@ function ExportDialog({ collection, onSubmit }: Props) {
const user = useCurrentUser();
const { collections, ui } = useStores();
const { t } = useTranslation();
const appName = env.APP_NAME;
const handleFormatChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setFormat(ev.target.value as FileOperationFormat);
},
[]
);
const handleIncludeAttachmentsChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
@@ -88,37 +84,30 @@ function ExportDialog({ collection, onSubmit }: Props) {
onSubmit();
};
const items = [
const formats: FileFormat<FileOperationFormat>[] = [
{
title: "Markdown",
description: t(
"A ZIP file containing the images, and documents in the Markdown format."
),
extension: ".markdown.zip",
value: FileOperationFormat.MarkdownZip,
icon: <MarkdownIcon />,
},
{
title: "HTML",
description: t(
"A ZIP file containing the images, and documents as HTML files."
),
extension: ".html.zip",
value: FileOperationFormat.HTMLZip,
icon: <CodeIcon />,
},
{
title: "TextBundle",
description: t(
"A ZIP file containing each document and its images as a TextBundle."
),
extension: ".textbundle.zip",
value: FileOperationFormat.TextBundleZip,
icon: <ArchiveIcon />,
},
{
title: "JSON",
description: t(
"Structured data that can be used to transfer data to another compatible {{ appName }} instance.",
{
appName,
}
),
extension: ".json.zip",
value: FileOperationFormat.JSON,
icon: <OutlineIcon />,
},
];
@@ -126,40 +115,16 @@ function ExportDialog({ collection, onSubmit }: Props) {
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Export")}>
{collection && (
<Text as="p">
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
values={{
collectionName: collection.name,
}}
components={{
em: <strong />,
}}
/>{" "}
{t("Exporting the collection may take some time.")}{" "}
{user.subscribedToEventType(NotificationEventType.ExportCompleted) &&
t("You will receive an email when it's complete.")}
</Text>
)}
<Flex gap={12} column>
{items.map((item) => (
<Option key={item.value}>
<input
type="radio"
name="format"
value={item.value}
checked={format === item.value}
onChange={handleFormatChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{item.title}
</Text>
<Text size="small" type="secondary">
{item.description}
</Text>
</div>
</Option>
))}
</Flex>
<FileFormatSelector
formats={formats}
value={format}
onChange={setFormat}
/>
<HR />
<Flex gap={12} column>
<Option>
@@ -175,7 +140,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
</Text>
<Text size="small" type="secondary">
{t("Including uploaded images and files in the exported data")}.
</Text>{" "}
</Text>
</div>
</Option>
{!collection && (
@@ -190,13 +155,19 @@ function ExportDialog({ collection, onSubmit }: Props) {
<Text as="p" size="small" weight="bold">
{t("Include private collections")}
</Text>
<Text size="small" type="secondary">
{t(
"As an admin you can export collections you are not currently a member of"
)}
.
</Text>
</div>
</Option>
)}
</Flex>
</ConfirmationDialog>
);
}
});
const HR = styled.hr`
margin: 16px 0;
@@ -215,5 +186,3 @@ const Option = styled.label`
margin: 0;
}
`;
export default observer(ExportDialog);
@@ -0,0 +1,113 @@
import { useCallback } from "react";
import type { ChangeEvent, ReactNode } from "react";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
export interface FileFormat<T extends string> {
/** The name of the format, e.g. "Markdown" */
title: string;
/** The extension of the file produced, e.g. ".md" */
extension: string;
/** An icon representing the format */
icon: ReactNode;
/** The value chosen when this format is selected */
value: T;
}
type Props<T extends string> = {
/** The formats available to choose between */
formats: FileFormat<T>[];
/** The currently selected format */
value: NoInfer<T>;
/** Called with the newly selected format */
onChange: (value: NoInfer<T>) => void;
};
/**
* A list of file formats to choose between, displayed as selectable cards.
*
* @param props The formats to display, the selected format, and a change handler.
* @returns a radio group of file formats.
*/
export function FileFormatSelector<T extends string>({
formats,
value,
onChange,
}: Props<T>) {
const handleChange = useCallback(
(ev: ChangeEvent<HTMLInputElement>) => {
onChange(ev.target.value as T);
},
[onChange]
);
return (
<Flex gap={8} column>
{formats.map((format) => (
<Format key={format.value}>
<HiddenInput
type="radio"
name="format"
value={format.value}
checked={value === format.value}
onChange={handleChange}
/>
<Flex align="center" gap={6}>
<FormatIcon>{format.icon}</FormatIcon>
<Text size="small" weight="xbold">
{format.title}
</Text>
</Flex>
<Text size="small" type="tertiary">
{format.extension}
</Text>
</Format>
))}
</Flex>
);
}
const HiddenInput = styled.input`
position: absolute;
opacity: 0;
pointer-events: none;
`;
const FormatIcon = styled.span`
display: flex;
flex-shrink: 0;
color: ${s("textSecondary")};
transition: color 100ms ease-in-out;
`;
const Format = styled.label`
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 8px;
cursor: var(--pointer);
background: ${s("backgroundSecondary")};
box-shadow: inset 0 0 0 2px transparent;
transition: box-shadow 100ms ease-in-out;
&: ${hover} {
background: ${s("backgroundTertiary")};
}
&:has(input:checked) {
box-shadow: inset 0 0 0 2px ${s("accent")};
${FormatIcon} {
color: ${s("text")};
}
}
&:has(input:focus-visible) {
outline: 2px solid ${s("accent")};
outline-offset: 2px;
}
`;
+1 -1
View File
@@ -10,7 +10,7 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import ExportDialog from "../../components/ExportDialog";
import { ExportDialog } from "~/components/Export/ExportDialog";
import FileOperationListItem from "./components/FileOperationListItem";
function Export() {
+8 -11
View File
@@ -296,12 +296,6 @@
"Deleted Collection": "Deleted Collection",
"Collection options": "Collection options",
"Document options": "Document options",
"Export started": "Export started",
"A link to your file will be sent through email soon": "A link to your file will be sent through email soon",
"Preparing your download": "Preparing your download",
"Include child documents": "Include child documents",
"When selected, exporting the document will take extra time.": "When selected, exporting the document will take extra time.",
"You will receive an email when it's complete.": "You will receive an email when it's complete.",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
@@ -375,14 +369,17 @@
"our engineers have been notified": "our engineers have been notified",
"Clear cache + reload": "Clear cache + reload",
"Show detail": "Show detail",
"A ZIP file containing the images, and documents in the Markdown format.": "A ZIP file containing the images, and documents in the Markdown format.",
"A ZIP file containing the images, and documents as HTML files.": "A ZIP file containing the images, and documents as HTML files.",
"A ZIP file containing each document and its images as a TextBundle.": "A ZIP file containing each document and its images as a TextBundle.",
"Structured data that can be used to transfer data to another compatible {{ appName }} instance.": "Structured data that can be used to transfer data to another compatible {{ appName }} instance.",
"Exporting the collection <em>{{collectionName}}</em> may take some time.": "Exporting the collection <em>{{collectionName}}</em> may take some time.",
"Export started": "Export started",
"A link to your file will be sent through email soon": "A link to your file will be sent through email soon",
"Preparing your download": "Preparing your download",
"Include child documents": "Include child documents",
"When selected, exporting the document will take extra time.": "When selected, exporting the document will take extra time.",
"You will receive an email when it's complete.": "You will receive an email when it's complete.",
"Exporting the collection may take some time.": "Exporting the collection may take some time.",
"Include attachments": "Include attachments",
"Including uploaded images and files in the exported data": "Including uploaded images and files in the exported data",
"Include private collections": "Include private collections",
"As an admin you can export collections you are not currently a member of": "As an admin you can export collections you are not currently a member of",
"{{count}} more user": "{{count}} more user",
"{{count}} more user_plural": "{{count}} more users",
"Filter options": "Filter options",