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 { type ResourceDelete struct {
ID int 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 { 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")) resourceID, err := strconv.Atoi(c.Param("resourceId"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err) return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("ID is not a number: %s", c.Param("resourceId"))).SetInternal(err)
} }
resourceDelete := &api.ResourceDelete{ resourceDelete := &api.ResourceDelete{
ID: resourceID, ID: resourceID,
CreatorID: userID,
} }
if err := s.Store.DeleteResource(resourceDelete); err != nil { if err := s.Store.DeleteResource(resourceDelete); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete resource").SetInternal(err) 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 creator_id
) )
VALUES (?, ?, ?, ?, ?) 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.Filename,
create.Blob, create.Blob,
@ -123,6 +123,7 @@ func createResource(db *sql.DB, create *api.ResourceCreate) (*resourceRaw, error
&resourceRaw.Blob, &resourceRaw.Blob,
&resourceRaw.Type, &resourceRaw.Type,
&resourceRaw.Size, &resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs, &resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs, &resourceRaw.UpdatedTs,
); err != nil { ); err != nil {
@ -152,6 +153,7 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
blob, blob,
type, type,
size, size,
creator_id,
created_ts, created_ts,
updated_ts updated_ts
FROM resource FROM resource
@ -173,6 +175,7 @@ func findResourceList(db *sql.DB, find *api.ResourceFind) ([]*resourceRaw, error
&resourceRaw.Blob, &resourceRaw.Blob,
&resourceRaw.Type, &resourceRaw.Type,
&resourceRaw.Size, &resourceRaw.Size,
&resourceRaw.CreatorID,
&resourceRaw.CreatedTs, &resourceRaw.CreatedTs,
&resourceRaw.UpdatedTs, &resourceRaw.UpdatedTs,
); err != nil { ); 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 { func deleteResource(db *sql.DB, delete *api.ResourceDelete) error {
result, err := db.Exec(` result, err := db.Exec(`
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
DELETE FROM resource WHERE id = ? DELETE FROM resource WHERE id = ? AND creator_id = ?
`, delete.ID) `, delete.ID, delete.CreatorID)
if err != nil { if err != nil {
return FormatError(err) 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( generateDialog(
{ {
className: "archived-memo-dialog", 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 showDailyReviewDialog from "./DailyReviewDialog";
import showSettingDialog from "./SettingDialog"; import showSettingDialog from "./SettingDialog";
import showArchivedMemoDialog from "./ArchivedMemoDialog"; import showArchivedMemoDialog from "./ArchivedMemoDialog";
import showResourcesDialog from "./ResourcesDialog";
import UserBanner from "./UserBanner"; import UserBanner from "./UserBanner";
import UsageHeatMap from "./UsageHeatMap"; import UsageHeatMap from "./UsageHeatMap";
import ShortcutList from "./ShortcutList"; import ShortcutList from "./ShortcutList";
@ -17,6 +18,10 @@ const Sidebar: React.FC<Props> = () => {
showSettingDialog(); showSettingDialog();
}; };
const handleResourcesBtnClick = () => {
showResourcesDialog();
};
const handleArchivedBtnClick = () => { const handleArchivedBtnClick = () => {
showArchivedMemoDialog(); showArchivedMemoDialog();
}; };
@ -35,6 +40,9 @@ const Sidebar: React.FC<Props> = () => {
<span className="icon">📅</span> Daily Review <span className="icon">📅</span> Daily Review
</button> </button>
<Only when={!userService.isVisitorMode()}> <Only when={!userService.isVisitorMode()}>
<button className="btn action-btn" onClick={handleResourcesBtnClick}>
<span className="icon">🌄</span> Resources
</button>
<button className="btn action-btn" onClick={handleMyAccountBtnClick}> <button className="btn action-btn" onClick={handleMyAccountBtnClick}>
<span className="icon"></span> Setting <span className="icon"></span> Setting
</button> </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}`); return axios.delete(`/api/shortcut/${shortcutId}`);
} }
export function getResourceList() {
return axios.get<ResponseObject<Resource[]>>("/api/resource");
}
export function uploadFile(formData: FormData) { export function uploadFile(formData: FormData) {
return axios.post<ResponseObject<Resource>>("/api/resource", 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) { export function getTagList(tagFind?: TagFind) {
const queryList = []; const queryList = [];
if (tagFind?.creatorId) { if (tagFind?.creatorId) {

View File

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

View File

@ -7,12 +7,8 @@
> .dialog-content-container { > .dialog-content-container {
@apply flex flex-col justify-start items-start; @apply flex flex-col justify-start items-start;
> .content-text {
@apply pt-2;
}
> .btns-container { > .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 { > .btn {
@apply text-sm py-1 px-3 mr-2 rounded-md hover:opacity-80; @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; @apply flex flex-row justify-center items-center w-6 h-6 shrink-0;
&.toggle-btn { &.toggle-btn {
@apply opacity-60; @apply w-4 h-auto text-gray-600;
&:hover { &:hover {
& + .action-btns-wrapper { & + .action-btns-wrapper {

View File

@ -1,6 +1,19 @@
import * as api from "../helpers/api"; import * as api from "../helpers/api";
const convertResponseModelResource = (resource: Resource): Resource => {
return {
...resource,
createdTs: resource.createdTs * 1000,
updatedTs: resource.updatedTs * 1000,
};
};
const resourceService = { 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, * Upload resource file to server,
* @param file file * @param file file
@ -19,6 +32,9 @@ const resourceService = {
return data; return data;
}, },
async deleteResourceById(id: ResourceId) {
return api.deleteResourceById(id);
},
}; };
export default resourceService; export default resourceService;

View File

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