mirror of
https://github.com/usememos/memos.git
synced 2025-02-19 12:50:41 +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 {
|
type ResourceDelete struct {
|
||||||
ID int
|
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 {
|
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)
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
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 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>
|
||||||
|
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}`);
|
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) {
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
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;
|
@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 {
|
||||||
|
@ -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;
|
||||||
|
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;
|
type ResourceId = number;
|
||||||
|
|
||||||
interface Resource {
|
interface Resource {
|
||||||
id: string;
|
id: ResourceId;
|
||||||
|
|
||||||
createdTs: TimeStamp;
|
createdTs: TimeStamp;
|
||||||
updatedTs: TimeStamp;
|
updatedTs: TimeStamp;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user