Compare commits

..

9 Commits

17 changed files with 993 additions and 368 deletions

View File

@@ -30,3 +30,6 @@ BACKUP_SCHEDULE=0 2 * * *
# Retention policy (days) # Retention policy (days)
BACKUP_RETENTION_DAYS=30 BACKUP_RETENTION_DAYS=30
# Download Configuration
DOWNLOAD_SECRET_KEY=your_download_secret_key_here

View File

@@ -0,0 +1,426 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
MdOutlineChevronLeft,
MdOutlineChevronRight,
MdOutlineClose,
} from "react-icons/md";
import { network } from "../network/network.ts";
import Badge from "./badge.tsx";
export default function Gallery({
images,
nsfw,
}: {
images: number[];
nsfw: 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);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
// 预加载下一张图片
useEffect(() => {
if (!images || images.length <= 1) return;
const nextIndex = (currentIndex + 1) % images.length;
const nextImageUrl = network.getImageUrl(images[nextIndex]);
const img = new Image();
img.src = nextImageUrl;
}, [currentIndex, images]);
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);
};
if (nsfw == null) {
nsfw = [];
}
// 如果图片数量超过8张显示数字而不是圆点
const showDots = images.length <= 8;
return (
<>
<GalleryFullscreen
dialogRef={dialogRef}
images={images}
currentIndex={currentIndex}
direction={direction}
goToPrevious={goToPrevious}
goToNext={goToNext}
setDirection={setDirection}
setCurrentIndex={setCurrentIndex}
/>
<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"
onClick={() => {
dialogRef.current?.showModal();
}}
>
{width > 0 && (
<AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={currentIndex}
className="absolute inset-0 w-full h-full"
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}
>
<GalleryImage
src={network.getImageUrl(images[currentIndex])}
nfsw={nsfw.includes(currentIndex)}
/>
</motion.div>
</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">
{showDots ? (
/* 圆点指示器 */
<div className="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 className="bg-base-100/20 px-2 py-1 rounded-full text-xs">
{currentIndex + 1} / {images.length}
</div>
)}
</div>
)}
</div>
</>
);
}
function GalleryFullscreen({
dialogRef,
images,
currentIndex,
direction,
goToPrevious,
goToNext,
setDirection,
setCurrentIndex,
}: {
dialogRef: React.RefObject<HTMLDialogElement | null>;
images: number[];
currentIndex: number;
direction: number;
goToPrevious: () => void;
goToNext: () => void;
setDirection: (direction: number) => void;
setCurrentIndex: (index: number) => void;
}) {
const [width, setWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const thumbnailContainerRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<number | null>(null);
const [isHovered, setIsHovered] = useState(true);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
console.log(containerRef.current.clientWidth);
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
useEffect(() => {
const handleMouseMove = () => {
setIsHovered(true);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
hideTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
}, 2000);
};
if (dialogRef.current?.open) {
window.addEventListener("mousemove", handleMouseMove);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, [dialogRef.current?.open, setIsHovered]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (dialogRef.current?.open) {
if (e.key === "ArrowLeft") {
e.preventDefault();
goToPrevious();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goToNext();
} else if (e.key === "Escape") {
dialogRef.current?.close();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [dialogRef, goToPrevious, goToNext]);
useEffect(() => {
if (thumbnailContainerRef.current && dialogRef.current?.open) {
const thumbnail = thumbnailContainerRef.current.children[currentIndex] as HTMLElement;
if (thumbnail) {
thumbnail.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}
}
}, [currentIndex, dialogRef]);
return (
<dialog
ref={dialogRef}
onClick={() => {
dialogRef.current?.close();
}}
className="modal"
onMouseLeave={() => setIsHovered(false)}
>
<div
ref={containerRef}
className="modal-box w-full h-full max-h-screen max-w-screen p-4 bg-transparent shadow-none relative overflow-clip"
>
{width > 0 && (
<AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={`fullscreen-${currentIndex}`}
className="absolute inset-0 w-full h-full"
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}
>
<img
src={network.getImageUrl(images[currentIndex])}
alt=""
className="w-full h-full object-contain rounded-xl"
/>
</motion.div>
</AnimatePresence>
)}
{/* 全屏模式下的左右切换按钮 */}
{images.length > 1 && (
<>
<button
className={`absolute left-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToPrevious();
}}
>
<MdOutlineChevronLeft size={24} />
</button>
<button
className={`absolute right-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
>
<MdOutlineChevronRight size={24} />
</button>
{/* 图片缩略图列表 */}
<div
className={`absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity ${
isHovered ? "opacity-100" : "opacity-0"
}`}
onClick={(e) => e.stopPropagation()}
>
<div
ref={thumbnailContainerRef}
className="flex gap-2 overflow-x-auto max-w-[80vw] px-2 py-2 bg-base-100/60 rounded-xl scrollbar-thin scrollbar-thumb-base-content/30 scrollbar-track-transparent"
>
{images.map((imageId, index) => (
<button
key={index}
className={`flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden transition-all ${
index === currentIndex
? "ring-2 ring-primary scale-110"
: "opacity-60 hover:opacity-100"
}`}
onClick={(e) => {
e.stopPropagation();
const newDirection = index > currentIndex ? 1 : -1;
setDirection(newDirection);
setCurrentIndex(index);
}}
>
<img
src={network.getResampledImageUrl(imageId)}
alt={`Thumbnail ${index + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
{/* 关闭按钮 */}
<button
className={`absolute top-4 right-4 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors`}
onClick={(e) => {
e.stopPropagation();
dialogRef.current?.close();
}}
>
<MdOutlineClose size={24} />
</button>
</>
)}
</div>
</dialog>
);
}
function GalleryImage({ src, nfsw }: { src: string; nfsw: boolean }) {
const [show, setShow] = useState(!nfsw);
return (
<div className="relative w-full h-full">
<img
src={src}
alt=""
className={`w-full h-full object-contain transition-all duration-300 ${!show ? "blur-xl" : ""}`}
/>
{!show && (
<>
<div
className="absolute inset-0 bg-base-content/20 cursor-pointer"
onClick={(event) => {
setShow(true);
event.stopPropagation();
}}
/>
<div className="absolute top-4 left-4">
<Badge className="badge-error shadow-lg">NSFW</Badge>
</div>
</>
)}
</div>
);
}

View File

@@ -67,7 +67,7 @@ export default function Navigator() {
{/* Background overlay */} {/* Background overlay */}
{background && ( {background && (
<div <div
className="bg-base-100 opacity-60 dark:opacity-40" className="bg-base-100 opacity-20 dark:opacity-40"
style={{ style={{
position: "fixed", position: "fixed",
top: 0, top: 0,

View File

@@ -457,6 +457,28 @@ class Network {
); );
} }
async createFTPStorage(
name: string,
host: string,
username: string,
password: string,
basePath: string,
domain: string,
maxSizeInMB: number,
): Promise<Response<any>> {
return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/storage/ftp`, {
name,
host,
username,
password,
basePath,
domain,
maxSizeInMB,
}),
);
}
async listStorages(): Promise<Response<Storage[]>> { async listStorages(): Promise<Response<Storage[]>> {
return this._callApi(() => axios.get(`${this.apiBaseUrl}/storage`)); return this._callApi(() => axios.get(`${this.apiBaseUrl}/storage`));
} }

View File

@@ -70,6 +70,7 @@ export default function HomePage() {
function HomeHeader() { function HomeHeader() {
const [pinnedResources, setPinnedResources] = useState<Resource[]>([]); const [pinnedResources, setPinnedResources] = useState<Resource[]>([]);
const [statistic, setStatistic] = useState<Statistics | null>(null); const [statistic, setStatistic] = useState<Statistics | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const navigator = useNavigator(); const navigator = useNavigator();
const appContext = useAppContext(); const appContext = useAppContext();
@@ -127,13 +128,30 @@ function HomeHeader() {
fetchStatistics(); fetchStatistics();
}, [appContext, navigator]); }, [appContext, navigator]);
// Auto-scroll carousel every 5 seconds
useEffect(() => {
if (pinnedResources.length <= 1) {
return;
}
const interval = setInterval(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % pinnedResources.length);
}, 5000);
return () => clearInterval(interval);
}, [pinnedResources.length, currentIndex]);
if (pinnedResources.length == 0 || statistic == null) { if (pinnedResources.length == 0 || statistic == null) {
return <></>; return <></>;
} }
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 p-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 p-4 gap-4">
<PinnedResourceItem resource={pinnedResources[0]} /> <PinnedResourcesCarousel
resources={pinnedResources}
currentIndex={currentIndex}
onIndexChange={setCurrentIndex}
/>
<div className={"hidden md:flex h-52 md:h-60 flex-col"}> <div className={"hidden md:flex h-52 md:h-60 flex-col"}>
<div className={"card w-full shadow p-4 mb-4 bg-base-100-tr82 flex-1"}> <div className={"card w-full shadow p-4 mb-4 bg-base-100-tr82 flex-1"}>
<h2 className={"text-lg font-bold pb-2"}>{app.appName}</h2> <h2 className={"text-lg font-bold pb-2"}>{app.appName}</h2>
@@ -145,6 +163,49 @@ function HomeHeader() {
); );
} }
function PinnedResourcesCarousel({
resources,
currentIndex,
onIndexChange,
}: {
resources: Resource[];
currentIndex: number;
onIndexChange: (index: number) => void;
}) {
return (
<div className="relative">
<div className="overflow-hidden rounded-2xl">
<div
className="flex transition-transform duration-500 ease-in-out"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{resources.map((resource) => (
<div key={resource.id} className="w-full flex-shrink-0">
<PinnedResourceItem resource={resource} />
</div>
))}
</div>
</div>
{resources.length > 1 && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2 z-10">
{resources.map((_, index) => (
<button
key={index}
onClick={() => onIndexChange(index)}
className={`w-2 h-2 rounded-full transition-all ${
index === currentIndex
? "bg-white w-6"
: "bg-white/50 hover:bg-white/75"
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
)}
</div>
);
}
function PinnedResourceItem({ resource }: { resource: Resource }) { function PinnedResourceItem({ resource }: { resource: Resource }) {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -244,6 +244,7 @@ export default function StorageView() {
enum StorageType { enum StorageType {
local, local,
s3, s3,
ftp,
} }
function NewStorageDialog({ onAdded }: { onAdded: () => void }) { function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
@@ -259,6 +260,10 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
bucketName: "", bucketName: "",
maxSizeInMB: 0, maxSizeInMB: 0,
domain: "", domain: "",
host: "",
username: "",
password: "",
basePath: "",
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -305,6 +310,28 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
params.maxSizeInMB, params.maxSizeInMB,
params.domain, params.domain,
); );
} else if (storageType === StorageType.ftp) {
if (
params.host === "" ||
params.username === "" ||
params.password === "" ||
params.domain === "" ||
params.name === "" ||
params.maxSizeInMB <= 0
) {
setError(t("All fields are required"));
setIsLoading(false);
return;
}
response = await network.createFTPStorage(
params.name,
params.host,
params.username,
params.password,
params.basePath,
params.domain,
params.maxSizeInMB,
);
} }
if (response!.success) { if (response!.success) {
@@ -368,6 +395,15 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
setStorageType(StorageType.s3); setStorageType(StorageType.s3);
}} }}
/> />
<input
className="btn"
type="radio"
name="type"
aria-label={t("FTP")}
onInput={() => {
setStorageType(StorageType.ftp);
}}
/>
</form> </form>
{storageType === StorageType.local && ( {storageType === StorageType.local && (
@@ -525,6 +561,114 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
</> </>
)} )}
{storageType === StorageType.ftp && (
<>
<label className="input w-full my-2">
{t("Name")}
<input
type="text"
className="w-full"
value={params.name}
onChange={(e) => {
setParams({
...params,
name: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Host")}
<input
type="text"
placeholder="ftp.example.com:21"
className="w-full"
value={params.host}
onChange={(e) => {
setParams({
...params,
host: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Username")}
<input
type="text"
className="w-full"
value={params.username}
onChange={(e) => {
setParams({
...params,
username: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Password")}
<input
type="password"
className="w-full"
value={params.password}
onChange={(e) => {
setParams({
...params,
password: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Base Path")}
<input
type="text"
placeholder="/uploads"
className="w-full"
value={params.basePath}
onChange={(e) => {
setParams({
...params,
basePath: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Domain")}
<input
type="text"
placeholder="files.example.com"
className="w-full"
value={params.domain}
onChange={(e) => {
setParams({
...params,
domain: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Max Size (MB)")}
<input
type="number"
className="validator"
required
min="0"
value={params.maxSizeInMB.toString()}
onChange={(e) => {
setParams({
...params,
maxSizeInMB: parseInt(e.target.value),
});
}}
/>
</label>
</>
)}
{error !== "" && <ErrorAlert message={error} className={"my-2"} />} {error !== "" && <ErrorAlert message={error} className={"my-2"} />}
<div className="modal-action"> <div className="modal-action">

View File

@@ -32,9 +32,6 @@ import {
MdOutlineAdd, MdOutlineAdd,
MdOutlineArchive, MdOutlineArchive,
MdOutlineArticle, MdOutlineArticle,
MdOutlineChevronLeft,
MdOutlineChevronRight,
MdOutlineClose,
MdOutlineCloud, MdOutlineCloud,
MdOutlineComment, MdOutlineComment,
MdOutlineContentCopy, MdOutlineContentCopy,
@@ -71,7 +68,7 @@ import KunApi, {
} from "../network/kun.ts"; } from "../network/kun.ts";
import { Debounce } from "../utils/debounce.ts"; import { Debounce } from "../utils/debounce.ts";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { AnimatePresence, motion } from "framer-motion"; import Gallery from "../components/gallery.tsx";
export default function ResourcePage() { export default function ResourcePage() {
const params = useParams(); const params = useParams();
@@ -881,6 +878,8 @@ function CloudflarePopup({ file }: { file: RFile }) {
const [isLoading, setLoading] = useState(true); const [isLoading, setLoading] = useState(true);
const [downloadToken, setDownloadToken] = useState<string | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -890,13 +889,17 @@ function CloudflarePopup({ file }: { file: RFile }) {
{isLoading ? ( {isLoading ? (
<div <div
className={ className={
"absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center" "absolute top-0 bottom-8 left-0 right-0 flex items-center justify-center"
} }
> >
<span className={"loading loading-spinner loading-lg"}></span> <span className={"loading loading-spinner loading-lg"}></span>
</div> </div>
) : null} ) : null}
<h3 className={"font-bold m-2"}>{t("Verifying your request")}</h3> <h3 className={"font-bold m-2"}>
{downloadToken ? t("Verification successful") : t("Verifying your request")}
</h3>
{!downloadToken && (
<>
<div className={"h-20 w-full"}> <div className={"h-20 w-full"}>
<Turnstile <Turnstile
siteKey={app.cloudflareTurnstileSiteKey!} siteKey={app.cloudflareTurnstileSiteKey!}
@@ -904,9 +907,7 @@ function CloudflarePopup({ file }: { file: RFile }) {
setLoading(false); setLoading(false);
}} }}
onSuccess={(token) => { onSuccess={(token) => {
closePopup(); setDownloadToken(token);
const link = network.getFileDownloadLink(file.id, token);
window.open(link, "_blank");
}} }}
></Turnstile> ></Turnstile>
</div> </div>
@@ -915,6 +916,23 @@ function CloudflarePopup({ file }: { file: RFile }) {
"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.",
)} )}
</p> </p>
</>
)}
{downloadToken && (
<div className="p-2">
<a
href={network.getFileDownloadLink(file.id, downloadToken)}
target="_blank"
className="btn btn-primary btn-sm w-full"
onClick={() => {
closePopup();
}}
>
<MdOutlineDownload size={20} />
{t("Download")}
</a>
</div>
)}
</div> </div>
); );
} }
@@ -2050,346 +2068,6 @@ function CollectionSelector({
); );
} }
function Gallery({ images, nsfw }: { images: number[], nsfw: 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);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
// 预加载下一张图片
useEffect(() => {
if (!images || images.length <= 1) return;
const nextIndex = (currentIndex + 1) % images.length;
const nextImageUrl = network.getImageUrl(images[nextIndex]);
const img = new Image();
img.src = nextImageUrl;
}, [currentIndex, images]);
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);
};
if (nsfw == null) {
nsfw = [];
}
// 如果图片数量超过8张显示数字而不是圆点
const showDots = images.length <= 8;
return (
<>
<GalleryFullscreen
dialogRef={dialogRef}
images={images}
currentIndex={currentIndex}
direction={direction}
isHovered={isHovered}
setIsHovered={setIsHovered}
goToPrevious={goToPrevious}
goToNext={goToNext}
/>
<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" onClick={() => {
dialogRef.current?.showModal();
}}>
{width > 0 && (
<AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={currentIndex}
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}
>
<GalleryImage
src={network.getImageUrl(images[currentIndex])}
nfsw={nsfw.includes(images[currentIndex])}
/>
</motion.div>
</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">
{showDots ? (
/* 圆点指示器 */
<div className="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 className="bg-base-100/20 px-2 py-1 rounded-full text-xs">
{currentIndex + 1} / {images.length}
</div>
)}
</div>
)}
</div>
</>
);
}
function GalleryFullscreen({
dialogRef,
images,
currentIndex,
direction,
isHovered,
setIsHovered,
goToPrevious,
goToNext,
}: {
dialogRef: React.RefObject<HTMLDialogElement | null>;
images: number[];
currentIndex: number;
direction: number;
isHovered: boolean;
setIsHovered: (hovered: boolean) => void;
goToPrevious: () => void;
goToNext: () => void;
}) {
const [width, setWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
console.log(containerRef.current.clientWidth);
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (dialogRef.current?.open) {
if (e.key === "ArrowLeft") {
e.preventDefault();
goToPrevious();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goToNext();
} else if (e.key === "Escape") {
dialogRef.current?.close();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [dialogRef, goToPrevious, goToNext]);
return (
<dialog
ref={dialogRef}
onClick={() => {
dialogRef.current?.close();
}}
className="modal"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div ref={containerRef} className="modal-box w-full h-full max-h-screen max-w-screen p-4 bg-transparent shadow-none relative overflow-clip">
{width > 0 && <AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={`fullscreen-${currentIndex}`}
className="absolute inset-0 w-full h-full"
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}
>
<img
src={network.getImageUrl(images[currentIndex])}
alt=""
className="w-full h-full object-contain rounded-xl p-4 sm:p-6"
/>
</motion.div>
</AnimatePresence>}
{/* 全屏模式下的左右切换按钮 */}
{images.length > 1 && (
<>
<button
className={`absolute left-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToPrevious();
}}
>
<MdOutlineChevronLeft size={24} />
</button>
<button
className={`absolute right-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
>
<MdOutlineChevronRight size={24} />
</button>
{/* 全屏模式下的指示器 */}
<div className={`absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity ${
isHovered ? "opacity-100" : "opacity-0"
}`}>
<div className="bg-base-100/60 backdrop-blur-sm px-3 py-1.5 rounded-full text-sm font-medium select-none">
{currentIndex + 1} / {images.length}
</div>
</div>
{/* 关闭按钮 */}
<button
className={`absolute top-4 right-4 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors`}
onClick={(e) => {
e.stopPropagation();
dialogRef.current?.close();
}}
>
<MdOutlineClose size={24} />
</button>
</>
)}
</div>
</dialog>
);
}
function GalleryImage({src, nfsw}: {src: string, nfsw: boolean}) {
const [show, setShow] = useState(!nfsw);
return (
<div className="relative w-full h-full">
<img
src={src}
alt=""
className={`w-full h-full object-contain transition-all duration-300 ${!show ? 'blur-xl' : ''}`}
/>
{!show && (
<>
<div className="absolute inset-0 bg-base-content/20 cursor-pointer" onClick={(event) => {
setShow(true)
event.stopPropagation();
}} />
<div className="absolute top-4 left-4">
<Badge className="badge-error shadow-lg">
NSFW
</Badge>
</div>
</>
)}
</div>
);
}
function Characters({ characters }: { characters: CharacterParams[] }) { function Characters({ characters }: { characters: CharacterParams[] }) {
const { t } = useTranslation(); const { t } = useTranslation();

3
go.mod
View File

@@ -39,6 +39,9 @@ require (
github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/golang/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.3.2 // indirect
github.com/golang/snappy v0.0.1 // indirect github.com/golang/snappy v0.0.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jlaffaye/ftp v0.2.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect github.com/mschoch/smat v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/steveyen/gtreap v0.1.0 // indirect github.com/steveyen/gtreap v0.1.0 // indirect

6
go.sum
View File

@@ -95,6 +95,10 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 h1:twflg0XRTjwKpxb/jFExr4HGq6on2dEOmnL6FV+fgPw= github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 h1:twflg0XRTjwKpxb/jFExr4HGq6on2dEOmnL6FV+fgPw=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg= github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg=
@@ -103,6 +107,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg=
github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI=
github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U= github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U=
github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ= github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=

View File

@@ -225,7 +225,18 @@ func downloadFile(c fiber.Ctx) error {
return err return err
} }
if strings.HasPrefix(s, "http") { if strings.HasPrefix(s, "http") {
return c.Redirect().Status(fiber.StatusFound).To(s) uri, err := url.Parse(s)
if err != nil {
return err
}
token, err := utils.GenerateDownloadToken(s)
if err != nil {
return err
}
q := uri.Query()
q.Set("token", token)
uri.RawQuery = q.Encode()
return c.Redirect().Status(fiber.StatusFound).To(uri.String())
} }
data := map[string]string{ data := map[string]string{
"path": s, "path": s,

View File

@@ -88,6 +88,10 @@ func handleGetResampledImage(c fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
if image == nil {
// No resampled image, redirect to original
return c.Redirect().To("/api/image/" + idStr)
}
contentType := http.DetectContentType(image) contentType := http.DetectContentType(image)
c.Set("Content-Type", contentType) c.Set("Content-Type", contentType)
c.Set("Cache-Control", "public, max-age=31536000") c.Set("Cache-Control", "public, max-age=31536000")

View File

@@ -69,6 +69,37 @@ func handleCreateLocalStorage(c fiber.Ctx) error {
}) })
} }
func handleCreateFTPStorage(c fiber.Ctx) error {
var params service.CreateFTPStorageParams
if err := c.Bind().JSON(&params); err != nil {
return model.NewRequestError("Invalid request body")
}
if params.Name == "" || params.Host == "" || params.Username == "" ||
params.Password == "" || params.Domain == "" {
return model.NewRequestError("All fields are required")
}
if params.MaxSizeInMB <= 0 {
return model.NewRequestError("Max size must be greater than 0")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You are not authorized to perform this action")
}
err := service.CreateFTPStorage(uid, params)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(model.Response[any]{
Success: true,
Message: "FTP storage created successfully",
})
}
func handleListStorages(c fiber.Ctx) error { func handleListStorages(c fiber.Ctx) error {
storages, err := service.ListStorages() storages, err := service.ListStorages()
if err != nil { if err != nil {
@@ -136,6 +167,7 @@ func AddStorageRoutes(r fiber.Router) {
s := r.Group("storage") s := r.Group("storage")
s.Post("/s3", handleCreateS3Storage) s.Post("/s3", handleCreateS3Storage)
s.Post("/local", handleCreateLocalStorage) s.Post("/local", handleCreateLocalStorage)
s.Post("/ftp", handleCreateFTPStorage)
s.Get("/", handleListStorages) s.Get("/", handleListStorages)
s.Delete("/:id", handleDeleteStorage) s.Delete("/:id", handleDeleteStorage)
s.Put("/:id/default", handleSetDefaultStorage) s.Put("/:id/default", handleSetDefaultStorage)

View File

@@ -173,12 +173,17 @@ func deleteImage(id uint) error {
return nil return nil
} }
// GetResampledImage returns a resampled version of the image if it exceeds the maximum pixel limit, otherwise returns nil.
func GetResampledImage(id uint) ([]byte, error) { func GetResampledImage(id uint) ([]byte, error) {
i, err := dao.GetImageByID(id) i, err := dao.GetImageByID(id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if i.Width*i.Height <= resampledMaxPixels {
return nil, nil
}
data, err := getOrCreateResampledImage(i) data, err := getOrCreateResampledImage(i)
if err != nil { if err != nil {
log.Error("Error getting or creating resampled image:", err) log.Error("Error getting or creating resampled image:", err)

View File

@@ -78,6 +78,42 @@ func CreateLocalStorage(uid uint, params CreateLocalStorageParams) error {
return err return err
} }
type CreateFTPStorageParams struct {
Name string `json:"name"`
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
BasePath string `json:"basePath"`
Domain string `json:"domain"`
MaxSizeInMB uint `json:"maxSizeInMB"`
}
func CreateFTPStorage(uid uint, params CreateFTPStorageParams) error {
isAdmin, err := CheckUserIsAdmin(uid)
if err != nil {
log.Errorf("check user is admin failed: %s", err)
return model.NewInternalServerError("check user is admin failed")
}
if !isAdmin {
return model.NewUnAuthorizedError("only admin can create ftp storage")
}
ftp := storage.FTPStorage{
Host: params.Host,
Username: params.Username,
Password: params.Password,
BasePath: params.BasePath,
Domain: params.Domain,
}
s := model.Storage{
Name: params.Name,
Type: ftp.Type(),
Config: ftp.ToString(),
MaxSize: int64(params.MaxSizeInMB) * 1024 * 1024,
}
_, err = dao.CreateStorage(s)
return err
}
func ListStorages() ([]model.StorageView, error) { func ListStorages() ([]model.StorageView, error) {
storages, err := dao.GetStorages() storages, err := dao.GetStorages()
if err != nil { if err != nil {

164
server/storage/ftp.go Normal file
View File

@@ -0,0 +1,164 @@
package storage
import (
"encoding/json"
"errors"
"os"
"path"
"time"
"github.com/gofiber/fiber/v3/log"
"github.com/google/uuid"
"github.com/jlaffaye/ftp"
)
type FTPStorage struct {
Host string // FTP服务器地址例如: "ftp.example.com:21"
Username string // FTP用户名
Password string // FTP密码
BasePath string // FTP服务器上的基础路径例如: "/uploads"
Domain string // 文件服务器域名,用于生成下载链接,例如: "files.example.com"
}
func (f *FTPStorage) Upload(filePath string, fileName string) (string, error) {
// 连接到FTP服务器
conn, err := ftp.Dial(f.Host, ftp.DialWithTimeout(10*time.Second))
if err != nil {
log.Error("Failed to connect to FTP server: ", err)
return "", errors.New("failed to connect to FTP server")
}
defer conn.Quit()
// 登录
err = conn.Login(f.Username, f.Password)
if err != nil {
log.Error("Failed to login to FTP server: ", err)
return "", errors.New("failed to login to FTP server")
}
// 生成唯一的存储键
storageKey := uuid.NewString() + "/" + fileName
remotePath := path.Join(f.BasePath, storageKey)
// 创建远程目录
remoteDir := path.Dir(remotePath)
err = f.createRemoteDir(conn, remoteDir)
if err != nil {
log.Error("Failed to create remote directory: ", err)
return "", errors.New("failed to create remote directory")
}
// 打开本地文件
file, err := os.Open(filePath)
if err != nil {
log.Error("Failed to open local file: ", err)
return "", errors.New("failed to open local file")
}
defer file.Close()
// 上传文件
err = conn.Stor(remotePath, file)
if err != nil {
log.Error("Failed to upload file to FTP server: ", err)
return "", errors.New("failed to upload file to FTP server")
}
return storageKey, nil
}
func (f *FTPStorage) Download(storageKey string, fileName string) (string, error) {
// 返回文件下载链接:域名 + 存储键
if f.Domain == "" {
return "", errors.New("domain is not configured")
}
return "https://" + f.Domain + "/" + storageKey, nil
}
func (f *FTPStorage) Delete(storageKey string) error {
// 连接到FTP服务器
conn, err := ftp.Dial(f.Host, ftp.DialWithTimeout(10*time.Second))
if err != nil {
log.Error("Failed to connect to FTP server: ", err)
return errors.New("failed to connect to FTP server")
}
defer conn.Quit()
// 登录
err = conn.Login(f.Username, f.Password)
if err != nil {
log.Error("Failed to login to FTP server: ", err)
return errors.New("failed to login to FTP server")
}
// 删除文件
remotePath := path.Join(f.BasePath, storageKey)
err = conn.Delete(remotePath)
if err != nil {
log.Error("Failed to delete file from FTP server: ", err)
return errors.New("failed to delete file from FTP server")
}
return nil
}
func (f *FTPStorage) ToString() string {
data, _ := json.Marshal(f)
return string(data)
}
func (f *FTPStorage) FromString(config string) error {
var ftpConfig FTPStorage
if err := json.Unmarshal([]byte(config), &ftpConfig); err != nil {
return err
}
f.Host = ftpConfig.Host
f.Username = ftpConfig.Username
f.Password = ftpConfig.Password
f.BasePath = ftpConfig.BasePath
f.Domain = ftpConfig.Domain
if f.Host == "" || f.Username == "" || f.Password == "" || f.Domain == "" {
return errors.New("invalid FTP configuration")
}
if f.BasePath == "" {
f.BasePath = "/"
}
return nil
}
func (f *FTPStorage) Type() string {
return "ftp"
}
// createRemoteDir 递归创建远程目录
func (f *FTPStorage) createRemoteDir(conn *ftp.ServerConn, dirPath string) error {
if dirPath == "" || dirPath == "/" || dirPath == "." {
return nil
}
// 尝试进入目录,如果失败则创建
err := conn.ChangeDir(dirPath)
if err == nil {
// 目录存在,返回根目录
conn.ChangeDir("/")
return nil
}
// 递归创建父目录
parentDir := path.Dir(dirPath)
if parentDir != dirPath {
err = f.createRemoteDir(conn, parentDir)
if err != nil {
return err
}
}
// 创建当前目录
err = conn.MakeDir(dirPath)
if err != nil {
// 忽略目录已存在的错误
return nil
}
return nil
}

View File

@@ -43,6 +43,14 @@ func NewStorage(s model.Storage) IStorage {
return nil return nil
} }
return &r return &r
case "ftp":
r := FTPStorage{}
err := r.FromString(s.Config)
if err != nil {
return nil
}
return &r
} }
return nil return nil
} }

View File

@@ -3,9 +3,10 @@ package utils
import ( import (
"crypto/rand" "crypto/rand"
"errors" "errors"
"github.com/golang-jwt/jwt/v5"
"os" "os"
"time" "time"
"github.com/golang-jwt/jwt/v5"
) )
var ( var (
@@ -93,3 +94,24 @@ func ParseTemporaryToken(token string) (string, error) {
} }
return "", errors.New("invalid token") return "", errors.New("invalid token")
} }
func GenerateDownloadToken(fileKey string) (string, error) {
secretKeyStr := os.Getenv("DOWNLOAD_SECRET_KEY")
var secretKey []byte
if secretKeyStr == "" {
secretKey = key
} else {
secretKey = []byte(secretKeyStr)
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{
"fileKey": fileKey,
"exp": time.Now().Add(1 * time.Hour).Unix(),
})
s, err := t.SignedString(secretKey)
if err != nil {
return "", err
}
return s, nil
}