Merge pull request #360 from revoltchat/rework/categories-kanban

This commit is contained in:
Paul Makles
2021-10-31 16:38:10 +00:00
committed by GitHub
10 changed files with 699 additions and 152 deletions

View File

@@ -50,6 +50,7 @@ export default observer(() => {
title: (
<Text id="app.settings.server_pages.categories.title" />
),
hideTitle: true,
},
{
id: "members",

View File

@@ -1,139 +1,460 @@
import isEqual from "lodash.isequal";
import { Plus, X } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { DragDropContext } from "react-beautiful-dnd";
import { TextChannel, VoiceChannel } from "revolt-api/types/Channels";
import { Category } from "revolt-api/types/Servers";
import { Server } from "revolt.js/dist/maps/Servers";
import styled, { css } from "styled-components";
import { ulid } from "ulid";
import { useState } from "preact/hooks";
import { Text } from "preact-i18n";
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import { useAutosave } from "../../../lib/debounce";
import { Draggable, Droppable } from "../../../lib/dnd";
import { noop } from "../../../lib/js";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import ChannelIcon from "../../../components/common/ChannelIcon";
import Button from "../../../components/ui/Button";
import ComboBox from "../../../components/ui/ComboBox";
import InputBox from "../../../components/ui/InputBox";
import Tip from "../../../components/ui/Tip";
import SaveStatus, { EditStatus } from "../../../components/ui/SaveStatus";
const KanbanEntry = styled.div`
padding: 2px 4px;
> .inner {
display: flex;
align-items: center;
gap: 4px;
height: 40px;
padding: 8px;
flex-shrink: 0;
font-size: 0.9em;
background: var(--primary-background);
img {
flex-shrink: 0;
}
span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
`;
const KanbanList = styled.div<{ last: boolean }>`
${(props) =>
!props.last &&
css`
padding-inline-end: 4px;
`}
> .inner {
width: 180px;
display: flex;
flex-shrink: 0;
overflow-y: auto;
padding-bottom: 2px;
flex-direction: column;
background: var(--secondary-background);
input {
width: 100%;
height: 100%;
border: none;
font-size: 1em;
text-align: center;
background: transparent;
color: var(--foreground);
}
> [data-rbd-droppable-id] {
min-height: 24px;
}
}
`;
const Row = styled.div`
gap: 2px;
margin: 4px;
display: flex;
> :first-child {
flex-grow: 1;
}
`;
const KanbanListHeader = styled.div`
height: 34px;
display: grid;
min-width: 34px;
place-items: center;
cursor: pointer !important;
transition: 0.2s ease background-color;
&:hover {
background: var(--background);
}
`;
const KanbanBoard = styled.div`
display: flex;
flex-direction: row;
`;
const FullSize = styled.div`
flex-grow: 1;
min-height: 0;
> * {
height: 100%;
overflow-x: scroll;
}
`;
const Header = styled.div`
display: flex;
h1 {
flex-grow: 1;
}
`;
interface Props {
server: Server;
}
// ! FIXME: really bad code
export const Categories = observer(({ server }: Props) => {
const channels = server.channels.filter((x) => typeof x !== "undefined");
const [status, setStatus] = useState<EditStatus>("saved");
const [categories, setCategories] = useState<Category[]>(
server.categories ?? [],
);
const [cats, setCats] = useState<Category[]>(server.categories ?? []);
const [name, setName] = useState("");
useAutosave(
async () => {
setStatus("saving");
await server.edit({ categories });
setStatus("saved");
},
categories,
server.categories,
() => setStatus("editing"),
);
const defaultCategory = useMemo(() => {
return {
title: "Uncategorized",
channels: [...server.channels]
.filter((x) => x)
.map((x) => x!._id)
.filter(
(x) => !categories.find((cat) => cat.channels.includes(x)),
),
id: "none",
};
}, [categories, server.channels]);
return (
<div>
<Tip warning>This section is under construction.</Tip>
<p>
<Button
contrast
disabled={isEqual(server.categories ?? [], cats)}
onClick={() => server.edit({ categories: cats })}>
save categories
</Button>
</p>
<h2>categories</h2>
{cats.map((category) => (
<div style={{ background: "var(--hover)" }} key={category.id}>
<InputBox
value={category.title}
onChange={(e) =>
setCats(
cats.map((y) =>
y.id === category.id
? {
...y,
title: e.currentTarget.value,
}
: y,
),
)
}
contrast
/>
<Button
contrast
onClick={() =>
setCats(cats.filter((x) => x.id !== category.id))
}>
delete {category.title}
</Button>
</div>
))}
<h2>create new</h2>
<p>
<InputBox
value={name}
onChange={(e) => setName(e.currentTarget.value)}
contrast
/>
<Button
contrast
onClick={() => {
setName("");
setCats([
...cats,
{
id: ulid(),
title: name,
channels: [],
},
]);
}}>
create
</Button>
</p>
<h2>channels</h2>
{channels.map((channel) => {
return (
<div
key={channel!._id}
style={{
display: "flex",
gap: "12px",
alignItems: "center",
}}>
<div style={{ flexShrink: 0 }}>
<ChannelIcon target={channel} size={24} />{" "}
<span>{channel!.name}</span>
</div>
<ComboBox
style={{ flexGrow: 1 }}
value={
cats.find((x) =>
x.channels.includes(channel!._id),
)?.id ?? "none"
}
onChange={(e) =>
setCats(
cats.map((x) => {
return {
...x,
channels: [
...x.channels.filter(
(y) => y !== channel!._id,
),
...(e.currentTarget.value ===
x.id
? [channel!._id]
: []),
],
};
}),
)
}>
<option value="none">Uncategorised</option>
{cats.map((x) => (
<option key={x.id} value={x.id}>
{x.title}
</option>
))}
</ComboBox>
</div>
);
})}
</div>
<>
<Header>
<h1>
<Text id={`app.settings.server_pages.categories.title`} />
</h1>
<SaveStatus status={status} />
</Header>
<DragDropContext
onDragEnd={(target) => {
const { destination, source, draggableId, type } = target;
if (
!destination ||
(destination.droppableId === source.droppableId &&
destination.index === source.index)
) {
return;
}
if (type === "column") {
if (destination.index === 0) return;
// Remove from array.
const cat = categories.find(
(x) => x.id === draggableId,
);
const arr = categories.filter(
(x) => x.id !== draggableId,
);
// Insert at new position.
arr.splice(destination.index - 1, 0, cat!);
setCategories(arr);
} else {
setCategories(
categories.map((category) => {
if (category.id === destination.droppableId) {
const channels = category.channels.filter(
(x) => x !== draggableId,
);
channels.splice(
destination.index,
0,
draggableId,
);
return {
...category,
channels,
};
} else if (category.id === source.droppableId) {
return {
...category,
channels: category.channels.filter(
(x) => x !== draggableId,
),
};
}
return category;
}),
);
}
}}>
<FullSize>
<Droppable
droppableId="categories"
direction="horizontal"
type="column">
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}>
<KanbanBoard>
<ListElement
category={defaultCategory}
server={server}
index={0}
addChannel={noop}
/>
{categories.map((category, index) => (
<ListElement
draggable
category={category}
server={server}
index={index + 1}
key={category.id}
setTitle={(title) => {
setCategories(
categories.map((x) =>
x.id === category.id
? {
...x,
title,
}
: x,
),
);
}}
deleteSelf={() =>
setCategories(
categories.filter(
(x) =>
x.id !==
category.id,
),
)
}
addChannel={(channel) => {
setCategories(
categories.map((x) =>
x.id === category.id
? {
...x,
channels: [
...x.channels,
channel._id,
],
}
: x,
),
);
}}
/>
))}
<KanbanList last>
<div class="inner">
<KanbanListHeader
onClick={() =>
setCategories([
...categories,
{
id: ulid(),
title: "New Category",
channels: [],
},
])
}>
<Plus size={24} />
</KanbanListHeader>
</div>
</KanbanList>
{provided.placeholder}
</KanbanBoard>
</div>
)}
</Droppable>
</FullSize>
</DragDropContext>
</>
);
});
function ListElement({
category,
server,
index,
setTitle,
deleteSelf,
addChannel,
draggable,
}: {
category: Category;
server: Server;
index: number;
setTitle?: (title: string) => void;
deleteSelf?: () => void;
addChannel: (channel: TextChannel | VoiceChannel) => void;
draggable?: boolean;
}) {
const { openScreen } = useIntermediate();
const [editing, setEditing] = useState<string>();
const startEditing = () => setTitle && setEditing(category.title);
const save = useCallback(() => {
setEditing(undefined);
setTitle!(editing!);
}, [editing, setTitle]);
useEffect(() => {
if (!editing) return;
function onClick(ev: MouseEvent) {
if ((ev.target as HTMLElement)?.id !== category.id) {
save();
}
}
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [editing, category.id, save]);
return (
<Draggable
isDragDisabled={!draggable}
key={category.id}
draggableId={category.id}
index={index}>
{(provided) => (
<div {...provided.draggableProps} ref={provided.innerRef}>
<KanbanList last={false} key={category.id}>
<div class="inner">
<Row>
<KanbanListHeader {...provided.dragHandleProps}>
{editing ? (
<input
value={editing}
onChange={(e) =>
setEditing(
e.currentTarget.value,
)
}
onKeyDown={(e) =>
e.key === "Enter" && save()
}
id={category.id}
/>
) : (
<span onClick={startEditing}>
{category.title}
</span>
)}
</KanbanListHeader>
{deleteSelf && (
<KanbanListHeader onClick={deleteSelf}>
<X size={24} />
</KanbanListHeader>
)}
</Row>
<Droppable
droppableId={category.id}
key={category.id}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}>
{category.channels.map((x, index) => {
const channel =
server.client.channels.get(x);
if (!channel) return null;
return (
<Draggable
key={x}
draggableId={x}
index={index}>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={
provided.innerRef
}>
<KanbanEntry>
<div class="inner">
<ChannelIcon
target={
channel
}
size={
24
}
/>
<span>
{
channel.name
}
</span>
</div>
</KanbanEntry>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
<KanbanListHeader
onClick={() =>
openScreen({
id: "special_prompt",
type: "create_channel",
target: server,
cb: addChannel,
})
}>
<Plus size={24} />
</KanbanListHeader>
</div>
</KanbanList>
</div>
)}
</Draggable>
);
}