θΏ™ζ˜―indexlocζδΎ›ηš„ζœεŠ‘οΌŒδΈθ¦θΎ“ε…₯任何密码
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import Workspace from "@/models/workspace";
import paths from "@/utils/paths";
import showToast from "@/utils/toast";
import { DotsThree, PencilSimple, Trash } from "@phosphor-icons/react";
import {
ArrowCounterClockwise,
DotsThree,
PencilSimple,
Trash,
X,
} from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import truncate from "truncate";
Expand All @@ -14,7 +20,9 @@ export default function ThreadItem({
workspace,
thread,
onRemove,
toggleMarkForDeletion,
hasNext,
ctrlPressed = false,
}) {
const { slug } = useParams();
const optionsContainer = useRef(null);
Expand Down Expand Up @@ -57,14 +65,30 @@ export default function ThreadItem({
/>
<div className="flex w-full items-center justify-between pr-2 group relative">
{thread.deleted ? (
<a className="w-full">
<p className={`text-left text-sm text-slate-400/50 italic`}>
deleted thread
</p>
</a>
<div className="w-full flex justify-between">
<div className="w-full ">
<p className={`text-left text-sm text-slate-400/50 italic`}>
deleted thread
</p>
</div>
{ctrlPressed && (
<button
type="button"
className="border-none"
onClick={() => toggleMarkForDeletion(thread.id)}
>
<ArrowCounterClockwise
className="text-zinc-300 hover:text-white"
size={18}
/>
</button>
)}
</div>
) : (
<a
href={window.location.pathname === linkTo ? "#" : linkTo}
href={
window.location.pathname === linkTo || ctrlPressed ? "#" : linkTo
}
className="w-full"
aria-current={isActive ? "page" : ""}
>
Expand All @@ -79,15 +103,30 @@ export default function ThreadItem({
)}
{!!thread.slug && !thread.deleted && (
<div ref={optionsContainer}>
<div className="flex items-center w-fit group-hover:visible md:invisible gap-x-1">
{ctrlPressed ? (
<button
type="button"
onClick={() => setShowOptions(!showOptions)}
aria-label="Thread options"
className="border-none"
onClick={() => toggleMarkForDeletion(thread.id)}
>
<DotsThree className="text-slate-300" size={25} />
<X
className="text-zinc-300 hover:text-white"
weight="bold"
size={18}
/>
</button>
</div>
) : (
<div className="flex items-center w-fit group-hover:visible md:invisible gap-x-1">
<button
type="button"
className="border-none"
onClick={() => setShowOptions(!showOptions)}
aria-label="Thread options"
>
<DotsThree className="text-slate-300" size={25} />
</button>
</div>
)}
{showOptions && (
<OptionsMenu
containerRef={optionsContainer}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Workspace from "@/models/workspace";
import paths from "@/utils/paths";
import showToast from "@/utils/toast";
import { Plus, CircleNotch } from "@phosphor-icons/react";
import { Plus, CircleNotch, Trash } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import ThreadItem from "./ThreadItem";
import { useParams } from "react-router-dom";
Expand All @@ -10,6 +10,7 @@ export default function ThreadContainer({ workspace }) {
const { threadSlug = null } = useParams();
const [threads, setThreads] = useState([]);
const [loading, setLoading] = useState(true);
const [ctrlPressed, setCtrlPressed] = useState(false);

useEffect(() => {
async function fetchThreads() {
Expand All @@ -21,13 +22,56 @@ export default function ThreadContainer({ workspace }) {
fetchThreads();
}, [workspace.slug]);

// Enable toggling of meta-key (ctrl on win and cmd/fn on others)
useEffect(() => {
const handleKeyDown = (event) => {
if (["Control", "Meta"].includes(event.key)) {
setCtrlPressed((prev) => !prev);
// when toggling, unset bulk progress so
// previously marked threads that were never deleted
// come back to life.
setThreads((prev) =>
prev.map((t) => {
return { ...t, deleted: false };
})
);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);

const toggleForDeletion = (id) => {
setThreads((prev) =>
prev.map((t) => {
if (t.id !== id) return t;
return { ...t, deleted: !t.deleted };
})
);
};

const handleDeleteAll = async () => {
const slugs = threads.filter((t) => t.deleted === true).map((t) => t.slug);
await Workspace.threads.deleteBulk(workspace.slug, slugs);
setThreads((prev) => prev.filter((t) => !t.deleted));
setCtrlPressed(false);
};

function removeThread(threadId) {
setThreads((prev) =>
prev.map((_t) => {
if (_t.id !== threadId) return _t;
return { ..._t, deleted: true };
})
);

// Show thread was deleted, but then remove from threads entirely so it will
// not appear in bulk-selection.
setTimeout(() => {
setThreads((prev) => prev.filter((t) => !t.deleted));
}, 500);
}

if (loading) {
Expand Down Expand Up @@ -58,6 +102,8 @@ export default function ThreadContainer({ workspace }) {
<ThreadItem
key={thread.slug}
idx={i + 1}
ctrlPressed={ctrlPressed}
toggleMarkForDeletion={toggleForDeletion}
activeIdx={activeThreadIdx}
isActive={activeThreadIdx === i + 1}
workspace={workspace}
Expand All @@ -66,6 +112,11 @@ export default function ThreadContainer({ workspace }) {
hasNext={i !== threads.length - 1}
/>
))}
<DeleteAllThreadButton
ctrlPressed={ctrlPressed}
threads={threads}
onDelete={handleDeleteAll}
/>
<NewThreadButton workspace={workspace} />
</div>
);
Expand Down Expand Up @@ -113,3 +164,28 @@ function NewThreadButton({ workspace }) {
</button>
);
}

function DeleteAllThreadButton({ ctrlPressed, threads, onDelete }) {
if (!ctrlPressed || threads.filter((t) => t.deleted).length === 0)
return null;
return (
<button
type="button"
onClick={onDelete}
className="w-full relative flex h-[40px] items-center border-none hover:bg-red-400/20 rounded-lg group"
>
<div className="flex w-full gap-x-2 items-center pl-4">
<div className="bg-zinc-600 p-2 rounded-lg h-[24px] w-[24px] flex items-center justify-center">
<Trash
weight="bold"
size={14}
className="shrink-0 text-slate-100 group-hover:text-red-400"
/>
</div>
<p className="text-white text-left text-sm group-hover:text-red-400">
Delete Selected
</p>
</div>
</button>
);
}
12 changes: 12 additions & 0 deletions frontend/src/models/workspaceThread.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ const WorkspaceThread = {
.then((res) => res.ok)
.catch(() => false);
},
deleteBulk: async function (workspaceSlug, threadSlugs = []) {
return await fetch(
`${API_BASE}/workspace/${workspaceSlug}/thread-bulk-delete`,
{
method: "DELETE",
body: JSON.stringify({ slugs: threadSlugs }),
headers: baseHeaders(),
}
)
.then((res) => res.ok)
.catch(() => false);
},
chatHistory: async function (workspaceSlug, threadSlug) {
const history = await fetch(
`${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}/chats`,
Expand Down
23 changes: 23 additions & 0 deletions server/endpoints/workspaceThreads.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,29 @@ function workspaceThreadEndpoints(app) {
}
);

app.delete(
"/workspace/:slug/thread-bulk-delete",
[validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug],
async (request, response) => {
try {
const { slugs = [] } = reqBody(request);
if (slugs.length === 0) return response.sendStatus(200).end();

const user = await userFromSession(request, response);
const workspace = response.locals.workspace;
await WorkspaceThread.delete({
slug: { in: slugs },
user_id: user?.id ?? null,
workspace_id: workspace.id,
});
response.sendStatus(200).end();
} catch (e) {
console.log(e.message, e);
response.sendStatus(500).end();
}
}
);

app.get(
"/workspace/:slug/thread/:threadSlug/chats",
[
Expand Down
2 changes: 1 addition & 1 deletion server/models/workspaceThread.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const WorkspaceThread = {

delete: async function (clause = {}) {
try {
await prisma.workspace_threads.delete({
await prisma.workspace_threads.deleteMany({
where: clause,
});
return true;
Expand Down