Compare commits

...

14 Commits

Author SHA1 Message Date
2288926e31 Update collection title. 2025-07-31 16:45:38 +08:00
a79c92f9e7 fix collection page. 2025-07-31 16:43:58 +08:00
5ef6b091e8 fix updating collection. 2025-07-31 16:39:25 +08:00
619dc01bf4 Add missing translations. 2025-07-31 16:32:19 +08:00
4eede5e76a hide the create collection button if the user is visiting another user's page. 2025-07-31 16:29:50 +08:00
f762e74e4d SEO for collection page. 2025-07-31 16:24:40 +08:00
63ebbebb02 SEO for collection page. 2025-07-31 16:23:55 +08:00
4ae7a19cc9 Add a button to create collection. 2025-07-31 16:11:47 +08:00
3359a5a9e4 fix resource details page. 2025-07-31 15:57:44 +08:00
eed2af4278 remove unused code. 2025-07-31 15:45:02 +08:00
08c70a0b52 feat: add collection 2025-07-31 15:41:15 +08:00
1e5b12f531 fix collection api. 2025-07-31 15:07:05 +08:00
724f96beb8 fix collection api. 2025-07-31 14:50:27 +08:00
fd7c3797ea Add resources_count to collection. 2025-07-31 14:08:38 +08:00
42 changed files with 1190 additions and 453 deletions

View File

@@ -12,8 +12,6 @@
"@tailwindcss/vite": "^4.1.5",
"axios": "^1.9.0",
"framer-motion": "^12.23.5",
"i18next": "^25.1.1",
"i18next-browser-languagedetector": "^8.1.0",
"masonic": "^4.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -3690,6 +3688,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.26.10"
},
@@ -3702,15 +3701,6 @@
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz",
"integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",

View File

@@ -19,8 +19,6 @@
"@tailwindcss/vite": "^4.1.5",
"axios": "^1.9.0",
"framer-motion": "^12.23.5",
"i18next": "^25.1.1",
"i18next-browser-languagedetector": "^8.1.0",
"masonic": "^4.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -15,29 +15,43 @@ import TagsPage from "./pages/tags_page.tsx";
import RandomPage from "./pages/random_page.tsx";
import ActivitiesPage from "./pages/activities_page.tsx";
import CommentPage from "./pages/comment_page.tsx";
import CreateCollectionPage from "./pages/create_collection_page.tsx";
import CollectionPage from "./pages/collection_page.tsx";
import { i18nData } from "./i18n.ts";
import { i18nContext } from "./utils/i18n.ts";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path={"/login"} element={<LoginPage />} />
<Route path={"/register"} element={<RegisterPage />} />
<Route element={<Navigator />}>
<Route path={"/"} element={<HomePage />} />
<Route path={"/publish"} element={<PublishPage />} />
<Route path={"/search"} element={<SearchPage />} />
<Route path={"/resources/:id"} element={<ResourcePage />} />
<Route path={"/manage"} element={<ManagePage />} />
<Route path={"/tag/:tag"} element={<TaggedResourcesPage />} />
<Route path={"/user/:username"} element={<UserPage />} />
<Route path={"/resource/edit/:rid"} element={<EditResourcePage />} />
<Route path={"/about"} element={<AboutPage />} />
<Route path={"/tags"} element={<TagsPage />} />
<Route path={"/random"} element={<RandomPage />} />
<Route path={"/activity"} element={<ActivitiesPage />} />
<Route path={"/comments/:id"} element={<CommentPage />} />
</Route>
</Routes>
</BrowserRouter>
<i18nContext.Provider value={i18nData}>
<BrowserRouter>
<Routes>
<Route path={"/login"} element={<LoginPage />} />
<Route path={"/register"} element={<RegisterPage />} />
<Route element={<Navigator />}>
<Route path={"/"} element={<HomePage />} />
<Route path={"/publish"} element={<PublishPage />} />
<Route path={"/search"} element={<SearchPage />} />
<Route path={"/resources/:id"} element={<ResourcePage />} />
<Route path={"/manage"} element={<ManagePage />} />
<Route path={"/tag/:tag"} element={<TaggedResourcesPage />} />
<Route path={"/user/:username"} element={<UserPage />} />
<Route
path={"/resource/edit/:rid"}
element={<EditResourcePage />}
/>
<Route path={"/about"} element={<AboutPage />} />
<Route path={"/tags"} element={<TagsPage />} />
<Route path={"/random"} element={<RandomPage />} />
<Route path={"/activity"} element={<ActivitiesPage />} />
<Route path={"/comments/:id"} element={<CommentPage />} />
<Route
path={"/create-collection"}
element={<CreateCollectionPage />}
/>
<Route path={"/collection/:id"} element={<CollectionPage />} />
</Route>
</Routes>
</BrowserRouter>
</i18nContext.Provider>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import showToast from "./toast";
import { network } from "../network/network";
import { InfoAlert } from "./alert";

