mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 20:27:23 +00:00
Initial commit
This commit is contained in:
9
frontend/src/components/alert.tsx
Normal file
9
frontend/src/components/alert.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
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>;
|
||||
}
|
10
frontend/src/components/loading.tsx
Normal file
10
frontend/src/components/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Loading() {
|
||||
const {t} = useTranslation();
|
||||
|
||||
return <div className={"flex justify-center py-4"}>
|
||||
<span className="loading loading-spinner loading-lg mr-2"></span>
|
||||
<span>{t("Loading")}</span>
|
||||
</div>;
|
||||
}
|
191
frontend/src/components/navigator.tsx
Normal file
191
frontend/src/components/navigator.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import {app} from "../app.ts";
|
||||
import {network} from "../network/network.ts";
|
||||
import {useNavigate, useOutlet} from "react-router";
|
||||
import {useEffect, useState} from "react";
|
||||
import {MdOutlinePerson, MdSearch, MdSettings} from "react-icons/md";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Navigator() {
|
||||
const outlet = useOutlet()
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
return <>
|
||||
<div className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10">
|
||||
<div className={"flex-1 max-w-7xl mx-auto flex"}>
|
||||
<div className="flex-1">
|
||||
<button className="btn btn-ghost text-xl" onClick={() => {
|
||||
navigate(`/`);
|
||||
}}>{app.appName}</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<SearchBar/>
|
||||
{
|
||||
app.isAdmin() && <button className={"btn btn-circle btn-ghost"} onClick={() => {
|
||||
navigate("/manage");
|
||||
}}>
|
||||
<MdSettings 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 className={"max-w-7xl mx-auto pt-16"}>
|
||||
{outlet}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function UserButton() {
|
||||
let avatar = "./avatar.png";
|
||||
if (app.user) {
|
||||
avatar = network.getUserAvatar(app.user)
|
||||
}
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
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}/>
|
||||
</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?.id}`)}>{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>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
}
|
||||
|
||||
function SearchBar() {
|
||||
const [small, setSmall] = useState(window.innerWidth < 640);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const {t} = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth < 640) {
|
||||
setSmall(true);
|
||||
} else {
|
||||
setSmall(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const doSearch = () => {
|
||||
if (search.length === 0) {
|
||||
return;
|
||||
}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
}
|
||||
|
||||
return searchField
|
||||
}
|
36
frontend/src/components/pagination.tsx
Normal file
36
frontend/src/components/pagination.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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 }) {
|
||||
const items: ReactNode[] = [];
|
||||
|
||||
if (page > 1) {
|
||||
items.push(<button className="join-item btn" onClick={() => setPage(1)}>1</button>);
|
||||
}
|
||||
if (page - 2 > 1) {
|
||||
items.push(<button className="join-item btn">...</button>);
|
||||
}
|
||||
if (page-1 > 1) {
|
||||
items.push(<button className="join-item btn" onClick={() => setPage(page-1)}>{page-1}</button>);
|
||||
}
|
||||
items.push(<button className="join-item btn btn-active">{page}</button>);
|
||||
if (page+1 < totalPages) {
|
||||
items.push(<button className="join-item btn" onClick={() => setPage(page+1)}>{page+1}</button>);
|
||||
}
|
||||
if (page+2 < totalPages) {
|
||||
items.push(<button className="join-item btn">...</button>);
|
||||
}
|
||||
if (page < totalPages) {
|
||||
items.push(<button className="join-item btn" onClick={() => setPage(totalPages)}>{totalPages}</button>);
|
||||
}
|
||||
|
||||
return <div className="join">
|
||||
<button className={`join-item btn ${page === 1 && "btn-disabled"}`} onClick={() => setPage(page-1)}>
|
||||
<MdChevronLeft size={20} className="opacity-50"/>
|
||||
</button>
|
||||
{items}
|
||||
<button className={`join-item btn ${page === totalPages && "btn-disabled"}`} onClick={() => setPage(page+1)}>
|
||||
<MdChevronRight size={20} className="opacity-50"/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
61
frontend/src/components/popup.tsx
Normal file
61
frontend/src/components/popup.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
export default function showPopup(content: React.ReactNode, element: HTMLElement) {
|
||||
const eRect = element.getBoundingClientRect();
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.style.position = "fixed";
|
||||
if (eRect.x > window.innerWidth / 2) {
|
||||
div.style.right = `${window.innerWidth - eRect.x}px`;
|
||||
} else {
|
||||
div.style.left = `${eRect.x}px`;
|
||||
}
|
||||
if (eRect.y > window.innerHeight / 2) {
|
||||
div.style.bottom = `${window.innerHeight - eRect.y}px`;
|
||||
} else {
|
||||
div.style.top = `${eRect.y}px`;
|
||||
}
|
||||
|
||||
div.style.zIndex = "9999";
|
||||
div.className = "animate-appearance-in";
|
||||
|
||||
document.body.appendChild(div);
|
||||
|
||||
const mask = document.createElement("div");
|
||||
|
||||
const close = () => {
|
||||
console.log("close popup");
|
||||
document.body.removeChild(div);
|
||||
document.body.removeChild(mask);
|
||||
};
|
||||
|
||||
mask.style.position = "fixed";
|
||||
mask.style.top = "0";
|
||||
mask.style.left = "0";
|
||||
mask.style.width = "100%";
|
||||
mask.style.height = "100%";
|
||||
mask.style.zIndex = "9998";
|
||||
mask.onclick = close;
|
||||
document.body.appendChild(mask);
|
||||
|
||||
createRoot(div).render(<context.Provider value={close}>
|
||||
{content}
|
||||
</context.Provider>)
|
||||
}
|
||||
|
||||
const context = React.createContext<() => void>(() => {});
|
||||
|
||||
export function useClosePopup() {
|
||||
return React.useContext(context);
|
||||
}
|
||||
|
||||
export function PopupMenuItem({ children, onClick }: { children: React.ReactNode, onClick: () => void }) {
|
||||
const close = useClosePopup();
|
||||
return <li onClick={() => {
|
||||
close();
|
||||
onClick();
|
||||
}}>
|
||||
{children}
|
||||
</li>
|
||||
}
|
45
frontend/src/components/resource_card.tsx
Normal file
45
frontend/src/components/resource_card.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Resource } from "../network/models.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function ResourceCard({ resource }: { resource: Resource }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
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>
|
||||
{
|
||||
resource.tags.map((tag) => {
|
||||
return <span key={tag.id} className={"badge badge-primary mr-2"}>{tag.name}</span>
|
||||
})
|
||||
}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
44
frontend/src/components/resources_view.tsx
Normal file
44
frontend/src/components/resources_view.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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} from "masonic";
|
||||
import Loading from "./loading.tsx";
|
||||
|
||||
export default function ResourcesView({loader}: {loader: (page: number) => Promise<PageResponse<Resource>>}) {
|
||||
const [data, setData] = useState<Resource[]>([])
|
||||
const pageRef = useRef(1)
|
||||
const totalPagesRef = useRef(1)
|
||||
const isLoadingRef = useRef(false)
|
||||
|
||||
const loadPage = useCallback(async () => {
|
||||
if (pageRef.current > totalPagesRef.current) return
|
||||
if (isLoadingRef.current) return
|
||||
isLoadingRef.current = true
|
||||
const res = await loader(pageRef.current)
|
||||
if (!res.success) {
|
||||
showToast({message: "Error loading resources", type: "error"})
|
||||
} else {
|
||||
isLoadingRef.current = false
|
||||
pageRef.current = pageRef.current + 1
|
||||
totalPagesRef.current = res.totalPages ?? 1
|
||||
setData((prev) => [...prev, ...res.data!])
|
||||
}
|
||||
}, [loader])
|
||||
|
||||
useEffect(() => {
|
||||
loadPage()
|
||||
}, [loadPage]);
|
||||
|
||||
return <div className={"px-2 pt-2"}>
|
||||
<Masonry columnWidth={300} items={data} render={(e) => {
|
||||
if (e.index === data.length - 1) {
|
||||
loadPage()
|
||||
}
|
||||
return <ResourceCard resource={e.data} key={e.data.id}/>
|
||||
} }></Masonry>
|
||||
{
|
||||
pageRef.current <= totalPagesRef.current && <Loading/>
|
||||
}
|
||||
</div>
|
||||
}
|
14
frontend/src/components/toast.ts
Normal file
14
frontend/src/components/toast.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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"}">
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
</div>`
|
||||
document.body.appendChild(div)
|
||||
setTimeout(() => {
|
||||
div.remove()
|
||||
}, 3000)
|
||||
}
|
Reference in New Issue
Block a user