mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 12:17:24 +00:00
Compare commits
14 Commits
40fb3a93b6
...
2288926e31
Author | SHA1 | Date | |
---|---|---|---|
2288926e31 | |||
a79c92f9e7 | |||
5ef6b091e8 | |||
619dc01bf4 | |||
4eede5e76a | |||
f762e74e4d | |||
63ebbebb02 | |||
4ae7a19cc9 | |||
3359a5a9e4 | |||
eed2af4278 | |||
08c70a0b52 | |||
1e5b12f531 | |||
724f96beb8 | |||
fd7c3797ea |
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -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",
|
||||
|
@@ -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",
|
||||
|
@@ -15,9 +15,14 @@ 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 (
|
||||
<i18nContext.Provider value={i18nData}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path={"/login"} element={<LoginPage />} />
|
||||
@@ -30,14 +35,23 @@ export default function App() {
|
||||
<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={"/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>
|
||||
);
|
||||
}
|
||||
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
|
||||
export default function Loading() {
|
||||
const { t } = useTranslation();
|
||||
|
@@ -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";
|
||||
|
@@ -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>
|
||||
|
@@ -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 />}
|
||||
|
@@ -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,
|
||||
}: {
|
||||
|
@@ -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 {
|
||||
|
@@ -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>
|
||||
|
@@ -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.": "您沒有上傳檔案的權限,請聯繫管理員。",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@@ -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(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<AppContext>
|
||||
<App />
|
||||
</AppContext>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
);
|
||||
|
@@ -197,5 +197,6 @@ export interface Collection {
|
||||
title: string;
|
||||
article: string;
|
||||
user: User;
|
||||
resources_count: number;
|
||||
images: Image[];
|
||||
}
|
@@ -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";
|
||||
|
352
frontend/src/pages/collection_page.tsx
Normal file
352
frontend/src/pages/collection_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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));
|
||||
@@ -118,7 +120,10 @@ export default function CommentPage() {
|
||||
<div className="flex items-center">
|
||||
<div className="avatar">
|
||||
<div className="w-6 rounded-full">
|
||||
<img src={network.getUserAvatar(comment.user)} alt={"avatar"} />
|
||||
<img
|
||||
src={network.getUserAvatar(comment.user)}
|
||||
alt={"avatar"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2"></div>
|
||||
|
138
frontend/src/pages/create_collection_page.tsx
Normal file
138
frontend/src/pages/create_collection_page.tsx
Normal 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) => ``).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>
|
||||
);
|
||||
}
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -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();
|
||||
|
@@ -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";
|
||||
|
@@ -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"}
|
||||
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -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";
|
||||
|
@@ -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() {
|
||||
|
@@ -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,9 +217,8 @@ export default function ResourcePage() {
|
||||
</div>
|
||||
</button>
|
||||
<Tags tags={resource.tags} />
|
||||
{resource.links && (
|
||||
<p className={"px-3 mt-2"}>
|
||||
{resource.links.map((l) => {
|
||||
{resource.links && resource.links.map((l) => {
|
||||
return (
|
||||
<a href={l.url} target={"_blank"}>
|
||||
<span
|
||||
@@ -233,8 +236,8 @@ export default function ResourcePage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
@@ -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();
|
||||
|
@@ -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";
|
||||
|
@@ -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>;
|
||||
}
|
||||
|
24
frontend/src/utils/debounce.ts
Normal file
24
frontend/src/utils/debounce.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
19
frontend/src/utils/i18n.ts
Normal file
19
frontend/src/utils/i18n.ts
Normal 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),
|
||||
};
|
||||
}
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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)
|
||||
|
@@ -8,6 +8,7 @@ type Collection struct {
|
||||
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;"`
|
||||
}
|
||||
@@ -17,6 +18,7 @@ type CollectionView struct {
|
||||
Title string `json:"title"`
|
||||
Article string `json:"article"`
|
||||
User UserView `json:"user"`
|
||||
ResourcesCount int `json:"resources_count"`
|
||||
Images []Image `json:"images"`
|
||||
}
|
||||
|
||||
@@ -26,6 +28,7 @@ func (c Collection) ToView() *CollectionView {
|
||||
Title: c.Title,
|
||||
Article: c.Article,
|
||||
User: c.User.ToView(),
|
||||
ResourcesCount: c.ResourcesCount,
|
||||
Images: c.Images,
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user