diff --git a/api/resource.go b/api/resource.go index 913c4b5f..4589add1 100644 --- a/api/resource.go +++ b/api/resource.go @@ -38,4 +38,7 @@ type ResourceFind struct { type ResourceDelete struct { ID int + + // Standard fields + CreatorID int } diff --git a/server/resource.go b/server/resource.go index 41298668..0b95d0ce 100644 --- a/server/resource.go +++ b/server/resource.go @@ -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) diff --git a/store/resource.go b/store/resource.go index d2b24f00..b98cb227 100644 --- a/store/resource.go +++ b/store/resource.go @@ -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) } diff --git a/web/src/components/ArchivedMemoDialog.tsx b/web/src/components/ArchivedMemoDialog.tsx index 532ee0f1..c82ea081 100644 --- a/web/src/components/ArchivedMemoDialog.tsx +++ b/web/src/components/ArchivedMemoDialog.tsx @@ -62,7 +62,7 @@ const ArchivedMemoDialog: React.FC = (props: Props) => { ); }; -export default function showArchivedMemo(): void { +export default function showArchivedMemoDialog(): void { generateDialog( { className: "archived-memo-dialog", diff --git a/web/src/components/ResourcesDialog.tsx b/web/src/components/ResourcesDialog.tsx new file mode 100644 index 00000000..38fd2c0b --- /dev/null +++ b/web/src/components/ResourcesDialog.tsx @@ -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) => { + const { destroy } = props; + const loadingState = useLoading(); + const [resources, setResources] = useState([]); + + 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 ( + <> +
+

+ 🌄 + Resources +

+ +
+
+
(👨‍💻WIP) View your static resources in memos. e.g. images
+
+ {loadingState.isLoading ? ( +
+

fetching data...

+
+ ) : ( +
+
+ ID + NAME + TYPE + +
+ {resources.map((resource) => ( +
+ {resource.id} + {resource.filename} + {resource.type} +
+ + + + +
+
+ ))} +
+ )} +
+ + ); +}; + +export default function showResourcesDialog() { + generateDialog( + { + className: "resources-dialog", + useAppContext: true, + }, + ResourcesDialog, + {} + ); +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index a8451685..4d22518c 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -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 = () => { showSettingDialog(); }; + const handleResourcesBtnClick = () => { + showResourcesDialog(); + }; + const handleArchivedBtnClick = () => { showArchivedMemoDialog(); }; @@ -35,6 +40,9 @@ const Sidebar: React.FC = () => { 📅 Daily Review + diff --git a/web/src/components/common/Dropdown.tsx b/web/src/components/common/Dropdown.tsx new file mode 100644 index 00000000..e90db05d --- /dev/null +++ b/web/src/components/common/Dropdown.tsx @@ -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 = (props: DropdownProps) => { + const { children, className } = props; + const [dropdownStatus, toggleDropdownStatus] = useToggle(false); + const dropdownWrapperRef = useRef(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 ( +
toggleDropdownStatus()}> + + + +
{children}
+
+ ); +}; + +export default Dropdown; diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts index 51f9d134..5e47b9c8 100644 --- a/web/src/helpers/api.ts +++ b/web/src/helpers/api.ts @@ -109,10 +109,18 @@ export function deleteShortcutById(shortcutId: ShortcutId) { return axios.delete(`/api/shortcut/${shortcutId}`); } +export function getResourceList() { + return axios.get>("/api/resource"); +} + export function uploadFile(formData: FormData) { return axios.post>("/api/resource", formData); } +export function deleteResourceById(id: ResourceId) { + return axios.delete(`/api/resource/${id}`); +} + export function getTagList(tagFind?: TagFind) { const queryList = []; if (tagFind?.creatorId) { diff --git a/web/src/less/archived-memo-dialog.less b/web/src/less/archived-memo-dialog.less index 131c2ee4..c2d8dc7c 100644 --- a/web/src/less/archived-memo-dialog.less +++ b/web/src/less/archived-memo-dialog.less @@ -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; } } } diff --git a/web/src/less/common-dialog.less b/web/src/less/common-dialog.less index 87627903..f11120fe 100644 --- a/web/src/less/common-dialog.less +++ b/web/src/less/common-dialog.less @@ -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; diff --git a/web/src/less/common/dropdown.less b/web/src/less/common/dropdown.less new file mode 100644 index 00000000..02de7b3d --- /dev/null +++ b/web/src/less/common/dropdown.less @@ -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; + } + } +} diff --git a/web/src/less/resources-dialog.less b/web/src/less/resources-dialog.less new file mode 100644 index 00000000..9d488886 --- /dev/null +++ b/web/src/less/resources-dialog.less @@ -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; + } + } + } + } + } +} diff --git a/web/src/less/shortcut-list.less b/web/src/less/shortcut-list.less index f0e02d8d..9a4ec642 100644 --- a/web/src/less/shortcut-list.less +++ b/web/src/less/shortcut-list.less @@ -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 { diff --git a/web/src/services/resourceService.ts b/web/src/services/resourceService.ts index 3a1a529c..1413edbc 100644 --- a/web/src/services/resourceService.ts +++ b/web/src/services/resourceService.ts @@ -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 { + 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; diff --git a/web/src/types/modules/resource.d.ts b/web/src/types/modules/resource.d.ts index 1a8edd0f..717ba7e1 100644 --- a/web/src/types/modules/resource.d.ts +++ b/web/src/types/modules/resource.d.ts @@ -1,7 +1,7 @@ type ResourceId = number; interface Resource { - id: string; + id: ResourceId; createdTs: TimeStamp; updatedTs: TimeStamp;