Compare commits

...

3 Commits

Author SHA1 Message Date
2499962815 improve gallery 2025-11-01 17:28:24 +08:00
a4de7a1d54 fix 2025-11-01 17:16:58 +08:00
3e7ce7b4cd Add gallery 2025-11-01 17:12:06 +08:00
6 changed files with 237 additions and 51 deletions

View File

@@ -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 {

View File

@@ -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"}

View File

@@ -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"}

View File

@@ -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>
);
}

View File

@@ -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,
}
}

View File

@@ -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 {