feat: add collection

This commit is contained in:
2025-07-31 15:41:15 +08:00
parent 1e5b12f531
commit 08c70a0b52
38 changed files with 1079 additions and 418 deletions

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Activity, ActivityType } from "../network/models.ts";
import { network } from "../network/network.ts";
import showToast from "../components/toast.ts";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { useNavigate } from "react-router";
import Loading from "../components/loading.tsx";
import { CommentContent } from "../components/comment_tile.tsx";

View File

@@ -0,0 +1,341 @@
import { useEffect, useRef, useState } from "react";
import { useParams, useNavigate } from "react-router"; // 新增 useNavigate
import showToast from "../components/toast";
import { network } from "../network/network";
import { Collection } from "../network/models";
import Markdown from "react-markdown";
import ResourcesView from "../components/resources_view";
import Loading from "../components/loading";
import { MdOutlineDelete, MdOutlineEdit } from "react-icons/md";
import { app } from "../app";
import { useTranslation } from "../utils/i18n";
import Button from "../components/button";
export default function CollectionPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [collection, setCollection] = useState<Collection | null>(null);
const [resourcesKey, setResourcesKey] = useState(0);
const { t } = useTranslation();
const [isDeleting, setIsDeleting] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => {
const idInt = parseInt(id || "0", 10);
if (isNaN(idInt)) {
showToast({
type: "error",
message: "Invalid collection ID",
});
return;
}
network.getCollection(idInt).then((res) => {
if (res.success) {
setCollection(res.data!);
} else {
showToast({
type: "error",
message: res.message || "Failed to load collection",
});
}
});
}, [resourcesKey]);
const toBeDeletedRID = useRef<number | null>(null);
const handleDeleteResource = (resourceId: number) => {
toBeDeletedRID.current = resourceId;
const dialog = document.getElementById(
"deleteResourceDialog",
) as HTMLDialogElement | null;
if (dialog) {
dialog.showModal();
}
};
const handleDeletedResourceConfirmed = () => {
if (toBeDeletedRID.current === null) return;
network
.removeResourceFromCollection(collection!.id, toBeDeletedRID.current)
.then((res) => {
if (res.success) {
showToast({
type: "success",
message: "Resource deleted successfully",
});
setResourcesKey((prev) => prev + 1); // Trigger re-render of ResourcesView
} else {
showToast({
type: "error",
message: res.message || "Failed to delete resource",
});
}
});
toBeDeletedRID.current = null;
const dialog = document.getElementById(
"deleteResourceDialog",
) as HTMLDialogElement | null;
if (dialog) {
dialog.close();
}
};
const handleDeleteCollection = () => setDeleteOpen(true);
const handleDeleteCollectionConfirmed = async () => {
if (!collection) return;
setIsDeleting(true);
const res = await network.deleteCollection(collection.id);
setIsDeleting(false);
if (res.success) {
showToast({
type: "success",
message: "Collection deleted successfully",
});
setDeleteOpen(false);
if (window.history.length > 1) {
navigate(-1);
} else {
navigate("/", { replace: true });
}
} else {
showToast({
type: "error",
message: res.message || "Failed to delete collection",
});
setDeleteOpen(false);
}
};
const isOwner = collection?.user?.id === app?.user?.id;
const openEditDialog = () => setEditOpen(true);
const handleEditSaved = (newCollection: Collection) => {
setCollection(newCollection);
setEditOpen(false);
};
if (!collection) {
return <Loading />;
}
return (
<>
<div className="mx-4 mt-4 p-4 bg-base-100-tr82 shadow rounded-xl">
<h1 className="text-2xl font-bold">{collection?.title}</h1>
<article>
<CollectionContent content={collection?.article || ""} />
</article>
<div className="flex items-center flex-row-reverse">
{isOwner && (
<>
<button
className="btn btn-sm btn-ghost ml-2"
onClick={openEditDialog}
>
<MdOutlineEdit size={16} />
{t("Edit")}
</button>
<button
className="btn btn-sm btn-error btn-ghost ml-2"
onClick={handleDeleteCollection}
>
<MdOutlineDelete size={16} />
{t("Delete")}
</button>
</>
)}
</div>
</div>
<ResourcesView
loader={() => {
return network.listCollectionResources(collection!.id);
}}
actionBuilder={
isOwner
? (r) => {
return (
<button
className="btn btn-sm btn-rounded btn-error btn-ghost"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleDeleteResource(r.id);
}}
>
<MdOutlineDelete size={16} />
</button>
);
}
: undefined
}
key={resourcesKey}
/>
<dialog id="deleteResourceDialog" className="modal">
<div className="modal-box">
<h2 className="font-bold text-lg">Remove Resource</h2>
<p>Are you sure you want to remove this resource?</p>
<div className="modal-action">
<Button
onClick={() => {
const dialog = document.getElementById(
"deleteResourceDialog",
) as HTMLDialogElement | null;
if (dialog) {
dialog.close();
}
}}
>
Cancel
</Button>
<Button
className="btn-error"
onClick={handleDeletedResourceConfirmed}
>
Delete
</Button>
</div>
</div>
</dialog>
{deleteOpen && (
<div className="modal modal-open">
<div className="modal-box">
<h2 className="font-bold text-lg mb-2">{t("Delete Collection")}</h2>
<p>
{t(
"Are you sure you want to delete this collection? This action cannot be undone.",
)}
</p>
<div className="modal-action">
<Button className="btn" onClick={() => setDeleteOpen(false)}>
{t("Cancel")}
</Button>
<Button
className="btn btn-error"
onClick={handleDeleteCollectionConfirmed}
isLoading={isDeleting}
>
{t("Delete")}
</Button>
</div>
</div>
</div>
)}
{editOpen && collection && (
<EditCollectionDialog
open={editOpen}
collection={collection}
onClose={() => setEditOpen(false)}
onSaved={handleEditSaved}
/>
)}
</>
);
}
function CollectionContent({ content }: { content: string }) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
if (!line.endsWith(" ")) {
// Ensure that each line ends with two spaces for Markdown to recognize it as a line break
lines[i] = line + " ";
}
}
content = lines.join("\n");
return <Markdown>{content}</Markdown>;
}
function EditCollectionDialog({
open,
collection,
onClose,
onSaved,
}: {
open: boolean;
collection: Collection;
onClose: () => void;
onSaved: (newCollection: Collection) => void;
}) {
const [editTitle, setEditTitle] = useState(collection.title);
const [editArticle, setEditArticle] = useState(collection.article);
const [editLoading, setEditLoading] = useState(false);
const { t } = useTranslation();
const handleEditSave = async () => {
if (editTitle.trim() === "" || editArticle.trim() === "") {
showToast({
type: "error",
message: t("Title and description cannot be empty"),
});
return;
}
setEditLoading(true);
const res = await network.updateCollection(
collection.id,
editTitle,
editArticle,
);
setEditLoading(false);
if (res.success) {
showToast({ type: "success", message: t("Edit successful") });
const getRes = await network.getCollection(collection.id);
if (getRes.success) {
onSaved(getRes.data!);
} else {
onSaved({ ...collection, title: editTitle, article: editArticle });
}
} else {
showToast({
type: "error",
message: res.message || t("Failed to save changes"),
});
}
};
if (!open) return null;
return (
<div className="modal modal-open">
<div className="modal-box">
<h2 className="font-bold text-lg mb-2">{t("Edit Collection")}</h2>
<label className="block mb-1">{t("Title")}</label>
<input
className="input w-full mb-2"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
disabled={editLoading}
/>
<label className="block mb-1">{t("Description")}</label>
<textarea
className="textarea w-full min-h-32"
value={editArticle}
onChange={(e) => setEditArticle(e.target.value)}
disabled={editLoading}
/>
<div className="modal-action">
<button className="btn" onClick={onClose} disabled={editLoading}>
{t("Cancel")}
</button>
<button
className="btn btn-primary"
onClick={handleEditSave}
disabled={editLoading}
>
{editLoading ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
t("Save")
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { network } from "../network/network";
import showToast from "../components/toast";
import { useNavigate, useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { CommentWithRef, Resource } from "../network/models";
import Loading from "../components/loading";
import Markdown from "react-markdown";
@@ -91,7 +91,9 @@ export default function CommentPage() {
useEffect(() => {
if (comment?.resource && comment.resource.image) {
navigator.setBackground(network.getResampledImageUrl(comment.resource.image.id));
navigator.setBackground(
network.getResampledImageUrl(comment.resource.image.id),
);
} else if (comment?.images?.length) {
// comment images are not resampled
navigator.setBackground(network.getImageUrl(comment.images[0].id));
@@ -109,36 +111,39 @@ export default function CommentPage() {
<div className="h-2"></div>
<div className="bg-base-100-tr82 rounded-2xl p-4 shadow">
<div className="flex items-center">
<button
onClick={() => {
navigate(`/user/${encodeURIComponent(comment.user.username)}`);
}}
className="border-b-2 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out"
>
<div className="flex items-center">
<div className="avatar">
<div className="w-6 rounded-full">
<img src={network.getUserAvatar(comment.user)} alt={"avatar"} />
<button
onClick={() => {
navigate(`/user/${encodeURIComponent(comment.user.username)}`);
}}
className="border-b-2 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out"
>
<div className="flex items-center">
<div className="avatar">
<div className="w-6 rounded-full">
<img
src={network.getUserAvatar(comment.user)}
alt={"avatar"}
/>
</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{comment.user.username}</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{comment.user.username}</div>
</div>
</button>
<span className="text-xs text-base-content/80 ml-2">
{t("Commented on")}
{new Date(comment.created_at).toLocaleDateString()}
</span>
</div>
<article>
<CommentContent content={comment.content} />
</article>
{app.user?.id === comment.user.id && (
<div className="flex flex-row justify-end mt-2">
<EditCommentDialog comment={comment} onUpdated={onUpdated} />
<DeleteCommentDialog commentId={comment.id} onUpdated={onDeleted} />
</button>
<span className="text-xs text-base-content/80 ml-2">
{t("Commented on")}
{new Date(comment.created_at).toLocaleDateString()}
</span>
</div>
)}
<article>
<CommentContent content={comment.content} />
</article>
{app.user?.id === comment.user.id && (
<div className="flex flex-row justify-end mt-2">
<EditCommentDialog comment={comment} onUpdated={onUpdated} />
<DeleteCommentDialog commentId={comment.id} onUpdated={onDeleted} />
</div>
)}
</div>
<div className="h-4" />
<div className="border-t border-base-300" />

View File

@@ -0,0 +1,138 @@
import { useState } from "react";
import { useTranslation } from "../utils/i18n";
import { MdOutlineImage, MdOutlineInfo } from "react-icons/md";
import showToast from "../components/toast";
import { network } from "../network/network";
import { useNavigate } from "react-router";
export default function CreateCollectionPage() {
const [title, setTitle] = useState<string>("");
const [article, setArticle] = useState<string>("");
const [isLoading, setLoading] = useState(false);
const [isUploadingimage, setUploadingImage] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
const handleAddImage = () => {
if (isUploadingimage) {
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.onchange = async (e) => {
const files = (e.target as HTMLInputElement).files;
if (files) {
for (let i = 0; i < files.length; i++) {
if (files[i].size > 8 * 1024 * 1024) {
showToast({
message: t("Image size exceeds 5MB limit"),
type: "error",
});
return;
}
}
setUploadingImage(true);
const imageIds: number[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const res = await network.uploadImage(file);
if (res.success) {
imageIds.push(res.data!);
} else {
showToast({ message: res.message, type: "error" });
setUploadingImage(false);
return;
}
}
if (imageIds.length > 0) {
setArticle((prev) => {
return (
prev +
"\n" +
imageIds.map((id) => `![Image](/api/image/${id})`).join(" ")
);
});
}
setUploadingImage(false);
}
};
input.click();
};
const createCollection = async () => {
if (isLoading) {
return;
}
if (title.trim() === "" || article.trim() === "") {
showToast({
message: t("Title and description cannot be empty"),
type: "error",
});
return;
}
setLoading(true);
const res = await network.createCollection(title, article);
if (res.success) {
showToast({
message: t("Collection created successfully"),
type: "success",
});
navigate(`/collection/${res.data?.id}`, { replace: true });
} else {
showToast({ message: res.message, type: "error" });
setLoading(false);
}
};
return (
<div className="bg-base-100-tr82 shadow m-4 p-4 rounded-lg">
<h1 className="text-xl font-bold">{t("Create Collection")}</h1>
<div className="mt-4">
<label className="block">{t("Title")}</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="input mt-1 w-full"
/>
<label className="mt-8 flex items-center">
{t("Description")}
<span className="w-2"></span>
<div className="badge badge-info badge-soft badge-sm">
<MdOutlineInfo className="inline-block" size={16} />
<span className="text-sm">Markdown</span>
</div>
</label>
<textarea
value={article}
onChange={(e) => setArticle(e.target.value)}
className="textarea mt-1 w-full min-h-80"
/>
</div>
<div className={"flex items-center mt-4"}>
<button
className={"btn btn-sm btn-circle mr-2"}
onClick={handleAddImage}
>
{isUploadingimage ? (
<span className={"loading loading-spinner loading-sm"}></span>
) : (
<MdOutlineImage size={18} />
)}
</button>
<span className={"grow"} />
<button
onClick={createCollection}
className={`btn btn-primary h-8 text-sm mx-2 ${article === "" && "btn-disabled"}`}
>
{isLoading ? (
<span className={"loading loading-spinner loading-sm"}></span>
) : null}
{t("Submit")}
</button>
</div>
</div>
);
}

View File

@@ -10,7 +10,7 @@ import { Tag } from "../network/models.ts";
import { network } from "../network/network.ts";
import { useNavigate, useParams } from "react-router";
import showToast from "../components/toast.ts";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { app } from "../app.ts";
import { ErrorAlert } from "../components/alert.tsx";
import Loading from "../components/loading.tsx";

View File

@@ -3,7 +3,7 @@ import ResourcesView from "../components/resources_view.tsx";
import { network } from "../network/network.ts";
import { app } from "../app.ts";
import { RSort } from "../network/models.ts";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { useAppContext } from "../components/AppContext.tsx";
import Select from "../components/select.tsx";

View File

@@ -2,7 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
import { network } from "../network/network.ts";
import { app } from "../app.ts";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
export default function LoginPage() {
const { t } = useTranslation();

View File

@@ -1,4 +1,4 @@
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { app } from "../app";
import { ErrorAlert } from "../components/alert";
import { network } from "../network/network";

View File

@@ -7,7 +7,7 @@ import {
import { ReactNode, useEffect, useState } from "react";
import StorageView from "./manage_storage_page.tsx";
import UserView from "./manage_user_page.tsx";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { ManageMePage } from "./manage_me_page.tsx";
import ManageServerConfigPage from "./manage_server_config_page.tsx";
@@ -70,9 +70,7 @@ export default function ManagePage() {
return (
<div className="drawer lg:drawer-open lg:pl-4">
<input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
<div
className="drawer-content overflow-y-auto bg-base-100-tr82 lg:m-4 rounded-md lg:p-2 h-[calc(100vh-64px)] lg:h-[calc(100vh-96px)]"
>
<div className="drawer-content overflow-y-auto bg-base-100-tr82 lg:m-4 rounded-md lg:p-2 h-[calc(100vh-64px)] lg:h-[calc(100vh-96px)]">
<div className={"flex w-full h-14 items-center gap-2 px-4"}>
<label
className={"btn btn-square btn-ghost lg:hidden"}

View File

@@ -1,4 +1,4 @@
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { app } from "../app";
import { ErrorAlert, InfoAlert } from "../components/alert";
import { useEffect, useState } from "react";

View File

@@ -5,7 +5,7 @@ import showToast from "../components/toast.ts";
import Loading from "../components/loading.tsx";
import { MdAdd, MdMoreHoriz } from "react-icons/md";
import { ErrorAlert } from "../components/alert.tsx";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { app } from "../app.ts";
import showPopup, { PopupMenuItem } from "../components/popup.tsx";
import Badge from "../components/badge.tsx";

View File

@@ -6,7 +6,7 @@ import Loading from "../components/loading";
import { MdMoreHoriz, MdSearch } from "react-icons/md";
import Pagination from "../components/pagination";
import showPopup, { PopupMenuItem } from "../components/popup";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { app } from "../app";
import { ErrorAlert } from "../components/alert";

View File

@@ -9,7 +9,7 @@ import {
import { Tag } from "../network/models.ts";
import { network } from "../network/network.ts";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { app } from "../app.ts";
import { ErrorAlert } from "../components/alert.tsx";
import { useAppContext } from "../components/AppContext.tsx";

View File

@@ -2,7 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
import { network } from "../network/network.ts";
import { app } from "../app.ts";
import { useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { Turnstile } from "@marsidev/react-turnstile";
export default function RegisterPage() {

View File

@@ -17,6 +17,7 @@ import {
Comment,
Tag,
Resource,
Collection,
} from "../network/models.ts";
import { network } from "../network/network.ts";
import showToast from "../components/toast.ts";
@@ -32,13 +33,15 @@ import {
MdOutlineDelete,
MdOutlineDownload,
MdOutlineEdit,
MdOutlineFolder,
MdOutlineFolderSpecial,
MdOutlineLink,
MdOutlineOpenInNew,
} from "react-icons/md";
import { app } from "../app.ts";
import { uploadingManager } from "../network/uploading.ts";
import { ErrorAlert } from "../components/alert.tsx";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import Pagination from "../components/pagination.tsx";
import showPopup, { useClosePopup } from "../components/popup.tsx";
import { Turnstile } from "@marsidev/react-turnstile";
@@ -57,6 +60,7 @@ import KunApi, {
kunPlatformToString,
kunResourceTypeToString,
} from "../network/kun.ts";
import { Debounce } from "../utils/debounce.ts";
export default function ResourcePage() {
const params = useParams();
@@ -213,28 +217,27 @@ export default function ResourcePage() {
</div>
</button>
<Tags tags={resource.tags} />
{resource.links && (
<p className={"px-3 mt-2"}>
{resource.links.map((l) => {
return (
<a href={l.url} target={"_blank"}>
<span
className={
"py-1 px-3 inline-flex items-center m-1 border border-base-300 bg-base-100 opacity-90 rounded-2xl hover:bg-base-200 transition-colors cursor-pointer select-none"
}
>
{l.url.includes("steampowered.com") ? (
<BiLogoSteam size={20} />
) : (
<MdOutlineLink size={20} />
)}
<span className={"ml-2 text-sm"}>{l.label}</span>
</span>
</a>
);
})}
</p>
)}
<p className={"px-3 mt-2"}>
{resource.links.map((l) => {
return (
<a href={l.url} target={"_blank"}>
<span
className={
"py-1 px-3 inline-flex items-center m-1 border border-base-300 bg-base-100 opacity-90 rounded-2xl hover:bg-base-200 transition-colors cursor-pointer select-none"
}
>
{l.url.includes("steampowered.com") ? (
<BiLogoSteam size={20} />
) : (
<MdOutlineLink size={20} />
)}
<span className={"ml-2 text-sm"}>{l.label}</span>
</span>
</a>
);
})}
<CollectionDialog rid={resource.id} />
</p>
<div
className="tabs tabs-box my-4 mx-2 p-4 shadow"
@@ -1581,3 +1584,174 @@ function KunFile({
</div>
);
}
function CollectionDialog({ rid }: { rid: number }) {
const { t } = useTranslation();
const [searchKeyword, setSearchKeyword] = useState("");
const [realSearchKeyword, setRealSearchKeyword] = useState("");
const [dialogVisited, setDialogVisited] = useState(false);
const [selectedCID, setSelectedCID] = useState<number | null>(null);
const debounce = new Debounce(500);
const delayedSetSearchKeyword = (keyword: string) => {
setSearchKeyword(keyword);
debounce.run(() => {
setSelectedCID(null);
setRealSearchKeyword(keyword);
});
};
const handleAddToCollection = () => {
if (selectedCID == null) {
return;
}
network.addResourceToCollection(selectedCID, rid).then((res) => {
if (res.success) {
showToast({
message: t("Resource added to collection successfully"),
type: "success",
});
setSelectedCID(null);
setRealSearchKeyword("");
setSearchKeyword("");
setDialogVisited(false);
const dialog = document.getElementById(
"collection_dialog",
) as HTMLDialogElement;
dialog.close();
} else {
showToast({
message: res.message,
type: "error",
parent: document.getElementById("collection_dialog_content"),
});
}
});
};
return (
<>
<span
className={
"py-1 px-3 inline-flex items-center m-1 border border-base-300 bg-base-100 opacity-90 rounded-2xl hover:bg-base-200 transition-colors cursor-pointer select-none"
}
onClick={() => {
setDialogVisited(true);
const dialog = document.getElementById(
"collection_dialog",
) as HTMLDialogElement;
dialog.showModal();
}}
>
<MdOutlineFolderSpecial size={20} />
<span className={"ml-2 text-sm"}>{t("Collect")}</span>
</span>
<dialog id="collection_dialog" className="modal">
<div className="modal-box" id="collection_dialog_content">
<h3 className="font-bold text-lg mb-2">{t("Add to Collection")}</h3>
<input
type="text"
placeholder="Search"
className="input input-bordered w-full max-w-2xs mr-2"
value={searchKeyword}
onChange={(e) => delayedSetSearchKeyword(e.target.value)}
/>
{dialogVisited && (
<CollectionSelector
resourceId={rid}
keyword={realSearchKeyword}
seletedID={selectedCID}
selectCallback={(collection) => {
if (selectedCID === collection.id) {
setSelectedCID(null);
} else {
setSelectedCID(collection.id);
}
}}
key={realSearchKeyword}
/>
)}
<div className="modal-action">
<form method="dialog">
<Button className="btn">Close</Button>
</form>
<Button
className="btn-primary"
disabled={selectedCID == null}
onClick={handleAddToCollection}
>
{t("Add")}
</Button>
</div>
</div>
</dialog>
</>
);
}
function CollectionSelector({
resourceId,
keyword,
seletedID: selectedID,
selectCallback,
}: {
resourceId: number;
keyword: string;
seletedID?: number | null;
selectCallback: (collection: Collection) => void;
}) {
const [collections, setCollections] = useState<Collection[] | null>(null);
useEffect(() => {
setCollections(null);
network
.searchUserCollections(app.user!.username, keyword, resourceId)
.then((res) => {
if (res.success) {
setCollections(res.data! || []);
} else {
showToast({
message: res.message,
type: "error",
});
}
});
}, [keyword]);
if (collections == null) {
return (
<div className={"w-full"}>
<Loading />
</div>
);
}
return (
<div className="py-2 max-h-80 overflow-y-auto w-full overflow-x-clip">
{collections.map((collection) => {
return (
<div
className={`${selectedID === collection.id && "bg-base-200 shadow"} rounded-lg transition-all p-2 hover:bg-base-200 w-full overflow-ellipsis hover:cursor-pointer`}
key={collection.id}
onClick={() => {
selectCallback(collection);
}}
>
<input
type="checkbox"
className="checkbox checkbox-primary mr-2"
checked={selectedID === collection.id}
readOnly
/>
{collection.title}
</div>
);
})}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useSearchParams } from "react-router";
import { network } from "../network/network.ts";
import ResourcesView from "../components/resources_view.tsx";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
export default function SearchPage() {
const [params, _] = useSearchParams();

View File

@@ -3,7 +3,7 @@ import { ErrorAlert } from "../components/alert.tsx";
import ResourcesView from "../components/resources_view.tsx";
import { network } from "../network/network.ts";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { Tag } from "../network/models.ts";
import Button from "../components/button.tsx";
import Markdown from "react-markdown";

View File

@@ -1,5 +1,11 @@
import { useParams, useLocation, useNavigate } from "react-router";
import { CommentWithResource, RFile, User } from "../network/models";
import {
Collection,
CommentWithResource,
PageResponse,
RFile,
User,
} from "../network/models";
import { network } from "../network/network";
import showToast from "../components/toast";
import { useCallback, useEffect, useState } from "react";
@@ -9,13 +15,17 @@ import Pagination from "../components/pagination";
import { CommentTile } from "../components/comment_tile.tsx";
import Badge from "../components/badge.tsx";
import {
MdOutlineAdd,
MdOutlineArchive,
MdOutlineComment,
MdOutlinePhotoAlbum,
} from "react-icons/md";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { app } from "../app.ts";
import Markdown from "react-markdown";
import Button from "../components/button.tsx";
import { t } from "i18next";
import { Debounce } from "../utils/debounce.ts";
export default function UserPage() {
const [user, setUser] = useState<User | null>(null);
@@ -27,12 +37,12 @@ export default function UserPage() {
// 解码用户名,确保特殊字符被还原
const username = rawUsername ? decodeURIComponent(rawUsername) : "";
// 从 hash 中获取当前页面,默认为 resources
// 从 hash 中获取当前页面,默认为 collections
const getPageFromHash = useCallback(() => {
const hash = location.hash.slice(1); // 移除 # 号
if (hash === "comments") return 1;
if (hash === "files") return 2;
return 0; // 默认为 resources
const hashs = ["collections", "resources", "comments", "files"];
const index = hashs.indexOf(hash);
return index !== -1 ? index : 0; // 如果 hash 不在预定义的列表中,默认为 0
}, [location.hash]);
const [page, setPage] = useState(getPageFromHash());
@@ -44,8 +54,8 @@ export default function UserPage() {
// 更新 hash 的函数
const updateHash = (newPage: number) => {
const hashs = ["resources", "comments", "files"];
const newHash = hashs[newPage] || "resources";
const hashs = ["collections", "resources", "comments", "files"];
const newHash = hashs[newPage] || "collections";
if (location.hash.slice(1) !== newHash) {
navigate(`/user/${username}#${newHash}`, { replace: true });
}
@@ -93,27 +103,35 @@ export default function UserPage() {
className={`tab ${page === 0 ? "tab-active" : ""} `}
onClick={() => updateHash(0)}
>
Resources
Collections
</div>
<div
role="tab"
className={`tab ${page === 1 ? "tab-active" : ""}`}
className={`tab ${page === 1 ? "tab-active" : ""} `}
onClick={() => updateHash(1)}
>
Comments
Resources
</div>
<div
role="tab"
className={`tab ${page === 2 ? "tab-active" : ""}`}
onClick={() => updateHash(2)}
>
Comments
</div>
<div
role="tab"
className={`tab ${page === 3 ? "tab-active" : ""}`}
onClick={() => updateHash(3)}
>
Files
</div>
</div>
<div className="w-full">
{page === 0 && <UserResources user={user} />}
{page === 1 && <UserComments user={user} />}
{page === 2 && <UserFiles user={user} />}
{page === 0 && <Collections username={username} />}
{page === 1 && <UserResources user={user} />}
{page === 2 && <UserComments user={user} />}
{page === 3 && <UserFiles user={user} />}
</div>
<div className="h-16"></div>
</div>
@@ -365,3 +383,158 @@ function FilesList({
</>
);
}
function Collections({ username }: { username?: string }) {
const [searchKeyword, setSearchKeyword] = useState("");
const [realSearchKeyword, setRealSearchKeyword] = useState("");
const { t } = useTranslation();
const navigate = useNavigate();
const debounce = new Debounce(500);
const delayedSetSearchKeyword = (keyword: string) => {
setSearchKeyword(keyword);
debounce.run(() => {
setRealSearchKeyword(keyword);
});
};
return (
<>
<div className="flex m-4">
<input
type="text"
placeholder="Search"
className="input input-bordered w-full max-w-2xs mr-2"
value={searchKeyword}
onChange={(e) => delayedSetSearchKeyword(e.target.value)}
/>
<span className="flex-1" />
<button
className="btn btn-primary btn-soft"
onClick={() => {
navigate("/create-collection");
}}
>
<MdOutlineAdd size={20} className="inline-block mr-1" />
{t("Create")}
</button>
</div>
<CollectionsList
username={username}
keyword={realSearchKeyword}
key={realSearchKeyword}
/>
</>
);
}
async function getOrSearchUserCollections(
username: string,
keyword: string,
page: number,
): Promise<PageResponse<Collection>> {
if (keyword.trim() === "") {
return network.listUserCollections(username, page);
} else {
let res = await network.searchUserCollections(username, keyword);
return {
success: res.success,
data: res.data || [],
totalPages: 1,
message: res.message || "",
};
}
}
function CollectionsList({
username,
keyword,
}: {
username?: string;
keyword: string;
}) {
const [page, setPage] = useState(1);
const [maxPage, setMaxPage] = useState(1);
const [collections, setCollections] = useState<Collection[] | null>(null);
useEffect(() => {
if (!username) return;
setCollections(null);
getOrSearchUserCollections(username, keyword, page).then((res) => {
if (res.success) {
setCollections(res.data! || []);
setMaxPage(res.totalPages || 1);
} else {
showToast({
message: res.message,
type: "error",
});
}
});
}, [username, keyword, page]);
if (collections == null) {
return (
<div className={"w-full"}>
<Loading />
</div>
);
}
return (
<>
{collections.map((collection) => {
return <CollectionCard collection={collection} key={collection.id} />;
})}
{maxPage > 1 ? (
<div className={"w-full flex justify-center"}>
<Pagination page={page} setPage={setPage} totalPages={maxPage} />
</div>
) : null}
</>
);
}
function CollectionCard({ collection }: { collection: Collection }) {
const navigate = useNavigate();
return (
<div
className={
"card m-4 p-2 bg-base-100-tr82 shadow hover:shadow-md transition-shadow cursor-pointer"
}
onClick={() => {
navigate(`/collection/${collection.id}`);
}}
>
<h3 className={"card-title mx-2 mt-2"}>{collection.title}</h3>
<div className={"p-2 comment_tile"}>
<CollectionContent content={collection.article} />
</div>
<div className="flex">
<Badge className="badge-soft badge-primary text-xs mr-2">
<MdOutlinePhotoAlbum size={16} className="inline-block" />
{collection.resources_count} {t("Resources")}
</Badge>
</div>
</div>
);
}
function CollectionContent({ content }: { content: string }) {
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
if (!line.endsWith(" ")) {
// Ensure that each line ends with two spaces for Markdown to recognize it as a line break
lines[i] = line + " ";
}
}
content = lines.join("\n");
return <Markdown>{content}</Markdown>;
}