feat: add view resource dialog

This commit is contained in:
boojack 2022-08-07 01:30:48 +08:00
parent 8c8bb9e59f
commit 84564891be
15 changed files with 279 additions and 18 deletions

View File

@ -38,4 +38,7 @@ type ResourceFind struct {
type ResourceDelete struct {
ID int
// Standard fields
CreatorID int
}

View File

@ -138,13 +138,19 @@ func (s *Server) registerResourceRoutes(g *echo.Group) {
})
g.DELETE("/resource/:resourceId", func(c echo.Context) error {
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
}
resourceDelete := &api.ResourceDelete{
ID: resourceID,
ID: resourceID,
CreatorID: userID,
}
if err := s.Store.DeleteResource(resourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err)

View File

@ -102,7 +102,7 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
creator_id
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, filename, blob, type, size, created_ts, updated_ts
RETURNING id, filename, blob, type, size, creator_id, created_ts, updated_ts
`,
create.Filename,
create.Blob,
@ -123,6 +123,7 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@ -152,6 +153,7 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
blob,
type,
size,
creator_id,
created_ts,
updated_ts
FROM resource
@ -173,6 +175,7 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
&resourceRaw.Blob,
&resourceRaw.Type,
&resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs,
); err != nil {
@ -192,8 +195,8 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
func deleteResource(db *sql.DB, delete *api.ResourceDelete) error {
result, err := db.Exec(`
PRAGMA foreign_keys = ON;
DELETE FROM resource WHERE id = ?
`, delete.ID)
DELETE FROM resource WHERE id = ? AND creator_id = ?
`, delete.ID, delete.CreatorID)
if err != nil {
return FormatError(err)
}

View File

