mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 20:27:23 +00:00
format
This commit is contained in:
@@ -1,10 +1,6 @@
|
||||
import {createContext, ReactNode, useContext} from "react";
|
||||
import { createContext, ReactNode, useContext } from "react";
|
||||
|
||||
export default function AppContext({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
export default function AppContext({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<context.Provider value={new Map<string, any>()}>
|
||||
{children}
|
||||
@@ -15,5 +11,5 @@ export default function AppContext({
|
||||
const context = createContext<Map<string, any>>(new Map<string, any>());
|
||||
|
||||
export function useAppContext() {
|
||||
return useContext(context)
|
||||
}
|
||||
return useContext(context);
|
||||
}
|
||||
|
@@ -1,18 +1,53 @@
|
||||
export function ErrorAlert({ message, className }: { message: string, className?: string }) {
|
||||
return <div role="alert" className={`alert alert-error ${className}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{message}</span>
|
||||
</div>;
|
||||
export function ErrorAlert({
|
||||
message,
|
||||
className,
|
||||
}: {
|
||||
message: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="alert" className={`alert alert-error ${className}`}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoAlert({ message, className }: { message: string, className?: string }) {
|
||||
return <div role="alert" className={`alert alert-info ${className}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="h-6 w-6 shrink-0 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>{message}</span>
|
||||
</div>;
|
||||
}
|
||||
export function InfoAlert({
|
||||
message,
|
||||
className,
|
||||
}: {
|
||||
message: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div role="alert" className={`alert alert-info ${className}`}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,9 +1,39 @@
|
||||
import {ReactNode} from "react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function Badge({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) {
|
||||
return <span className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`} onClick={onClick}>{children}</span>
|
||||
export default function Badge({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BadgeAccent({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) {
|
||||
return <span className={`badge badge-accent text-sm ${className}`} onClick={onClick}>{children}</span>
|
||||
}
|
||||
export function BadgeAccent({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`badge badge-accent text-sm ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@@ -1,14 +1,28 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function Button({ children, onClick, className, disabled, isLoading }: { children: ReactNode, onClick?: () => void, className?: string, disabled?: boolean, isLoading?: boolean }) {
|
||||
return <button
|
||||
className={`btn ${className} ${disabled ? "btn-disabled" : ""} h-9`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isLoading && <span className="loading loading-spinner loading-sm mr-2"></span>}
|
||||
<span className="text-sm">
|
||||
{children}
|
||||
</span>
|
||||
</button>;
|
||||
}
|
||||
export default function Button({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
disabled,
|
||||
isLoading,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`btn ${className} ${disabled ? "btn-disabled" : ""} h-9`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{isLoading && (
|
||||
<span className="loading loading-spinner loading-sm mr-2"></span>
|
||||
)}
|
||||
<span className="text-sm">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import {MdAdd} from "react-icons/md";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {network} from "../network/network.ts";
|
||||
import { MdAdd } from "react-icons/md";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { network } from "../network/network.ts";
|
||||
import showToast from "./toast.ts";
|
||||
import {useState} from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
async function uploadImages(files: File[]): Promise<number[]> {
|
||||
const images: number[] = [];
|
||||
|
||||
|
||||
for (const file of files) {
|
||||
const res = await network.uploadImage(file);
|
||||
if (res.success) {
|
||||
@@ -15,65 +15,83 @@ async function uploadImages(files: File[]): Promise<number[]> {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: `Failed to upload image: ${res.message}`,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
export function SelectAndUploadImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) {
|
||||
const [isUploading, setUploading] = useState(false)
|
||||
export function SelectAndUploadImageButton({
|
||||
onUploaded,
|
||||
}: {
|
||||
onUploaded: (image: number[]) => void;
|
||||
}) {
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const addImage = () => {
|
||||
if (isUploading) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "image/*"
|
||||
input.multiple = true
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true;
|
||||
input.onchange = async () => {
|
||||
if (!input.files || input.files.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
setUploading(true)
|
||||
setUploading(true);
|
||||
const files = Array.from(input.files);
|
||||
const uploadedImages = await uploadImages(files);
|
||||
setUploading(false);
|
||||
if (uploadedImages.length > 0) {
|
||||
onUploaded(uploadedImages);
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
return <button className={"btn my-2"} type={"button"} onClick={addImage}>
|
||||
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
||||
{t("Upload Image")}
|
||||
</button>
|
||||
return (
|
||||
<button className={"btn my-2"} type={"button"} onClick={addImage}>
|
||||
{isUploading ? (
|
||||
<span className="loading loading-spinner"></span>
|
||||
) : (
|
||||
<MdAdd />
|
||||
)}
|
||||
{t("Upload Image")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadClipboardImageButton({onUploaded}: {onUploaded: (image: number[]) => void}) {
|
||||
const [isUploading, setUploading] = useState(false)
|
||||
export function UploadClipboardImageButton({
|
||||
onUploaded,
|
||||
}: {
|
||||
onUploaded: (image: number[]) => void;
|
||||
}) {
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const addClipboardImage = async () => {
|
||||
if (isUploading) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
const files: File[] = [];
|
||||
for (const item of clipboardItems) {
|
||||
console.log(item)
|
||||
console.log(item);
|
||||
for (const type of item.types) {
|
||||
if (type.startsWith("image/")) {
|
||||
const blob = await item.getType(type);
|
||||
files.push(new File([blob], `clipboard-image.${type.split("/")[1]}`, { type }));
|
||||
files.push(
|
||||
new File([blob], `clipboard-image.${type.split("/")[1]}`, {
|
||||
type,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,15 +114,27 @@ export function UploadClipboardImageButton({onUploaded}: {onUploaded: (image: nu
|
||||
message: t("Failed to read clipboard image"),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return <button className={"btn my-2"} type={"button"} onClick={addClipboardImage}>
|
||||
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
|
||||
{t("Upload Clipboard Image")}
|
||||
</button>
|
||||
return (
|
||||
<button className={"btn my-2"} type={"button"} onClick={addClipboardImage}>
|
||||
{isUploading ? (
|
||||
<span className="loading loading-spinner"></span>
|
||||
) : (
|
||||
<MdAdd />
|
||||
)}
|
||||
{t("Upload Clipboard Image")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode, onUploaded: (image: number[]) => void}) {
|
||||
export function ImageDrapArea({
|
||||
children,
|
||||
onUploaded,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onUploaded: (image: number[]) => void;
|
||||
}) {
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -128,7 +158,7 @@ export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
setUploading(true);
|
||||
let files = Array.from(e.dataTransfer.files);
|
||||
files = files.filter(file => file.type.startsWith("image/"));
|
||||
files = files.filter((file) => file.type.startsWith("image/"));
|
||||
if (files.length === 0) {
|
||||
setUploading(false);
|
||||
return;
|
||||
@@ -160,4 +190,4 @@ export function ImageDrapArea({children, onUploaded}: {children: React.ReactNode
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -11,15 +11,31 @@ interface InputProps {
|
||||
|
||||
export default function Input(props: InputProps) {
|
||||
if (props.inlineLabel) {
|
||||
return <label className="input w-full">
|
||||
{props.label}
|
||||
<input type={props.type} className="grow" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
|
||||
</label>
|
||||
return (
|
||||
<label className="input w-full">
|
||||
{props.label}
|
||||
<input
|
||||
type={props.type}
|
||||
className="grow"
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
} else {
|
||||
return <fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{props.label}</legend>
|
||||
<input type={props.type} className="input w-full" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
|
||||
</fieldset>
|
||||
return (
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{props.label}</legend>
|
||||
<input
|
||||
type={props.type}
|
||||
className="input w-full"
|
||||
placeholder={props.placeholder}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +47,17 @@ interface TextAreaProps {
|
||||
}
|
||||
|
||||
export function TextArea(props: TextAreaProps) {
|
||||
return <fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{props.label}</legend>
|
||||
<textarea className={`textarea w-full ${props.height != undefined ? "resize-none" : ""}`} value={props.value} onChange={props.onChange} style={{
|
||||
height: props.height,
|
||||
}} />
|
||||
</fieldset>
|
||||
}
|
||||
return (
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{props.label}</legend>
|
||||
<textarea
|
||||
className={`textarea w-full ${props.height != undefined ? "resize-none" : ""}`}
|
||||
value={props.value}
|
||||
onChange={props.onChange}
|
||||
style={{
|
||||
height: props.height,
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
@@ -1,10 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Loading() {
|
||||
const {t} = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <div className={"flex justify-center py-4"}>
|
||||
<span className="loading loading-spinner progress-primary loading-lg mr-2"></span>
|
||||
<span>{t("Loading")}</span>
|
||||
</div>;
|
||||
}
|
||||
return (
|
||||
<div className={"flex justify-center py-4"}>
|
||||
<span className="loading loading-spinner progress-primary loading-lg mr-2"></span>
|
||||
<span>{t("Loading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -2,16 +2,21 @@ import { app } from "../app.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import { useNavigate, useOutlet } from "react-router";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import {MdArrowUpward, MdOutlinePerson, MdSearch, MdSettings} from "react-icons/md";
|
||||
import {
|
||||
MdArrowUpward,
|
||||
MdOutlinePerson,
|
||||
MdSearch,
|
||||
MdSettings,
|
||||
} from "react-icons/md";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UploadingSideBar from "./uploading_side_bar.tsx";
|
||||
import { IoLogoGithub } from "react-icons/io";
|
||||
import {useAppContext} from "./AppContext.tsx";
|
||||
import { useAppContext } from "./AppContext.tsx";
|
||||
|
||||
export default function Navigator() {
|
||||
const outlet = useOutlet()
|
||||
const outlet = useOutlet();
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
@@ -25,86 +30,154 @@ export default function Navigator() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <>
|
||||
<FloatingToTopButton/>
|
||||
<div className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10" key={key}>
|
||||
<div className={"flex-1 max-w-7xl mx-auto flex items-center"}>
|
||||
<div className="dropdown">
|
||||
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle lg:hidden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" /> </svg>
|
||||
return (
|
||||
<>
|
||||
<FloatingToTopButton />
|
||||
<div
|
||||
className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10"
|
||||
key={key}
|
||||
>
|
||||
<div className={"flex-1 max-w-7xl mx-auto flex items-center"}>
|
||||
<div className="dropdown">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-ghost btn-circle lg:hidden"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{" "}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h7"
|
||||
/>{" "}
|
||||
</svg>
|
||||
</div>
|
||||
<ul
|
||||
id={"navi_menu"}
|
||||
tabIndex={0}
|
||||
className="menu menu-md dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
|
||||
>
|
||||
<li
|
||||
onClick={() => {
|
||||
const menu = document.getElementById(
|
||||
"navi_menu",
|
||||
) as HTMLElement;
|
||||
menu.blur();
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
<a>{t("Home")}</a>
|
||||
</li>
|
||||
<li
|
||||
onClick={() => {
|
||||
const menu = document.getElementById(
|
||||
"navi_menu",
|
||||
) as HTMLElement;
|
||||
menu.blur();
|
||||
navigate("/tags");
|
||||
}}
|
||||
>
|
||||
<a>{t("Tags")}</a>
|
||||
</li>
|
||||
<li
|
||||
onClick={() => {
|
||||
const menu = document.getElementById(
|
||||
"navi_menu",
|
||||
) as HTMLElement;
|
||||
menu.blur();
|
||||
navigate("/about");
|
||||
}}
|
||||
>
|
||||
<a>{t("About")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul id={"navi_menu"}
|
||||
tabIndex={0}
|
||||
className="menu menu-md dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
|
||||
<li onClick={() => {
|
||||
const menu = document.getElementById("navi_menu") as HTMLElement;
|
||||
menu.blur();
|
||||
navigate("/");
|
||||
}}><a>{t("Home")}</a></li>
|
||||
<li onClick={() => {
|
||||
const menu = document.getElementById("navi_menu") as HTMLElement;
|
||||
menu.blur();
|
||||
navigate("/tags")
|
||||
}}><a>{t("Tags")}</a></li>
|
||||
<li onClick={() => {
|
||||
const menu = document.getElementById("navi_menu") as HTMLElement;
|
||||
menu.blur();
|
||||
navigate("/about")
|
||||
}}><a>{t("About")}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn btn-ghost text-xl" onClick={() => {
|
||||
appContext.clear()
|
||||
navigate(`/`, { replace: true});
|
||||
}}>{app.appName}</button>
|
||||
</div>
|
||||
<div className="hidden lg:flex">
|
||||
<ul className="menu menu-horizontal px-1">
|
||||
<li onClick={() => {
|
||||
navigate("/");
|
||||
}}><a>{t("Home")}</a></li>
|
||||
<li onClick={() => {
|
||||
navigate("/tags")
|
||||
}}><a>{t("Tags")}</a></li>
|
||||
<li onClick={() => {
|
||||
navigate("/about")
|
||||
}}><a>{t("About")}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={"flex-1"}></div>
|
||||
<div className="flex gap-2">
|
||||
<SearchBar/>
|
||||
<UploadingSideBar/>
|
||||
{
|
||||
app.isLoggedIn() && <button className={"btn btn-circle btn-ghost"} onClick={() => {
|
||||
navigate("/manage");
|
||||
}}>
|
||||
<MdSettings size={24}/>
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-ghost text-xl"
|
||||
onClick={() => {
|
||||
appContext.clear();
|
||||
navigate(`/`, { replace: true });
|
||||
}}
|
||||
>
|
||||
{app.appName}
|
||||
</button>
|
||||
}
|
||||
<button className={"btn btn-circle btn-ghost"} onClick={() => {
|
||||
window.open("https://github.com/wgh136/nysoure", "_blank");
|
||||
}}>
|
||||
<IoLogoGithub size={24}/>
|
||||
</button>
|
||||
{
|
||||
app.isLoggedIn() ? <UserButton/> :
|
||||
<button className={"btn btn-primary btn-square btn-soft"} onClick={() => {
|
||||
navigate("/login");
|
||||
}}>
|
||||
</div>
|
||||
<div className="hidden lg:flex">
|
||||
<ul className="menu menu-horizontal px-1">
|
||||
<li
|
||||
onClick={() => {
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
<a>{t("Home")}</a>
|
||||
</li>
|
||||
<li
|
||||
onClick={() => {
|
||||
navigate("/tags");
|
||||
}}
|
||||
>
|
||||
<a>{t("Tags")}</a>
|
||||
</li>
|
||||
<li
|
||||
onClick={() => {
|
||||
navigate("/about");
|
||||
}}
|
||||
>
|
||||
<a>{t("About")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={"flex-1"}></div>
|
||||
<div className="flex gap-2">
|
||||
<SearchBar />
|
||||
<UploadingSideBar />
|
||||
{app.isLoggedIn() && (
|
||||
<button
|
||||
className={"btn btn-circle btn-ghost"}
|
||||
onClick={() => {
|
||||
navigate("/manage");
|
||||
}}
|
||||
>
|
||||
<MdSettings size={24} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={"btn btn-circle btn-ghost"}
|
||||
onClick={() => {
|
||||
window.open("https://github.com/wgh136/nysoure", "_blank");
|
||||
}}
|
||||
>
|
||||
<IoLogoGithub size={24} />
|
||||
</button>
|
||||
{app.isLoggedIn() ? (
|
||||
<UserButton />
|
||||
) : (
|
||||
<button
|
||||
className={"btn btn-primary btn-square btn-soft"}
|
||||
onClick={() => {
|
||||
navigate("/login");
|
||||
}}
|
||||
>
|
||||
<MdOutlinePerson size={24}></MdOutlinePerson>
|
||||
</button>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<navigatorContext.Provider value={naviContext}>
|
||||
<div className={"max-w-7xl mx-auto pt-16"}>
|
||||
{outlet}
|
||||
</div>
|
||||
</navigatorContext.Provider>
|
||||
</>
|
||||
<navigatorContext.Provider value={naviContext}>
|
||||
<div className={"max-w-7xl mx-auto pt-16"}>{outlet}</div>
|
||||
</navigatorContext.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavigatorContext {
|
||||
@@ -114,8 +187,8 @@ interface NavigatorContext {
|
||||
const navigatorContext = createContext<NavigatorContext>({
|
||||
refresh: () => {
|
||||
// do nothing
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
export function useNavigator() {
|
||||
return useContext(navigatorContext);
|
||||
@@ -124,61 +197,95 @@ export function useNavigator() {
|
||||
function UserButton() {
|
||||
let avatar = "./avatar.png";
|
||||
if (app.user) {
|
||||
avatar = network.getUserAvatar(app.user)
|
||||
avatar = network.getUserAvatar(app.user);
|
||||
}
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <>
|
||||
<div className="dropdown dropdown-end">
|
||||
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle avatar">
|
||||
<div className="w-10 rounded-full">
|
||||
<img
|
||||
alt="Avatar"
|
||||
src={avatar} />
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown dropdown-end">
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className="btn btn-ghost btn-circle avatar"
|
||||
>
|
||||
<div className="w-10 rounded-full">
|
||||
<img alt="Avatar" src={avatar} />
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
id={"navi_dropdown_menu"}
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
onClick={() => {
|
||||
navigate(`/user/${app.user?.username}`);
|
||||
const menu = document.getElementById(
|
||||
"navi_dropdown_menu",
|
||||
) as HTMLUListElement;
|
||||
menu.blur();
|
||||
}}
|
||||
>
|
||||
{t("My Profile")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={() => {
|
||||
navigate(`/publish`);
|
||||
const menu = document.getElementById(
|
||||
"navi_dropdown_menu",
|
||||
) as HTMLUListElement;
|
||||
menu.blur();
|
||||
}}
|
||||
>
|
||||
{t("Publish")}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"confirm_logout",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}
|
||||
>
|
||||
{t("Log out")}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul
|
||||
id={"navi_dropdown_menu"}
|
||||
tabIndex={0}
|
||||
className="menu dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
|
||||
<li><a onClick={() => {
|
||||
navigate(`/user/${app.user?.username}`);
|
||||
const menu = document.getElementById("navi_dropdown_menu") as HTMLUListElement;
|
||||
menu.blur();
|
||||
}}>{t("My Profile")}</a></li>
|
||||
<li><a onClick={() => {
|
||||
navigate(`/publish`);
|
||||
const menu = document.getElementById("navi_dropdown_menu") as HTMLUListElement;
|
||||
menu.blur();
|
||||
}}>{t("Publish")}</a></li>
|
||||
<li><a onClick={() => {
|
||||
const dialog = document.getElementById("confirm_logout") as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>{t("Log out")}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<dialog id="confirm_logout" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="text-lg font-bold">{t("Log out")}</h3>
|
||||
<p className="py-4">{t("Are you sure you want to log out?")}</p>
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<button className="btn">{t('Cancel')}</button>
|
||||
<button className="btn btn-error mx-2" type={"button"} onClick={() => {
|
||||
app.user = null;
|
||||
app.token = null;
|
||||
app.saveData();
|
||||
navigate(`/login`, { replace: true });
|
||||
}}>{t('Confirm')}
|
||||
</button>
|
||||
</form>
|
||||
<dialog id="confirm_logout" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="text-lg font-bold">{t("Log out")}</h3>
|
||||
<p className="py-4">{t("Are you sure you want to log out?")}</p>
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<button className="btn">{t("Cancel")}</button>
|
||||
<button
|
||||
className="btn btn-error mx-2"
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
app.user = null;
|
||||
app.token = null;
|
||||
app.saveData();
|
||||
navigate(`/login`, { replace: true });
|
||||
}}
|
||||
>
|
||||
{t("Confirm")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchBar() {
|
||||
@@ -212,64 +319,94 @@ function SearchBar() {
|
||||
}
|
||||
const replace = window.location.pathname === "/search";
|
||||
navigate(`/search?keyword=${search}`, { replace: replace });
|
||||
}
|
||||
};
|
||||
|
||||
const searchField = <label className={`input input-primary ${small ? "w-full" : "w-64"}`}>
|
||||
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
const searchField = (
|
||||
<label className={`input input-primary ${small ? "w-full" : "w-64"}`}>
|
||||
<svg
|
||||
className="h-[1em] opacity-50"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<form className={"w-full"} onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSearch();
|
||||
}}>
|
||||
<input type="search" className={"w-full"} required placeholder={t("Search")} value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</form>
|
||||
</label>
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<form
|
||||
className={"w-full"}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSearch();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
className={"w-full"}
|
||||
required
|
||||
placeholder={t("Search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</label>
|
||||
);
|
||||
|
||||
if (small) {
|
||||
return <>
|
||||
<button className={"btn btn-circle btn-ghost"} onClick={() => {
|
||||
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>
|
||||
<MdSearch size={24} />
|
||||
</button>
|
||||
<dialog id="search_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<form method="dialog">
|
||||
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<h3 className="text-lg font-bold">{t("Search")}</h3>
|
||||
<div className={"h-4"} />
|
||||
{searchField}
|
||||
<div className={"h-4"} />
|
||||
<div className={"flex flex-row-reverse"}>
|
||||
<button className={"btn btn-primary"} onClick={() => {
|
||||
if (search.length === 0) {
|
||||
return;
|
||||
}
|
||||
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
|
||||
dialog.close();
|
||||
doSearch();
|
||||
}}>
|
||||
{t("Search")}
|
||||
</button>
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={"btn btn-circle btn-ghost"}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"search_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}
|
||||
>
|
||||
<MdSearch size={24} />
|
||||
</button>
|
||||
<dialog id="search_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<form method="dialog">
|
||||
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
<h3 className="text-lg font-bold">{t("Search")}</h3>
|
||||
<div className={"h-4"} />
|
||||
{searchField}
|
||||
<div className={"h-4"} />
|
||||
<div className={"flex flex-row-reverse"}>
|
||||
<button
|
||||
className={"btn btn-primary"}
|
||||
onClick={() => {
|
||||
if (search.length === 0) {
|
||||
return;
|
||||
}
|
||||
const dialog = document.getElementById(
|
||||
"search_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
doSearch();
|
||||
}}
|
||||
>
|
||||
{t("Search")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return searchField
|
||||
return searchField;
|
||||
}
|
||||
|
||||
function FloatingToTopButton() {
|
||||
@@ -293,9 +430,14 @@ function FloatingToTopButton() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <button className={`btn btn-circle btn-soft btn-secondary border shadow-lg btn-lg fixed right-4 ${visible ? "bottom-4" : "-bottom-12"} transition-all z-50`} onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}>
|
||||
<MdArrowUpward size={20}/>
|
||||
</button>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={`btn btn-circle btn-soft btn-secondary border shadow-lg btn-lg fixed right-4 ${visible ? "bottom-4" : "-bottom-12"} transition-all z-50`}
|
||||
onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
<MdArrowUpward size={20} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
@@ -1,48 +1,106 @@
|
||||
import {ReactNode} from "react";
|
||||
import {MdChevronLeft, MdChevronRight} from "react-icons/md";
|
||||
import { ReactNode } from "react";
|
||||
import { MdChevronLeft, MdChevronRight } from "react-icons/md";
|
||||
|
||||
export default function Pagination({page, setPage, totalPages}: {
|
||||
page: number,
|
||||
setPage: (page: number) => void,
|
||||
totalPages: number
|
||||
export default function Pagination({
|
||||
page,
|
||||
setPage,
|
||||
totalPages,
|
||||
}: {
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
totalPages: number;
|
||||
}) {
|
||||
const items: ReactNode[] = [];
|
||||
|
||||
if (page > 1) {
|
||||
items.push(<button key={"btn-1"} className="join-item btn" onClick={() => setPage(1)}>1</button>);
|
||||
items.push(
|
||||
<button
|
||||
key={"btn-1"}
|
||||
className="join-item btn"
|
||||
onClick={() => setPage(1)}
|
||||
>
|
||||
1
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
if (page - 2 > 1) {
|
||||
items.push(<button key={"btn-2"} className="join-item btn">...</button>);
|
||||
items.push(
|
||||
<button key={"btn-2"} className="join-item btn">
|
||||
...
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
if (page - 1 > 1) {
|
||||
items.push(<button key={"btn-3"} className="join-item btn" onClick={() => setPage(page - 1)}>{page - 1}</button>);
|
||||
items.push(
|
||||
<button
|
||||
key={"btn-3"}
|
||||
className="join-item btn"
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
{page - 1}
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
items.push(<button key={"btn-4"} className="join-item btn btn-active">{page}</button>);
|
||||
items.push(
|
||||
<button key={"btn-4"} className="join-item btn btn-active">
|
||||
{page}
|
||||
</button>,
|
||||
);
|
||||
if (page + 1 < totalPages) {
|
||||
items.push(<button key={"btn-5"} className="join-item btn" onClick={() => setPage(page + 1)}>{page + 1}</button>);
|
||||
items.push(
|
||||
<button
|
||||
key={"btn-5"}
|
||||
className="join-item btn"
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{page + 1}
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
if (page + 2 < totalPages) {
|
||||
items.push(<button key={"btn-6"} className="join-item btn">...</button>);
|
||||
items.push(
|
||||
<button key={"btn-6"} className="join-item btn">
|
||||
...
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
if (page < totalPages) {
|
||||
items.push(<button key={"btn-7"} className="join-item btn" onClick={() => setPage(totalPages)}>{totalPages}</button>);
|
||||
items.push(
|
||||
<button
|
||||
key={"btn-7"}
|
||||
className="join-item btn"
|
||||
onClick={() => setPage(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="join shadow rounded-field">
|
||||
<button key={"btn-prev"} className={`join-item btn`} onClick={() => {
|
||||
if (page > 1) {
|
||||
setPage(page - 1);
|
||||
}
|
||||
}}>
|
||||
<MdChevronLeft size={20} className="opacity-50"/>
|
||||
</button>
|
||||
{items}
|
||||
<button key={"btn-next"} className={`join-item btn`} onClick={() => {
|
||||
if (page < totalPages) {
|
||||
setPage(page + 1);
|
||||
}
|
||||
}}>
|
||||
<MdChevronRight size={20} className="opacity-50"/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div className="join shadow rounded-field">
|
||||
<button
|
||||
key={"btn-prev"}
|
||||
className={`join-item btn`}
|
||||
onClick={() => {
|
||||
if (page > 1) {
|
||||
setPage(page - 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MdChevronLeft size={20} className="opacity-50" />
|
||||
</button>
|
||||
{items}
|
||||
<button
|
||||
key={"btn-next"}
|
||||
className={`join-item btn`}
|
||||
onClick={() => {
|
||||
if (page < totalPages) {
|
||||
setPage(page + 1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MdChevronRight size={20} className="opacity-50" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,7 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
export default function showPopup(content: React.ReactNode, element: HTMLElement) {
|
||||
export default function showPopup(
|
||||
content: React.ReactNode,
|
||||
element: HTMLElement,
|
||||
) {
|
||||
const eRect = element.getBoundingClientRect();
|
||||
|
||||
const div = document.createElement("div");
|
||||
@@ -39,23 +42,33 @@ export default function showPopup(content: React.ReactNode, element: HTMLElement
|
||||
mask.onclick = close;
|
||||
document.body.appendChild(mask);
|
||||
|
||||
createRoot(div).render(<context.Provider value={close}>
|
||||
{content}
|
||||
</context.Provider>)
|
||||
createRoot(div).render(
|
||||
<context.Provider value={close}>{content}</context.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
const context = React.createContext<() => void>(() => { });
|
||||
const context = React.createContext<() => void>(() => {});
|
||||
|
||||
export function useClosePopup() {
|
||||
return React.useContext(context);
|
||||
}
|
||||
|
||||
export function PopupMenuItem({ children, onClick }: { children: React.ReactNode, onClick: () => void }) {
|
||||
export function PopupMenuItem({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const close = useClosePopup();
|
||||
return <li onClick={() => {
|
||||
close();
|
||||
onClick();
|
||||
}}>
|
||||
{children}
|
||||
</li>
|
||||
}
|
||||
return (
|
||||
<li
|
||||
onClick={() => {
|
||||
close();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
@@ -4,48 +4,57 @@ import { useNavigate } from "react-router";
|
||||
import Badge from "./badge.tsx";
|
||||
|
||||
export default function ResourceCard({ resource }: { resource: Resource }) {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
let tags = resource.tags
|
||||
let tags = resource.tags;
|
||||
if (tags.length > 10) {
|
||||
tags = tags.slice(0, 10)
|
||||
tags = tags.slice(0, 10);
|
||||
}
|
||||
|
||||
return <div className={"p-2 cursor-pointer"} onClick={() => {
|
||||
navigate(`/resources/${resource.id}`)
|
||||
}}>
|
||||
<div className={"card shadow hover:shadow-md transition-shadow"}>
|
||||
{
|
||||
resource.image != null && <figure>
|
||||
<img
|
||||
src={network.getImageUrl(resource.image.id)}
|
||||
alt="cover" style={{
|
||||
width: "100%",
|
||||
aspectRatio: resource.image.width / resource.image.height,
|
||||
}}/>
|
||||
</figure>
|
||||
}
|
||||
<div className="flex flex-col p-4">
|
||||
<h2 className="card-title">{resource.title}</h2>
|
||||
<div className="h-2"></div>
|
||||
<p>
|
||||
{
|
||||
tags.map((tag) => {
|
||||
return <Badge key={tag.id} className={"m-0.5"}>{tag.name}</Badge>
|
||||
})
|
||||
}
|
||||
</p>
|
||||
<div className="h-2"></div>
|
||||
<div className="flex items-center">
|
||||
<div className="avatar">
|
||||
<div className="w-6 rounded-full">
|
||||
<img src={network.getUserAvatar(resource.author)} />
|
||||
return (
|
||||
<div
|
||||
className={"p-2 cursor-pointer"}
|
||||
onClick={() => {
|
||||
navigate(`/resources/${resource.id}`);
|
||||
}}
|
||||
>
|
||||
<div className={"card shadow hover:shadow-md transition-shadow"}>
|
||||
{resource.image != null && (
|
||||
<figure>
|
||||
<img
|
||||
src={network.getImageUrl(resource.image.id)}
|
||||
alt="cover"
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: resource.image.width / resource.image.height,
|
||||
}}
|
||||
/>
|
||||
</figure>
|
||||
)}
|
||||
<div className="flex flex-col p-4">
|
||||
<h2 className="card-title">{resource.title}</h2>
|
||||
<div className="h-2"></div>
|
||||
<p>
|
||||
{tags.map((tag) => {
|
||||
return (
|
||||
<Badge key={tag.id} className={"m-0.5"}>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
<div className="h-2"></div>
|
||||
<div className="flex items-center">
|
||||
<div className="avatar">
|
||||
<div className="w-6 rounded-full">
|
||||
<img src={network.getUserAvatar(resource.author)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2"></div>
|
||||
<div className="text-sm">{resource.author.username}</div>
|
||||
</div>
|
||||
<div className="w-2"></div>
|
||||
<div className="text-sm">{resource.author.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@@ -1,69 +1,80 @@
|
||||
import {PageResponse, Resource} from "../network/models.ts";
|
||||
import {useCallback, useEffect, useRef, useState} from "react";
|
||||
import { PageResponse, Resource } from "../network/models.ts";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import showToast from "./toast.ts";
|
||||
import ResourceCard from "./resource_card.tsx";
|
||||
import {Masonry, useInfiniteLoader} from "masonic";
|
||||
import { Masonry, useInfiniteLoader } from "masonic";
|
||||
import Loading from "./loading.tsx";
|
||||
import {useAppContext} from "./AppContext.tsx";
|
||||
import { useAppContext } from "./AppContext.tsx";
|
||||
|
||||
export default function ResourcesView({loader, storageKey}: {loader: (page: number) => Promise<PageResponse<Resource>>, storageKey?: string}) {
|
||||
const [data, setData] = useState<Resource[]>([])
|
||||
const pageRef = useRef(1)
|
||||
const totalPagesRef = useRef(1)
|
||||
const isLoadingRef = useRef(false)
|
||||
|
||||
const appContext = useAppContext()
|
||||
export default function ResourcesView({
|
||||
loader,
|
||||
storageKey,
|
||||
}: {
|
||||
loader: (page: number) => Promise<PageResponse<Resource>>;
|
||||
storageKey?: string;
|
||||
}) {
|
||||
const [data, setData] = useState<Resource[]>([]);
|
||||
const pageRef = useRef(1);
|
||||
const totalPagesRef = useRef(1);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
const appContext = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey) {
|
||||
const data = appContext.get(storageKey + "/data")
|
||||
const page = appContext.get(storageKey + "/page")
|
||||
const totalPages = appContext.get(storageKey + "/totalPages")
|
||||
console.log("loading data", data, page, totalPages)
|
||||
const data = appContext.get(storageKey + "/data");
|
||||
const page = appContext.get(storageKey + "/page");
|
||||
const totalPages = appContext.get(storageKey + "/totalPages");
|
||||
console.log("loading data", data, page, totalPages);
|
||||
if (data) {
|
||||
setData(data)
|
||||
pageRef.current = page
|
||||
totalPagesRef.current = totalPages
|
||||
setData(data);
|
||||
pageRef.current = page;
|
||||
totalPagesRef.current = totalPages;
|
||||
}
|
||||
}
|
||||
}, [appContext, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey && data.length > 0) {
|
||||
console.log("storing data", data)
|
||||
appContext.set(storageKey + "/data", data)
|
||||
appContext.set(storageKey + "/page", pageRef.current)
|
||||
appContext.set(storageKey + "/totalPages", totalPagesRef.current)
|
||||
console.log("storing data", data);
|
||||
appContext.set(storageKey + "/data", data);
|
||||
appContext.set(storageKey + "/page", pageRef.current);
|
||||
appContext.set(storageKey + "/totalPages", totalPagesRef.current);
|
||||
}
|
||||
}, [appContext, data, storageKey]);
|
||||
|
||||
const loadPage = useCallback(async () => {
|
||||
if (pageRef.current > totalPagesRef.current) return
|
||||
if (isLoadingRef.current) return
|
||||
isLoadingRef.current = true
|
||||
const res = await loader(pageRef.current)
|
||||
if (pageRef.current > totalPagesRef.current) return;
|
||||
if (isLoadingRef.current) return;
|
||||
isLoadingRef.current = true;
|
||||
const res = await loader(pageRef.current);
|
||||
if (!res.success) {
|
||||
showToast({message: res.message, type: "error"})
|
||||
showToast({ message: res.message, type: "error" });
|
||||
} else {
|
||||
isLoadingRef.current = false
|
||||
pageRef.current = pageRef.current + 1
|
||||
totalPagesRef.current = res.totalPages ?? 1
|
||||
setData((prev) => [...prev, ...res.data!])
|
||||
isLoadingRef.current = false;
|
||||
pageRef.current = pageRef.current + 1;
|
||||
totalPagesRef.current = res.totalPages ?? 1;
|
||||
setData((prev) => [...prev, ...res.data!]);
|
||||
}
|
||||
}, [loader])
|
||||
}, [loader]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPage()
|
||||
loadPage();
|
||||
}, [loadPage]);
|
||||
|
||||
const maybeLoadMore = useInfiniteLoader(loadPage)
|
||||
const maybeLoadMore = useInfiniteLoader(loadPage);
|
||||
|
||||
return <div className={"px-2 pt-2"}>
|
||||
<Masonry onRender={maybeLoadMore} columnWidth={300} items={data} render={(e) => {
|
||||
return <ResourceCard resource={e.data} key={e.data.id}/>
|
||||
} }></Masonry>
|
||||
{
|
||||
pageRef.current <= totalPagesRef.current && <Loading/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div className={"px-2 pt-2"}>
|
||||
<Masonry
|
||||
onRender={maybeLoadMore}
|
||||
columnWidth={300}
|
||||
items={data}
|
||||
render={(e) => {
|
||||
return <ResourceCard resource={e.data} key={e.data.id} />;
|
||||
}}
|
||||
></Masonry>
|
||||
{pageRef.current <= totalPagesRef.current && <Loading />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,229 +1,332 @@
|
||||
import {Tag} from "../network/models.ts";
|
||||
import {useRef, useState} from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {network} from "../network/network.ts";
|
||||
import {LuInfo} from "react-icons/lu";
|
||||
import {MdSearch} from "react-icons/md";
|
||||
import { Tag } from "../network/models.ts";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { network } from "../network/network.ts";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
import { MdSearch } from "react-icons/md";
|
||||
import Button from "./button.tsx";
|
||||
import Input, {TextArea} from "./input.tsx";
|
||||
import {ErrorAlert} from "./alert.tsx";
|
||||
import Input, { TextArea } from "./input.tsx";
|
||||
import { ErrorAlert } from "./alert.tsx";
|
||||
|
||||
export default function TagInput({ onAdd, mainTag }: { onAdd: (tag: Tag) => void, mainTag?: boolean }) {
|
||||
const [keyword, setKeyword] = useState<string>("")
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
export default function TagInput({
|
||||
onAdd,
|
||||
mainTag,
|
||||
}: {
|
||||
onAdd: (tag: Tag) => void;
|
||||
mainTag?: boolean;
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState<string>("");
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
const debounce = useRef(new Debounce(500))
|
||||
const debounce = useRef(new Debounce(500));
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const searchTags = async (keyword: string) => {
|
||||
if (keyword.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
setLoading(true)
|
||||
setTags([])
|
||||
setError(null)
|
||||
const res = await network.searchTags(keyword, mainTag)
|
||||
setLoading(true);
|
||||
setTags([]);
|
||||
setError(null);
|
||||
const res = await network.searchTags(keyword, mainTag);
|
||||
if (!res.success) {
|
||||
setError(res.message)
|
||||
setLoading(false)
|
||||
return
|
||||
setError(res.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setTags(res.data!)
|
||||
setLoading(false)
|
||||
}
|
||||
setTags(res.data!);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleChange = async (v: string) => {
|
||||
setKeyword(v)
|
||||
setTags([])
|
||||
setError(null)
|
||||
setKeyword(v);
|
||||
setTags([]);
|
||||
setError(null);
|
||||
if (v.length !== 0) {
|
||||
setLoading(true)
|
||||
debounce.current.run(() => searchTags(v))
|
||||
setLoading(true);
|
||||
debounce.current.run(() => searchTags(v));
|
||||
} else {
|
||||
setLoading(false)
|
||||
debounce.current.cancel()
|
||||
setLoading(false);
|
||||
debounce.current.cancel();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTag = async (name: string) => {
|
||||
setLoading(true)
|
||||
const res = await network.createTag(name)
|
||||
setLoading(true);
|
||||
const res = await network.createTag(name);
|
||||
if (!res.success) {
|
||||
setError(res.message)
|
||||
setLoading(false)
|
||||
return
|
||||
setError(res.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
onAdd(res.data!)
|
||||
setKeyword("")
|
||||
setTags([])
|
||||
setLoading(false)
|
||||
const input = document.getElementById("search_tags_input") as HTMLInputElement
|
||||
input.blur()
|
||||
}
|
||||
onAdd(res.data!);
|
||||
setKeyword("");
|
||||
setTags([]);
|
||||
setLoading(false);
|
||||
const input = document.getElementById(
|
||||
"search_tags_input",
|
||||
) as HTMLInputElement;
|
||||
input.blur();
|
||||
};
|
||||
|
||||
let dropdownContent
|
||||
let dropdownContent;
|
||||
if (error) {
|
||||
dropdownContent = <div className="alert alert-error my-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
dropdownContent = (
|
||||
<div className="alert alert-error my-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (!keyword) {
|
||||
dropdownContent = <div className="flex flex-row py-2 px-4">
|
||||
<LuInfo size={20} />
|
||||
<span className={"w-2"} />
|
||||
<span className={"flex-1"}>{t("Please enter a search keyword")}</span>
|
||||
</div>
|
||||
dropdownContent = (
|
||||
<div className="flex flex-row py-2 px-4">
|
||||
<LuInfo size={20} />
|
||||
<span className={"w-2"} />
|
||||
<span className={"flex-1"}>{t("Please enter a search keyword")}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (isLoading) {
|
||||
dropdownContent = <div className="flex flex-row py-2 px-4">
|
||||
<span className={"loading loading-spinner loading-sm"}></span>
|
||||
<span className={"w-2"} />
|
||||
<span className={"flex-1"}>{t("Searching...")}</span>
|
||||
</div>
|
||||
dropdownContent = (
|
||||
<div className="flex flex-row py-2 px-4">
|
||||
<span className={"loading loading-spinner loading-sm"}></span>
|
||||
<span className={"w-2"} />
|
||||
<span className={"flex-1"}>{t("Searching...")}</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined
|
||||
dropdownContent = <>
|
||||
{
|
||||
tags.map((t) => {
|
||||
return <li key={t.id} onClick={() => {
|
||||
onAdd(t);
|
||||
setKeyword("")
|
||||
setTags([])
|
||||
const input = document.getElementById("search_tags_input") as HTMLInputElement
|
||||
input.blur()
|
||||
}}><a>
|
||||
<span>{t.name}</span>
|
||||
{t.type && <span className="badge badge-secondary badge-sm ml-2 text-xs">{t.type}</span>}
|
||||
</a></li>
|
||||
})
|
||||
}
|
||||
{
|
||||
!haveExactMatch && <li onClick={() => {
|
||||
handleCreateTag(keyword)
|
||||
}}><a>{t("Create Tag")}: {keyword}</a></li>
|
||||
}
|
||||
</>
|
||||
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined;
|
||||
dropdownContent = (
|
||||
<>
|
||||
{tags.map((t) => {
|
||||
return (
|
||||
<li
|
||||
key={t.id}
|
||||
onClick={() => {
|
||||
onAdd(t);
|
||||
setKeyword("");
|
||||
setTags([]);
|
||||
const input = document.getElementById(
|
||||
"search_tags_input",
|
||||
) as HTMLInputElement;
|
||||
input.blur();
|
||||
}}
|
||||
>
|
||||
<a>
|
||||
<span>{t.name}</span>
|
||||
{t.type && (
|
||||
<span className="badge badge-secondary badge-sm ml-2 text-xs">
|
||||
{t.type}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!haveExactMatch && (
|
||||
<li
|
||||
onClick={() => {
|
||||
handleCreateTag(keyword);
|
||||
}}
|
||||
>
|
||||
<a>
|
||||
{t("Create Tag")}: {keyword}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={"dropdown dropdown-end"}>
|
||||
<label className="input w-64">
|
||||
<MdSearch size={18}/>
|
||||
<input autoComplete={"off"} id={"search_tags_input"} tabIndex={0} type="text" className="grow" placeholder={t("Search Tags")} value={keyword} onChange={(e) => handleChange(e.target.value)} />
|
||||
</label>
|
||||
<ul tabIndex={0} className="dropdown-content menu bg-base-100 rounded-box z-1 w-64 p-2 shadow mt-2 border border-base-300">
|
||||
{dropdownContent}
|
||||
</ul>
|
||||
</div>
|
||||
return (
|
||||
<div className={"dropdown dropdown-end"}>
|
||||
<label className="input w-64">
|
||||
<MdSearch size={18} />
|
||||
<input
|
||||
autoComplete={"off"}
|
||||
id={"search_tags_input"}
|
||||
tabIndex={0}
|
||||
type="text"
|
||||
className="grow"
|
||||
placeholder={t("Search Tags")}
|
||||
value={keyword}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content menu bg-base-100 rounded-box z-1 w-64 p-2 shadow mt-2 border border-base-300"
|
||||
>
|
||||
{dropdownContent}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class Debounce {
|
||||
private timer: number | null = null
|
||||
private readonly delay: number
|
||||
private timer: number | null = null;
|
||||
private readonly delay: number;
|
||||
|
||||
constructor(delay: number) {
|
||||
this.delay = delay
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
run(callback: () => void) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
callback()
|
||||
}, this.delay)
|
||||
callback();
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickAddTagDialog({ onAdded }: { onAdded: (tags: Tag[]) => void }) {
|
||||
const {t} = useTranslation();
|
||||
export function QuickAddTagDialog({
|
||||
onAdded,
|
||||
}: {
|
||||
onAdded: (tags: Tag[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [text, setText] = useState<string>("")
|
||||
const [text, setText] = useState<string>("");
|
||||
|
||||
const [type, setType] = useState<string>("")
|
||||
const [type, setType] = useState<string>("");
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [separator, setSeparator] = useState<string>(",")
|
||||
const [separator, setSeparator] = useState<string>(",");
|
||||
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isLoading) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (text.trim().length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
setError(null)
|
||||
const names = text.split(separator).filter((n) => n.length > 0)
|
||||
setLoading(true)
|
||||
const res = await network.getOrCreateTags(names, type)
|
||||
setLoading(false)
|
||||
setError(null);
|
||||
const names = text.split(separator).filter((n) => n.length > 0);
|
||||
setLoading(true);
|
||||
const res = await network.getOrCreateTags(names, type);
|
||||
setLoading(false);
|
||||
if (!res.success) {
|
||||
setError(res.message)
|
||||
return
|
||||
setError(res.message);
|
||||
return;
|
||||
}
|
||||
const tags = res.data!
|
||||
onAdded(tags)
|
||||
setText("")
|
||||
setType("")
|
||||
const dialog = document.getElementById("quick_add_tag_dialog") as HTMLDialogElement
|
||||
dialog.close()
|
||||
}
|
||||
const tags = res.data!;
|
||||
onAdded(tags);
|
||||
setText("");
|
||||
setType("");
|
||||
const dialog = document.getElementById(
|
||||
"quick_add_tag_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
};
|
||||
|
||||
return <>
|
||||
<Button className={"btn-soft btn-primary"} onClick={() => {
|
||||
const dialog = document.getElementById("quick_add_tag_dialog") as HTMLDialogElement
|
||||
dialog.showModal()
|
||||
}}>{t("Quick Add")}</Button>
|
||||
<dialog id="quick_add_tag_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t('Add Tags')}</h3>
|
||||
<p className="py-2 text-sm">
|
||||
{t("Input tags separated by separator.")}
|
||||
<br/>
|
||||
{t("If the tag does not exist, it will be created automatically.")}
|
||||
<br/>
|
||||
{t("Optionally, you can specify a type for the new tags.")}
|
||||
</p>
|
||||
<p className={"flex my-2"}>
|
||||
<span className={"flex-1"}>{t("Separator")}:</span>
|
||||
<label className="label text-sm mx-2">
|
||||
<input type="radio" name="radio-1" className="radio radio-primary" checked={separator == ","} onChange={() => setSeparator(",")}/>
|
||||
Comma
|
||||
</label>
|
||||
<label className="label text-sm mx-2">
|
||||
<input type="radio" name="radio-2" className="radio radio-primary" checked={separator == ";"} onChange={() => setSeparator(";")}/>
|
||||
Semicolon
|
||||
</label>
|
||||
<label className="label text-sm mx-2">
|
||||
<input type="radio" name="radio-3" className="radio radio-primary" checked={separator == " "} onChange={() => setSeparator(" ")}/>
|
||||
Space
|
||||
</label>
|
||||
</p>
|
||||
<TextArea value={text} onChange={(e) => setText(e.target.value)} label={"Tags"}/>
|
||||
<Input value={type} onChange={(e) => setType(e.target.value)} label={"Type"}/>
|
||||
{error && <ErrorAlert className={"mt-2"} message={error}/>}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button className="btn">{t("Cancel")}</Button>
|
||||
</form>
|
||||
<Button isLoading={isLoading} className={"btn-primary"} disabled={text === ""} onClick={handleSubmit}>{t("Submit")}</Button>
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className={"btn-soft btn-primary"}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"quick_add_tag_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}
|
||||
>
|
||||
{t("Quick Add")}
|
||||
</Button>
|
||||
<dialog id="quick_add_tag_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t("Add Tags")}</h3>
|
||||
<p className="py-2 text-sm">
|
||||
{t("Input tags separated by separator.")}
|
||||
<br />
|
||||
{t("If the tag does not exist, it will be created automatically.")}
|
||||
<br />
|
||||
{t("Optionally, you can specify a type for the new tags.")}
|
||||
</p>
|
||||
<p className={"flex my-2"}>
|
||||
<span className={"flex-1"}>{t("Separator")}:</span>
|
||||
<label className="label text-sm mx-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="radio-1"
|
||||
className="radio radio-primary"
|
||||
checked={separator == ","}
|
||||
onChange={() => setSeparator(",")}
|
||||
/>
|
||||
Comma
|
||||
</label>
|
||||
<label className="label text-sm mx-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="radio-2"
|
||||
className="radio radio-primary"
|
||||
checked={separator == ";"}
|
||||
onChange={() => setSeparator(";")}
|
||||
/>
|
||||
Semicolon
|
||||
</label>
|
||||
<label className="label text-sm mx-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="radio-3"
|
||||
className="radio radio-primary"
|
||||
checked={separator == " "}
|
||||
onChange={() => setSeparator(" ")}
|
||||
/>
|
||||
Space
|
||||
</label>
|
||||
</p>
|
||||
<TextArea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
label={"Tags"}
|
||||
/>
|
||||
<Input
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
label={"Type"}
|
||||
/>
|
||||
{error && <ErrorAlert className={"mt-2"} message={error} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button className="btn">{t("Cancel")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className={"btn-primary"}
|
||||
disabled={text === ""}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t("Submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -1,14 +1,20 @@
|
||||
export default function showToast({message, type}: {message: string, type?: "success" | "error" | "warning" | "info"}) {
|
||||
type = type || "info"
|
||||
const div = document.createElement("div")
|
||||
export default function showToast({
|
||||
message,
|
||||
type,
|
||||
}: {
|
||||
message: string;
|
||||
type?: "success" | "error" | "warning" | "info";
|
||||
}) {
|
||||
type = type || "info";
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = `
|
||||
<div class="toast toast-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>`
|
||||
document.body.appendChild(div)
|
||||
</div>`;
|
||||
document.body.appendChild(div);
|
||||
setTimeout(() => {
|
||||
div.remove()
|
||||
}, 3000)
|
||||
}
|
||||
div.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
@@ -7,41 +7,57 @@ export default function UploadingSideBar() {
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
console.log("Uploading tasks changed; show uploading: ", uploadingManager.hasTasks());
|
||||
setShowUploading(uploadingManager.hasTasks())
|
||||
console.log(
|
||||
"Uploading tasks changed; show uploading: ",
|
||||
uploadingManager.hasTasks(),
|
||||
);
|
||||
setShowUploading(uploadingManager.hasTasks());
|
||||
};
|
||||
|
||||
uploadingManager.addListener(listener)
|
||||
uploadingManager.addListener(listener);
|
||||
|
||||
return () => {
|
||||
uploadingManager.removeListener(listener)
|
||||
}
|
||||
uploadingManager.removeListener(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!showUploading) {
|
||||
return <></>
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<label htmlFor={"uploading-drawer"} className={"btn btn-square btn-ghost relative btn-accent text-primary"}>
|
||||
<div className={"w-6 h-6 overflow-hidden relative"}>
|
||||
<MdArrowUpward className={"move-up-animation pb-0.5"} size={24} />
|
||||
<div className={"absolute border-b-2 w-5 bottom-1 left-0.5"}></div>
|
||||
</div>
|
||||
</label>
|
||||
<div className="drawer w-0">
|
||||
<input id="uploading-drawer" type="checkbox" className="drawer-toggle" />
|
||||
<div className="drawer-side">
|
||||
<label htmlFor="uploading-drawer" aria-label="close sidebar" className="drawer-overlay"></label>
|
||||
<div className="menu bg-base-200 text-base-content h-full w-80 p-4 overflow-y-auto ">
|
||||
<div className={"grid grid-cols-1"}>
|
||||
<h2 className={"text-xl mb-2"}>Uploading</h2>
|
||||
<UploadingList />
|
||||
return (
|
||||
<>
|
||||
<label
|
||||
htmlFor={"uploading-drawer"}
|
||||
className={"btn btn-square btn-ghost relative btn-accent text-primary"}
|
||||
>
|
||||
<div className={"w-6 h-6 overflow-hidden relative"}>
|
||||
<MdArrowUpward className={"move-up-animation pb-0.5"} size={24} />
|
||||
<div className={"absolute border-b-2 w-5 bottom-1 left-0.5"}></div>
|
||||
</div>
|
||||
</label>
|
||||
<div className="drawer w-0">
|
||||
<input
|
||||
id="uploading-drawer"
|
||||
type="checkbox"
|
||||
className="drawer-toggle"
|
||||
/>
|
||||
<div className="drawer-side">
|
||||
<label
|
||||
htmlFor="uploading-drawer"
|
||||
aria-label="close sidebar"
|
||||
className="drawer-overlay"
|
||||
></label>
|
||||
<div className="menu bg-base-200 text-base-content h-full w-80 p-4 overflow-y-auto ">
|
||||
<div className={"grid grid-cols-1"}>
|
||||
<h2 className={"text-xl mb-2"}>Uploading</h2>
|
||||
<UploadingList />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadingList() {
|
||||
@@ -50,22 +66,22 @@ function UploadingList() {
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
setTasks(uploadingManager.getTasks());
|
||||
}
|
||||
};
|
||||
|
||||
uploadingManager.addListener(listener)
|
||||
uploadingManager.addListener(listener);
|
||||
|
||||
return () => {
|
||||
uploadingManager.removeListener(listener)
|
||||
}
|
||||
uploadingManager.removeListener(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>
|
||||
{
|
||||
tasks.map((task) => {
|
||||
return <TaskTile key={task.id} task={task} />
|
||||
})
|
||||
}
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
{tasks.map((task) => {
|
||||
return <TaskTile key={task.id} task={task} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskTile({ task }: { task: UploadingTask }) {
|
||||
@@ -77,51 +93,73 @@ function TaskTile({ task }: { task: UploadingTask }) {
|
||||
const listener = () => {
|
||||
setProgress(task.progress);
|
||||
setError(task.errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
task.addListener(listener)
|
||||
task.addListener(listener);
|
||||
|
||||
return () => {
|
||||
task.removeListener(listener)
|
||||
}
|
||||
task.removeListener(listener);
|
||||
};
|
||||
}, [task]);
|
||||
|
||||
return <div className={"card card-border border-base-300 p-2 my-2 w-full"}>
|
||||
<p className={"p-1 mb-2 w-full break-all line-clamp-2"}>{task.filename}</p>
|
||||
<progress className="progress progress-primary my-2" value={100 * progress} max={100} />
|
||||
{error && <p className={"text-error p-1"}>{error}</p>}
|
||||
<div className={"my-2 flex flex-row-reverse"}>
|
||||
{
|
||||
error && <button className={"btn h-7 mr-1"} onClick={() => {
|
||||
task.start();
|
||||
}}>
|
||||
Retry
|
||||
</button>
|
||||
}
|
||||
<button className={"btn btn-error h-7"} onClick={() => {
|
||||
const dialog = document.getElementById(`cancel_task_${task.id}`) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<dialog id={`cancel_task_${task.id}`} className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="text-lg font-bold">Cancel Task</h3>
|
||||
<p className="py-4">Are you sure you want to cancel this task?</p>
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<button className="btn">Close</button>
|
||||
</form>
|
||||
<button className="btn btn-error mx-2" type={"button"} onClick={() => {
|
||||
task.cancel();
|
||||
const dialog = document.getElementById(`cancel_task_${task.id}`) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
}}>
|
||||
Confirm
|
||||
return (
|
||||
<div className={"card card-border border-base-300 p-2 my-2 w-full"}>
|
||||
<p className={"p-1 mb-2 w-full break-all line-clamp-2"}>
|
||||
{task.filename}
|
||||
</p>
|
||||
<progress
|
||||
className="progress progress-primary my-2"
|
||||
value={100 * progress}
|
||||
max={100}
|
||||
/>
|
||||
{error && <p className={"text-error p-1"}>{error}</p>}
|
||||
<div className={"my-2 flex flex-row-reverse"}>
|
||||
{error && (
|
||||
<button
|
||||
className={"btn h-7 mr-1"}
|
||||
onClick={() => {
|
||||
task.start();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={"btn btn-error h-7"}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
`cancel_task_${task.id}`,
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
}
|
||||
<dialog id={`cancel_task_${task.id}`} className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="text-lg font-bold">Cancel Task</h3>
|
||||
<p className="py-4">Are you sure you want to cancel this task?</p>
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<button className="btn">Close</button>
|
||||
</form>
|
||||
<button
|
||||
className="btn btn-error mx-2"
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
task.cancel();
|
||||
const dialog = document.getElementById(
|
||||
`cancel_task_${task.id}`,
|
||||
) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user