mirror of
https://github.com/usememos/memos.git
synced 2025-02-16 03:12:13 +01:00
feat: add view resource dialog
This commit is contained in:
parent
8c8bb9e59f
commit
84564891be
@ -38,4 +38,7 @@ type ResourceFind struct {
|
||||
|
||||
type ResourceDelete struct {
|
||||
ID int
|
||||
|
||||
// Standard fields
|
||||
CreatorID int
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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",
|
||||
|
108
web/src/components/ResourcesDialog.tsx
Normal file
108
web/src/components/ResourcesDialog.tsx
Normal 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,
|
||||
{}
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
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;
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
21
web/src/less/common/dropdown.less
Normal file
21
web/src/less/common/dropdown.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
55
web/src/less/resources-dialog.less
Normal file
55
web/src/less/resources-dialog.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
2
web/src/types/modules/resource.d.ts
vendored
2
web/src/types/modules/resource.d.ts
vendored
@ -1,7 +1,7 @@
|
||||
type ResourceId = number;
|
||||
|
||||
interface Resource {
|
||||
id: string;
|
||||
id: ResourceId;
|
||||
|
||||
createdTs: TimeStamp;
|
||||
updatedTs: TimeStamp;
|
||||
|
Loading…
x
Reference in New Issue
Block a user