@ -62,7 +62,7 @@ const ArchivedMemoDialog: React.FC<Props> = (props: Props) => {
);
};
export default function showArchivedMemo(): void {
export default function showArchivedMemoDialog(): void {
generateDialog(
{
className: "archived-memo-dialog",

View File

@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import * as utils from "../helpers/utils";
import useLoading from "../hooks/useLoading";
import { resourceService } from "../services";
import Dropdown from "./common/Dropdown";
import { generateDialog } from "./Dialog";
import { showCommonDialog } from "./Dialog/CommonDialog";
import toastHelper from "./Toast";
import Icon from "./Icon";
import "../less/resources-dialog.less";
interface Props extends DialogProps {}
const ResourcesDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const loadingState = useLoading();
const [resources, setResources] = useState<Resource[]>([]);
useEffect(() => {
fetchResources()
.catch((error) => {
toastHelper.error("Failed to fetch archived memos: ", error);
})
.finally(() => {
loadingState.setFinish();
});
}, []);
const fetchResources = async () => {
const data = await resourceService.getResourceList();
setResources(data);
};
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
utils.copyTextToClipboard(`${window.location.origin}/h/r/${resource.id}/${resource.filename}`);
toastHelper.success("Succeed to copy resource link to clipboard");
};
const handleDeleteResourceBtnClick = (resource: Resource) => {
showCommonDialog({
title: `Delete Resource`,
content: `Are you sure to delete this resource? THIS ACTION IS IRREVERSIABLE.❗️`,
style: "warning",
onConfirm: async () => {
await resourceService.deleteResourceById(resource.id);
await fetchResources();
},
});
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">
<span className="icon-text">🌄</span>
Resources
</p>
<button className="btn close-btn" onClick={destroy}>
<Icon.X className="icon-img" />
</button>
</div>
<div className="dialog-content-container">
<div className="tip-text-container">(👨💻WIP) View your static resources in memos. e.g. images</div>
<div className="actions-container"></div>
{loadingState.isLoading ? (
<div className="loading-text-container">
<p className="tip-text">fetching data...</p>
</div>
) : (
<div className="resource-table-container">
<div className="fields-container">
<span className="field-text">ID</span>
<span className="field-text name-text">NAME</span>
<span className="field-text">TYPE</span>
<span></span>
</div>
{resources.map((resource) => (
<div key={resource.id} className="resource-container">
<span className="field-text">{resource.id}</span>
<span className="field-text name-text">{resource.filename}</span>
<span className="field-text">{resource.type}</span>
<div className="buttons-container">
<Dropdown className="actions-dropdown">
<button onClick={() => handleCopyResourceLinkBtnClick(resource)}>Copy Link</button>
<button className="delete-btn" onClick={() => handleDeleteResourceBtnClick(resource)}>
Delete
</button>
</Dropdown>
</div>
</div>
))}
</div>
)}
</div>
</>
);
};
export default function showResourcesDialog() {
generateDialog(
{
className: "resources-dialog",
useAppContext: true,
},
ResourcesDialog,
{}
);
}

View File

@ -4,6 +4,7 @@ import Only from "./common/OnlyWhen";
import showDailyReviewDialog from "./DailyReviewDialog";
import showSettingDialog from "./SettingDialog";
import showArchivedMemoDialog from "./ArchivedMemoDialog";
import showResourcesDialog from "./ResourcesDialog";
import UserBanner from "./UserBanner";
import UsageHeatMap from "./UsageHeatMap";
import ShortcutList from "./ShortcutList";
@ -17,6 +18,10 @@ const Sidebar: React.FC<Props> = () => {
showSettingDialog();
};
const handleResourcesBtnClick = () => {
showResourcesDialog();
};
const handleArchivedBtnClick = () => {
showArchivedMemoDialog();
};
@ -35,6 +40,9 @@ const Sidebar: React.FC<Props> = () => {
<span className="icon">📅</span> Daily Review
</button>
<Only when={!userService.isVisitorMode()}>
<button className="btn action-btn" onClick={handleResourcesBtnClick}>
<span className="icon">🌄</span> Resources
</button>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon"></span> Setting
</button>

View 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;

View File

@ -109,10 +109,18 @@ export function deleteShortcutById(shortcutId: ShortcutId) {
return axios.delete(`/api/shortcut/${shortcutId}`);
}
export function getResourceList() {
return axios.get<ResponseObject<Resource[]>>("/api/resource");
}
export function uploadFile(formData: FormData) {
return axios.post<ResponseObject<Resource>>("/api/resource", formData);
}
export function deleteResourceById(id: ResourceId) {
return axios.delete(`/api/resource/${id}`);
}
export function getTagList(tagFind?: TagFind) {
const queryList = [];
if (tagFind?.creatorId) {

View File

@ -7,17 +7,14 @@
@apply w-128 max-w-full mb-8;
> .dialog-content-container {
.flex(column, flex-start, flex-start);
@apply w-full overflow-y-auto;
@apply w-full flex flex-col justify-start items-start;
> .tip-text-container {
@apply w-full h-32;
.flex(column, center, center);
@apply w-full h-32 flex flex-col justify-center items-center;
}
> .archived-memos-container {
.flex(column, flex-start, flex-start);
@apply w-full;
@apply w-full flex flex-col justify-start items-start;
}
}
}

View File

@ -7,12 +7,8 @@
> .dialog-content-container {
@apply flex flex-col justify-start items-start;
> .content-text {
@apply pt-2;
}
> .btns-container {
@apply flex flex-row justify-end items-center w-full mt-3;
@apply flex flex-row justify-end items-center w-full mt-4;
> .btn {
@apply text-sm py-1 px-3 mr-2 rounded-md hover:opacity-80;

View File

@ -0,0 +1,21 @@
@import "../mixin.less";
.dropdown-wrapper {
@apply relative flex flex-col justify-start items-start select-none;
> .trigger-button {
@apply flex flex-row justify-center items-center border p-1 rounded shadow text-gray-600 cursor-pointer hover:opacity-80;
> .icon-img {
@apply w-4 h-auto;
}
}
> .action-buttons-container {
@apply w-28 mt-1 absolute top-full right-0 flex flex-col justify-start items-start bg-white z-1 border p-1 rounded shadow;
> button {
@apply w-full text-left px-2 text-sm leading-7 rounded hover:bg-gray-100;
}
}
}

View File

@ -0,0 +1,55 @@
@import "./mixin.less";
.resources-dialog {
@apply px-4;
> .dialog-container {
@apply w-128 max-w-full mb-8;
> .dialog-content-container {
@apply flex flex-col justify-start items-start w-full;
> .tip-text-container {
@apply w-full flex flex-row justify-start items-start border border-yellow-600 rounded px-2 py-1 mb-2 text-yellow-600 bg-yellow-50 text-sm;
}
> .loading-text-container {
@apply flex flex-col justify-center items-center w-full h-32;
}
> .resource-table-container {
@apply flex flex-col justify-start items-start w-full;
> .fields-container {
@apply px-2 py-2 w-full grid grid-cols-5 border-b;
> .field-text {
@apply font-mono text-gray-400;
}
}
> .resource-container {
@apply px-2 py-2 w-full grid grid-cols-5;
> .buttons-container {
@apply w-full flex flex-row justify-end items-center;
> .actions-dropdown {
.delete-btn {
@apply text-red-600;
}
}
}
}
.field-text {
@apply w-full truncate text-base pr-2 last:pr-0;
&.name-text {
@apply col-span-2;
}
}
}
}
}
}

View File

@ -57,7 +57,7 @@
@apply flex flex-row justify-center items-center w-6 h-6 shrink-0;
&.toggle-btn {
@apply opacity-60;
@apply w-4 h-auto text-gray-600;
&:hover {
& + .action-btns-wrapper {

View File

@ -1,6 +1,19 @@
import * as api from "../helpers/api";
const convertResponseModelResource = (resource: Resource): Resource => {
return {
...resource,
createdTs: resource.createdTs * 1000,
updatedTs: resource.updatedTs * 1000,
};
};
const resourceService = {
async getResourceList(): Promise<Resource[]> {
const { data } = (await api.getResourceList()).data;
const resourceList = data.map((m) => convertResponseModelResource(m));
return resourceList;
},
/**
* Upload resource file to server,
* @param file file
@ -19,6 +32,9 @@ const resourceService = {
return data;
},
async deleteResourceById(id: ResourceId) {
return api.deleteResourceById(id);
},
};
export default resourceService;

View File

@ -1,7 +1,7 @@
type ResourceId = number;
interface Resource {
id: string;
id: ResourceId;
createdTs: TimeStamp;
updatedTs: TimeStamp;