mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
Refine designs
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t 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",
|
||||
|
||||
Reference in New Issue
Block a user