mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b9da3a001 | |||
| 43b6bb41a4 | |||
| 8c9174b506 | |||
| 03d3a11f71 |
@@ -9,14 +9,14 @@ import ActionButton, {
|
||||
} from "~/components/ActionButton";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
|
||||
type RealProps = {
|
||||
export type RealProps = {
|
||||
$fullwidth?: boolean;
|
||||
$borderOnHover?: boolean;
|
||||
$neutral?: boolean;
|
||||
$danger?: boolean;
|
||||
};
|
||||
|
||||
const RealButton = styled(ActionButton)<RealProps>`
|
||||
export const RealButton = styled(ActionButton)<RealProps>`
|
||||
display: ${(props) => (props.$fullwidth ? "block" : "inline-block")};
|
||||
width: ${(props) => (props.$fullwidth ? "100%" : "auto")};
|
||||
margin: 0;
|
||||
|
||||
@@ -6,7 +6,11 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission, TeamPreference } from "@shared/types";
|
||||
import {
|
||||
CollectionDisplayPreferences,
|
||||
CollectionPermission,
|
||||
TeamPreference,
|
||||
} from "@shared/types";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
@@ -30,6 +34,7 @@ export interface FormData {
|
||||
icon: string;
|
||||
color: string | null;
|
||||
sharing: boolean;
|
||||
displayPreferences: CollectionDisplayPreferences | undefined;
|
||||
permission: CollectionPermission | undefined;
|
||||
commenting?: boolean | null;
|
||||
}
|
||||
@@ -86,6 +91,10 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
permission: collection?.permission,
|
||||
commenting: collection?.commenting ?? true,
|
||||
color: iconColor,
|
||||
displayPreferences: {
|
||||
showFooterNavigation:
|
||||
collection?.displayPreferences?.showFooterNavigation ?? false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,6 +225,22 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="displayPreferences.showFooterNavigation"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="showFooterNavigation"
|
||||
label={t("Show Footer Navigation")}
|
||||
note={t(
|
||||
"Show footer navigation on documents within this collection."
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -2,6 +2,8 @@ import invariant from "invariant";
|
||||
import { action, comparer, computed, observable, runInAction } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
CollectionDisplayPreference,
|
||||
type CollectionDisplayPreferences,
|
||||
FileOperationFormat,
|
||||
type NavigationNode,
|
||||
NavigationNodeType,
|
||||
@@ -99,6 +101,12 @@ export default class Collection extends ParanoidModel {
|
||||
@observable
|
||||
archivedBy?: User;
|
||||
|
||||
/**
|
||||
* Display preferences for the collection.
|
||||
*/
|
||||
@observable
|
||||
displayPreferences: CollectionDisplayPreferences;
|
||||
|
||||
@computed
|
||||
get searchContent(): string {
|
||||
return this.name;
|
||||
@@ -231,6 +239,25 @@ export default class Collection extends ParanoidModel {
|
||||
}
|
||||
};
|
||||
|
||||
get flatDocuments(): Document[] {
|
||||
if (!this?.sortedDocuments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const flatten = (docs: Document[]): Document[] => {
|
||||
const result: Document[] = [];
|
||||
for (const doc of docs) {
|
||||
result.push(doc);
|
||||
if (doc.children && doc.children.length > 0) {
|
||||
result.push(...flatten(doc.children as Document[]));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return flatten(this.sortedDocuments as Document[]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the document identified by the given id in the collection in memory.
|
||||
* Does not update the document in the database.
|
||||
@@ -452,5 +479,22 @@ export default class Collection extends ParanoidModel {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value for a specific display preference key.
|
||||
*
|
||||
* @param key The DisplayPreference key to retrieve
|
||||
* @param value The value to set
|
||||
*/
|
||||
setPreference(key: CollectionDisplayPreference, value: boolean) {
|
||||
this.displayPreferences = {
|
||||
...this.displayPreferences,
|
||||
[key]: value,
|
||||
};
|
||||
|
||||
void this.save({
|
||||
displayPreferences: this.displayPreferences,
|
||||
});
|
||||
}
|
||||
|
||||
private isFetching = false;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import DocumentMeta from "./DocumentMeta";
|
||||
import DocumentTitle from "./DocumentTitle";
|
||||
import first from "lodash/first";
|
||||
import { getLangFor } from "~/utils/language";
|
||||
import NavigationButtons from "./NavigationButtons";
|
||||
|
||||
const extensions = withUIExtensions(withComments(richExtensions));
|
||||
|
||||
@@ -255,6 +256,9 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
{...rest}
|
||||
/>
|
||||
<div ref={childRef}>{children}</div>
|
||||
{document.collection?.displayPreferences?.showFooterNavigation && (
|
||||
<NavigationButtons document={document} />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { s } from "@shared/styles";
|
||||
import React, { useMemo } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { css } from "styled-components";
|
||||
import { RealButton, RealProps } from "~/components/Button";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Document from "~/models/Document";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import {
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
const NavigationButtons = ({ document }: { document: Document }) => {
|
||||
const { ui } = useStores();
|
||||
const history = useHistory();
|
||||
|
||||
const docs = useMemo(() => {
|
||||
const { collection } = document;
|
||||
let navDocs: Record<string, Document | null> = {
|
||||
prevDoc: null,
|
||||
nextDoc: null,
|
||||
};
|
||||
|
||||
if (collection && collection.flatDocuments) {
|
||||
const currentIndex = collection.flatDocuments.findIndex(
|
||||
(doc) => doc.id === document?.id
|
||||
);
|
||||
|
||||
if (currentIndex !== undefined && currentIndex !== -1) {
|
||||
const nextIdx = currentIndex + 1;
|
||||
if (nextIdx < collection.flatDocuments.length) {
|
||||
navDocs.nextDoc = collection.flatDocuments[nextIdx];
|
||||
}
|
||||
|
||||
const prevIdx = currentIndex - 1;
|
||||
if (prevIdx > -1) {
|
||||
navDocs.prevDoc = collection.flatDocuments[prevIdx];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return navDocs;
|
||||
}, [document.collection?.sortedDocuments, document.collection]);
|
||||
|
||||
const handleNavigate = (doc: Document | null) => {
|
||||
if (doc) {
|
||||
history.push(doc.url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
fullwidth={!(docs.prevDoc && docs.nextDoc)}
|
||||
sidebarOpen={!ui.sidebarIsClosed}
|
||||
>
|
||||
{docs.prevDoc && (
|
||||
<NavButton
|
||||
$neutral
|
||||
$fullwidth={!docs.nextDoc}
|
||||
onClick={() => handleNavigate(docs.prevDoc)}
|
||||
>
|
||||
<ButtonContent>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
<ButtonText style={{ alignItems: "flex-end" }}>
|
||||
<span style={{ fontSize: 12, opacity: 0.8 }}>Previous</span>
|
||||
<span>{docs.prevDoc?.title}</span>
|
||||
</ButtonText>
|
||||
</ButtonContent>
|
||||
</NavButton>
|
||||
)}
|
||||
|
||||
{docs.nextDoc && (
|
||||
<NavButton
|
||||
$neutral
|
||||
$fullwidth={!docs.prevDoc}
|
||||
onClick={() => handleNavigate(docs.nextDoc)}
|
||||
>
|
||||
<ButtonContent>
|
||||
<ButtonText style={{ alignItems: "flex-start" }}>
|
||||
<span style={{ fontSize: 12, opacity: 0.8 }}>Next</span>
|
||||
<span>{docs.nextDoc?.title}</span>
|
||||
</ButtonText>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</ButtonContent>
|
||||
</NavButton>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.div<
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
fullwidth?: boolean;
|
||||
sidebarOpen?: boolean;
|
||||
}
|
||||
>`
|
||||
position: sticky;
|
||||
margin-bottom: 100px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin: 20px 0;
|
||||
`}
|
||||
|
||||
${breakpoint("desktop")`
|
||||
flex-direction: row;
|
||||
`}
|
||||
`;
|
||||
|
||||
const NavButton = styled(RealButton)`
|
||||
height: 60px;
|
||||
padding: 4px 12px;
|
||||
font-weight: bold;
|
||||
color: ${s("text")};
|
||||
border-radius: 2px;
|
||||
line-height: 18px;
|
||||
background: none;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: none;
|
||||
}
|
||||
|
||||
${breakpoint("desktop")`
|
||||
${(props: RealProps) => css`
|
||||
width: ${props.$fullwidth ? "100%" : "50%"};
|
||||
`}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ButtonContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const ButtonText = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
bacground-color: red;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
export default observer(NavigationButtons);
|
||||
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("collections", "displayPreferences", {
|
||||
type: Sequelize.JSONB,
|
||||
defaultValue: {},
|
||||
});
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
UPDATE collections
|
||||
SET "displayPreferences" = '{}'::jsonb
|
||||
WHERE "displayPreferences" IS NULL
|
||||
`);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("collections", "displayPreferences");
|
||||
},
|
||||
};
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import type {
|
||||
CollectionSort,
|
||||
CollectionDisplayPreferences,
|
||||
ProsemirrorData,
|
||||
SourceMetadata,
|
||||
} from "@shared/types";
|
||||
@@ -314,6 +315,10 @@ class Collection extends ParanoidModel<
|
||||
@Column(DataType.JSONB)
|
||||
sourceMetadata: SourceMetadata | null;
|
||||
|
||||
@Default({})
|
||||
@Column(DataType.JSONB)
|
||||
displayPreferences: CollectionDisplayPreferences | null;
|
||||
|
||||
// getters
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,7 @@ export default async function presentCollection(
|
||||
updatedAt: collection.updatedAt,
|
||||
deletedAt: collection.deletedAt,
|
||||
archivedAt: collection.archivedAt,
|
||||
displayPreferences: collection.displayPreferences,
|
||||
archivedBy: undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ router.post(
|
||||
sort,
|
||||
index,
|
||||
commenting,
|
||||
displayPreferences,
|
||||
} = ctx.input.body;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -82,6 +83,7 @@ router.post(
|
||||
sort,
|
||||
index,
|
||||
commenting,
|
||||
displayPreferences,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
@@ -573,6 +575,7 @@ router.post(
|
||||
sort,
|
||||
sharing,
|
||||
commenting,
|
||||
displayPreferences,
|
||||
} = ctx.input.body;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
@@ -647,6 +650,10 @@ router.post(
|
||||
collection.commenting = commenting;
|
||||
}
|
||||
|
||||
if (displayPreferences !== undefined) {
|
||||
collection.displayPreferences = displayPreferences;
|
||||
}
|
||||
|
||||
await collection.saveWithCtx(ctx);
|
||||
|
||||
// must reload to update collection membership for correct policy calculation
|
||||
|
||||
@@ -2,6 +2,7 @@ import isUndefined from "lodash/isUndefined";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CollectionDisplayPreference,
|
||||
CollectionPermission,
|
||||
CollectionStatusFilter,
|
||||
FileOperationFormat,
|
||||
@@ -46,6 +47,9 @@ export const CollectionsCreateSchema = BaseSchema.extend({
|
||||
})
|
||||
.optional(),
|
||||
commenting: z.boolean().nullish(),
|
||||
displayPreferences: z
|
||||
.record(z.nativeEnum(CollectionDisplayPreference), z.boolean())
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -181,6 +185,9 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
|
||||
.optional(),
|
||||
sharing: z.boolean().optional(),
|
||||
commenting: z.boolean().nullish(),
|
||||
displayPreferences: z
|
||||
.record(z.nativeEnum(CollectionDisplayPreference), z.boolean())
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -188,6 +188,8 @@
|
||||
"Allow documents within this collection to be shared publicly on the internet.": "Allow documents within this collection to be shared publicly on the internet.",
|
||||
"Commenting": "Commenting",
|
||||
"Allow commenting on documents within this collection.": "Allow commenting on documents within this collection.",
|
||||
"Show Footer Navigation": "Show Footer Navigation",
|
||||
"Show footer navigation on documents within this collection.": "Show footer navigation on documents within this collection.",
|
||||
"Saving": "Saving",
|
||||
"Save": "Save",
|
||||
"Creating": "Creating",
|
||||
|
||||
@@ -246,6 +246,15 @@ export enum UserPreference {
|
||||
|
||||
export type UserPreferences = { [key in UserPreference]?: boolean };
|
||||
|
||||
export enum CollectionDisplayPreference {
|
||||
/** Whether the previous and next document buttons at the bottom of the document should be shown */
|
||||
showFooterNavigation = "showFooterNavigation",
|
||||
}
|
||||
|
||||
export type CollectionDisplayPreferences = {
|
||||
[key in CollectionDisplayPreference]?: boolean;
|
||||
};
|
||||
|
||||
export type SourceMetadata = {
|
||||
/** The original source file name. */
|
||||
fileName?: string;
|
||||
|
||||
Reference in New Issue
Block a user