mirror of
https://github.com/wgh136/nysoure.git
synced 2025-12-16 07:51:14 +00:00
Compare commits
3 Commits
f84bcbdadc
...
2499962815
| Author | SHA1 | Date | |
|---|---|---|---|
| 2499962815 | |||
| a4de7a1d54 | |||
| 3e7ce7b4cd |
@@ -47,6 +47,7 @@ export interface CreateResourceParams {
|
||||
tags: number[];
|
||||
article: string;
|
||||
images: number[];
|
||||
gallery: number[];
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
@@ -84,6 +85,7 @@ export interface ResourceDetails {
|
||||
downloads: number;
|
||||
comments: number;
|
||||
related: Resource[];
|
||||
gallery: number[];
|
||||
}
|
||||
|
||||
export interface Storage {
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function EditResourcePage() {
|
||||
const [article, setArticle] = useState<string>("");
|
||||
const [images, setImages] = useState<number[]>([]);
|
||||
const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
|
||||
const [galleryImages, setGalleryImages] = useState<number[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
@@ -55,6 +56,7 @@ export default function EditResourcePage() {
|
||||
setArticle(data.article);
|
||||
setImages(data.images.map((i) => i.id));
|
||||
setLinks(data.links ?? []);
|
||||
setGalleryImages(data.gallery ?? []);
|
||||
setLoading(false);
|
||||
} else {
|
||||
showToast({ message: t("Failed to load resource"), type: "error" });
|
||||
@@ -98,6 +100,7 @@ export default function EditResourcePage() {
|
||||
article: article,
|
||||
images: images,
|
||||
links: links,
|
||||
gallery: galleryImages,
|
||||
});
|
||||
if (res.success) {
|
||||
setSubmitting(false);
|
||||
@@ -318,6 +321,7 @@ export default function EditResourcePage() {
|
||||
<tr>
|
||||
<td>{t("Preview")}</td>
|
||||
<td>{"Markdown"}</td>
|
||||
<td>{t("Gallery")}</td>
|
||||
<td>{t("Action")}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -345,6 +349,22 @@ export default function EditResourcePage() {
|
||||
<MdContentCopy />
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-accent"
|
||||
checked={galleryImages.includes(image)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setGalleryImages((prev) => [...prev, image]);
|
||||
} else {
|
||||
setGalleryImages((prev) =>
|
||||
prev.filter((id) => id !== image),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className={"btn btn-square"}
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function PublishPage() {
|
||||
const [article, setArticle] = useState<string>("");
|
||||
const [images, setImages] = useState<number[]>([]);
|
||||
const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
|
||||
const [galleryImages, setGalleryImages] = useState<number[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -106,6 +107,7 @@ export default function PublishPage() {
|
||||
article: article,
|
||||
images: images,
|
||||
links: links,
|
||||
gallery: galleryImages,
|
||||
});
|
||||
if (res.success) {
|
||||
localStorage.removeItem("publish_data");
|
||||
@@ -329,6 +331,7 @@ export default function PublishPage() {
|
||||
<tr>
|
||||
<td>{t("Preview")}</td>
|
||||
<td>{"Markdown"}</td>
|
||||
<td>{"Gallery"}</td>
|
||||
<td>{t("Action")}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -356,6 +359,22 @@ export default function PublishPage() {
|
||||
<MdContentCopy />
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-accent"
|
||||
checked={galleryImages.includes(image)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setGalleryImages((prev) => [...prev, image]);
|
||||
} else {
|
||||
setGalleryImages((prev) =>
|
||||
prev.filter((id) => id !== image),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className={"btn btn-square"}
|
||||
|
||||
@@ -25,10 +25,13 @@ import Markdown from "react-markdown";
|
||||
import "../markdown.css";
|
||||
import Loading from "../components/loading.tsx";
|
||||
import {
|
||||
MdAdd, MdOutlineAccessTime,
|
||||
MdAdd,
|
||||
MdOutlineAccessTime,
|
||||
MdOutlineAdd,
|
||||
MdOutlineArchive,
|
||||
MdOutlineArticle,
|
||||
MdOutlineChevronLeft,
|
||||
MdOutlineChevronRight,
|
||||
MdOutlineCloud,
|
||||
MdOutlineComment,
|
||||
MdOutlineContentCopy,
|
||||
@@ -65,6 +68,7 @@ import KunApi, {
|
||||
} from "../network/kun.ts";
|
||||
import { Debounce } from "../utils/debounce.ts";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
export default function ResourcePage() {
|
||||
const params = useParams();
|
||||
@@ -190,59 +194,73 @@ export default function ResourcePage() {
|
||||
return (
|
||||
<context.Provider value={reload}>
|
||||
<div className={"pt-2"}>
|
||||
<h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1>
|
||||
{resource.alternativeTitles.map((e, i) => {
|
||||
return (
|
||||
<h2
|
||||
key={i}
|
||||
className={"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"}
|
||||
>
|
||||
{e}
|
||||
</h2>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/user/${encodeURIComponent(resource.author.username)}`);
|
||||
}}
|
||||
className="border-b-2 mx-4 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div className="flex items-center ">
|
||||
<div className="avatar">
|
||||
<div className="w-6 rounded-full">
|
||||
<img
|
||||
src={network.getUserAvatar(resource.author)}
|
||||
alt={"avatar"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2"></div>
|
||||
<div className="text-sm">{resource.author.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
<Tags tags={resource.tags} />
|
||||
|
||||
<div className={"px-3 mt-2 flex flex-wrap"}>
|
||||
{resource.links &&
|
||||
resource.links.map((l) => {
|
||||
<div className="flex">
|
||||
<div className="flex-1">
|
||||
<h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1>
|
||||
{resource.alternativeTitles.map((e, i) => {
|
||||
return (
|
||||
<a href={l.url} target={"_blank"}>
|
||||
<span
|
||||
className={
|
||||
"py-1 px-3 inline-flex items-center m-1 border border-base-300 bg-base-100 opacity-90 rounded-2xl hover:bg-base-200 transition-colors cursor-pointer select-none"
|
||||
}
|
||||
>
|
||||
{l.url.includes("steampowered.com") ? (
|
||||
<BiLogoSteam size={20} />
|
||||
) : (
|
||||
<MdOutlineLink size={20} />
|
||||
)}
|
||||
<span className={"ml-2 text-sm"}>{l.label}</span>
|
||||
</span>
|
||||
</a>
|
||||
<h2
|
||||
key={i}
|
||||
className={
|
||||
"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"
|
||||
}
|
||||
>
|
||||
{e}
|
||||
</h2>
|
||||
);
|
||||
})}
|
||||
<CollectionDialog rid={resource.id} />
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(
|
||||
`/user/${encodeURIComponent(resource.author.username)}`,
|
||||
);
|
||||
}}
|
||||
className="border-b-2 mx-4 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out"
|
||||
>
|
||||
<div className="flex items-center ">
|
||||
<div className="avatar">
|
||||
<div className="w-6 rounded-full">
|
||||
<img
|
||||
src={network.getUserAvatar(resource.author)}
|
||||
alt={"avatar"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2"></div>
|
||||
<div className="text-sm">{resource.author.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
<Tags tags={resource.tags} />
|
||||
<div className={"px-3 mt-2 flex flex-wrap"}>
|
||||
{resource.links &&
|
||||
resource.links.map((l) => {
|
||||
return (
|
||||
<a href={l.url} target={"_blank"}>
|
||||
<span
|
||||
className={
|
||||
"py-1 px-3 inline-flex items-center m-1 border border-base-300 bg-base-100 opacity-90 rounded-2xl hover:bg-base-200 transition-colors cursor-pointer select-none"
|
||||
}
|
||||
>
|
||||
{l.url.includes("steampowered.com") ? (
|
||||
<BiLogoSteam size={20} />
|
||||
) : (
|
||||
<MdOutlineLink size={20} />
|
||||
)}
|
||||
<span className={"ml-2 text-sm"}>{l.label}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<CollectionDialog rid={resource.id} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-96 md:w-md lg:w-lg p-4 hidden sm:flex items-center justify-center">
|
||||
<Gallery images={resource.gallery} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full p-4 flex sm:hidden items-center justify-center">
|
||||
<Gallery images={resource.gallery} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -1860,3 +1878,124 @@ function CollectionSelector({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gallery({ images }: { images: number[] }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [direction, setDirection] = useState(0); // 方向:1=向右,-1=向左
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (containerRef.current) {
|
||||
setWidth(containerRef.current.clientWidth);
|
||||
}
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateWidth);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const goToPrevious = () => {
|
||||
setDirection(-1);
|
||||
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
setDirection(1);
|
||||
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
|
||||
};
|
||||
|
||||
const goToIndex = (index: number) => {
|
||||
setDirection(index > currentIndex ? 1 : -1);
|
||||
setCurrentIndex(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-xl bg-base-100-tr82 shadow-sm"
|
||||
style={{ aspectRatio: "16/9" }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 图片区域 */}
|
||||
<div ref={containerRef} className="w-full h-full relative">
|
||||
{width > 0 && (
|
||||
<AnimatePresence initial={false} custom={direction} mode="sync">
|
||||
<motion.img
|
||||
key={currentIndex}
|
||||
src={network.getImageUrl(images[currentIndex])}
|
||||
alt={`Gallery image ${currentIndex + 1}`}
|
||||
className="absolute w-full h-full object-contain"
|
||||
variants={{
|
||||
enter: (dir: number) => ({
|
||||
x: dir > 0 ? width : -width,
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
transition: { duration: 0.3, ease: "linear" },
|
||||
},
|
||||
exit: (dir: number) => ({
|
||||
x: dir > 0 ? -width : width,
|
||||
transition: { duration: 0.3, ease: "linear" },
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
custom={direction}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 左右按钮 */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={goToPrevious}
|
||||
>
|
||||
<MdOutlineChevronLeft size={28} />
|
||||
</button>
|
||||
<button
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={goToNext}
|
||||
>
|
||||
<MdOutlineChevronRight size={28} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 底部圆点 */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
index === currentIndex
|
||||
? "bg-primary w-4"
|
||||
: "bg-base-content/30 hover:bg-base-content/50"
|
||||
}`}
|
||||
onClick={() => goToIndex(index)}
|
||||
aria-label={`Go to image ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type Resource struct {
|
||||
Downloads uint
|
||||
Comments uint
|
||||
ModifiedTime time.Time
|
||||
Gallery []uint `gorm:"serializer:json"`
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
@@ -52,6 +53,7 @@ type ResourceDetailView struct {
|
||||
Downloads uint `json:"downloads"`
|
||||
Comments uint `json:"comments"`
|
||||
Related []ResourceView `json:"related"`
|
||||
Gallery []uint `json:"gallery"`
|
||||
}
|
||||
|
||||
func (r *Resource) ToView() ResourceView {
|
||||
@@ -104,5 +106,6 @@ func (r *Resource) ToDetailView() ResourceDetailView {
|
||||
Views: r.Views,
|
||||
Downloads: r.Downloads,
|
||||
Comments: r.Comments,
|
||||
Gallery: r.Gallery,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type ResourceParams struct {
|
||||
Tags []uint `json:"tags"`
|
||||
Article string `json:"article"`
|
||||
Images []uint `json:"images"`
|
||||
Gallery []uint `json:"gallery"`
|
||||
}
|
||||
|
||||
func CreateResource(uid uint, params *ResourceParams) (uint, error) {
|
||||
@@ -62,6 +63,7 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
|
||||
Images: images,
|
||||
Tags: tags,
|
||||
UserID: uid,
|
||||
Gallery: params.Gallery,
|
||||
}
|
||||
if r, err = dao.CreateResource(r); err != nil {
|
||||
return 0, err
|
||||
@@ -452,6 +454,7 @@ func EditResource(uid, rid uint, params *ResourceParams) error {
|
||||
r.AlternativeTitles = params.AlternativeTitles
|
||||
r.Article = params.Article
|
||||
r.Links = params.Links
|
||||
r.Gallery = params.Gallery
|
||||
|
||||
images := make([]model.Image, len(params.Images))
|
||||
for i, id := range params.Images {
|
||||
|
||||
Reference in New Issue
Block a user