View File

@@ -1,4 +1,4 @@
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { useNavigate } from "react-router";
import { MdOutlineComment } from "react-icons/md";
import { Comment } from "../network/models";

View File

@@ -1,5 +1,5 @@
import { MdAdd } from "react-icons/md";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { network } from "../network/network.ts";
import showToast from "./toast.ts";
import { useState } from "react";

View File

@@ -1,4 +1,4 @@
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
export default function Loading() {
const { t } = useTranslation();

View File

@@ -3,7 +3,7 @@ import { network } from "../network/network.ts";
import { useNavigate, useOutlet } from "react-router";
import { createContext, useContext, useEffect, useState } from "react";
import { MdArrowUpward, MdOutlinePerson, MdSearch } from "react-icons/md";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import UploadingSideBar from "./uploading_side_bar.tsx";
import { ThemeSwitcher } from "./theme_switcher.tsx";
import { IoLogoGithub } from "react-icons/io";

View File

@@ -2,8 +2,15 @@ import { Resource } from "../network/models.ts";
import { network } from "../network/network.ts";
import { useNavigate } from "react-router";
import Badge from "./badge.tsx";
import React from "react";
export default function ResourceCard({ resource }: { resource: Resource }) {
export default function ResourceCard({
resource,
action,
}: {
resource: Resource;
action?: React.ReactNode;
}) {
const navigate = useNavigate();
let tags = resource.tags;
@@ -58,6 +65,8 @@ export default function ResourceCard({ resource }: { resource: Resource }) {
</div>
<div className="w-2"></div>
<div className="text-sm">{resource.author.username}</div>
<div className="flex-1"></div>
{action}
</div>
</div>
</div>

View File

@@ -9,9 +9,11 @@ import { useAppContext } from "./AppContext.tsx";
export default function ResourcesView({
loader,
storageKey,
actionBuilder,
}: {
loader: (page: number) => Promise<PageResponse<Resource>>;
storageKey?: string;
actionBuilder?: (resource: Resource) => React.ReactNode;
}) {
const [data, setData] = useState<Resource[]>([]);
const pageRef = useRef(1);
@@ -54,7 +56,8 @@ export default function ResourcesView({
isLoadingRef.current = false;
pageRef.current = pageRef.current + 1;
totalPagesRef.current = res.totalPages ?? 1;
setData((prev) => [...prev, ...res.data!]);
let data = res.data ?? [];
setData((prev) => [...prev, ...data]);
}
}, [loader]);
@@ -71,7 +74,13 @@ export default function ResourcesView({
columnWidth={300}
items={data}
render={(e) => {
return <ResourceCard resource={e.data} key={e.data.id} />;
return (
<ResourceCard
resource={e.data}
key={e.data.id}
action={actionBuilder?.(e.data)}
/>
);
}}
></Masonry>
{pageRef.current <= totalPagesRef.current && <Loading />}

View File

@@ -1,12 +1,13 @@
import { Tag } from "../network/models.ts";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { network } from "../network/network.ts";
import { LuInfo } from "react-icons/lu";
import { MdSearch } from "react-icons/md";
import Button from "./button.tsx";
import Input, { TextArea } from "./input.tsx";
import { ErrorAlert } from "./alert.tsx";
import { Debounce } from "../utils/debounce.ts";
export default function TagInput({
onAdd,
@@ -177,31 +178,6 @@ export default function TagInput({
);
}
class Debounce {
private timer: number | null = null;
private readonly delay: number;
constructor(delay: number) {
this.delay = delay;
}
run(callback: () => void) {
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
callback();
}, this.delay);
}
cancel() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}
export function QuickAddTagDialog({
onAdded,
}: {

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "../utils/i18n";
import { MdPalette } from "react-icons/md";
interface ThemeOption {

View File

@@ -10,7 +10,7 @@ export default function showToast({
type = type || "info";
const div = document.createElement("div");
div.innerHTML = `
<div class="toast toast-center">
<div class="toast toast-center z-10">
<div class="alert shadow ${type === "success" && "alert-success"} ${type === "error" && "alert-error"} ${type === "warning" && "alert-warning"} ${type === "info" && "alert-info"}">
<span>${message}</span>
</div>

View File

@@ -1,243 +1,4 @@
export const i18nData = {
"en": {
translation: {
"My Profile": "My Profile",
"Publish": "Publish",
"Log out": "Log out",
"Are you sure you want to log out?": "Are you sure you want to log out?",
"Cancel": "Cancel",
"Confirm": "Confirm",
"Search": "Search",
"Login": "Login",
"Register": "Register",
"Username": "Username",
"Password": "Password",
"Confirm Password": "Confirm Password",
"Username and password cannot be empty":
"Username and password cannot be empty",
"Passwords do not match": "Passwords do not match",
"Continue": "Continue",
"Don't have an account? Register": "Don't have an account? Register",
"Already have an account? Login": "Already have an account? Login",
"Publish Resource": "Publish Resource",
"All information can be modified after publishing":
"All information can be modified after publishing",
"Title": "Title",
"Alternative Titles": "Alternative Titles",
"Add Alternative Title": "Add Alternative Title",
"Tags": "Tags",
"Description": "Description",
"Use Markdown format": "Use Markdown format",
"Images": "Images",
"Images will not be displayed automatically, you need to reference them in the description":
"Images will not be displayed automatically, you need to reference them in the description",
"Preview": "Preview",
"Link": "Link",
"Action": "Action",
"Upload Image": "Upload Image",
"Error": "Error",
"Title cannot be empty": "Title cannot be empty",
"Alternative title cannot be empty": "Alternative title cannot be empty",
"At least one tag required": "At least one tag required",
"Description cannot be empty": "Description cannot be empty",
"Loading": "Loading",
"Enter a search keyword to continue":
"Enter a search keyword to continue",
"My Info": "My Info",
"Server": "Server",
// Management page translations
"Settings": "Settings",
"Manage": "Manage",
"Storage": "Storage",
"Users": "Users",
"You are not logged in. Please log in to access this page.":
"You are not logged in. Please log in to access this page.",
"You are not authorized to access this page.":
"You are not authorized to access this page.",
// Storage management
"No storage found. Please create a new storage.":
"No storage found. Please create a new storage.",
"Name": "Name",
"Created At": "Created At",
"Actions": "Actions",
"Delete Storage": "Delete Storage",
"Are you sure you want to delete this storage? This action cannot be undone.":
"Are you sure you want to delete this storage? This action cannot be undone.",
"Delete": "Delete",
"Storage deleted successfully": "Storage deleted successfully",
"New Storage": "New Storage",
"Type": "Type",
"Local": "Local",
"S3": "S3",
"Path": "Path",
"Max Size (MB)": "Max Size (MB)",
"Endpoint": "Endpoint",
"Access Key ID": "Access Key ID",
"Secret Access Key": "Secret Access Key",
"Bucket Name": "Bucket Name",
"All fields are required": "All fields are required",
"Storage created successfully": "Storage created successfully",
"Close": "Close",
"Submit": "Submit",
// User management
"Admin": "Admin",
"Can Upload": "Can Upload",
"Yes": "Yes",
"No": "No",
"Delete User": "Delete User",
"Are you sure you want to delete user":
"Are you sure you want to delete user",
"This action cannot be undone.": "This action cannot be undone.",
"User deleted successfully": "User deleted successfully",
"Set as user": "Set as user",
"Set as admin": "Set as admin",
"Remove upload permission": "Remove upload permission",
"Grant upload permission": "Grant upload permission",
"User set as admin successfully": "User set as admin successfully",
"User set as user successfully": "User set as user successfully",
"User set as upload permission successfully":
"User set as upload permission successfully",
"User removed upload permission successfully":
"User removed upload permission successfully",
// Resource details page
"Resource ID is required": "Resource ID is required",
"Files": "Files",
"Comments": "Comments",
"Upload": "Upload",
"Create File": "Create File",
"Please select a file type": "Please select a file type",
"Please fill in all fields": "Please fill in all fields",
"File created successfully": "File created successfully",
"Successfully create uploading task.":
"Successfully create uploading task.",
"Please select a file and storage": "Please select a file and storage",
"Redirect": "Redirect",
"User who click the file will be redirected to the URL":
"User who click the file will be redirected to the URL",
"File Name": "File Name",
"URL": "URL",
"Upload a file to server, then the file will be moved to the selected storage.":
"Upload a file to server, then the file will be moved to the selected storage.",
"Select Storage": "Select Storage",
"Resource Details": "Resource Details",
"Delete Resource": "Delete Resource",
"Are you sure you want to delete the resource":
"Are you sure you want to delete the resource",
"Delete File": "Delete File",
"Are you sure you want to delete the file":
"Are you sure you want to delete the file",
// 评论删除相关
"Delete Comment": "Delete Comment",
"Are you sure you want to delete this comment? This action cannot be undone.":
"Are you sure you want to delete this comment? This action cannot be undone.",
"Comment deleted successfully": "Comment deleted successfully",
// New translations
"Change Avatar": "Change Avatar",
"Change Username": "Change Username",
"Change Password": "Change Password",
"New Username": "New Username",
"Enter new username": "Enter new username",
"Save": "Save",
"Current Password": "Current Password",
"Enter current password": "Enter current password",
"New Password": "New Password",
"Enter new password": "Enter new password",
"Confirm New Password": "Confirm New Password",
"Confirm new password": "Confirm new password",
"Avatar changed successfully": "Avatar changed successfully",
"Username changed successfully": "Username changed successfully",
"Password changed successfully": "Password changed successfully",
// Manage server config page translations
"Update server config successfully": "Update server config successfully",
"Max uploading size (MB)": "Max uploading size (MB)",
"Max file size (MB)": "Max file size (MB)",
"Max downloads per day for single IP":
"Max downloads per day for single IP",
"Allow register": "Allow register",
"Server name": "Server name",
"Server description": "Server description",
"Cloudflare Turnstile Site Key": "Cloudflare Turnstile Site Key",
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile Secret Key",
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.",
"The first image will be used as the cover image":
"The first image will be used as the cover image",
"Please enter a search keyword": "Please enter a search keyword",
"Searching...": "Searching...",
"Create Tag": "Create Tag",
"Search Tags": "Search Tags",
"Edit Resource": "Edit Resource",
"Change Bio": "Change Bio",
"About this site": "About this site",
"Tag not found": "Tag not found",
"Description is too long": "Description is too long",
"Unknown error": "Unknown error",
"Edit": "Edit",
"Edit Tag": "Edit Tag",
"Set the description of the tag.": "Set the description of the tag.",
"Tag: ": "Tag: ",
"Select a Order": "Select a Order",
"Time Ascending": "Time Ascending",
"Time Descending": "Time Descending",
"Views Ascending": "Views Ascending",
"Views Descending": "Views Descending",
"Downloads Ascending": "Downloads Ascending",
"Downloads Descending": "Downloads Descending",
"File Url": "File Url",
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
"Provide a file url for the server to download, and the file will be moved to the selected storage.",
"Verifying your request": "Verifying your request",
"Please check your network if the verification takes too long or the captcha does not appear.":
"Please check your network if the verification takes too long or the captcha does not appear.",
"About": "About",
"Home": "Home",
"Other": "Other",
"Quick Add": "Quick Add",
"Add Tags": "Add Tags",
"Input tags separated by separator.":
"Input tags separated by separator.",
"If the tag does not exist, it will be created automatically.":
"If the tag does not exist, it will be created automatically.",
"Optionally, you can specify a type for the new tags.":
"Optionally, you can specify a type for the new tags.",
"Upload Clipboard Image": "Upload Clipboard Image",
"Show more": "Show more",
"Show less": "Show less",
"You need to log in to comment": "You need to log in to comment",
// Color Scheme Translation
"Light Pink": "Light Pink",
"Ocean Breeze": "Ocean Breeze",
"Mint Leaf": "Mint Leaf",
"Golden Glow": "Golden Glow",
"Random": "Random",
// Activity Page
"Activity": "Activity",
"Published a resource": "Published a resource",
"Updated a resource": "Updated a resource",
"Commented on a resource": "Commented on a resource",
"Comment": "Comment",
"Replies": "Replies",
"Reply": "Reply",
"Commented on": "Commented on",
"Write down your comment": "Write down your comment",
"Click to view more": "Click to view more",
"Comment Details": "Comment Details",
"Posted a comment": "Posted a comment",
"Resources": "Resources",
"Added a new file": "Added a new file",
"Data from": "Data from",
},
},
"zh-CN": {
translation: {
"My Profile": "我的资料",
@@ -466,6 +227,30 @@ export const i18nData = {
"Added a new file": "添加了新文件",
"Data from": "数据来源",
"Collections": "合集",
"Create Collection": "创建合集",
"Create": "创建",
"Image size exceeds 5MB limit": "图片大小超过5MB限制",
"Title and description cannot be empty": "标题和描述不能为空",
"Collection created successfully": "合集创建成功",
"Collection deleted successfully": "合集删除成功",
"Remove Resource": "移除资源",
"Are you sure you want to remove this resource?": "您确定要移除此资源吗?",
"Resource deleted successfully": "资源移除成功",
"Edit Collection": "编辑合集",
"Edit successful": "编辑成功",
"Failed to save changes": "保存更改失败",
"Collect": "收藏",
"Add to Collection": "添加到合集",
"Add": "添加",
"Resource added to collection successfully": "资源已成功添加到合集",
"No patches found for this VN.": "未找到该作品的补丁。",
"Update File Info": "更新文件信息",
"File info updated successfully": "文件信息更新成功",
"File URL": "文件URL",
"You do not have permission to upload files, please contact the administrator.": "您没有上传文件的权限,请联系管理员。",
},
},
"zh-TW": {
@@ -696,6 +481,30 @@ export const i18nData = {
"Added a new file": "添加了新檔案",
"Data from": "數據來源",
"Collections": "合集",
"Create Collection": "創建合集",
"Create": "創建",
"Image size exceeds 5MB limit": "圖片大小超過5MB限制",
"Title and description cannot be empty": "標題和描述不能為空",
"Collection created successfully": "合集創建成功",
"Collection deleted successfully": "合集刪除成功",
"Remove Resource": "移除資源",
"Are you sure you want to remove this resource?": "您確定要移除此資源嗎?",
"Resource deleted successfully": "資源移除成功",
"Edit Collection": "編輯合集",
"Edit successful": "編輯成功",
"Failed to save changes": "保存更改失敗",
"Collect": "收藏",
"Add to Collection": "添加到合集",
"Add": "添加",
"Resource added to collection successfully": "資源已成功添加到合集",
"No patches found for this VN.": "未找到該作品的補丁。",
"Update File Info": "更新檔案信息",
"File info updated successfully": "檔案信息更新成功",
"File URL": "檔案URL",
"You do not have permission to upload files, please contact the administrator.": "您沒有上傳檔案的權限,請聯繫管理員。",
},
},
};

View File

@@ -2,29 +2,12 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./app.tsx";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { i18nData } from "./i18n.ts";
import AppContext from "./components/AppContext.tsx";
i18n
.use(initReactI18next)
.use(LanguageDetector)
.init({
resources: i18nData,
debug: true,
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
})
.then(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppContext>
<App />
</AppContext>
</StrictMode>,
);
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppContext>
<App />
</AppContext>
</StrictMode>,
);

View File

@@ -197,5 +197,6 @@ export interface Collection {
title: string;
article: string;
user: User;
resources_count: number;
images: Image[];
}

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,352 @@
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;
}
const prefetchData = app.getPreFetchData();
if (prefetchData?.collection?.id === idInt) {
setCollection(prefetchData.collection);
return;
}
network.getCollection(idInt).then((res) => {
if (res.success) {
setCollection(res.data!);
} else {
showToast({
type: "error",
message: res.message || "Failed to load collection",
});
}
});
}, [id]);
useEffect(() => {
if (!collection) return;
document.title = collection.title;
}, [collection])
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";
@@ -25,6 +26,7 @@ import "../markdown.css";
import Loading from "../components/loading.tsx";
import {
MdAdd,
MdOutlineAdd,
MdOutlineArchive,
MdOutlineArticle,
MdOutlineComment,
@@ -32,13 +34,14 @@ import {
MdOutlineDelete,
MdOutlineDownload,
MdOutlineEdit,
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 && 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,193 @@ 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 navigate = useNavigate();
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"),
});
}
});
};
if (!app.isLoggedIn()) {
return <></>
}
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">
<Button className="btn-ghost" onClick={() => {
const dialog = document.getElementById(
"collection_dialog",
) as HTMLDialogElement;
dialog.close();
navigate("/create-collection");
}}>
<div className="flex items-center">
<MdOutlineAdd size={20} className={"inline-block mr-1"} />
{t("Create")}
</div>
</Button>
<span className="flex-1"></span>
<form method="dialog">
<Button className="btn">{t("Cancel")}</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,15 @@ 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 { Debounce } from "../utils/debounce.ts";
export default function UserPage() {
const [user, setUser] = useState<User | null>(null);
@@ -24,15 +32,17 @@ export default function UserPage() {
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation();
// 解码用户名,确保特殊字符被还原
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
{t("Collections")}
</div>
<div
role="tab"
className={`tab ${page === 1 ? "tab-active" : ""}`}
className={`tab ${page === 1 ? "tab-active" : ""} `}
onClick={() => updateHash(1)}
>
Comments
{t("Resources")}
</div>
<div
role="tab"
className={`tab ${page === 2 ? "tab-active" : ""}`}
onClick={() => updateHash(2)}
>
Files
{t("Comments")}
</div>
<div
role="tab"
className={`tab ${page === 3 ? "tab-active" : ""}`}
onClick={() => updateHash(3)}
>
{t("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,160 @@ 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" />
{username == app.user?.username && <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();
const { t } = useTranslation();
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>;
}

View File

@@ -0,0 +1,24 @@
export class Debounce {
private timer: number | null = null;
private readonly delay: number;
constructor(delay: number) {
this.delay = delay;
}
run(callback: () => void) {
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
callback();
}, this.delay);
}
cancel() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}

View File

@@ -0,0 +1,19 @@
import { createContext, useContext } from "react";
function t(data: any, language: string) {
return (key: string) => {
return data[language]?.["translation"]?.[key] || key;
};
}
export const i18nContext = createContext<any>({});
export function useTranslation() {
const data = useContext(i18nContext);
const userLang = navigator.language;
console.log("Using language:", userLang);
return {
t: t(data, userLang),
};
}

View File

@@ -35,11 +35,14 @@ func CreateCollection(uid uint, title string, article string, images []uint) (mo
func UpdateCollection(id uint, title string, article string, images []uint) error {
return db.Transaction(func(tx *gorm.DB) error {
collection := &model.Collection{
Model: gorm.Model{
ID: id,
},
Title: title,
Article: article,
}
if err := tx.Model(collection).Where("id = ?", id).Updates(collection).Error; err != nil {
if err := tx.Model(collection).Updates(collection).Error; err != nil {
return err
}
@@ -80,7 +83,11 @@ func AddResourceToCollection(collectionID uint, resourceID uint) error {
collection := &model.Collection{}
if err := tx.Where("id = ?", collectionID).First(collection).Error; err != nil {
return err
return model.NewRequestError("Invalid collection ID")
}
if err := tx.Model(&model.Resource{}).Where("id = ?", resourceID).First(&model.Resource{}).Error; err != nil {
return model.NewRequestError("Invalid resource ID")
}
if err := tx.Model(collection).Association("Resources").Append(&model.Resource{
@@ -91,6 +98,10 @@ func AddResourceToCollection(collectionID uint, resourceID uint) error {
return err
}
if err := tx.Model(collection).UpdateColumn("resources_count", gorm.Expr("resources_count + ?", 1)).Error; err != nil {
return err
}
return nil
})
}
@@ -100,7 +111,11 @@ func RemoveResourceFromCollection(collectionID uint, resourceID uint) error {
collection := &model.Collection{}
if err := tx.Where("id = ?", collectionID).First(collection).Error; err != nil {
return err
return model.NewRequestError("Invalid collection ID")
}
if err := tx.Model(&model.Resource{}).Where("id = ?", resourceID).First(&model.Resource{}).Error; err != nil {
return model.NewRequestError("Invalid resource ID")
}
if err := tx.Model(collection).Association("Resources").Delete(&model.Resource{
@@ -111,13 +126,17 @@ func RemoveResourceFromCollection(collectionID uint, resourceID uint) error {
return err
}
if err := tx.Model(collection).UpdateColumn("resources_count", gorm.Expr("resources_count - ?", 1)).Error; err != nil {
return err
}
return nil
})
}
func GetCollectionByID(id uint) (*model.Collection, error) {
collection := &model.Collection{}
if err := db.Preload("Images").Preload("Resources").Where("id = ?", id).First(collection).Error; err != nil {
if err := db.Preload("Images").Preload("Resources").Preload("User").Where("id = ?", id).First(collection).Error; err != nil {
return nil, err
}
return collection, nil

View File

@@ -66,6 +66,7 @@ func ErrorHandler(c fiber.Ctx) error {
})
}
}
log.Error("Internal Server Error: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(model.Response[any]{
Success: false,
Data: nil,

View File

@@ -133,6 +133,28 @@ func serveIndexHtml(c fiber.Ctx) error {
preFetchData = url.PathEscape(string(preFetchDataJson))
}
}
} else if strings.HasPrefix(path, "/collection/") {
collectionIDStr := strings.TrimPrefix(path, "/collection/")
collectionID, err := strconv.Atoi(collectionIDStr)
if err == nil {
coll, err := service.GetCollectionByID(uint(collectionID))
if err == nil {
title = coll.Title
description = utils.ArticleToDescription(coll.Article, 256)
if len(coll.Images) > 0 {
preview = fmt.Sprintf("%s/api/image/%d", serverBaseURL, coll.Images[0].ID)
} else {
preview = fmt.Sprintf("%s/api/avatar/%d", serverBaseURL, coll.User.ID)
}
if len(coll.Images) > 0 {
preview = fmt.Sprintf("%s/api/image/%d", serverBaseURL, coll.Images[0].ID)
}
preFetchDataJson, _ := json.Marshal(map[string]interface{}{
"collection": coll,
})
preFetchData = url.PathEscape(string(preFetchDataJson))
}
}
}
content = strings.ReplaceAll(content, "{{SiteName}}", siteName)

View File

@@ -4,28 +4,31 @@ import "gorm.io/gorm"
type Collection struct {
gorm.Model
Title string `gorm:"not null"`
Article string `gorm:"not null"`
UserID uint `gorm:"not null"`
User User `gorm:"foreignKey:UserID;references:ID"`
Images []Image `gorm:"many2many:collection_images;"`
Resources []Resource `gorm:"many2many:collection_resources;"`
Title string `gorm:"not null"`
Article string `gorm:"not null"`
UserID uint `gorm:"not null"`
User User `gorm:"foreignKey:UserID;references:ID"`
ResourcesCount int `gorm:"default:0"`
Images []Image `gorm:"many2many:collection_images;"`
Resources []Resource `gorm:"many2many:collection_resources;"`
}
type CollectionView struct {
ID uint `json:"id"`
Title string `json:"title"`
Article string `json:"article"`
User UserView `json:"user"`
Images []Image `json:"images"`
ID uint `json:"id"`
Title string `json:"title"`
Article string `json:"article"`
User UserView `json:"user"`
ResourcesCount int `json:"resources_count"`
Images []Image `json:"images"`
}
func (c Collection) ToView() *CollectionView {
return &CollectionView{
ID: c.ID,
Title: c.Title,
Article: c.Article,
User: c.User.ToView(),
Images: c.Images,
ID: c.ID,
Title: c.Title,
Article: c.Article,
User: c.User.ToView(),
ResourcesCount: c.ResourcesCount,
Images: c.Images,
}
}

View File

@@ -1,7 +1,6 @@
package service
import (
"errors"
"nysoure/server/dao"
"nysoure/server/model"
)
@@ -9,7 +8,7 @@ import (
// Create a new collection.
func CreateCollection(uid uint, title, article string, host string) (*model.CollectionView, error) {
if uid == 0 || title == "" || article == "" {
return nil, errors.New("invalid parameters")
return nil, model.NewRequestError("invalid parameters")
}
c, err := dao.CreateCollection(uid, title, article, findImagesInContent(article, host))
if err != nil {
@@ -22,21 +21,21 @@ func CreateCollection(uid uint, title, article string, host string) (*model.Coll
// Update an existing collection with user validation.
func UpdateCollection(uid, id uint, title, article string, host string) error {
if uid == 0 || id == 0 || title == "" || article == "" {
return errors.New("invalid parameters")
return model.NewRequestError("invalid parameters")
}
collection, err := dao.GetCollectionByID(id)
if err != nil {
return err
}
if collection.UserID != uid {
return errors.New("user does not have permission to update this collection")
return model.NewUnAuthorizedError("user does not have permission to update this collection")
}
return dao.UpdateCollection(id, title, article, findImagesInContent(article, host))
}
// Delete a collection by ID.
func DeleteCollection(uint, id uint) error {
user, err := dao.GetUserByID(id)
func DeleteCollection(uid, id uint) error {
user, err := dao.GetUserByID(uid)
if err != nil {
return err
}
@@ -47,7 +46,7 @@ func DeleteCollection(uint, id uint) error {
}
if user.ID != collection.UserID && !user.IsAdmin {
return errors.New("user does not have permission to delete this collection")
return model.NewUnAuthorizedError("user does not have permission to delete this collection")
}
return dao.DeleteCollection(id)
@@ -56,14 +55,14 @@ func DeleteCollection(uint, id uint) error {
// Add a resource to a collection with user validation.
func AddResourceToCollection(uid, collectionID, resourceID uint) error {
if uid == 0 || collectionID == 0 || resourceID == 0 {
return errors.New("invalid parameters")
return model.NewRequestError("invalid parameters")
}
collection, err := dao.GetCollectionByID(collectionID)
if err != nil {
return err
}
if collection.UserID != uid {
return errors.New("user does not have permission to modify this collection")
return model.NewUnAuthorizedError("user does not have permission to modify this collection")
}
return dao.AddResourceToCollection(collectionID, resourceID)
}
@@ -71,14 +70,14 @@ func AddResourceToCollection(uid, collectionID, resourceID uint) error {
// Remove a resource from a collection with user validation.
func RemoveResourceFromCollection(uid, collectionID, resourceID uint) error {
if uid == 0 || collectionID == 0 || resourceID == 0 {
return errors.New("invalid parameters")
return model.NewRequestError("invalid parameters")
}
collection, err := dao.GetCollectionByID(collectionID)
if err != nil {
return err
}
if collection.UserID != uid {
return errors.New("user does not have permission to modify this collection")
return model.NewUnAuthorizedError("user does not have permission to modify this collection")
}
return dao.RemoveResourceFromCollection(collectionID, resourceID)
}
@@ -86,7 +85,7 @@ func RemoveResourceFromCollection(uid, collectionID, resourceID uint) error {
// Get a collection by ID.
func GetCollectionByID(id uint) (*model.CollectionView, error) {
if id == 0 {
return nil, errors.New("invalid collection id")
return nil, model.NewRequestError("invalid collection id")
}
c, err := dao.GetCollectionByID(id)
if err != nil {
@@ -98,7 +97,7 @@ func GetCollectionByID(id uint) (*model.CollectionView, error) {
// List collections of a user with pagination.
func ListUserCollections(username string, page int) ([]*model.CollectionView, int64, error) {
if username == "" || page < 1 {
return nil, 0, errors.New("invalid parameters")
return nil, 0, model.NewRequestError("invalid parameters")
}
user, err := dao.GetUserByUsername(username)
if err != nil {
@@ -119,7 +118,7 @@ func ListUserCollections(username string, page int) ([]*model.CollectionView, in
// List resources in a collection with pagination.
func ListCollectionResources(collectionID uint, page int) ([]*model.ResourceView, int64, error) {
if collectionID == 0 || page < 1 {
return nil, 0, errors.New("invalid parameters")
return nil, 0, model.NewRequestError("invalid parameters")
}
resources, total, err := dao.ListCollectionResources(collectionID, page, pageSize)
if err != nil {
@@ -137,7 +136,7 @@ func ListCollectionResources(collectionID uint, page int) ([]*model.ResourceView
// excludedRID: if >0, only return collections not containing this resource.
func SearchUserCollections(username string, keyword string, excludedRID uint) ([]*model.CollectionView, error) {
if username == "" {
return nil, errors.New("invalid parameters")
return nil, model.NewRequestError("invalid parameters")
}
user, err := dao.GetUserByUsername(username)
if err != nil {