mirror of
https://github.com/aykhans/slash-e.git
synced 2025-07-25 06:14:25 +00:00
chore: init web project
This commit is contained in:
108
web/src/components/CreateShortcutDialog.tsx
Normal file
108
web/src/components/CreateShortcutDialog.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { shortcutService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
workspaceId: WorkspaceId;
|
||||
shortcutId?: ShortcutId;
|
||||
}
|
||||
|
||||
const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy, workspaceId, shortcutId } = props;
|
||||
const [name, setName] = useState<string>("");
|
||||
const [link, setLink] = useState<string>("");
|
||||
const requestState = useLoading(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (shortcutId) {
|
||||
const shortcutTemp = shortcutService.getShortcutById(shortcutId);
|
||||
if (shortcutTemp) {
|
||||
setName(shortcutTemp.name);
|
||||
setLink(shortcutTemp.link);
|
||||
}
|
||||
}
|
||||
}, [shortcutId]);
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setName(text);
|
||||
};
|
||||
|
||||
const handleLinkInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setLink(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!name) {
|
||||
toastHelper.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (shortcutId) {
|
||||
await shortcutService.patchShortcut({
|
||||
id: shortcutId,
|
||||
name,
|
||||
link,
|
||||
});
|
||||
} else {
|
||||
await shortcutService.createShortcut({
|
||||
workspaceId,
|
||||
name,
|
||||
link,
|
||||
visibility: "PRIVATE",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<p className="text-base">{shortcutId ? "Edit Shortcut" : "Create Shortcut"}</p>
|
||||
<button className="rounded p-1 hover:bg-gray-100" onClick={destroy}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<input className="rounded border px-2 py-2" type="text" placeholder="Name" value={name} onChange={handleNameInputChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<input className="rounded border px-2 py-2" type="text" placeholder="Link" value={link} onChange={handleLinkInputChange} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-2">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<button
|
||||
className={`border rounded px-2 py-1 border-green-600 text-green-600 ${requestState.isLoading ? "opacity-80" : ""}`}
|
||||
onClick={handleSaveBtnClick}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showCreateShortcutDialog(workspaceId: WorkspaceId, shortcutId?: ShortcutId): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "px-2 sm:px-0",
|
||||
},
|
||||
CreateShortcutDialog,
|
||||
{
|
||||
workspaceId,
|
||||
shortcutId,
|
||||
}
|
||||
);
|
||||
}
|
110
web/src/components/CreateWorkspaceDialog.tsx
Normal file
110
web/src/components/CreateWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { workspaceService } from "../services";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "./Icon";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
workspaceId?: WorkspaceId;
|
||||
}
|
||||
|
||||
const CreateWorkspaceDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy, workspaceId } = props;
|
||||
const [name, setName] = useState<string>("");
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const requestState = useLoading(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
const workspaceTemp = workspaceService.getWorkspaceById(workspaceId);
|
||||
if (workspaceTemp) {
|
||||
setName(workspaceTemp.name);
|
||||
setDescription(workspaceTemp.description);
|
||||
}
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setName(text);
|
||||
};
|
||||
|
||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setDescription(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!name) {
|
||||
toastHelper.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (workspaceId) {
|
||||
await workspaceService.patchWorkspace({
|
||||
id: workspaceId,
|
||||
name,
|
||||
description,
|
||||
});
|
||||
} else {
|
||||
await workspaceService.createWorkspace({
|
||||
name,
|
||||
description,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toastHelper.error(error.response.data.message);
|
||||
}
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||
<p className="text-base">{workspaceId ? "Edit Workspace" : "Create Workspace"}</p>
|
||||
<button className="rounded p-1 hover:bg-gray-100" onClick={destroy}>
|
||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<input className="rounded border px-2 py-2" type="text" placeholder="Name" value={name} onChange={handleNameInputChange} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-2">
|
||||
<input
|
||||
className="rounded border px-2 py-2"
|
||||
type="text"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onChange={handleDescriptionInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-end items-center mt-2">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<button
|
||||
className={`border rounded px-2 py-1 border-green-600 text-green-600 ${requestState.isLoading ? "opacity-80" : ""}`}
|
||||
onClick={handleSaveBtnClick}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showCreateWorkspaceDialog(workspaceId?: WorkspaceId): void {
|
||||
generateDialog(
|
||||
{
|
||||
className: "px-2 sm:px-0",
|
||||
},
|
||||
CreateWorkspaceDialog,
|
||||
{
|
||||
workspaceId,
|
||||
}
|
||||
);
|
||||
}
|
89
web/src/components/Dialog/BaseDialog.tsx
Normal file
89
web/src/components/Dialog/BaseDialog.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { ANIMATION_DURATION } from "../../helpers/consts";
|
||||
import store from "../../store";
|
||||
import "../../less/base-dialog.less";
|
||||
|
||||
interface DialogConfig {
|
||||
className: string;
|
||||
clickSpaceDestroy?: boolean;
|
||||
}
|
||||
|
||||
interface Props extends DialogConfig, DialogProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BaseDialog: React.FC<Props> = (props: Props) => {
|
||||
const { children, className, clickSpaceDestroy, destroy } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === "Escape") {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
document.body.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSpaceClicked = () => {
|
||||
if (clickSpaceDestroy) {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`dialog-wrapper ${className}`} onClick={handleSpaceClicked}>
|
||||
<div className="dialog-container" onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function generateDialog<T extends DialogProps>(
|
||||
config: DialogConfig,
|
||||
DialogComponent: React.FC<T>,
|
||||
props?: Omit<T, "destroy">
|
||||
): DialogCallback {
|
||||
const tempDiv = document.createElement("div");
|
||||
const dialog = createRoot(tempDiv);
|
||||
document.body.append(tempDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
tempDiv.firstElementChild?.classList.add("showup");
|
||||
}, 0);
|
||||
|
||||
const cbs: DialogCallback = {
|
||||
destroy: () => {
|
||||
tempDiv.firstElementChild?.classList.remove("showup");
|
||||
tempDiv.firstElementChild?.classList.add("showoff");
|
||||
setTimeout(() => {
|
||||
dialog.unmount();
|
||||
tempDiv.remove();
|
||||
}, ANIMATION_DURATION);
|
||||
},
|
||||
};
|
||||
|
||||
const dialogProps = {
|
||||
...props,
|
||||
destroy: cbs.destroy,
|
||||
} as T;
|
||||
|
||||
const Fragment = (
|
||||
<Provider store={store}>
|
||||
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
|
||||
<DialogComponent {...dialogProps} />
|
||||
</BaseDialog>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
dialog.render(Fragment);
|
||||
|
||||
return cbs;
|
||||
}
|
85
web/src/components/Dialog/CommonDialog.tsx
Normal file
85
web/src/components/Dialog/CommonDialog.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import Icon from "../Icon";
|
||||
import { generateDialog } from "./BaseDialog";
|
||||
import "../../less/common-dialog.less";
|
||||
|
||||
type DialogStyle = "info" | "warning";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
title: string;
|
||||
content: string;
|
||||
style?: DialogStyle;
|
||||
closeBtnText?: string;
|
||||
confirmBtnText?: string;
|
||||
onClose?: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
title: "",
|
||||
content: "",
|
||||
style: "info",
|
||||
closeBtnText: "Close",
|
||||
confirmBtnText: "Confirm",
|
||||
onClose: () => null,
|
||||
onConfirm: () => null,
|
||||
};
|
||||
|
||||
const CommonDialog: React.FC<Props> = (props: Props) => {
|
||||
const { title, content, destroy, closeBtnText, confirmBtnText, onClose, onConfirm, style } = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
};
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onClose();
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleConfirmBtnClick = async () => {
|
||||
onConfirm();
|
||||
destroy();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">{title}</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<Icon.X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<p className="content-text">{content}</p>
|
||||
<div className="btns-container">
|
||||
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
|
||||
{closeBtnText}
|
||||
</span>
|
||||
<span className={`btn confirm-btn ${style}`} onClick={handleConfirmBtnClick}>
|
||||
{confirmBtnText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface CommonDialogProps {
|
||||
title: string;
|
||||
content: string;
|
||||
className?: string;
|
||||
style?: DialogStyle;
|
||||
closeBtnText?: string;
|
||||
confirmBtnText?: string;
|
||||
onClose?: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export const showCommonDialog = (props: CommonDialogProps) => {
|
||||
generateDialog(
|
||||
{
|
||||
className: `common-dialog ${props?.className ?? ""}`,
|
||||
},
|
||||
CommonDialog,
|
||||
props
|
||||
);
|
||||
};
|
1
web/src/components/Dialog/index.ts
Normal file
1
web/src/components/Dialog/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { generateDialog } from "./BaseDialog";
|
42
web/src/components/Header.tsx
Normal file
42
web/src/components/Header.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAppSelector } from "../store";
|
||||
import { userService } from "../services";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import Icon from "./Icon";
|
||||
import styles from "../less/header.module.less";
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAppSelector((state) => state.user);
|
||||
const [showDropdown, toggleShowDropdown] = useToggle(false);
|
||||
|
||||
const handleSignOutButtonClick = async () => {
|
||||
await userService.doSignOut();
|
||||
navigate("/auth");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className="w-full max-w-4xl mx-auto px-3 py-4 flex flex-row justify-between items-center">
|
||||
<span>Corgi</span>
|
||||
<div className="relative">
|
||||
<div className="flex flex-row justify-end items-center" onClick={() => toggleShowDropdown()}>
|
||||
<span>{user?.name}</span>
|
||||
<Icon.ChevronDown className="ml-1 w-5 h-auto text-gray-600" />
|
||||
</div>
|
||||
<div
|
||||
className={`bg-white flex flex-col justify-start items-start p-1 w-32 absolute top-8 right-0 shadow rounded ${
|
||||
showDropdown ? "" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<span className="w-full px-2 leading-8 cursor-pointer rounded hover:bg-gray-100" onClick={() => handleSignOutButtonClick()}>
|
||||
Sign out
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
3
web/src/components/Icon.ts
Normal file
3
web/src/components/Icon.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as Icon from "react-feather";
|
||||
|
||||
export default Icon;
|
22
web/src/components/ShortcutListView.tsx
Normal file
22
web/src/components/ShortcutListView.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
interface Props {
|
||||
shortcutList: Shortcut[];
|
||||
}
|
||||
|
||||
const ShortcutListView: React.FC<Props> = (props: Props) => {
|
||||
const { shortcutList } = props;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
{shortcutList.map((shortcut) => {
|
||||
return (
|
||||
<div key={shortcut.id} className="w-full flex flex-col justify-start items-start border px-6 py-4 mb-2 rounded-lg">
|
||||
<span className="text-xl font-medium">{shortcut.name}</span>
|
||||
<span className="text-base text-gray-600">{shortcut.link}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutListView;
|
110
web/src/components/Toast.tsx
Normal file
110
web/src/components/Toast.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect } from "react";
|
||||
import { createRoot, Root } from "react-dom/client";
|
||||
import "../less/toast.less";
|
||||
|
||||
type ToastType = "normal" | "success" | "info" | "error";
|
||||
|
||||
type ToastConfig = {
|
||||
type: ToastType;
|
||||
content: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type ToastItemProps = {
|
||||
type: ToastType;
|
||||
content: string;
|
||||
duration: number;
|
||||
destory: FunctionType;
|
||||
};
|
||||
|
||||
const Toast: React.FC<ToastItemProps> = (props: ToastItemProps) => {
|
||||
const { destory, duration } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
destory();
|
||||
}, duration);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="toast-container" onClick={destory}>
|
||||
<p className="content-text">{props.content}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// toast animation duration.
|
||||
const TOAST_ANIMATION_DURATION = 400;
|
||||
|
||||
const initialToastHelper = () => {
|
||||
const shownToastContainers: [Root, HTMLDivElement][] = [];
|
||||
let shownToastAmount = 0;
|
||||
|
||||
const wrapperClassName = "toast-list-container";
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.className = wrapperClassName;
|
||||
document.body.appendChild(tempDiv);
|
||||
const toastWrapper = tempDiv;
|
||||
|
||||
const showToast = (config: ToastConfig) => {
|
||||
const tempDiv = document.createElement("div");
|
||||
const toast = createRoot(tempDiv);
|
||||
tempDiv.className = `toast-wrapper ${config.type}`;
|
||||
toastWrapper.appendChild(tempDiv);
|
||||
shownToastAmount++;
|
||||
shownToastContainers.push([toast, tempDiv]);
|
||||
|
||||
const cbs = {
|
||||
destory: () => {
|
||||
tempDiv.classList.add("destory");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!tempDiv.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
shownToastAmount--;
|
||||
if (shownToastAmount === 0) {
|
||||
for (const [root, tempDiv] of shownToastContainers) {
|
||||
root.unmount();
|
||||
tempDiv.remove();
|
||||
}
|
||||
shownToastContainers.splice(0, shownToastContainers.length);
|
||||
}
|
||||
}, TOAST_ANIMATION_DURATION);
|
||||
},
|
||||
};
|
||||
|
||||
toast.render(<Toast {...config} destory={cbs.destory} />);
|
||||
|
||||
setTimeout(() => {
|
||||
tempDiv.classList.add("showup");
|
||||
}, 10);
|
||||
|
||||
return cbs;
|
||||
};
|
||||
|
||||
const info = (content: string, duration = 3000) => {
|
||||
return showToast({ type: "normal", content, duration });
|
||||
};
|
||||
|
||||
const success = (content: string, duration = 3000) => {
|
||||
return showToast({ type: "success", content, duration });
|
||||
};
|
||||
|
||||
const error = (content: string, duration = 3000) => {
|
||||
return showToast({ type: "error", content, duration });
|
||||
};
|
||||
|
||||
return {
|
||||
info,
|
||||
success,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
const toastHelper = initialToastHelper();
|
||||
|
||||
export default toastHelper;
|
31
web/src/components/WorkspaceListView.tsx
Normal file
31
web/src/components/WorkspaceListView.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface Props {
|
||||
workspaceList: Workspace[];
|
||||
}
|
||||
|
||||
const WorkspaceListView: React.FC<Props> = (props: Props) => {
|
||||
const { workspaceList } = props;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const gotoWorkspaceDetailPage = (workspace: Workspace) => {
|
||||
navigate(`/workspace/${workspace.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
{workspaceList.map((workspace) => {
|
||||
return (
|
||||
<div key={workspace.id} className="w-full flex flex-col justify-start items-start border px-6 py-4 mb-2 rounded-lg">
|
||||
<span className="text-xl font-medium" onClick={() => gotoWorkspaceDetailPage(workspace)}>
|
||||
{workspace.name}
|
||||
</span>
|
||||
<span className="text-base text-gray-600">{workspace.description}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceListView;
|
120
web/src/components/common/DatePicker.tsx
Normal file
120
web/src/components/common/DatePicker.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { DAILY_TIMESTAMP } from "../../helpers/consts";
|
||||
import Icon from "../Icon";
|
||||
import "../../less/common/date-picker.less";
|
||||
|
||||
interface DatePickerProps {
|
||||
className?: string;
|
||||
datestamp: DateStamp;
|
||||
handleDateStampChange: (datastamp: DateStamp) => void;
|
||||
}
|
||||
|
||||
const DatePicker: React.FC<DatePickerProps> = (props: DatePickerProps) => {
|
||||
const { className, datestamp, handleDateStampChange } = props;
|
||||
const [currentDateStamp, setCurrentDateStamp] = useState<DateStamp>(getMonthFirstDayDateStamp(datestamp));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentDateStamp(getMonthFirstDayDateStamp(datestamp));
|
||||
}, [datestamp]);
|
||||
|
||||
const firstDate = new Date(currentDateStamp);
|
||||
const firstDateDay = firstDate.getDay() === 0 ? 7 : firstDate.getDay();
|
||||
const dayList = [];
|
||||
for (let i = 1; i < firstDateDay; i++) {
|
||||
dayList.push({
|
||||
date: 0,
|
||||
datestamp: firstDate.getTime() - DAILY_TIMESTAMP * (7 - i),
|
||||
});
|
||||
}
|
||||
const dayAmount = getMonthDayAmount(currentDateStamp);
|
||||
for (let i = 1; i <= dayAmount; i++) {
|
||||
dayList.push({
|
||||
date: i,
|
||||
datestamp: firstDate.getTime() + DAILY_TIMESTAMP * (i - 1),
|
||||
});
|
||||
}
|
||||
|
||||
const handleDateItemClick = (datestamp: DateStamp) => {
|
||||
handleDateStampChange(datestamp);
|
||||
};
|
||||
|
||||
const handleChangeMonthBtnClick = (i: -1 | 1) => {
|
||||
const year = firstDate.getFullYear();
|
||||
const month = firstDate.getMonth() + 1;
|
||||
let nextDateStamp = 0;
|
||||
if (month === 1 && i === -1) {
|
||||
nextDateStamp = new Date(`${year - 1}/12/1`).getTime();
|
||||
} else if (month === 12 && i === 1) {
|
||||
nextDateStamp = new Date(`${year + 1}/1/1`).getTime();
|
||||
} else {
|
||||
nextDateStamp = new Date(`${year}/${month + i}/1`).getTime();
|
||||
}
|
||||
setCurrentDateStamp(getMonthFirstDayDateStamp(nextDateStamp));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`date-picker-wrapper ${className}`}>
|
||||
<div className="date-picker-header">
|
||||
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(-1)}>
|
||||
<Icon.ChevronLeft className="icon-img" />
|
||||
</span>
|
||||
<span className="normal-text">
|
||||
{firstDate.getFullYear()}/{firstDate.getMonth() + 1}
|
||||
</span>
|
||||
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(1)}>
|
||||
<Icon.ChevronRight className="icon-img" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="date-picker-day-container">
|
||||
<div className="date-picker-day-header">
|
||||
<span className="day-item">Mon</span>
|
||||
<span className="day-item">Tue</span>
|
||||
<span className="day-item">Web</span>
|
||||
<span className="day-item">Thu</span>
|
||||
<span className="day-item">Fri</span>
|
||||
<span className="day-item">Sat</span>
|
||||
<span className="day-item">Sun</span>
|
||||
</div>
|
||||
|
||||
{dayList.map((d) => {
|
||||
if (d.date === 0) {
|
||||
return (
|
||||
<span key={d.datestamp} className="day-item null">
|
||||
{""}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span
|
||||
key={d.datestamp}
|
||||
className={`day-item ${d.datestamp === datestamp ? "current" : ""}`}
|
||||
onClick={() => handleDateItemClick(d.datestamp)}
|
||||
>
|
||||
{d.date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getMonthDayAmount(datestamp: DateStamp): number {
|
||||
const dateTemp = new Date(datestamp);
|
||||
const currentDate = new Date(`${dateTemp.getFullYear()}/${dateTemp.getMonth() + 1}/1`);
|
||||
const nextMonthDate =
|
||||
currentDate.getMonth() === 11
|
||||
? new Date(`${currentDate.getFullYear() + 1}/1/1`)
|
||||
: new Date(`${currentDate.getFullYear()}/${currentDate.getMonth() + 2}/1`);
|
||||
|
||||
return (nextMonthDate.getTime() - currentDate.getTime()) / DAILY_TIMESTAMP;
|
||||
}
|
||||
|
||||
function getMonthFirstDayDateStamp(timestamp: TimeStamp): DateStamp {
|
||||
const dateTemp = new Date(timestamp);
|
||||
const currentDate = new Date(`${dateTemp.getFullYear()}/${dateTemp.getMonth() + 1}/1`);
|
||||
return currentDate.getTime();
|
||||
}
|
||||
|
||||
export default DatePicker;
|
40
web/src/components/common/Dropdown.tsx
Normal file
40
web/src/components/common/Dropdown.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import useToggle from "../../hooks/useToggle";
|
||||
import Icon from "../Icon";
|
||||
import "../../less/common/dropdown.less";
|
||||
|
||||
interface DropdownProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = (props: DropdownProps) => {
|
||||
const { children, className } = props;
|
||||
const [dropdownStatus, toggleDropdownStatus] = useToggle(false);
|
||||
const dropdownWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dropdownStatus) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!dropdownWrapperRef.current?.contains(event.target as Node)) {
|
||||
toggleDropdownStatus(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}, [dropdownStatus]);
|
||||
|
||||
return (
|
||||
<div ref={dropdownWrapperRef} className={`dropdown-wrapper ${className ?? ""}`} onClick={() => toggleDropdownStatus()}>
|
||||
<span className="trigger-button">
|
||||
<Icon.MoreHorizontal className="icon-img" />
|
||||
</span>
|
||||
<div className={`action-buttons-container ${dropdownStatus ? "" : "!hidden"}`}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
15
web/src/components/common/OnlyWhen.tsx
Normal file
15
web/src/components/common/OnlyWhen.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface OnlyWhenProps {
|
||||
children: ReactNode;
|
||||
when: boolean;
|
||||
}
|
||||
|
||||
const OnlyWhen: React.FC<OnlyWhenProps> = (props: OnlyWhenProps) => {
|
||||
const { children, when } = props;
|
||||
return when ? <>{children}</> : null;
|
||||
};
|
||||
|
||||
const Only = OnlyWhen;
|
||||
|
||||
export default Only;
|
95
web/src/components/common/Selector.tsx
Normal file
95
web/src/components/common/Selector.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import useToggle from "../../hooks/useToggle";
|
||||
import Icon from "../Icon";
|
||||
import "../../less/common/selector.less";
|
||||
|
||||
interface SelectorItem {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value: string;
|
||||
dataSource: SelectorItem[];
|
||||
handleValueChanged?: (value: string) => void;
|
||||
}
|
||||
|
||||
const nullItem = {
|
||||
text: "Select",
|
||||
value: "",
|
||||
};
|
||||
|
||||
const Selector: React.FC<Props> = (props: Props) => {
|
||||
const { className, dataSource, handleValueChanged, value } = props;
|
||||
const [showSelector, toggleSelectorStatus] = useToggle(false);
|
||||
|
||||
const seletorElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
let currentItem = nullItem;
|
||||
for (const d of dataSource) {
|
||||
if (d.value === value) {
|
||||
currentItem = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showSelector) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!seletorElRef.current?.contains(event.target as Node)) {
|
||||
toggleSelectorStatus(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}, [showSelector]);
|
||||
|
||||
const handleItemClick = (item: SelectorItem) => {
|
||||
if (handleValueChanged) {
|
||||
handleValueChanged(item.value);
|
||||
}
|
||||
toggleSelectorStatus(false);
|
||||
};
|
||||
|
||||
const handleCurrentValueClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
toggleSelectorStatus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`selector-wrapper ${className ?? ""}`} ref={seletorElRef}>
|
||||
<div className={`current-value-container ${showSelector ? "active" : ""}`} onClick={handleCurrentValueClick}>
|
||||
<span className="value-text">{currentItem.text}</span>
|
||||
<span className="arrow-text">
|
||||
<Icon.ChevronDown className="icon-img" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`items-wrapper ${showSelector ? "" : "!hidden"}`}>
|
||||
{dataSource.length > 0 ? (
|
||||
dataSource.map((d) => {
|
||||
return (
|
||||
<div
|
||||
className={`item-container ${d.value === value ? "selected" : ""}`}
|
||||
key={d.value}
|
||||
onClick={() => {
|
||||
handleItemClick(d);
|
||||
}}
|
||||
>
|
||||
{d.text}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<p className="tip-text">Null</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Selector);
|
Reference in New Issue
Block a user