mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 12:17:24 +00:00
feat: add collection
This commit is contained in:
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -12,8 +12,6 @@
|
|||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"framer-motion": "^12.23.5",
|
"framer-motion": "^12.23.5",
|
||||||
"i18next": "^25.1.1",
|
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
|
||||||
"masonic": "^4.1.0",
|
"masonic": "^4.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -3690,6 +3688,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.26.10"
|
"@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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
@@ -19,8 +19,6 @@
|
|||||||
"@tailwindcss/vite": "^4.1.5",
|
"@tailwindcss/vite": "^4.1.5",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"framer-motion": "^12.23.5",
|
"framer-motion": "^12.23.5",
|
||||||
"i18next": "^25.1.1",
|
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
|
||||||
"masonic": "^4.1.0",
|
"masonic": "^4.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
@@ -15,29 +15,43 @@ import TagsPage from "./pages/tags_page.tsx";
|
|||||||
import RandomPage from "./pages/random_page.tsx";
|
import RandomPage from "./pages/random_page.tsx";
|
||||||
import ActivitiesPage from "./pages/activities_page.tsx";
|
import ActivitiesPage from "./pages/activities_page.tsx";
|
||||||
import CommentPage from "./pages/comment_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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<i18nContext.Provider value={i18nData}>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Routes>
|
||||||
<Route path={"/register"} element={<RegisterPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
<Route element={<Navigator />}>
|
<Route path={"/register"} element={<RegisterPage />} />
|
||||||
<Route path={"/"} element={<HomePage />} />
|
<Route element={<Navigator />}>
|
||||||
<Route path={"/publish"} element={<PublishPage />} />
|
<Route path={"/"} element={<HomePage />} />
|
||||||
<Route path={"/search"} element={<SearchPage />} />
|
<Route path={"/publish"} element={<PublishPage />} />
|
||||||
<Route path={"/resources/:id"} element={<ResourcePage />} />
|
<Route path={"/search"} element={<SearchPage />} />
|
||||||
<Route path={"/manage"} element={<ManagePage />} />
|
<Route path={"/resources/:id"} element={<ResourcePage />} />
|
||||||
<Route path={"/tag/:tag"} element={<TaggedResourcesPage />} />
|
<Route path={"/manage"} element={<ManagePage />} />
|
||||||
<Route path={"/user/:username"} element={<UserPage />} />
|
<Route path={"/tag/:tag"} element={<TaggedResourcesPage />} />
|
||||||
<Route path={"/resource/edit/:rid"} element={<EditResourcePage />} />
|
<Route path={"/user/:username"} element={<UserPage />} />
|
||||||
<Route path={"/about"} element={<AboutPage />} />
|
<Route
|
||||||
<Route path={"/tags"} element={<TagsPage />} />
|
path={"/resource/edit/:rid"}
|
||||||
<Route path={"/random"} element={<RandomPage />} />
|
element={<EditResourcePage />}
|
||||||
<Route path={"/activity"} element={<ActivitiesPage />} />
|
/>
|
||||||
<Route path={"/comments/:id"} element={<CommentPage />} />
|
<Route path={"/about"} element={<AboutPage />} />
|
||||||
</Route>
|
<Route path={"/tags"} element={<TagsPage />} />
|
||||||
</Routes>
|
<Route path={"/random"} element={<RandomPage />} />
|
||||||
</BrowserRouter>
|
<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 { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import showToast from "./toast";
|
import showToast from "./toast";
|
||||||
import { network } from "../network/network";
|
import { network } from "../network/network";
|
||||||
import { InfoAlert } from "./alert";
|
import { InfoAlert } from "./alert";
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { MdOutlineComment } from "react-icons/md";
|
import { MdOutlineComment } from "react-icons/md";
|
||||||
import { Comment } from "../network/models";
|
import { Comment } from "../network/models";
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { MdAdd } from "react-icons/md";
|
import { MdAdd } from "react-icons/md";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import showToast from "./toast.ts";
|
import showToast from "./toast.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@@ -3,7 +3,7 @@ import { network } from "../network/network.ts";
|
|||||||
import { useNavigate, useOutlet } from "react-router";
|
import { useNavigate, useOutlet } from "react-router";
|
||||||
import { createContext, useContext, useEffect, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { MdArrowUpward, MdOutlinePerson, MdSearch } from "react-icons/md";
|
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 UploadingSideBar from "./uploading_side_bar.tsx";
|
||||||
import { ThemeSwitcher } from "./theme_switcher.tsx";
|
import { ThemeSwitcher } from "./theme_switcher.tsx";
|
||||||
import { IoLogoGithub } from "react-icons/io";
|
import { IoLogoGithub } from "react-icons/io";
|
||||||
|
@@ -2,8 +2,15 @@ import { Resource } from "../network/models.ts";
|
|||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import Badge from "./badge.tsx";
|
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();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
let tags = resource.tags;
|
let tags = resource.tags;
|
||||||
@@ -58,6 +65,8 @@ export default function ResourceCard({ resource }: { resource: Resource }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-2"></div>
|
<div className="w-2"></div>
|
||||||
<div className="text-sm">{resource.author.username}</div>
|
<div className="text-sm">{resource.author.username}</div>
|
||||||
|
<div className="flex-1"></div>
|
||||||
|
{action}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -9,9 +9,11 @@ import { useAppContext } from "./AppContext.tsx";
|
|||||||
export default function ResourcesView({
|
export default function ResourcesView({
|
||||||
loader,
|
loader,
|
||||||
storageKey,
|
storageKey,
|
||||||
|
actionBuilder,
|
||||||
}: {
|
}: {
|
||||||
loader: (page: number) => Promise<PageResponse<Resource>>;
|
loader: (page: number) => Promise<PageResponse<Resource>>;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
|
actionBuilder?: (resource: Resource) => React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [data, setData] = useState<Resource[]>([]);
|
const [data, setData] = useState<Resource[]>([]);
|
||||||
const pageRef = useRef(1);
|
const pageRef = useRef(1);
|
||||||
@@ -54,7 +56,8 @@ export default function ResourcesView({
|
|||||||
isLoadingRef.current = false;
|
isLoadingRef.current = false;
|
||||||
pageRef.current = pageRef.current + 1;
|
pageRef.current = pageRef.current + 1;
|
||||||
totalPagesRef.current = res.totalPages ?? 1;
|
totalPagesRef.current = res.totalPages ?? 1;
|
||||||
setData((prev) => [...prev, ...res.data!]);
|
let data = res.data ?? [];
|
||||||
|
setData((prev) => [...prev, ...data]);
|
||||||
}
|
}
|
||||||
}, [loader]);
|
}, [loader]);
|
||||||
|
|
||||||
@@ -71,7 +74,13 @@ export default function ResourcesView({
|
|||||||
columnWidth={300}
|
columnWidth={300}
|
||||||
items={data}
|
items={data}
|
||||||
render={(e) => {
|
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>
|
></Masonry>
|
||||||
{pageRef.current <= totalPagesRef.current && <Loading />}
|
{pageRef.current <= totalPagesRef.current && <Loading />}
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
import { Tag } from "../network/models.ts";
|
import { Tag } from "../network/models.ts";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { LuInfo } from "react-icons/lu";
|
import { LuInfo } from "react-icons/lu";
|
||||||
import { MdSearch } from "react-icons/md";
|
import { MdSearch } from "react-icons/md";
|
||||||
import Button from "./button.tsx";
|
import Button from "./button.tsx";
|
||||||
import Input, { TextArea } from "./input.tsx";
|
import Input, { TextArea } from "./input.tsx";
|
||||||
import { ErrorAlert } from "./alert.tsx";
|
import { ErrorAlert } from "./alert.tsx";
|
||||||
|
import { Debounce } from "../utils/debounce.ts";
|
||||||
|
|
||||||
export default function TagInput({
|
export default function TagInput({
|
||||||
onAdd,
|
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({
|
export function QuickAddTagDialog({
|
||||||
onAdded,
|
onAdded,
|
||||||
}: {
|
}: {
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { MdPalette } from "react-icons/md";
|
import { MdPalette } from "react-icons/md";
|
||||||
|
|
||||||
interface ThemeOption {
|
interface ThemeOption {
|
||||||
|
@@ -10,7 +10,7 @@ export default function showToast({
|
|||||||
type = type || "info";
|
type = type || "info";
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.innerHTML = `
|
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"}">
|
<div class="alert shadow ${type === "success" && "alert-success"} ${type === "error" && "alert-error"} ${type === "warning" && "alert-warning"} ${type === "info" && "alert-info"}">
|
||||||
<span>${message}</span>
|
<span>${message}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,243 +1,4 @@
|
|||||||
export const i18nData = {
|
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": {
|
"zh-CN": {
|
||||||
translation: {
|
translation: {
|
||||||
"My Profile": "我的资料",
|
"My Profile": "我的资料",
|
||||||
@@ -466,6 +227,29 @@ export const i18nData = {
|
|||||||
"Added a new file": "添加了新文件",
|
"Added a new file": "添加了新文件",
|
||||||
|
|
||||||
"Data from": "数据来源",
|
"Data from": "数据来源",
|
||||||
|
|
||||||
|
"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": {
|
"zh-TW": {
|
||||||
@@ -696,6 +480,29 @@ export const i18nData = {
|
|||||||
"Added a new file": "添加了新檔案",
|
"Added a new file": "添加了新檔案",
|
||||||
|
|
||||||
"Data from": "數據來源",
|
"Data from": "數據來源",
|
||||||
|
|
||||||
|
"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 { createRoot } from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./app.tsx";
|
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";
|
import AppContext from "./components/AppContext.tsx";
|
||||||
|
|
||||||
i18n
|
createRoot(document.getElementById("root")!).render(
|
||||||
.use(initReactI18next)
|
<StrictMode>
|
||||||
.use(LanguageDetector)
|
<AppContext>
|
||||||
.init({
|
<App />
|
||||||
resources: i18nData,
|
</AppContext>
|
||||||
debug: true,
|
</StrictMode>,
|
||||||
fallbackLng: "en",
|
);
|
||||||
interpolation: {
|
|
||||||
escapeValue: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<AppContext>
|
|
||||||
<App />
|
|
||||||
</AppContext>
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { Activity, ActivityType } from "../network/models.ts";
|
import { Activity, ActivityType } from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import showToast from "../components/toast.ts";
|
import showToast from "../components/toast.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import Loading from "../components/loading.tsx";
|
import Loading from "../components/loading.tsx";
|
||||||
import { CommentContent } from "../components/comment_tile.tsx";
|
import { CommentContent } from "../components/comment_tile.tsx";
|
||||||
|
341
frontend/src/pages/collection_page.tsx
Normal file
341
frontend/src/pages/collection_page.tsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router"; // 新增 useNavigate
|
||||||
|
import showToast from "../components/toast";
|
||||||
|
import { network } from "../network/network";
|
||||||
|
import { Collection } from "../network/models";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
import ResourcesView from "../components/resources_view";
|
||||||
|
import Loading from "../components/loading";
|
||||||
|
import { MdOutlineDelete, MdOutlineEdit } from "react-icons/md";
|
||||||
|
import { app } from "../app";
|
||||||
|
import { useTranslation } from "../utils/i18n";
|
||||||
|
import Button from "../components/button";
|
||||||
|
|
||||||
|
export default function CollectionPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [collection, setCollection] = useState<Collection | null>(null);
|
||||||
|
const [resourcesKey, setResourcesKey] = useState(0);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const idInt = parseInt(id || "0", 10);
|
||||||
|
if (isNaN(idInt)) {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
message: "Invalid collection ID",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
network.getCollection(idInt).then((res) => {
|
||||||
|
if (res.success) {
|
||||||
|
setCollection(res.data!);
|
||||||
|
} else {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
message: res.message || "Failed to load collection",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [resourcesKey]);
|
||||||
|
|
||||||
|
const toBeDeletedRID = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const handleDeleteResource = (resourceId: number) => {
|
||||||
|
toBeDeletedRID.current = resourceId;
|
||||||
|
const dialog = document.getElementById(
|
||||||
|
"deleteResourceDialog",
|
||||||
|
) as HTMLDialogElement | null;
|
||||||
|
if (dialog) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletedResourceConfirmed = () => {
|
||||||
|
if (toBeDeletedRID.current === null) return;
|
||||||
|
network
|
||||||
|
.removeResourceFromCollection(collection!.id, toBeDeletedRID.current)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.success) {
|
||||||
|
showToast({
|
||||||
|
type: "success",
|
||||||
|
message: "Resource deleted successfully",
|
||||||
|
});
|
||||||
|
setResourcesKey((prev) => prev + 1); // Trigger re-render of ResourcesView
|
||||||
|
} else {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
message: res.message || "Failed to delete resource",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toBeDeletedRID.current = null;
|
||||||
|
const dialog = document.getElementById(
|
||||||
|
"deleteResourceDialog",
|
||||||
|
) as HTMLDialogElement | null;
|
||||||
|
if (dialog) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCollection = () => setDeleteOpen(true);
|
||||||
|
const handleDeleteCollectionConfirmed = async () => {
|
||||||
|
if (!collection) return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
const res = await network.deleteCollection(collection.id);
|
||||||
|
setIsDeleting(false);
|
||||||
|
if (res.success) {
|
||||||
|
showToast({
|
||||||
|
type: "success",
|
||||||
|
message: "Collection deleted successfully",
|
||||||
|
});
|
||||||
|
setDeleteOpen(false);
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
navigate(-1);
|
||||||
|
} else {
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
message: res.message || "Failed to delete collection",
|
||||||
|
});
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOwner = collection?.user?.id === app?.user?.id;
|
||||||
|
|
||||||
|
const openEditDialog = () => setEditOpen(true);
|
||||||
|
|
||||||
|
const handleEditSaved = (newCollection: Collection) => {
|
||||||
|
setCollection(newCollection);
|
||||||
|
setEditOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!collection) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-4 mt-4 p-4 bg-base-100-tr82 shadow rounded-xl">
|
||||||
|
<h1 className="text-2xl font-bold">{collection?.title}</h1>
|
||||||
|
<article>
|
||||||
|
<CollectionContent content={collection?.article || ""} />
|
||||||
|
</article>
|
||||||
|
<div className="flex items-center flex-row-reverse">
|
||||||
|
{isOwner && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-ghost ml-2"
|
||||||
|
onClick={openEditDialog}
|
||||||
|
>
|
||||||
|
<MdOutlineEdit size={16} />
|
||||||
|
{t("Edit")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-error btn-ghost ml-2"
|
||||||
|
onClick={handleDeleteCollection}
|
||||||
|
>
|
||||||
|
<MdOutlineDelete size={16} />
|
||||||
|
{t("Delete")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ResourcesView
|
||||||
|
loader={() => {
|
||||||
|
return network.listCollectionResources(collection!.id);
|
||||||
|
}}
|
||||||
|
actionBuilder={
|
||||||
|
isOwner
|
||||||
|
? (r) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-rounded btn-error btn-ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
handleDeleteResource(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdOutlineDelete size={16} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
key={resourcesKey}
|
||||||
|
/>
|
||||||
|
<dialog id="deleteResourceDialog" className="modal">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h2 className="font-bold text-lg">Remove Resource</h2>
|
||||||
|
<p>Are you sure you want to remove this resource?</p>
|
||||||
|
<div className="modal-action">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const dialog = document.getElementById(
|
||||||
|
"deleteResourceDialog",
|
||||||
|
) as HTMLDialogElement | null;
|
||||||
|
if (dialog) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="btn-error"
|
||||||
|
onClick={handleDeletedResourceConfirmed}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
{deleteOpen && (
|
||||||
|
<div className="modal modal-open">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h2 className="font-bold text-lg mb-2">{t("Delete Collection")}</h2>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
"Are you sure you want to delete this collection? This action cannot be undone.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="modal-action">
|
||||||
|
<Button className="btn" onClick={() => setDeleteOpen(false)}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="btn btn-error"
|
||||||
|
onClick={handleDeleteCollectionConfirmed}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
>
|
||||||
|
{t("Delete")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editOpen && collection && (
|
||||||
|
<EditCollectionDialog
|
||||||
|
open={editOpen}
|
||||||
|
collection={collection}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
onSaved={handleEditSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionContent({ content }: { content: string }) {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let line = lines[i];
|
||||||
|
if (!line.endsWith(" ")) {
|
||||||
|
// Ensure that each line ends with two spaces for Markdown to recognize it as a line break
|
||||||
|
lines[i] = line + " ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = lines.join("\n");
|
||||||
|
|
||||||
|
return <Markdown>{content}</Markdown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditCollectionDialog({
|
||||||
|
open,
|
||||||
|
collection,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
collection: Collection;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (newCollection: Collection) => void;
|
||||||
|
}) {
|
||||||
|
const [editTitle, setEditTitle] = useState(collection.title);
|
||||||
|
const [editArticle, setEditArticle] = useState(collection.article);
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
if (editTitle.trim() === "" || editArticle.trim() === "") {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
message: t("Title and description cannot be empty"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditLoading(true);
|
||||||
|
const res = await network.updateCollection(
|
||||||
|
collection.id,
|
||||||
|
editTitle,
|
||||||
|
editArticle,
|
||||||
|
);
|
||||||
|
setEditLoading(false);
|
||||||
|
if (res.success) {
|
||||||
|
showToast({ type: "success", message: t("Edit successful") });
|
||||||
|
const getRes = await network.getCollection(collection.id);
|
||||||
|
if (getRes.success) {
|
||||||
|
onSaved(getRes.data!);
|
||||||
|
} else {
|
||||||
|
onSaved({ ...collection, title: editTitle, article: editArticle });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast({
|
||||||
|
type: "error",
|
||||||
|
message: res.message || t("Failed to save changes"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal modal-open">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h2 className="font-bold text-lg mb-2">{t("Edit Collection")}</h2>
|
||||||
|
<label className="block mb-1">{t("Title")}</label>
|
||||||
|
<input
|
||||||
|
className="input w-full mb-2"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
<label className="block mb-1">{t("Description")}</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea w-full min-h-32"
|
||||||
|
value={editArticle}
|
||||||
|
onChange={(e) => setEditArticle(e.target.value)}
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
<div className="modal-action">
|
||||||
|
<button className="btn" onClick={onClose} disabled={editLoading}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleEditSave}
|
||||||
|
disabled={editLoading}
|
||||||
|
>
|
||||||
|
{editLoading ? (
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
) : (
|
||||||
|
t("Save")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { network } from "../network/network";
|
import { network } from "../network/network";
|
||||||
import showToast from "../components/toast";
|
import showToast from "../components/toast";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { CommentWithRef, Resource } from "../network/models";
|
import { CommentWithRef, Resource } from "../network/models";
|
||||||
import Loading from "../components/loading";
|
import Loading from "../components/loading";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
@@ -91,7 +91,9 @@ export default function CommentPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (comment?.resource && comment.resource.image) {
|
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) {
|
} else if (comment?.images?.length) {
|
||||||
// comment images are not resampled
|
// comment images are not resampled
|
||||||
navigator.setBackground(network.getImageUrl(comment.images[0].id));
|
navigator.setBackground(network.getImageUrl(comment.images[0].id));
|
||||||
@@ -109,36 +111,39 @@ export default function CommentPage() {
|
|||||||
<div className="h-2"></div>
|
<div className="h-2"></div>
|
||||||
<div className="bg-base-100-tr82 rounded-2xl p-4 shadow">
|
<div className="bg-base-100-tr82 rounded-2xl p-4 shadow">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/user/${encodeURIComponent(comment.user.username)}`);
|
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"
|
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="flex items-center">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
<div className="w-6 rounded-full">
|
<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>
|
||||||
|
<div className="w-2"></div>
|
||||||
|
<div className="text-sm">{comment.user.username}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-2"></div>
|
</button>
|
||||||
<div className="text-sm">{comment.user.username}</div>
|
<span className="text-xs text-base-content/80 ml-2">
|
||||||
</div>
|
{t("Commented on")}
|
||||||
</button>
|
{new Date(comment.created_at).toLocaleDateString()}
|
||||||
<span className="text-xs text-base-content/80 ml-2">
|
</span>
|
||||||
{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>
|
||||||
)}
|
<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>
|
||||||
<div className="h-4" />
|
<div className="h-4" />
|
||||||
<div className="border-t border-base-300" />
|
<div className="border-t border-base-300" />
|
||||||
|
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 { network } from "../network/network.ts";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import showToast from "../components/toast.ts";
|
import showToast from "../components/toast.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import Loading from "../components/loading.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 { network } from "../network/network.ts";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import { RSort } from "../network/models.ts";
|
import { RSort } from "../network/models.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { useAppContext } from "../components/AppContext.tsx";
|
import { useAppContext } from "../components/AppContext.tsx";
|
||||||
import Select from "../components/select.tsx";
|
import Select from "../components/select.tsx";
|
||||||
|
|
||||||
|
@@ -2,7 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
|
|||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { app } from "../app";
|
import { app } from "../app";
|
||||||
import { ErrorAlert } from "../components/alert";
|
import { ErrorAlert } from "../components/alert";
|
||||||
import { network } from "../network/network";
|
import { network } from "../network/network";
|
||||||
|
@@ -7,7 +7,7 @@ import {
|
|||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import StorageView from "./manage_storage_page.tsx";
|
import StorageView from "./manage_storage_page.tsx";
|
||||||
import UserView from "./manage_user_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 { ManageMePage } from "./manage_me_page.tsx";
|
||||||
import ManageServerConfigPage from "./manage_server_config_page.tsx";
|
import ManageServerConfigPage from "./manage_server_config_page.tsx";
|
||||||
|
|
||||||
@@ -70,9 +70,7 @@ export default function ManagePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="drawer lg:drawer-open lg:pl-4">
|
<div className="drawer lg:drawer-open lg:pl-4">
|
||||||
<input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
|
<input id="my-drawer-2" type="checkbox" className="drawer-toggle" />
|
||||||
<div
|
<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)]">
|
||||||
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"}>
|
<div className={"flex w-full h-14 items-center gap-2 px-4"}>
|
||||||
<label
|
<label
|
||||||
className={"btn btn-square btn-ghost lg:hidden"}
|
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 { app } from "../app";
|
||||||
import { ErrorAlert, InfoAlert } from "../components/alert";
|
import { ErrorAlert, InfoAlert } from "../components/alert";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
@@ -5,7 +5,7 @@ import showToast from "../components/toast.ts";
|
|||||||
import Loading from "../components/loading.tsx";
|
import Loading from "../components/loading.tsx";
|
||||||
import { MdAdd, MdMoreHoriz } from "react-icons/md";
|
import { MdAdd, MdMoreHoriz } from "react-icons/md";
|
||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import showPopup, { PopupMenuItem } from "../components/popup.tsx";
|
import showPopup, { PopupMenuItem } from "../components/popup.tsx";
|
||||||
import Badge from "../components/badge.tsx";
|
import Badge from "../components/badge.tsx";
|
||||||
|
@@ -6,7 +6,7 @@ import Loading from "../components/loading";
|
|||||||
import { MdMoreHoriz, MdSearch } from "react-icons/md";
|
import { MdMoreHoriz, MdSearch } from "react-icons/md";
|
||||||
import Pagination from "../components/pagination";
|
import Pagination from "../components/pagination";
|
||||||
import showPopup, { PopupMenuItem } from "../components/popup";
|
import showPopup, { PopupMenuItem } from "../components/popup";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { app } from "../app";
|
import { app } from "../app";
|
||||||
import { ErrorAlert } from "../components/alert";
|
import { ErrorAlert } from "../components/alert";
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ import {
|
|||||||
import { Tag } from "../network/models.ts";
|
import { Tag } from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import { useAppContext } from "../components/AppContext.tsx";
|
import { useAppContext } from "../components/AppContext.tsx";
|
||||||
|
@@ -2,7 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
|
|||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { Turnstile } from "@marsidev/react-turnstile";
|
import { Turnstile } from "@marsidev/react-turnstile";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
|
@@ -17,6 +17,7 @@ import {
|
|||||||
Comment,
|
Comment,
|
||||||
Tag,
|
Tag,
|
||||||
Resource,
|
Resource,
|
||||||
|
Collection,
|
||||||
} from "../network/models.ts";
|
} from "../network/models.ts";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import showToast from "../components/toast.ts";
|
import showToast from "../components/toast.ts";
|
||||||
@@ -32,13 +33,15 @@ import {
|
|||||||
MdOutlineDelete,
|
MdOutlineDelete,
|
||||||
MdOutlineDownload,
|
MdOutlineDownload,
|
||||||
MdOutlineEdit,
|
MdOutlineEdit,
|
||||||
|
MdOutlineFolder,
|
||||||
|
MdOutlineFolderSpecial,
|
||||||
MdOutlineLink,
|
MdOutlineLink,
|
||||||
MdOutlineOpenInNew,
|
MdOutlineOpenInNew,
|
||||||
} from "react-icons/md";
|
} from "react-icons/md";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import { uploadingManager } from "../network/uploading.ts";
|
import { uploadingManager } from "../network/uploading.ts";
|
||||||
import { ErrorAlert } from "../components/alert.tsx";
|
import { ErrorAlert } from "../components/alert.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import Pagination from "../components/pagination.tsx";
|
import Pagination from "../components/pagination.tsx";
|
||||||
import showPopup, { useClosePopup } from "../components/popup.tsx";
|
import showPopup, { useClosePopup } from "../components/popup.tsx";
|
||||||
import { Turnstile } from "@marsidev/react-turnstile";
|
import { Turnstile } from "@marsidev/react-turnstile";
|
||||||
@@ -57,6 +60,7 @@ import KunApi, {
|
|||||||
kunPlatformToString,
|
kunPlatformToString,
|
||||||
kunResourceTypeToString,
|
kunResourceTypeToString,
|
||||||
} from "../network/kun.ts";
|
} from "../network/kun.ts";
|
||||||
|
import { Debounce } from "../utils/debounce.ts";
|
||||||
|
|
||||||
export default function ResourcePage() {
|
export default function ResourcePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -213,28 +217,27 @@ export default function ResourcePage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<Tags tags={resource.tags} />
|
<Tags tags={resource.tags} />
|
||||||
{resource.links && (
|
<p className={"px-3 mt-2"}>
|
||||||
<p className={"px-3 mt-2"}>
|
{resource.links.map((l) => {
|
||||||
{resource.links.map((l) => {
|
return (
|
||||||
return (
|
<a href={l.url} target={"_blank"}>
|
||||||
<a href={l.url} target={"_blank"}>
|
<span
|
||||||
<span
|
className={
|
||||||
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"
|
||||||
"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") ? (
|
||||||
{l.url.includes("steampowered.com") ? (
|
<BiLogoSteam size={20} />
|
||||||
<BiLogoSteam size={20} />
|
) : (
|
||||||
) : (
|
<MdOutlineLink size={20} />
|
||||||
<MdOutlineLink size={20} />
|
)}
|
||||||
)}
|
<span className={"ml-2 text-sm"}>{l.label}</span>
|
||||||
<span className={"ml-2 text-sm"}>{l.label}</span>
|
</span>
|
||||||
</span>
|
</a>
|
||||||
</a>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
<CollectionDialog rid={resource.id} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="tabs tabs-box my-4 mx-2 p-4 shadow"
|
className="tabs tabs-box my-4 mx-2 p-4 shadow"
|
||||||
@@ -1581,3 +1584,174 @@ function KunFile({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CollectionDialog({ rid }: { rid: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
|
||||||
|
const [realSearchKeyword, setRealSearchKeyword] = useState("");
|
||||||
|
|
||||||
|
const [dialogVisited, setDialogVisited] = useState(false);
|
||||||
|
|
||||||
|
const [selectedCID, setSelectedCID] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const debounce = new Debounce(500);
|
||||||
|
|
||||||
|
const delayedSetSearchKeyword = (keyword: string) => {
|
||||||
|
setSearchKeyword(keyword);
|
||||||
|
debounce.run(() => {
|
||||||
|
setSelectedCID(null);
|
||||||
|
setRealSearchKeyword(keyword);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToCollection = () => {
|
||||||
|
if (selectedCID == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
network.addResourceToCollection(selectedCID, rid).then((res) => {
|
||||||
|
if (res.success) {
|
||||||
|
showToast({
|
||||||
|
message: t("Resource added to collection successfully"),
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
setSelectedCID(null);
|
||||||
|
setRealSearchKeyword("");
|
||||||
|
setSearchKeyword("");
|
||||||
|
setDialogVisited(false);
|
||||||
|
const dialog = document.getElementById(
|
||||||
|
"collection_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
|
dialog.close();
|
||||||
|
} else {
|
||||||
|
showToast({
|
||||||
|
message: res.message,
|
||||||
|
type: "error",
|
||||||
|
parent: document.getElementById("collection_dialog_content"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"py-1 px-3 inline-flex items-center m-1 border border-base-300 bg-base-100 opacity-90 rounded-2xl hover:bg-base-200 transition-colors cursor-pointer select-none"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setDialogVisited(true);
|
||||||
|
const dialog = document.getElementById(
|
||||||
|
"collection_dialog",
|
||||||
|
) as HTMLDialogElement;
|
||||||
|
dialog.showModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdOutlineFolderSpecial size={20} />
|
||||||
|
<span className={"ml-2 text-sm"}>{t("Collect")}</span>
|
||||||
|
</span>
|
||||||
|
<dialog id="collection_dialog" className="modal">
|
||||||
|
<div className="modal-box" id="collection_dialog_content">
|
||||||
|
<h3 className="font-bold text-lg mb-2">{t("Add to Collection")}</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
className="input input-bordered w-full max-w-2xs mr-2"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => delayedSetSearchKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{dialogVisited && (
|
||||||
|
<CollectionSelector
|
||||||
|
resourceId={rid}
|
||||||
|
keyword={realSearchKeyword}
|
||||||
|
seletedID={selectedCID}
|
||||||
|
selectCallback={(collection) => {
|
||||||
|
if (selectedCID === collection.id) {
|
||||||
|
setSelectedCID(null);
|
||||||
|
} else {
|
||||||
|
setSelectedCID(collection.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
key={realSearchKeyword}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<Button className="btn">Close</Button>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={selectedCID == null}
|
||||||
|
onClick={handleAddToCollection}
|
||||||
|
>
|
||||||
|
{t("Add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionSelector({
|
||||||
|
resourceId,
|
||||||
|
keyword,
|
||||||
|
seletedID: selectedID,
|
||||||
|
selectCallback,
|
||||||
|
}: {
|
||||||
|
resourceId: number;
|
||||||
|
keyword: string;
|
||||||
|
seletedID?: number | null;
|
||||||
|
selectCallback: (collection: Collection) => void;
|
||||||
|
}) {
|
||||||
|
const [collections, setCollections] = useState<Collection[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCollections(null);
|
||||||
|
network
|
||||||
|
.searchUserCollections(app.user!.username, keyword, resourceId)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.success) {
|
||||||
|
setCollections(res.data! || []);
|
||||||
|
} else {
|
||||||
|
showToast({
|
||||||
|
message: res.message,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [keyword]);
|
||||||
|
|
||||||
|
if (collections == null) {
|
||||||
|
return (
|
||||||
|
<div className={"w-full"}>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-2 max-h-80 overflow-y-auto w-full overflow-x-clip">
|
||||||
|
{collections.map((collection) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${selectedID === collection.id && "bg-base-200 shadow"} rounded-lg transition-all p-2 hover:bg-base-200 w-full overflow-ellipsis hover:cursor-pointer`}
|
||||||
|
key={collection.id}
|
||||||
|
onClick={() => {
|
||||||
|
selectCallback(collection);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary mr-2"
|
||||||
|
checked={selectedID === collection.id}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
{collection.title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -2,7 +2,7 @@ import { useSearchParams } from "react-router";
|
|||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import ResourcesView from "../components/resources_view.tsx";
|
import ResourcesView from "../components/resources_view.tsx";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [params, _] = useSearchParams();
|
const [params, _] = useSearchParams();
|
||||||
|
@@ -3,7 +3,7 @@ import { ErrorAlert } from "../components/alert.tsx";
|
|||||||
import ResourcesView from "../components/resources_view.tsx";
|
import ResourcesView from "../components/resources_view.tsx";
|
||||||
import { network } from "../network/network.ts";
|
import { network } from "../network/network.ts";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { Tag } from "../network/models.ts";
|
import { Tag } from "../network/models.ts";
|
||||||
import Button from "../components/button.tsx";
|
import Button from "../components/button.tsx";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
|
@@ -1,5 +1,11 @@
|
|||||||
import { useParams, useLocation, useNavigate } from "react-router";
|
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 { network } from "../network/network";
|
||||||
import showToast from "../components/toast";
|
import showToast from "../components/toast";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
@@ -9,13 +15,17 @@ import Pagination from "../components/pagination";
|
|||||||
import { CommentTile } from "../components/comment_tile.tsx";
|
import { CommentTile } from "../components/comment_tile.tsx";
|
||||||
import Badge from "../components/badge.tsx";
|
import Badge from "../components/badge.tsx";
|
||||||
import {
|
import {
|
||||||
|
MdOutlineAdd,
|
||||||
MdOutlineArchive,
|
MdOutlineArchive,
|
||||||
MdOutlineComment,
|
MdOutlineComment,
|
||||||
MdOutlinePhotoAlbum,
|
MdOutlinePhotoAlbum,
|
||||||
} from "react-icons/md";
|
} from "react-icons/md";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "../utils/i18n";
|
||||||
import { app } from "../app.ts";
|
import { app } from "../app.ts";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
|
import Button from "../components/button.tsx";
|
||||||
|
import { t } from "i18next";
|
||||||
|
import { Debounce } from "../utils/debounce.ts";
|
||||||
|
|
||||||
export default function UserPage() {
|
export default function UserPage() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
@@ -27,12 +37,12 @@ export default function UserPage() {
|
|||||||
// 解码用户名,确保特殊字符被还原
|
// 解码用户名,确保特殊字符被还原
|
||||||
const username = rawUsername ? decodeURIComponent(rawUsername) : "";
|
const username = rawUsername ? decodeURIComponent(rawUsername) : "";
|
||||||
|
|
||||||
// 从 hash 中获取当前页面,默认为 resources
|
// 从 hash 中获取当前页面,默认为 collections
|
||||||
const getPageFromHash = useCallback(() => {
|
const getPageFromHash = useCallback(() => {
|
||||||
const hash = location.hash.slice(1); // 移除 # 号
|
const hash = location.hash.slice(1); // 移除 # 号
|
||||||
if (hash === "comments") return 1;
|
const hashs = ["collections", "resources", "comments", "files"];
|
||||||
if (hash === "files") return 2;
|
const index = hashs.indexOf(hash);
|
||||||
return 0; // 默认为 resources
|
return index !== -1 ? index : 0; // 如果 hash 不在预定义的列表中,默认为 0
|
||||||
}, [location.hash]);
|
}, [location.hash]);
|
||||||
|
|
||||||
const [page, setPage] = useState(getPageFromHash());
|
const [page, setPage] = useState(getPageFromHash());
|
||||||
@@ -44,8 +54,8 @@ export default function UserPage() {
|
|||||||
|
|
||||||
// 更新 hash 的函数
|
// 更新 hash 的函数
|
||||||
const updateHash = (newPage: number) => {
|
const updateHash = (newPage: number) => {
|
||||||
const hashs = ["resources", "comments", "files"];
|
const hashs = ["collections", "resources", "comments", "files"];
|
||||||
const newHash = hashs[newPage] || "resources";
|
const newHash = hashs[newPage] || "collections";
|
||||||
if (location.hash.slice(1) !== newHash) {
|
if (location.hash.slice(1) !== newHash) {
|
||||||
navigate(`/user/${username}#${newHash}`, { replace: true });
|
navigate(`/user/${username}#${newHash}`, { replace: true });
|
||||||
}
|
}
|
||||||
@@ -93,27 +103,35 @@ export default function UserPage() {
|
|||||||
className={`tab ${page === 0 ? "tab-active" : ""} `}
|
className={`tab ${page === 0 ? "tab-active" : ""} `}
|
||||||
onClick={() => updateHash(0)}
|
onClick={() => updateHash(0)}
|
||||||
>
|
>
|
||||||
Resources
|
Collections
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
role="tab"
|
role="tab"
|
||||||
className={`tab ${page === 1 ? "tab-active" : ""}`}
|
className={`tab ${page === 1 ? "tab-active" : ""} `}
|
||||||
onClick={() => updateHash(1)}
|
onClick={() => updateHash(1)}
|
||||||
>
|
>
|
||||||
Comments
|
Resources
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
role="tab"
|
role="tab"
|
||||||
className={`tab ${page === 2 ? "tab-active" : ""}`}
|
className={`tab ${page === 2 ? "tab-active" : ""}`}
|
||||||
onClick={() => updateHash(2)}
|
onClick={() => updateHash(2)}
|
||||||
|
>
|
||||||
|
Comments
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${page === 3 ? "tab-active" : ""}`}
|
||||||
|
onClick={() => updateHash(3)}
|
||||||
>
|
>
|
||||||
Files
|
Files
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{page === 0 && <UserResources user={user} />}
|
{page === 0 && <Collections username={username} />}
|
||||||
{page === 1 && <UserComments user={user} />}
|
{page === 1 && <UserResources user={user} />}
|
||||||
{page === 2 && <UserFiles user={user} />}
|
{page === 2 && <UserComments user={user} />}
|
||||||
|
{page === 3 && <UserFiles user={user} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-16"></div>
|
<div className="h-16"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -365,3 +383,158 @@ function FilesList({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Collections({ username }: { username?: string }) {
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
|
||||||
|
const [realSearchKeyword, setRealSearchKeyword] = useState("");
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const debounce = new Debounce(500);
|
||||||
|
|
||||||
|
const delayedSetSearchKeyword = (keyword: string) => {
|
||||||
|
setSearchKeyword(keyword);
|
||||||
|
debounce.run(() => {
|
||||||
|
setRealSearchKeyword(keyword);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex m-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
className="input input-bordered w-full max-w-2xs mr-2"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => delayedSetSearchKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-soft"
|
||||||
|
onClick={() => {
|
||||||
|
navigate("/create-collection");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MdOutlineAdd size={20} className="inline-block mr-1" />
|
||||||
|
{t("Create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<CollectionsList
|
||||||
|
username={username}
|
||||||
|
keyword={realSearchKeyword}
|
||||||
|
key={realSearchKeyword}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrSearchUserCollections(
|
||||||
|
username: string,
|
||||||
|
keyword: string,
|
||||||
|
page: number,
|
||||||
|
): Promise<PageResponse<Collection>> {
|
||||||
|
if (keyword.trim() === "") {
|
||||||
|
return network.listUserCollections(username, page);
|
||||||
|
} else {
|
||||||
|
let res = await network.searchUserCollections(username, keyword);
|
||||||
|
return {
|
||||||
|
success: res.success,
|
||||||
|
data: res.data || [],
|
||||||
|
totalPages: 1,
|
||||||
|
message: res.message || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionsList({
|
||||||
|
username,
|
||||||
|
keyword,
|
||||||
|
}: {
|
||||||
|
username?: string;
|
||||||
|
keyword: string;
|
||||||
|
}) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [maxPage, setMaxPage] = useState(1);
|
||||||
|
const [collections, setCollections] = useState<Collection[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
|
setCollections(null);
|
||||||
|
getOrSearchUserCollections(username, keyword, page).then((res) => {
|
||||||
|
if (res.success) {
|
||||||
|
setCollections(res.data! || []);
|
||||||
|
setMaxPage(res.totalPages || 1);
|
||||||
|
} else {
|
||||||
|
showToast({
|
||||||
|
message: res.message,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [username, keyword, page]);
|
||||||
|
|
||||||
|
if (collections == null) {
|
||||||
|
return (
|
||||||
|
<div className={"w-full"}>
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{collections.map((collection) => {
|
||||||
|
return <CollectionCard collection={collection} key={collection.id} />;
|
||||||
|
})}
|
||||||
|
{maxPage > 1 ? (
|
||||||
|
<div className={"w-full flex justify-center"}>
|
||||||
|
<Pagination page={page} setPage={setPage} totalPages={maxPage} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionCard({ collection }: { collection: Collection }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"card m-4 p-2 bg-base-100-tr82 shadow hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/collection/${collection.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className={"card-title mx-2 mt-2"}>{collection.title}</h3>
|
||||||
|
<div className={"p-2 comment_tile"}>
|
||||||
|
<CollectionContent content={collection.article} />
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<Badge className="badge-soft badge-primary text-xs mr-2">
|
||||||
|
<MdOutlinePhotoAlbum size={16} className="inline-block" />
|
||||||
|
{collection.resources_count} {t("Resources")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollectionContent({ content }: { content: string }) {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let line = lines[i];
|
||||||
|
if (!line.endsWith(" ")) {
|
||||||
|
// Ensure that each line ends with two spaces for Markdown to recognize it as a line break
|
||||||
|
lines[i] = line + " ";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = lines.join("\n");
|
||||||
|
|
||||||
|
return <Markdown>{content}</Markdown>;
|
||||||
|
}
|
||||||
|
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,6 +35,9 @@ func CreateCollection(uid uint, title string, article string, images []uint) (mo
|
|||||||
func UpdateCollection(id uint, title string, article string, images []uint) error {
|
func UpdateCollection(id uint, title string, article string, images []uint) error {
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
collection := &model.Collection{
|
collection := &model.Collection{
|
||||||
|
Model: gorm.Model{
|
||||||
|
ID: id,
|
||||||
|
},
|
||||||
Title: title,
|
Title: title,
|
||||||
Article: article,
|
Article: article,
|
||||||
}
|
}
|
||||||
|
@@ -34,8 +34,8 @@ func UpdateCollection(uid, id uint, title, article string, host string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete a collection by ID.
|
// Delete a collection by ID.
|
||||||
func DeleteCollection(uint, id uint) error {
|
func DeleteCollection(uid, id uint) error {
|
||||||
user, err := dao.GetUserByID(id)
|
user, err := dao.GetUserByID(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user