This commit is contained in:
2025-06-04 10:20:01 +08:00
parent 7994ecc100
commit ad1144ad69
42 changed files with 5536 additions and 3740 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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